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.

One webhook URL per partner. If you run multiple environments, route all events to one endpoint and branch on 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.

Your endpoint must respond with a 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

EventWhen it fires
payment.proof_attachedA 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_verifiedThe 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

FieldTypeDescription
eventstring"payment.proof_attached" or "payment.proof_verified".
delivery_iduuidUnique per delivery event. Retries use the same delivery_id — dedupe on this.
partner_iduuidYour partner id.
request_iduuidThe payment request id.
merchant_iduuidThe merchant id.
proof_statusstringCurrent proof status: "attached" or "verified". Mirrors the event field but always reflects the current database state.
proof_sourcestring | nullHow the proof was obtained, e.g. "trails_on_chain". Purely informational.
tx_hashstring | nullOn-chain transaction hash. Null in very early proof_attached deliveries; always set by the time proof_verified fires.
payer_addressstring | nullWallet address of the payer, if known.
metadataobject | nullThe 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).
timestampISO-8601Server 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

  1. Parse the x-jopay-signature header and extract v1 and t.
  2. Concatenate t + "." + the raw request body. Do not re-serialize the body — JSON whitespace and key order matter for the hash.
  3. Compute HMAC-SHA256 of that string using your webhook secret.
  4. Compare the computed hex digest with the v1 value using a timing-safe comparison.
  5. Reject requests where t is 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"),
  );
}
Framework bodies can be mutated before your handler runs (JSON parsing, decompression). Read the raw body for signature verification — in Express, use express.raw({ type: "application/json" }) on the webhook route; in Next.js route handlers, call req.text() before parsing JSON.

Request headers

HeaderValue
content-typeapplication/json
x-jopay-signaturev1=<hex-sig>,t=<unix-ts>
x-jopay-eventDuplicates the payload event for routing without parsing JSON.
x-jopay-deliveryDuplicates the payload delivery_id for logging without parsing JSON.
user-agentJoPay-Webhook/1.0

Retries

Failed deliveries are retried on a fixed schedule. Between attempt N and N+1 JoPay waits:

After attemptWait before retryCumulative elapsed
11 minute~1 minute
25 minutes~6 minutes
330 minutes~36 minutes
42 hours~2.6 hours
56 hours~8.6 hours
624 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.

Minimum viable dedup: maintain a table (or Redis set) of handled 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.

What next?