Payload Design: Versioning Webhooks Without Breaking Consumers
A practitioner’s guide to webhook versioning: what really breaks consumers, subscription-pinned versions, delivery-time transforms, brownouts and payload
A client platform we maintain sent out a change that nobody flagged in review: a new value, partiallyshipped, added to the fulfilmentstatus enum on their order.updated event. Purely additive. No fields removed, no renames, nothing in the “breaking” column of the checklist.
Within four hours, three integrations were returning 500s on every delivery for that event type. Two of them were Zapier-style no-code flows that silently dropped the event. One was a warehouse system whose parser had a switch with no default branch, so the worker threw, the queue backed up, and nobody noticed until orders stopped printing in the morning.
That is the actual problem with webhook versioning. It is not the obvious rename of userid to customerid. It is that you do not control the parser on the other end, you cannot see it, and your definition of “backwards compatible” is almost always looser than your consumers’ definition. Good payload design starts from that assumption and builds a release process around it.
- Adding an enum value, tightening nullability, or changing timestamp precision will break real consumers even though all three are “additive”. Treat enums as open sets from day one and document how unknown values must be handled.
- Pin the version to the subscription, not to the request. Webhooks have no caller to negotiate content with, so the version has to be chosen when the endpoint is registered and stored alongside it.
- Store events once in a canonical internal shape and transform to the consumer’s pinned version at delivery time. Storing rendered payloads means a four-year-old bug fix is unshippable.
- Use date-based versions (
2026-01-15) rather thanv1,v2. They sort, they communicate age, and they discourage the fantasy that you will only ever have three versions. - Deprecation needs per-consumer, per-version delivery telemetry plus a scheduled brownout. Email announcements alone move almost nobody off an old version.
What actually counts as a breaking change
Write this list down and put it in your PR template. On our projects it has caught more incidents than any amount of integration testing, because it changes what reviewers look for.
Unambiguously breaking: removing a field, renaming a field, changing a type ("42" to 42), moving a field deeper into a nested object, changing the semantics of an existing field while keeping its name.
Breaking in practice, even though it looks safe:
- New enum values. Any consumer with an exhaustive match, a strict JSON Schema with
enum, or a database column with a CHECK constraint will fail. - Making a nullable field non-nullable, or the reverse. Statically typed consumers (Go structs, Kotlin data classes, Pydantic models) break on both directions.
- Adding a field at all, if your schema is published with
additionalProperties: falseor consumers generated strict types from it. Rarer, but we have hit it twice. - Timestamp format drift. Going from
2026-01-15T09:30:00Zto2026-01-15T09:30:00.142Zbreaks naive parsers and anything comparing strings. - ID length or charset changes. If your IDs were 24-character hex and you ship ULIDs, every
VARCHAR(24)on the receiving end rejects the row. - Numeric precision. Switching money from
19.99to minor units1999is obviously breaking. Switching from19.99to19.990is not obviously breaking, and still is. - Array ordering. If you never documented an order, someone depends on the one you accidentally shipped.
The honest conclusion is that almost any change to an event body is breaking for somebody. That is why you need versioning rather than discipline alone.

