Callback verification and idempotency, ready to copy
The webhook endpoint is the easiest thing to forge and to hit twice. The signature algorithm, code in five languages, and handling duplicates, ordering and missed webhooks.
A payment webhook endpoint has two natural enemies: forgery (someone posts a fake order.paid to your /notify) and duplication (you receive the same event twice). Signature verification handles the first; idempotency handles the second. Get both right and the webhook can safely drive fulfilment.
What a webhook looks like
A CNIXPay webhook is a JSON event with the signature in the headers:
POST /notify HTTP/1.1
Content-Type: application/json
X-Webhook-Id: evt_7f3c9a...
X-Webhook-Timestamp: 1760670205
X-Webhook-Signature: v2=5b1f0e...
{
"id": "evt_7f3c9a...",
"event": "order.paid",
"createTime": 1760670200,
"data": { "orderNo": "20261017105142a1b2c3d4e5f6", "status": "paid", "version": 3, "...": "..." }
}
data is the full order object at the moment of the event — the same object the get-order endpoint returns.
How the signature works
Three steps:
- Read the
X-Webhook-Timestampheader and check it is within 5 minutes of now (replay protection); - Compute
HMAC-SHA256(timestamp + "." + raw body)with your API secret, as lowercase hex, prefixed withv2=; - Compare it with the
X-Webhook-Signatureheader in constant time.
The signature covers the raw bytes of the request body, which leads to the most common mistake:
- Do not parse the JSON and re-serialize it before verifying. Any difference in key order, whitespace or number format breaks the signature. Many frameworks parse the body for you by default; get the raw bytes first —
express.raw()in Express,file_get_contents('php://input')in PHP.
The upside comes from the same design: verification does not care which fields exist, so when new fields are added to the order object your verification code does not change at all.
Verification in five languages
Node.js
import crypto from 'node:crypto'
// rawBody must be the raw request body (Buffer)
export function verifyWebhook(rawBody, headers, apiSecret) {
const timestamp = headers['x-webhook-timestamp']
const signature = headers['x-webhook-signature'] ?? ''
if (!/^\d+$/.test(timestamp ?? '') || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const expected =
'v2=' + crypto.createHmac('sha256', apiSecret).update(`${timestamp}.`).update(rawBody).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(signature)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// Express: app.post('/notify', express.raw({ type: 'application/json' }), handler)
Python
import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, headers, api_secret: str) -> bool:
timestamp = headers.get("X-Webhook-Timestamp", "")
signature = headers.get("X-Webhook-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
expected = "v2=" + hmac.new(
api_secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Go
func VerifyWebhook(r *http.Request, rawBody []byte, apiSecret string) bool {
timestamp := r.Header.Get("X-Webhook-Timestamp")
sec, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || time.Since(time.Unix(sec, 0)).Abs() > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, []byte(apiSecret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "v2=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Webhook-Signature")))
}
PHP
function verifyWebhook(string $rawBody, string $timestamp, string $signature, string $apiSecret): bool
{
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = 'v2=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $apiSecret);
return hash_equals($expected, $signature);
}
// $raw = file_get_contents('php://input');
// verifyWebhook($raw, $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '', $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '', $secret);
Java
static boolean verifyWebhook(byte[] rawBody, String timestamp, String signature, String apiSecret)
throws GeneralSecurityException {
long ts;
try {
ts = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
return false;
}
if (Math.abs(System.currentTimeMillis() / 1000 - ts) > 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
String expected = "v2=" + HexFormat.of().formatHex(mac.doFinal(rawBody));
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8));
}
All five use a constant-time comparison (timingSafeEqual / compare_digest / hmac.Equal / hash_equals / MessageDigest.isEqual). Do not replace it with ==.
Why you get duplicate or out-of-order webhooks
All of these are normal:
- Retries. If you do not return HTTP 2xx within 5 seconds (a timeout, a 4xx / 5xx, or a 3xx redirect all count as failures), the event is resent after 15s, 1m, 5m, 10m, 30m, 1h, 2h, 6h and 12h — about 22 hours in total. All four event types are retried.
- Several events per order. If the customer pays in parts, every short payment sends an
order.partially_paid, followed byorder.paidonce it is paid in full. - Out of order. A retry resends exactly the same body and may arrive after a later event — for example the third retry of an
order.partially_paidlanding afterorder.paid.
So your handler must accept the same event any number of times, and never let an old event overwrite a newer state.
Handling duplicates and ordering
Two fields are enough:
X-Webhook-Id(the same asidin the body): unique per event, unchanged across retries. Dedupe on it.data.version: the order version, incremented on every change. Keep only the copy with the highestversion; ignore anything older.
CREATE TABLE webhook_events (
id VARCHAR(64) PRIMARY KEY, -- X-Webhook-Id
order_no VARCHAR(64) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
app.post('/notify', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifyWebhook(req.body, req.headers, API_SECRET)) return res.status(400).end()
const event = JSON.parse(req.body)
// INSERT succeeds = first time seen; primary-key conflict = already handled, just return 2xx
const isNew = await db.insertIgnore('webhook_events', { id: event.id, order_no: event.data.orderNo })
if (isNew) await queue.push('order-changed', event.data)
res.status(200).end()
})
// queue consumer
async function onOrderChanged(order) {
// write only if newer: UPDATE ... WHERE order_no = ? AND version < ?
const updated = await orders.saveIfNewer(order)
if (updated && order.status === 'paid') await fulfillOnce(order.orderNo)
}
Three details:
- Persist, return 2xx, fulfil from a queue. The handler only verifies, dedupes and enqueues, returning within tens of milliseconds so a timeout never triggers a retry.
- Fulfil only on
status == "paid", and make fulfilment itself idempotent per order (fulfillOnce). Sendunderpaidto manual review and voidclosed. - A voided order can still receive
order.paid. Funds that arrive after the grace period may be credited manually by the platform after review, turning the order fromclosedintopaid. Handling byversiongets this right — do not drop it just because you voided the order locally.
What if a webhook never arrives?
Webhooks are pushed, and pushes can be lost: your service is down for more than 22 hours, the callback URL is misconfigured, or a verification bug keeps returning 4xx. So add an active query as a fallback:
// every few minutes: batch-query the orders you still have open (up to 100 per request)
const open = await orders.findOpen({ limit: 100 })
const res = await cnixpay.get('/orders', { merchantOrderNo: open.map((o) => o.merchantOrderNo) })
for (const order of res.data.items) await onOrderChanged(order)
The query returns the authoritative order — the same object as the webhook’s data — so the same handler works for both. When the customer clicks “I’ve paid” on your payment page, you can also fetch that single order for instant feedback.
Checklist
Before going live:
- Verify against the raw body, check the timestamp is within 5 minutes, compare in constant time
- Return 4xx and log when verification fails
- Dedupe by
X-Webhook-Idand drop stale data byversion - Fulfil only on
status == "paid", idempotently per order - The handler only records and enqueues, returning 2xx within 5 seconds
- Periodically batch-query open orders by
merchantOrderNoas a fallback - The API secret lives only on the server — never in the front end, never in logs
Full event and field definitions are in the integration guide · Webhook events.