Webhooks
The six events the gateway actually sends, the exact HMAC signature scheme and headers, the five-attempt retry ladder, and how to verify a delivery without accepting a forgery.
Webhooks are how a merchant learns that a payment completed. Nothing else in the integration is reliable for that: the browser redirect can be lost or forged, and polling costs a request per order.
Registering an endpoint
There is no merchant-level webhook setting. The URL is a field on each payment you create.
{
"amount": 49.99,
"currency": "USD",
"returnUrl": "https://shop.example.com/thanks",
"webhookUrl": "https://shop.example.com/hooks/bicrypto"
}Omit webhookUrl and that payment will never notify anything — including its
expiry, its cancellation and any refund against it.
The API-key editor accepts webhookUrl, successUrl and cancelUrl, stores
them, and shows them back. No code path reads them when a payment is created.
The same is true of the merchant profile endpoint, which accepts those three
field names and then silently drops them because the merchant table has no such
columns. Send the URL on the payment or you get no callbacks.
The events that fire
Six, despite eight being defined in the source.
| Event | Sent when |
|---|---|
payment.created |
Immediately after the session is created, before anyone has paid |
payment.completed |
The buyer's wallets were debited and the merchant's balance credited |
payment.cancelled |
The buyer cancelled on the checkout page, or the merchant called the cancel endpoint |
payment.expired |
The expiry job closed a session that passed expiresAt unpaid |
payment.failed |
The confirmation hit a server fault (5xx) |
refund.completed |
A refund was processed, whether by the merchant's API call or by an admin |
refund.created and refund.failed exist in the type union and are never
emitted. Do not write a handler branch that waits for them.
A 4xx during confirmation — insufficient funds, a stale exchange-rate quote, a
wallet that is not allowed — leaves the session PENDING and the buyer on the
page, able to retry. Those do not emit payment.failed, deliberately: a
merchant that treats the event as terminal would cancel an order that then
succeeds thirty seconds later. Only a genuine server fault reports. The honest
terminal signals are payment.expired and payment.cancelled.
Payload
Always four top-level keys.
{
"id": "evt_pi_9fKq2mZx4Tn8bR6vLcYs1Dwe",
"type": "payment.completed",
"createdAt": "2026-08-03T14:12:07.412Z",
"data": {
"id": "pi_9fKq2mZx4Tn8bR6vLcYs1Dwe",
"merchantOrderId": "ORDER-1042",
"amount": 49.99,
"currency": "USD",
"feeAmount": 1.75,
"netAmount": 48.24,
"status": "COMPLETED",
"customerEmail": "buyer@example.com",
"metadata": { "cart": "abc" },
"completedAt": "2026-08-03T14:12:07.201Z",
"allocations": [
{
"walletId": "…",
"walletType": "SPOT",
"currency": "USDT",
"amount": 49.99,
"equivalentInPaymentCurrency": 49.99
}
]
}
}The event type is in type. Match on data.id (the pi_… value) or on
data.merchantOrderId to find your order. data.allocations appears on
payment.completed only, and tells you which of the buyer's wallets were used —
useful for reconciliation when someone pays a USD invoice out of three crypto
balances.
The id field is derived from the payment or refund identifier, so it is
stable across retries and across events of different types for the same
payment. It is not a per-delivery nonce. Deduplicate on
(type, data.id), not on id alone.
Headers and signature
Four headers ride with every delivery.
| Header | Value |
|---|---|
X-Gateway-Signature |
sha256= + hex HMAC-SHA256 |
X-Gateway-Timestamp |
Unix seconds, the value the signature covers |
X-Gateway-Event |
The event type, mirroring type |
User-Agent |
PaymentGateway-Webhook/1.0 |
The signed string is the timestamp, a literal dot, and the JSON body:
<X-Gateway-Timestamp>.<raw request body>The key is the merchant's webhookSecret — a 32-character string shown once
at registration and visible on the merchant's own settings screen. It is not the
API key and not the API secret.
const crypto = require("crypto");
function verify(rawBody, headers, secret) {
const timestamp = headers["x-gateway-timestamp"];
const signature = headers["x-gateway-signature"];
if (!timestamp || !signature) return false;
// Reject anything older than five minutes.
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) {
return false;
}
const expected =
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}<?php
function bicrypto_verify(string $rawBody, array $headers, string $secret): bool
{
$timestamp = $headers['X-Gateway-Timestamp'] ?? '';
$signature = $headers['X-Gateway-Signature'] ?? '';
if ($timestamp === '' || $signature === '') {
return false;
}
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}import hashlib
import hmac
import time
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
timestamp = headers.get("X-Gateway-Timestamp", "")
signature = headers.get("X-Gateway-Signature", "")
if not timestamp or not signature:
return False
if abs(int(time.time()) - int(timestamp)) > 300:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(
secret.encode(), signed, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)Three ways to get this wrong, all of which produce a signature that never matches:
- Signing the body alone. The timestamp and the dot are part of the signed string.
- Dropping the
sha256=prefix. It is part of the header value. - Re-serialising the JSON before hashing. Verify against the raw bytes you received. Any reordering, whitespace change or number reformatting breaks it.
Delivery, retries and timeouts
- The first attempt happens inline, while the request that caused the event is still running. A slow endpoint therefore slows the merchant's own API call.
- Each attempt has a hard 30-second timeout.
- Any 2xx counts as delivered. Anything else — including a 3xx — is a failure.
- Five attempts maximum, then the delivery is marked
FAILEDand never tried again. - Backoff between attempts: 1 minute, 5 minutes, 30 minutes, 2 hours, 24 hours.
- The retry job runs every 60 seconds. It also adopts deliveries stuck at
PENDINGfor more than two minutes, which is what happens when the process dies between writing the row and sending it.
gatewayWebhookRetryAttempts and gatewayWebhookRetryDelaySeconds are stored
and read into the settings object, and nothing consumes them. The five attempts
and the backoff ladder above are fixed in code. Changing those fields has no
effect on delivery behaviour.
Redelivery is a scheduled job (processGatewayWebhookRetries). If the cron
process is not running, a merchant endpoint that is down for sixty seconds loses
the event permanently — the row sits at RETRYING and nothing ever picks it up.
An unsigned webhook is never sent. If the merchant's webhookSecret cannot be
resolved, the delivery is marked FAILED with an explicit message rather than
going out with an empty key, because a receiver cannot tell an unverifiable
webhook from a forged one.
Writing a handler that survives production
-
Read the raw body first, before any JSON middleware reformats it. Verify the signature against those exact bytes.
-
Return 200 immediately, then do the work asynchronously. You have 30 seconds; a slow handler burns retries and slows the merchant's own checkout.
-
Deduplicate. Retries deliver the identical payload, and
idis stable across attempts. Key your idempotency on(type, data.id). -
Treat
payment.completedas the only fulfilment trigger. Not the redirect, notpayment.created. -
Reconcile on a schedule anyway. After five failures the gateway gives up. A nightly sweep that calls
GET /api/gateway/v1/payment/:idfor every order still awaiting payment closes the gap. The bundled WooCommerce plugin does exactly this, hourly.
Seeing what was sent
Every attempt is stored on the gateway_webhook row: the payload, the
signature actually sent, the HTTP status the endpoint returned, the first 1,000
characters of its response body, the round-trip time, the attempt count and any
transport error. Merchants see this under Gateway → Developers → Webhooks.
When a merchant reports "we never got it", that table tells you in one look
whether you sent it and what came back.
Next: WooCommerce.