Part 11
Webhook signature and retries
HMAC SHA256 verification, verification steps, code samples, and the retry policy.
Webhook headers
| Header | Content |
|---|---|
X-AntaraPay-Event-Id | Same as event_id in the body. |
X-AntaraPay-Timestamp | Delivery time as a Unix timestamp in seconds. |
X-AntaraPay-Signature | v1=<hex HMAC SHA256>; secret per merchant/environment. |
Signature formula
text
signed_payload = event_id + "." + timestamp + "." + raw_body
signature = hex(HMAC_SHA256(webhook_secret, signed_payload))
header = "v1=" + signatureVerification steps on the merchant server
- Take the raw request body as bytes before any JSON parser alters it. Read the three required headers and limit the payload size.
- Make sure the timestamp is an integer and differs from your server time by at most 300 seconds. Keep your server clock synchronised.
occurred_atcan be older because of retries; the freshness check uses the timestamp header. - Compute the HMAC over
event_id, the timestamp, the dot separators, and the raw body exactly as received. Compare the digest in constant time. Reject an invalid signature. - Parse the JSON only after the signature passes; make sure the body
event_idequals the header and thatschema_versionis supported. - Store
event_idwith a unique constraint, together with the event, into a durable inbox atomically. If it is already stored, reply 2xx without reprocessing. - Process the event using the latest resource version. Apply order status changes idempotently. If data conflicts or ordering is lost, confirm through the GET API.
Code samples
Pseudocode
text
raw = request.raw_body
event_id = header("X-AntaraPay-Event-Id")
ts = header("X-AntaraPay-Timestamp")
assert abs(unix_now() - int(ts)) <= 300
message = utf8(event_id + "." + ts + ".") + raw
expected = hmac_sha256(secret, message)
assert constant_time_equal(expected, decode_signature(header_sig))
event = json_parse(raw)
assert event.event_id == event_id
persist_once(event_id, event)
return 204Node.js (Express)
webhook.js
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.ANTARAPAY_WEBHOOK_SECRET;
// Raw body is required: do NOT use express.json() on this route.
app.post(
"/webhooks/antarapay",
express.raw({ type: "application/json", limit: "256kb" }),
async (req, res) => {
const eventId = req.get("X-AntaraPay-Event-Id");
const ts = req.get("X-AntaraPay-Timestamp");
const header = req.get("X-AntaraPay-Signature") || "";
if (!eventId || !/^[0-9]+$/.test(ts || "")) return res.sendStatus(400);
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", SECRET)
.update(Buffer.concat([Buffer.from(eventId + "." + ts + "."), req.body]))
.digest();
const given = Buffer.from(header.startsWith("v1=") ? header.slice(3) : "", "hex");
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.event_id !== eventId) return res.sendStatus(400);
// Insert into a durable inbox with a UNIQUE(event_id) constraint.
// If it already exists, still answer 2xx without reprocessing.
await saveOnce(eventId, event);
res.sendStatus(204);
},
);Python (Flask)
webhook.py
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["ANTARAPAY_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/antarapay")
def antarapay_webhook():
raw = request.get_data() # bytes, before any JSON parsing
event_id = request.headers.get("X-AntaraPay-Event-Id", "")
ts = request.headers.get("X-AntaraPay-Timestamp", "")
header = request.headers.get("X-AntaraPay-Signature", "")
if not event_id or not ts.isdigit():
abort(400)
if abs(time.time() - int(ts)) > 300:
abort(400)
message = (event_id + "." + ts + ".").encode() + raw
expected = "v1=" + hmac.new(SECRET, message, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, header):
abort(401)
event = json.loads(raw)
if event["event_id"] != event_id:
abort(400)
save_once(event_id, event) # UNIQUE(event_id); 2xx if already stored
return "", 204The Node.js and Python samples above illustrate how to implement the rules in this guide;
saveOnce/save_once is your own inbox storage.Retry policy
| Aspect | v1 design |
|---|---|
| Retry schedule | Immediately, 1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours, and 24 hours, with jitter. |
| Delivery timeout | 10 seconds. |
| Counted as failed | Non-2xx responses, timeouts, and network failures; redirects are not followed. |
| Retry/replay | Uses the same event_id with a new delivery timestamp and signature. |
| Retries exhausted | The webhook delivery status becomes FAILED; this is the delivery status, not the transaction's data.status. |
A transaction does not change because a notification failed. Events can arrive repeatedly and out of order; process the latest version and deduplicate on event_id. A replay uses the same event ID. When rotating the secret, use a transition period agreed with AntaraPay.