Skip to content
AX Principles
AX-SYS-36Intermediate

Sign every webhook and give each event a stable ID

Firma cada webhook y dale a cada evento un ID estable

#saas#fintech#e-commerce

Sign each webhook over its body and a timestamp, give every event an ID that stays the same across retries, and document that delivery is retried and unordered, so the receiving agent can reject forgeries and act on each event once.

Why

AX-SYS-17 says to send webhooks. Once you do, receivers act on them: a charge.refunded event might close a case or email a customer. A webhook URL is reachable by anyone who finds it, so without a signature a forged event looks like a real one, and a captured real event can be replayed. GitHub tells receivers to validate the signature before processing a delivery any further, so they know it came from GitHub and was not tampered with. Stripe puts a timestamp inside its Stripe-Signature header, which lets a receiver reject a correctly signed payload that is too old. Delivery is the other half. Stripe retries failed deliveries for up to three days with exponential backoff in live mode, warns that an endpoint might receive the same event more than once, tells receivers to log processed event IDs and skip repeats, and does not guarantee order. CloudEvents makes the ID part of the format: producers must keep source plus id unique per distinct event, a resend may carry the same id, and consumers may treat a repeated pair as a duplicate. Without a stable ID, a retried charge.refunded looks like a second refund.

Do

Sign the raw body plus a timestamp, keep the same event ID on every delivery attempt, and publish your retry schedule and the fact that order is not guaranteed.

Don't

Send unsigned webhooks, mint a new ID on each retry, or imply that events arrive exactly once and in order.

Artifact

POST /hooks/acme HTTP/1.1
Content-Type: application/cloudevents+json
Acme-Signature: t=1791036000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e7...
Acme-Delivery-Attempt: 2

{
  "specversion": "1.0",
  "id": "evt_4410",
  "source": "https://api.acme.com/charges",
  "type": "com.acme.charge.refunded",
  "time": "2026-10-03T14:00:00Z",
  "data": { "charge_id": "ch_123", "amount": 4900 }
}

The receiver checks the HMAC over the timestamp and raw body, rejects stale timestamps, and skips any id it has already processed; attempt 2 carries the same id as attempt 1.

Citations

  1. [01]
    Stripe Docs: Receive Stripe events in your webhook endpoint ↗

    The Stripe-Signature header in each signed event contains a timestamp and one or more signatures to verify; if the signature is valid but the timestamp is too old, the application can reject the payload. Stripe retries delivery for up to three days with exponential backoff in live mode, endpoints might receive the same event more than once (guard against it by logging processed event IDs), and Stripe doesn't guarantee delivery in the order events are generated

  2. [02]
    GitHub Docs: Validating webhook deliveries ↗

    To ensure your server only processes webhook deliveries that were sent by GitHub and were not tampered with, validate the webhook signature before processing the delivery further

  3. [03]
    CloudEvents Specification v1.0 (id attribute) ↗

    Producers MUST ensure that source + id is unique for each distinct event; a duplicate event re-sent (e.g. due to a network error) MAY have the same id, and consumers MAY assume that events with identical source and id are duplicates