Technical notes

SchemaVela

Webhooks

Webhook Payload Design: Structure, Versioning, and Reliability

How to shape a webhook payload using CloudEvents and the Standard Webhooks specification, including identity, signatures, duplicates, and retries.

A webhook is an HTTP request the producer sends to a consumer, usually when something has already happened. The payload is a contract in the same sense as a response body: consumers will parse it, store it, and fail closed when it changes shape. This note uses CloudEvents 1.0 for the envelope and the Standard Webhooks specification for signatures and delivery behavior. Where a sentence is this desk’s advice, it says so.

Give the event an identity before you design the fields

CloudEvents 1.0 requires four context attributes: id, source, specversion, and type. id must be a non-empty string. source must be a non-empty URI reference; an absolute URI is recommended. Producers must make source plus id unique for each distinct event. If a duplicate is re-sent, it may use the same id. Consumers may assume that events with the same source and id are duplicates.

specversion for that specification is the string 1.0. It identifies the envelope, not the business event. type describes what happened. It must be a non-empty string and should use a reverse-DNS prefix so the organization that owns the name is visible. The specification points to versioning of the type as a producer concern. Putting a version in the type name is one documented option. It is not required by the four mandatory attributes.

Optional attributes include datacontenttype, dataschema, and subject. A JSON-format event that omits datacontenttype is defined as equivalent to one that sets it to application/json. dataschema is the place to point at a schema for the data. Use it if a schema exists. Do not hide the schema location in a comment on a sample payload.

The data itself sits beside the context. Keep the context stable enough that a receiver can route and deduplicate without understanding every business field. The illustration is not a captured delivery.

{
  "specversion": "1.0",
  "id": "evt_01",
  "source": "https://api.client.test/catalog",
  "type": "dev.client.test.item.updated",
  "datacontenttype": "application/json",
  "data": {
    "itemId": "item_17",
    "revision": 4
  }
}

Illustration, not a captured message. Host and identifiers are reserved examples.

Version signals on request and response contracts should say whether this type string is covered by the same compatibility rule as the synchronous API. A new field inside data is the same kind of change as a new response field. Field changes inside an event body follow the same compatibility rule once you decide whether receivers ignore unknown names.

Sign the metadata with the body

The Standard Webhooks specification treats authenticity as a signature over more than the body. It defines three headers: webhook-id, webhook-timestamp, and webhook-signature. The id stays the same across retries of one event. The timestamp is the time of the attempt, and it can move when a failed delivery is retried, while the event id does not. The signature header can carry more than one signature, separated by spaces, so a secret can be rotated without a gap where every delivery fails.

For the symmetric scheme, the specification identifies the signature as HMAC-SHA256, labeled v1. The signed content is the id, the timestamp, and the payload, joined by full stops. An asymmetric scheme, labeled v1a, is also specified. This note does not reproduce a verifier. The properties a receiver needs are the ones the specification states: check that the timestamp is inside a tolerance you have chosen, so a captured request cannot be replayed forever, and treat the id as an idempotency key. The specification’s own example of storing ids is an example, not a retention period every receiver must copy.

Verify the raw body bytes that were signed. A parse-and-serialize step that changes key order or spacing will fail a correct signature. That failure should be a rejection, not a best-effort accept.

Retries are a second contract

RFC 9110 section 9.2.2 defines an idempotent method as one whose intended effect on the server, when repeated with an identical request, is the same as the effect of a single request. It lists PUT, DELETE, and the safe methods as idempotent. POST is not in that list. Webhook delivery is almost always POST. The protocol will not collapse two deliveries into one effect. The event id will, if the receiver stores it.

The Standard Webhooks specification says a delivery is successful when the response status is in the 200–299 range, and is a failure otherwise. It recommends that producers retry with exponential backoff and jitter across multiple days, and it publishes one example schedule that runs from an immediate attempt out past a day. Copying that table is optional. Operating without any retry rule is how events disappear. The specification also says a 410 Gone means the endpoint should be disabled, and 429, 502, and 504 are reasons to slow down. A Retry-After header on a response such as 503 should be honored when the next attempt is chosen.

Redirects are classified as failures in that specification, with the advice to update the subscribed URL instead of chasing them. A receiver that answers 302 and expects the producer to follow it is not implementing this delivery rule.

The specification recommends a request timeout somewhere between 15 and 30 seconds so the consumer can acknowledge work. A receiver that does the full downstream job before it returns 2xx will time out, and the producer will retry work that may already have started. Acknowledge after the id is stored, and do the slow work after the response, unless you have a different written rule. That last sentence is editorial. The timeout range is the specification’s recommendation.

How a receiver reports a rejected payload matters here. A 4xx that means “this body will never be acceptable” should stop retries only if the producer agrees. Under the Standard Webhooks rules, anything outside 2xx is a failure and is eligible for retry, except where a specific code such as 410 tells the sender to disable the endpoint. Do not invent a private meaning for 400 and assume every sender shares it.

A receiver checklist

  1. Read id and source, or webhook-id, and persist a duplicate check before side effects.
  2. Verify the signature over the raw body and the signed metadata. Reject failures.
  3. Apply a documented timestamp tolerance. State the window in your own runbook. Do not leave it as “some tolerance.”
  4. Return 2xx only after the event is safe to retry, which means a second delivery will not repeat the effect.
  5. Treat a second delivery of the same id as success if the first one was stored, so the sender can stop.
  6. Validate the data against the schema you subscribed to. Checking an inbound event against its schema says what that check does and does not prove.
  7. Version the type or the schema when the data changes incompatibly, using the same compatibility rule as the rest of the API.
  8. Subscribe only to event types the endpoint handles. The Standard Webhooks specification recommends filtering on the producer side so consumers are not sent events they will ignore.

What a contract test should pin

A sample payload in a wiki page rots. A contract test for a message the consumer actually reads can record the fields the consumer depends on and leave the rest free to change. That is a better match for an event than a test that asserts the entire JSON tree, because the entire tree will gain fields the consumer does not read. Pair the test with the duplicate rule above. A schema can show the shape. It cannot show that the second POST was ignored.

Tags: eventscompatibility

Sources

  1. CloudEvents 1.0 specification — CloudEvents
  2. Standard Webhooks specification — Standard Webhooks
  3. RFC 9110, HTTP Semantics, section 9.2.2 (idempotent methods) — IETF