Design the envelope before you design the event
Every event you will ever send shares a wrapper. Get it right once and the per-event payload design gets much easier: the wrapper carries all the operational metadata, and the data object stays purely about the domain.
{
"id": "evt_01JQ8X4ZB9K2M7N3P5R8T1V6W", // unique per event, stable across retries
"type": "order.updated",
"api_version": "2026-01-15", // the version THIS body was rendered in
"created": "2026-01-15T09:30:00Z", // when the event occurred, not when it was sent
"livemode": true,
"attempt": 3, // delivery attempt, 1-indexed
"data": {
"object": {
"id": "or_8812",
"object": "order",
"fulfilmentstatus": "partiallyshipped",
"amount_details": { "value": 1999, "currency": "GBP" }
},
"previous_attributes": { // only on .updated events
"fulfilment_status": "processing"
}
}
}
Four decisions in there are worth defending. The apiversion lives in the body as well as in a header, because consumers log bodies and almost never log headers, and when you are debugging a two-year-old integration you want the version in the log line. The id is the idempotency key, which is the other half of this problem and worth reading up on separately: see our piece on handling duplicate deliveries properly. Money is an object with a value and a currency rather than two sibling scalars, so a future taxbehaviour or exchangerate field has somewhere to live without a new top-level key. And previousattributes exists so consumers can react to transitions without keeping their own copy of your state machine.
Keep the signature and timestamp headers out of the versioning scheme entirely. Your payload version and your signing scheme have different lifecycles, and coupling them means a crypto upgrade forces a payload migration. If you have not locked down the transport side, our write-up on signature verification and replay attacks covers the headers you actually need.
Three models for webhook versioning, and the one to pick
Model 1: version in the event type
You send order.updated.v2 as a new event type alongside the old one. Consumers subscribe to whichever they understand.
This works for exactly one scenario: you have two or three event types, a handful of known consumers, and you expect to change the shape roughly never. It falls apart the moment you have 40 event types, because a single envelope-level change means duplicating all 40, and your event catalogue becomes a graveyard of .v2, .v3, .v2_fixed. We have inherited one of these. Migrating out took a quarter.
Model 2: a version header chosen by the sender per delivery
This is HTTP-style API versioning transplanted into a context where it does not fit. With a REST API the caller picks the version on each request. With webhooks there is no caller: you are the one making the request, so “negotiation” means you guessing, and the only sane guess is the latest version, which breaks everyone. Skip it.
Model 3: version pinned to the subscription
When a consumer registers an endpoint, they pin a version. That version is stored on the subscription row and never changes unless they change it. Every delivery to that endpoint is rendered in that version, forever, until you deprecate it.
This is the right default, and it is what the platforms that do this well converged on. Stripe pins an API version per account and stamps every event with it. Shopify pins a version per webhook subscription using date-based releases on a quarterly cadence with roughly a year of support. Both arrived there after trying the alternatives.
Use dates, not integers. 2026-01-15 tells you how old an integration is at a glance, sorts correctly in SQL, and nudges you towards small frequent releases rather than a heroic v2 that accumulates twelve breaking changes and never ships.
Thin payloads sidestep half the problem
There is a version of this where the event body is barely a payload at all:
{
"id": "evt_01JQ8X4ZB9K2M7N3P5R8T1V6W",
"type": "order.updated",
"created": "2026-01-15T09:30:00Z",
"data": { "objectid": "or8812", "object_type": "order" }
}
The consumer receives the notification and calls GET /v1/orders/or_8812, where your existing API versioning already handles shape differences. One versioning system instead of two. AWS EventBridge and plenty of internal event buses work this way, and for a system where every consumer is already an authenticated API client it is the cheaper design by a wide margin.
The trade-offs are real though. You add a round trip and a rate-limit surface per event, so a burst of 50,000 order updates becomes 50,000 inbound API calls at the worst possible moment. You also introduce a read-after-event race: by the time the consumer fetches, the order may have moved two states forward, so “notify me when status became shipped” is no longer reliably observable. And thin payloads are useless for consumers who cannot call your API at all, which includes most no-code destinations and most static-site setups, where the endpoint is a relay like WebForms pushing into Slack or a spreadsheet rather than a service with credentials.
Our rule: fat payloads for public platform webhooks, thin payloads for internal service-to-service events. If you need both, send fat payloads and let consumers opt into a lean variant per subscription, not the reverse.
Transform at delivery, never at write time
The single most expensive mistake in this area is rendering the payload when the event is created and storing that JSON blob. It feels efficient. It means that when you find a bug in your 2025-07-01 serializer, every queued and retried event carries the bug, you cannot re-render anything, and your event store becomes a pile of mutually incompatible documents with no canonical truth.
Store the event once in your newest internal shape. Render on the way out, against the subscription’s pinned version. Each version only needs to know how to step down to the previous one, and you chain the steps.
// serializer.js
// Ordered newest -> oldest. Each entry downgrades to the next one down.
const VERSIONS = ['2026-01-15', '2025-10-01', '2025-07-01'];
const DOWNGRADES = {
'2026-01-15': (d) => {
const out = structuredClone(d);
// 2025-10-01 had flat money fields instead of an amount_details object
out.amount = out.amount_details.value;
out.currency = out.amount_details.currency;
delete out.amount_details;
// partially_shipped did not exist before 2026-01-15; collapse to the
// nearest older value rather than leaking an unknown enum member
if (out.fulfilmentstatus === 'partiallyshipped') {
out.fulfilment_status = 'processing';
}
return out;
},
'2025-10-01': (d) => {
const out = structuredClone(d);
delete out.delivery_estimate; // introduced in 2025-10-01
return out;
},
};
export function render(event, target) {
const from = VERSIONS.indexOf(event.schema_version);
const to = VERSIONS.indexOf(target);
if (from === -1 || to === -1) throw new Error(Unknown version pair ${event.schema_version} -> ${target});
if (to < from) throw new Error('Cannot upgrade an event forward');
let data = event.data;
for (let i = from; i < to; i++) data = DOWNGRADES[VERSIONS[i]](data);
return {
id: event.id,
type: event.type,
api_version: target,
created: event.created,
data,
};
}
Two caveats. Downgrades are lossy by nature, and the partially_shipped collapse above is a judgement call you should document in the changelog, because a consumer on the old version will see an order sit in processing through a state you have no word for. And keep the chain short: once a version is fully retired, delete its downgrade function and drop it from the array rather than carrying nine hops forever. If you are doing the same thing inside WordPress, the mechanics of namespaced versions and transformation live in custom REST API endpoints: auth, caching and versioning.
Retiring a version without a support queue meltdown
Announcement emails do almost nothing. What works is telemetry plus a forced, pre-announced failure window.
Instrument every delivery with subscriptionid, apiversion, event_type and outcome. That gives you a table you can actually act on: which accounts are still on 2025-01-01, how many events a day each receives, and which are dormant and can be shut off without a conversation. On one platform we ran this query and found 60 percent of the “active” old-version subscriptions had not successfully received anything in 90 days.
Then run a schedule like this one, which we have used more or less unchanged on three projects:
- Day 0. New version ships. Changelog with a field-by-field diff, migration notes, and a sandbox toggle so consumers can test against their existing code before committing.
- Day 0 to 180. In-dashboard banner on the webhook settings page for pinned-to-old subscriptions. Not a blog post. The page they visit when something is broken.
- Day 180. First brownout: for 30 minutes, deliveries to old-version endpoints return your new shape, or simply fail with a
410and a descriptive body. Announce the exact window a fortnight ahead. - Day 210. Second brownout, two hours, during business hours in the consumer’s timezone if you have it.
- Day 240. Cutover. Old version renders are removed, remaining subscriptions are force-migrated to the oldest supported version.
The brownouts are the part people skip and the part that works. GitHub popularised the technique for API deprecations for a reason: a short, loud, scheduled failure gets an engineer assigned, whereas a banner gets screenshotted and ignored.
Golden fixtures beat schema docs
For every supported version, keep a committed fixture file per event type containing the exact bytes you promise to send. Generate them from the serializer in CI and fail the build on any diff that is not accompanied by a changelog entry. This is cheap, it catches the accidental key rename in a refactor, and it gives support a copy-pasteable example that is guaranteed current.
# CI: regenerate fixtures and fail if anything changed unexpectedly
npm run fixtures:generate
git diff --exit-code fixtures/ || {
echo "Payload shape changed. Add a changelog entry and a new version, or revert."
exit 1
}
Publish JSON Schema per version too, but publish it with additionalProperties: true and enums documented as open sets. If you hand consumers a strict schema, they will generate strict parsers from it, and you have manufactured the exact fragility you were trying to avoid. Tell them explicitly in the docs: unknown fields must be ignored, unknown enum values must fall through to a default branch, and any field documented as optional may be absent.
Frequently Asked Questions
Should webhook versions match my REST API versions?
Yes, if you can manage it. Sharing one version identifier means one changelog, one deprecation schedule and one mental model for consumers, which is why Stripe and Shopify both do it. The exception is when your webhook payloads are shaped quite differently from your API resources, in which case a separate schema version on the event is less confusing than pretending they move together.
How long should I support an old webhook version?
Twelve months of guaranteed support after a replacement ships is the current norm and a reasonable commitment for a paid platform. For internal services between teams you control, 30 to 60 days is plenty. The real constraint is not goodwill, it is how many downgrade transforms you are willing to maintain and test, so pick a number you can actually enforce.
Can I just add fields without bumping the version?
Usually yes, and you should design for it by documenting from day one that consumers must ignore unknown fields. The risk is consumers who generated strict types from your schema or who store payloads in a table with a fixed column set. Ship additive changes to a canary group first and watch the error rate on deliveries before rolling out widely.
What do I do about consumers who never migrate?
Force-migrate them to the oldest supported version after the brownout schedule completes, and accept that some integrations will break. Keeping a version alive indefinitely for a handful of unresponsive accounts taxes every future change for everyone else. Give them a clear log of the delivery failures afterwards so the fix is a 20-minute job when they finally look.
Is GraphQL or protobuf a better fit for event payloads?
Protobuf’s field-number rules do enforce real discipline, and if both ends are services you control it removes a whole class of mistakes. For public webhooks it is a poor trade: you are asking every consumer, including no-code tools and PHP shops, to adopt a toolchain just to read your events. JSON over HTTPS with a pinned version and open-set enums remains the pragmatic choice in 2026.
Where to start
If you are shipping webhooks today with no versioning at all, do two things this week. Add apiversion to your envelope with whatever date you are shipping, and add a nullable apiversion column to your subscriptions table defaulted to that same date. You have not changed any behaviour, and you have bought yourself the ability to make a breaking change later without a migration project.
If you already have versions, go look at whether you store rendered payloads or canonical events. That one answer determines whether your next three years of payload design are routine or painful.


