Webhook events
Event types, payload schemas, signature verification, and retry schedule.
Overview
JoPay sends webhook notifications to partners when payment proof statuses change. Webhooks are delivered as HTTP POST requests to the URL configured in the partner admin portal. All payloads are JSON and all deliveries are signed.
Event types
| Event | Fires when | Proof status |
|---|---|---|
payment.proof_attached | A customer submits a transaction hash as proof of payment. | attached |
payment.proof_verified | The Trails engine confirms the on-chain transfer matches the payment request. | verified |
payment.proof_verified as verification of payment. The payment.proof_attached event means a transaction hash was submitted but has not yet been validated on-chain.Payload schema
Both event types use the same payload structure:
{
"event": "payment.proof_verified",
"delivery_id": "del_a1b2c3d4e5f6",
"partner_id": "ptr_abc123",
"merchant_id": "mrc_def456",
"request_id": "req_ghi789",
"proof_status": "verified",
"proof_source": "customer",
"tx_hash": "0x1234abcd...ef56",
"payer_address": "0x9876fedc...ba01",
"timestamp": "2026-04-05T14:30:00.000Z"
}Field reference
| Field | Type | Description |
|---|---|---|
event | string | The event type: payment.proof_attached or payment.proof_verified. |
delivery_id | string | Unique identifier for this delivery attempt. Use for idempotency (see below). |
partner_id | string | The partner this payment belongs to. |
merchant_id | string | The merchant who created the payment request. |
request_id | string | The payment request ID. |
proof_status | string | Current proof status: attached or verified. |
proof_source | string | Who submitted the proof (e.g., customer). |
tx_hash | string | The on-chain transaction hash. |
payer_address | string | The wallet address that sent the USDC. |
timestamp | string (ISO 8601) | When the event occurred. |
Signature verification
Every webhook delivery includes a signature in the X-JoPay-Signature header. The signature format is:
v1={hmac_signature},t={unix_timestamp}The HMAC is computed using SHA-256 over the concatenation of the timestamp and the raw request body, separated by a period:
HMAC-SHA256(secret, "{timestamp}.{raw_body}")Verification steps
- Extract the
v1andtvalues from the header. - Concatenate the timestamp and the raw request body with a period separator.
- Compute the HMAC-SHA256 using your webhook secret.
- Compare your computed signature to the
v1value using a timing-safe comparison. - Optionally, reject deliveries where the timestamp is more than 5 minutes old to prevent replay attacks.
Node.js verification example
import crypto from "crypto";
function verifyWebhookSignature(
rawBody: string,
signatureHeader: string,
secret: string
): boolean {
// Parse "v1={sig},t={timestamp}" format
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => {
const [key, ...rest] = p.split("=");
return [key, rest.join("=")];
})
);
const signature = parts.v1;
const timestamp = parts.t;
if (!signature || !timestamp) return false;
// Check timestamp freshness (optional, 5-minute window)
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) return false;
// Compute expected signature
const payload = timestamp + "." + rawBody;
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expected, "hex")
);
}Retry schedule
If your endpoint returns a non-2xx status code or fails to respond within 10 seconds, JoPay retries the delivery on the following schedule:
| Attempt | Delay after failure | Cumulative time |
|---|---|---|
| 1st retry | 1 minute | 1 minute |
| 2nd retry | 5 minutes | 6 minutes |
| 3rd retry | 30 minutes | 36 minutes |
| 4th retry | 2 hours | 2 hours 36 minutes |
| 5th retry | 6 hours | 8 hours 36 minutes |
| 6th retry (final) | 24 hours | 32 hours 36 minutes |
After 6 failed retries, the delivery is marked as permanently failed. It will not be retried automatically. You can view failed deliveries in the partner admin portal and manually trigger a re-delivery.
Idempotency
Due to retries and network conditions, your endpoint may receive the same webhook delivery more than once. Use the delivery_id field to deduplicate:
- When you receive a webhook, check if you have already handled a delivery with this
delivery_id. - If yes, return a
200response without taking action again. - If no, handle the event and store the
delivery_idfor future deduplication.
delivery_id values for at least 48 hours to cover the full retry window. A simple database table or Redis set works well for this.Best practices
- Return 200 quickly. Handle the webhook asynchronously if you need to do significant work. Return a
200within 10 seconds to avoid triggering retries. - Verify signatures. Always verify the HMAC signature before trusting the payload.
- Use delivery_id for idempotency. Never assume a webhook will only be delivered once.
- Log all deliveries. Keep a log of received webhooks for debugging and audit purposes.
- Use HTTPS. Webhook URLs must use HTTPS. HTTP URLs are rejected.