Webhooks
Receive real-time payment notifications via HMAC-signed HTTP callbacks. Event types, payload, signature verification, retries.
Overview
JoPay pushes payment events to your HTTPS endpoint as HMAC-signed POST requests. The two events you care about are payment.proof_attached (customer submitted a tx hash, not yet verified on-chain) and payment.proof_verified (the tx is verified on-chain).
Webhooks remove the need to poll GET /api/v1/requests/:id and let you correlate the payment back to your own system in a single callback — your create-time metadataround-trips in the payload, so you never need a separate mapping table.
partner_id + the test_mode signal baked into the merchant (created via a jo_test_ key). Separate per-environment endpoints aren't supported yet.Setup
Set your webhook URL
In the partner portal settings (or via admin), enter your HTTPS endpoint URL. JoPay sends POST requests there whenever a payment event occurs.
Save your webhook secret
When webhooks are enabled, JoPay generates a 256-bit webhook secret (64 hex characters). Copy and store it securely — you need it to verify signatures. Rotating the secret is a single click; existing deliveries signed with the old secret still verify until you confirm the rotation.
Enable webhooks
Toggle webhook_enabled. JoPay starts dispatching events immediately for any new payment activity. Existing payments verified before the toggle flipped do not backfill — only forward-going events are delivered.
2xx status within 8 seconds. Anything else — timeouts, 5xx, 429 — counts as a failure and triggers the retry schedule. 4xx (other than 429) abandons the delivery immediately; fix your endpoint and rely on the next event to test.Event types
| Event | When it fires |
|---|---|
payment.proof_attached | A payer submitted a transaction hash for a payment request. The tx has not yet been verified on-chain. Optional to handle — use if you want an "in-flight" signal for long-running checkouts. Most integrations skip this event and wait for proof_verified. |
payment.proof_verified | The on-chain transfer is verified. The merchant has received USDC. This is the event you fulfill orders on, send customer emails from, and reconcile against. |
Payload
POST https://your-server.com/webhooks/jopay
Content-Type: application/json
x-jopay-signature: v1=abc123def456...,t=1712345678
x-jopay-event: payment.proof_verified
x-jopay-delivery: d1e2f3a4-5678-9abc-def0-123456789abc
User-Agent: JoPay-Webhook/1.0
{
"event": "payment.proof_verified",
"delivery_id": "d1e2f3a4-5678-9abc-def0-123456789abc",
"partner_id": "partner-uuid",
"request_id": "a1b2c3d4-...",
"merchant_id": "merchant-uuid",
"proof_status": "verified",
"proof_source": "trails_on_chain",
"tx_hash": "0x9f8e7d6c...",
"payer_address": "0x1a2b3c4d...",
"metadata": { "order_id": "ORD-12345" },
"timestamp": "2026-04-05T14:35:00.000Z"
}Field reference
| Field | Type | Description |
|---|---|---|
event | string | "payment.proof_attached" or "payment.proof_verified". |
delivery_id | uuid | Unique per delivery event. Retries use the same delivery_id — dedupe on this. |
partner_id | uuid | Your partner id. |
request_id | uuid | The payment request id. |
merchant_id | uuid | The merchant id. |
proof_status | string | Current proof status: "attached" or "verified". Mirrors the event field but always reflects the current database state. |
proof_source | string | null | How the proof was obtained, e.g. "trails_on_chain". Purely informational. |
tx_hash | string | null | On-chain transaction hash. Null in very early proof_attached deliveries; always set by the time proof_verified fires. |
payer_address | string | null | Wallet address of the payer, if known. |
metadata | object | null | The exact metadata you passed on POST /api/v1/requests/create. Use this to correlate to your own order id without a mapping table. Null for requests created in the merchant dashboard (which doesn't expose metadata). |
timestamp | ISO-8601 | Server time when the event was recorded. |
Signature verification
Every request carries an x-jopay-signature header in the form v1=<hex-signature>,t=<unix-timestamp>. The signature is HMAC-SHA256 of <timestamp>.<raw-body> using your webhook secret as the key.
How to verify
- Parse the
x-jopay-signatureheader and extractv1andt. - Concatenate
t+"."+ the raw request body. Do not re-serialize the body — JSON whitespace and key order matter for the hash. - Compute HMAC-SHA256 of that string using your webhook secret.
- Compare the computed hex digest with the
v1value using a timing-safe comparison. - Reject requests where
tis more than 5 minutes away from the current time — this prevents replay attacks with an old valid signature.
Node.js example
import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhook(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
// Parse "v1=<sig>,t=<ts>"
const parts: Record<string, string> = {};
for (const segment of signatureHeader.split(",")) {
const [key, value] = segment.split("=", 2);
if (key && value) parts[key.trim()] = value.trim();
}
const receivedSig = parts["v1"];
const timestamp = parts["t"];
if (!receivedSig || !timestamp) return false;
// Reject if timestamp is more than 5 minutes old
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) return false;
// Compute expected signature
const expectedSig = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// Timing-safe comparison
if (receivedSig.length !== expectedSig.length) return false;
return timingSafeEqual(
Buffer.from(receivedSig, "hex"),
Buffer.from(expectedSig, "hex"),
);
}express.raw({ type: "application/json" }) on the webhook route; in Next.js route handlers, call req.text() before parsing JSON.Request headers
| Header | Value |
|---|---|
content-type | application/json |
x-jopay-signature | v1=<hex-sig>,t=<unix-ts> |
x-jopay-event | Duplicates the payload event for routing without parsing JSON. |
x-jopay-delivery | Duplicates the payload delivery_id for logging without parsing JSON. |
user-agent | JoPay-Webhook/1.0 |
Retries
Failed deliveries are retried on a fixed schedule. Between attempt N and N+1 JoPay waits:
| After attempt | Wait before retry | Cumulative elapsed |
|---|---|---|
| 1 | 1 minute | ~1 minute |
| 2 | 5 minutes | ~6 minutes |
| 3 | 30 minutes | ~36 minutes |
| 4 | 2 hours | ~2.6 hours |
| 5 | 6 hours | ~8.6 hours |
| 6 | 24 hours | ~32.6 hours |
After the configured max attempts (default 6), the delivery is marked abandoned and dropped — no further retries.
What counts as a failure
- 2xx: success — delivery is marked
delivered, no retry. - 4xx (except 429): client error — abandoned immediately. Fix your endpoint; the next event will exercise it.
- 429, 5xx, timeout (> 8s), network error: transient — retried per the schedule above.
Idempotency
Retries reuse the same delivery_id. Your endpoint must be idempotent on delivery_id — if you've already handled it, return 2xx without re-handling.
delivery_id values with a 48-hour TTL. On receive, upsert; if the row already existed, return 200 without doing anything else. This covers both retry duplicates and the rare case where your endpoint succeeded but the response got lost.Correlating to your order
The cleanest way to tie a webhook back to your system is via the metadata you attached at request creation:
// 1. On create, tag the request with your order id
await fetch("https://<slug>.jopay.app/api/v1/requests/create", {
method: "POST",
headers: { "Authorization": `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
merchant_id: MERCHANT_ID,
fiat_amount_int: 2500,
metadata: { order_id: "ORD-12345" }, // <-- your id
}),
});
// 2. On webhook, read it back
export async function POST(req: Request) {
const body = await req.text();
if (!verifyWebhook(body, req.headers.get("x-jopay-signature") ?? "", SECRET)) {
return new Response("bad signature", { status: 400 });
}
const payload = JSON.parse(body);
if (payload.event !== "payment.proof_verified") {
return new Response("ok", { status: 200 });
}
const orderId = payload.metadata?.order_id;
if (!orderId) return new Response("missing order_id", { status: 200 });
await markOrderPaid(orderId, payload.tx_hash);
return new Response("ok", { status: 200 });
}No separate mapping table, no lookups by request_id. The metadata is partner-owned and round-trips verbatim.