Docs / Build

Webhooks

Receive signed events for every change, verify them, and handle retries, duplicates and ordering.

Set up your endpoint#

Set the HTTPS address of your endpoint in Webhooks in the developer portal. Every event is a POST with an Event object:

Example event
{
  "id": "evt_01Uj8Ik2Ol4",
  "object": "event",
  "type": "payment.completed",
  "api_version": "v1",
  "environment": "sandbox",
  "created_at": "2026-10-15T14:04:10Z",
  "data": {
    "object": { "id": "pay_3As6Df9Gh2", "object": "payment", "status": "completed", "version": 3 },
    "previous_attributes": { "status": "requires_confirmation" }
  }
}

data.object is the full record after the change (shortened above), and previous_attributes holds the earlier values of the fields that changed. All event types are in the webhook events reference.

Verify the signature#

Every request carries A2A-Signature: t=<unix seconds>,v1=<hex>. The v1 value is an HMAC-SHA256, with your webhook signing secret, of the timestamp, a full stop and the raw request body. During a secret rotation there is one v1 for each active secret; accept the event if any of them matches.

  1. Read the raw body before parsing JSON. Re-serialized JSON will not match.
  2. Reject a timestamp more than five minutes from your clock.
  3. Compare in constant time.
import crypto from "node:crypto";

export function verifyA2ASignature(rawBody, header, secrets, toleranceSeconds = 300) {
  const parts = String(header).split(",").map((p) => p.trim().split("="));
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  return secrets.some((secret) => {
    const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
    return sigs.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
  });
}
import hashlib
import hmac
import time


def verify_a2a_signature(raw_body: bytes, header: str, secrets: list[str], tolerance: int = 300) -> bool:
    items = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
    t = next((v for k, v in items if k == "t"), None)
    sigs = [v for k, v in items if k == "v1"]
    if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    signed = t.encode() + b"." + raw_body
    for secret in secrets:
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, s) for s in sigs):
            return True
    return False
using System.Security.Cryptography;
using System.Text;

public static class A2AWebhooks
{
    public static bool VerifySignature(string rawBody, string header, IEnumerable<string> secrets, int toleranceSeconds = 300)
    {
        var parts = header.Split(',').Select(p => p.Trim().Split('=', 2)).Where(p => p.Length == 2).ToList();
        var t = parts.FirstOrDefault(p => p[0] == "t")?[1];
        var sigs = parts.Where(p => p[0] == "v1").Select(p => p[1]).ToList();
        if (!long.TryParse(t, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > toleranceSeconds) return false;
        var payload = Encoding.UTF8.GetBytes($"{t}.{rawBody}");
        foreach (var secret in secrets)
        {
            using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
            var expected = Encoding.ASCII.GetBytes(Convert.ToHexString(hmac.ComputeHash(payload)).ToLowerInvariant());
            if (sigs.Any(s => CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(s), expected))) return true;
        }
        return false;
    }
}

The portal's Webhooks page has a signature checker, and a drill that sends a tampered event: your endpoint must refuse it with a 4xx status.

Acknowledge quickly, then process#

Answer with any 2xx status within 10 seconds, then process the event in the background. Redirects are not followed. If the delivery fails, it is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, then every 12 hours until 72 hours after the first attempt.

Duplicates and ordering#

  • Duplicates. The same event can arrive more than once. Store each id you have processed and skip repeats.
  • Ordering. Events can arrive out of order. Every record has a version that increases with each change; ignore an event whose object version is lower than the one you hold.
  • Missed events. After downtime, list what you missed with GET /v1/webhook-events?from=2026-10-15T00:00:00Z, or resend one with POST /v1/webhook-events/{id}/resend.

Rotate your signing secret#

  1. In the portal, add a new secret. Both secrets now sign every event.
  2. Deploy the new secret to your servers, alongside the old one.
  3. Retire the older secret in the portal.

Receive webhooks on your own computer#

The hosted sandbox can deliver only to public HTTPS addresses. To develop locally without a tunnel, run the forwarding tool: it receives every webhook for your workspace and forwards it, with its original body and signature, to your local server.

Shell
A2A_CLIENT_ID=cli_sbx_... A2A_CLIENT_SECRET=sk_sbx_... \
  node a2a-listen.mjs --base https://sandbox.a2ainstapay.com --forward-to http://localhost:3000/webhooks

Download a2a-listen.mjs. It needs Node.js 18 or later and no other software.

Test delivery problems#

In the portal settings, turn on the chaos options to receive duplicates, out-of-order events and a failed first attempt, or to receive 503 errors from the API. Use Send a sample event for any event type, and move the sandbox clock forward to see the retry schedule run.

Questions about this page? Contact developer support.