# AX-SYS-36: Sign every webhook and give each event a stable ID

> Notifications and change · intermediate · AX-SYS-36

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](ax-sys-17-tell-agents-when-things-change.mdx) 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

```http
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. [Stripe Docs: Receive Stripe events in your webhook endpoint](https://docs.stripe.com/webhooks): 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. [GitHub Docs: Validating webhook deliveries](https://docs.github.com/en/webhooks/using-webhooks/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. [CloudEvents Specification v1.0 (id attribute)](https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md): 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

---
Source: Agent Experience Principles (axprinciples.com)