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

Designing JSON Webhook Payloads That Don't Break Consumers

You design a webhook once, and after that you can't change it safely, because each consumer implementation depends on the exact shape you shipped on day one. This field guide covers JSON webhook payloads that stay compatible as they grow: envelopes, versioning strategies, idempotency, delivery retries, signature headers, and 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, 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. First, make data self-contained, with no required fields that force the consumer to call back for the rest. Second, make every new envelope field optional, and tell consumers prominently in your docs to ignore anything they don't recognize. Your compatibility strategy depends on that leniency.

Versioning without breaking anyone

Additive changes are the safest: 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 two designs work.

The first pins a global version 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 moves money from a float to minor units in an object. That breaking change ships 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. Repurposing an existing field never does: turning a string into an array or redefining what amount means breaks consumers, even though the key name stayed the same.

Assume duplicates: at-least-once is the contract

Webhook delivery semantics are at-least-once. If the consumer times out, the provider retries, and the same event arrives twice, sometimes hours apart, sometimes out of order. Providers don't do this by mistake; no other semantics survive real networks.

On the consumer side, deduplicate 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, so acknowledge and return. Build idempotency in from the start; retrofitting it after a customer's billing handler ran twice makes for an incident report you don't want to write.

Retries, timeouts, and fast handlers

As a provider, publish a concrete retry schedule (exponential backoff over 24 hours, then give up) and let customers disable or pause endpoints that keep failing. As a consumer, keep the handler to one fast job: verify, persist, enqueue. A background job does the real work. A handler that charges a card inline and times out at second 29 invites both duplicate deliveries and an auto-disabled endpoint.

Respond 2xx promptly for anything you've durably accepted. Reserve non-2xx for "I could not 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 at minimum) using a per-endpoint secret, and put the signature and a timestamp in headers. The timestamp bounds replay: reject anything older than five minutes.

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..."
}

Verify with a constant-time comparison over the raw bytes as received. Re-serialized JSON won't match, because key order and whitespace can differ. The classic integration bug: parse the body, re-stringify it, and wonder why every signature check fails.

Validate on receipt, generate types

After signature verification, the consumer validates the schema, against the provider's published JSON Schema or with a runtime library like Zod or pydantic. Here payload problems stay cheap: a schema failure at the handler boundary becomes a logged, alertable event instead of a TypeError three layers deep in billing code.

Don't index into a webhook payload on assumption. Check that data and the fields you need exist and have the right types before use, and ignore extra fields. 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 versioned schema files in a repository, lets consumers generate TypeScript interfaces or pydantic models from the real contract instead of transcribing examples. CI can diff schemas on each release, so reviewers catch a breaking change before it reaches a customer's incident channel.

Wire the registry into both sides: the provider validates outgoing events in tests, and consumers 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, with an application/json body. GET responses get cached, GET has no request body, and some proxy along the delivery path will mangle or cache it. Every webhook framework and provider expects POST, and 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. At-least-once delivery guarantees duplicates, so any handler that writes data needs deduplication. Without it, retries and network blips turn into 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, timeouts trigger retries, and retries duplicate work, while one slow downstream can back up the whole delivery pipeline. Persist the raw event before the 2xx so you lose nothing if processing fails later.
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