AntaraPayAPI v1

Part 11

Webhook signature and retries

HMAC SHA256 verification, verification steps, code samples, and the retry policy.

Webhook headers

HeaderContent
X-AntaraPay-Event-IdSame as event_id in the body.
X-AntaraPay-TimestampDelivery time as a Unix timestamp in seconds.
X-AntaraPay-Signaturev1=<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=" + signature

Verification steps on the merchant server

  1. Take the raw request body as bytes before any JSON parser alters it. Read the three required headers and limit the payload size.
  2. Make sure the timestamp is an integer and differs from your server time by at most 300 seconds. Keep your server clock synchronised. occurred_at can be older because of retries; the freshness check uses the timestamp header.
  3. 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.
  4. Parse the JSON only after the signature passes; make sure the body event_id equals the header and that schema_version is supported.
  5. Store event_id with a unique constraint, together with the event, into a durable inbox atomically. If it is already stored, reply 2xx without reprocessing.
  6. 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 204

Node.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 "", 204
The 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

Aspectv1 design
Retry scheduleImmediately, 1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours, and 24 hours, with jitter.
Delivery timeout10 seconds.
Counted as failedNon-2xx responses, timeouts, and network failures; redirects are not followed.
Retry/replayUses the same event_id with a new delivery timestamp and signature.
Retries exhaustedThe 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.