Skip to content
jsonforge.app
Back to blog
Best practices8 min read

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.

A minimal event envelope: routing, ordering, and version metadata live outside the payload.
json
{
  "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.

A v2 event: money moved from a float to minor units in an object — a breaking change shipped under a new type.
json
{
  "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.

Signed delivery headers: an HMAC-SHA256 computed over the raw request body, plus a timestamp.
json
{
  "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