Designing JSON Webhook Payloads That Don't Break Consumers
A webhook is an API you design once and then can never safely change, because every consumer implementation depends on the exact shape you shipped on day one. This is a field guide to JSON webhook payloads that stay compatible as they grow: envelopes, versioning strategies, idempotency, delivery retries, signature headers, and the validation that catches problems while they're still cheap.
Start with a stable envelope
Resist the urge to POST the resource itself. Wrap it in an envelope whose fields never change — an event id, an event type, a timestamp, and the payload under data. Consumers route on type, deduplicate on id, and order on time, all without knowing anything about the payload's internals.
{
"id": "evt_7f3a2b",
"type": "invoice.paid",
"api_version": "2025-08-05",
"occurred_at": "2025-08-05T14:03:11Z",
"data": {
"invoice_id": "in_4419",
"customer_id": "cus_88",
"amount": 129.0,
"currency": "usd",
"paid_at": "2025-08-05T14:03:09Z"
}
}Two rules keep the envelope stable forever. First, data must be self-contained: no required fields that force the consumer to call back for the rest. Second, new envelope fields are always optional, and consumers are told — in your docs, prominently — to ignore anything they don't recognize. Leniency is the compatibility strategy.
Versioning without breaking anyone
The safest changes are additive: a new optional field never breaks a parser that ignores unknowns. Breaking changes — removing a field, changing a type, changing a field's meaning — need an explicit version boundary, and there are two workable designs.
The first is a global version pinned per consumer, as Stripe does: each consumer declares the API version it wants and the provider transforms events on delivery. The second embeds the version in the event type itself — invoice.paid.v2 — and emits both versions during a migration window so consumers move one event at a time.
{
"id": "evt_9a01c4",
"type": "invoice.paid.v2",
"api_version": "2025-08-05",
"occurred_at": "2025-08-05T15:40:00Z",
"data": {
"invoice_id": "in_4419",
"amount": { "value": 12900, "currency": "usd" }
}
}Either design works. What never works is silently repurposing an existing field — turning a string into an array, redefining what amount means — and calling it backward compatible because the key name didn't change.
Assume duplicates: at-least-once is the contract
Webhook delivery semantics are at-least-once. If the consumer times out, the provider retries; the same event arrives twice, occasionally hours apart, occasionally out of order. That isn't a provider bug — it's the only semantics that survive real networks.
The consumer-side fix is deduplication keyed on the envelope id: before doing anything with side effects, insert the event id into a processed-events table (or a Redis set with a TTL) in the same transaction as the effect. If the insert conflicts, you've seen the event — acknowledge and return. Idempotency isn't something you add later; retrofitting it after a customer's billing handler ran twice is a story you don't want to write up.
Retries, timeouts, and fast handlers
As a provider, publish a concrete retry schedule — exponential backoff over 24 hours, then give up — and let endpoints be disabled or paused when they keep failing. As a consumer, the handler should do exactly one thing fast: verify, persist, enqueue. The real work happens in a background job. A handler that charges a card inline and then times out at second 29 has invited both duplicate deliveries and an auto-disabled endpoint.
Respond 2xx promptly for anything you've durably accepted. Reserve non-2xx for "I could not even accept this" — validation failures and authentication problems — and use 410 Gone to signal a permanently dead endpoint. Retrying a validation error for 24 hours helps nobody.
Sign every delivery
Any endpoint that accepts unauthenticated POSTs from the internet will eventually receive a forged or replayed event. Sign the raw request body with an HMAC (SHA-256 is the baseline) using a per-endpoint secret, and put the signature and a timestamp in headers. The timestamp bounds replay: older than five minutes, reject.
{
"Content-Type": "application/json",
"X-Webhook-Id": "evt_7f3a2b",
"X-Webhook-Timestamp": "1754402591",
"X-Webhook-Signature": "sha256=5257a1ff9c3e4b8d0a6f2e1d..."
}Verification must be a constant-time comparison over the raw bytes as received — not over re-serialized JSON, because key order and whitespace may legitimately differ. The classic integration bug is parsing the body, re-stringifying it, and wondering why every signature check fails.
Validate on receipt, generate types
The consumer's first job after signature verification is schema validation — against the provider's published JSON Schema, or with a runtime library like Zod or pydantic. This is where payload problems become cheap: a schema failure at the handler boundary is a logged, alertable event instead of a TypeError three layers deep in billing code.
Never index into a webhook payload by assumption. Check that data and the fields you need are present and correctly typed before use, and treat extra fields as noise to be ignored. A consumer that throws on unknown fields will break on the provider's next harmless additive release.
Publish a schema registry
Treat your event schemas as part of the product. A registry — even just versioned schema files in a repository — lets consumers generate TypeScript interfaces or pydantic models from the real contract instead of transcribing examples, and lets CI diff schemas on every release so a breaking change surfaces in review rather than in a customer's incident channel.
Wire the registry into both sides: the provider validates outgoing events in tests, and consumers can point code generation at the schema files. The payoff compounds — when invoice.paid.v3 ships, generated types turn 'trust me, the shape changed' into a compile error.
FAQ
- Should webhooks use GET or POST?
- POST, always, with an application/json body. GET is cacheable, has no request body, and will be mangled or cached by some proxy along the delivery path. Every webhook framework and provider expects POST; deviating buys nothing and breaks tooling.
- How do I handle duplicate webhook deliveries?
- Persist the event id the first time you process it and check it before any side effect, ideally in the same database transaction as the effect itself. At-least-once delivery guarantees duplicates will happen, so deduplication is table stakes for any handler that isn't read-only. Without it, retries and network blips become double charges and duplicate records.
- Should my handler do the work inline or enqueue it?
- Enqueue. Validate, verify the signature, persist the raw event, respond 2xx in under a second, then process in the background. Inline work risks timeouts, which trigger retries, which duplicate work — and one slow downstream can back up the entire delivery pipeline. The raw event should be persisted before the 2xx so nothing is lost if processing later fails.
- How do I test webhook consumers locally?
- Expose a local endpoint through a tunnel, or capture a real signed payload once and replay it against a local server with clock and signature checks stubbed for the replay. Keep a fixture corpus of real payloads — especially edge cases like missing optional fields and unexpected types — and run it against your handler in CI.
Try these tools
Related articles
What is JSON? A Complete Beginner's Guide →
JSON (JavaScript Object Notation) is the lightweight data-interchange format behind nearly every API, config file, and NoSQL database. Learn the syntax, the six data types, how parsing behaves in code, and the mistakes everyone makes hand-writing it.
The History of JSON: From 2000 to Industry Standard →
Trace JSON from Douglas Crockford's idea in 2001, through Yahoo's adoption, to becoming the ECMA-404 standard that powers 90% of modern APIs.
The Complete JSON Schema Guide (Draft 7) →
JSON Schema is the standard for describing and validating JSON structure. Learn the core keywords, build a real API schema, compose and reuse schemas with $ref, and run validation in code with Ajv.