Webhook versioning: the version your consumers never see
You version the event. They receive a type, a timestamp and a payload. Which changes break them, which ones are silent, and why the fix is rarely a v2.

You shipped order.created eight months ago. Three customers built against it, and one of
them has not touched their handler since. Now you need the payload to carry a currency. The question
everyone asks first is how to version the event. It is the wrong question, and answering it will not
tell you whether those three customers survive Tuesday.
The version is not in the envelope#
Here is what actually lands on a consumer's endpoint:
{
"type": "order.created",
"timestamp": "2026-07-17T12:00:00.000Z",
"data": { "id": "ord_1", "amount": 42.5 }
}A type, a time, a payload. The headers alongside it carry an id, a timestamp and a signature (the full envelope). Nowhere in there is a version number.
So whatever you record on your side, the consumer cannot branch on it. They parse the shape in front of them and hope it is the shape they wrote code for. Versioning is real, and it is a governance tool for you: a record of what changed, when, and by whom. It is not a contract term they can read at runtime.
Which means the useful question is not "how do I version this". It is which of my changes are visible to them, and which of those are silent.
What each change actually does to them#
model Order {
id String
amount Float
currency String?
}| What you change | What they see | What breaks |
|---|---|---|
| Add an optional field | One extra key | Nothing, unless they reject unknown keys |
| Add a required field | One extra key | Same as above. Required is your problem, not theirs |
Rename with @map | The old key vanishes, a new one appears | Their reads return undefined. Silent |
| Remove a field | The key vanishes | Same. Silent |
| Change a type | Same key, different JSON type | A parse error if you are lucky, a coercion if you are not |
Two rows carry the danger, and they are the two that produce no error on your
side at all. A consumer reading data.currency on a payload that no longer has
it gets undefined, passes it to something that accepts undefined, and
writes a row you will hear about in a month.
Adding is safe. Removing and renaming are quiet. That asymmetry is why the answer to most versioning questions turns out to be "add a field, and leave the old one alone".
What the wire name buys you#
@map decides the key in the payload, independently of the name in your code:
model Order {
reference String @map("order_reference")
}Your code reads order.reference, the payload carries order_reference. That
indirection is worth understanding in both directions. It lets you rename
things internally without touching a single consumer, which is the cheap
refactor. And it means changing the mapped name is a breaking change dressed as
a rename, which is the expensive one. The compiler will not stop you, because
from its point of view nothing happened.
Where versioning does earn its place#
Not at runtime, but in review. Schema changes are recorded as migrations in your repo, the way database migrations are (migrations and deploys):
repost schema migrate dev --name add_currencyThe migration is committed, so the contract change shows up in git diff next
to the clients it regenerates. A reviewer sees the change and its blast
radius on one screen, which is the moment a silent rename is still cheap to
catch.
Two guarantees are worth knowing because they change what you can do under pressure. The server refuses any deploy whose history does not descend from what is already deployed, so production history cannot be lost by accident. And removing event types is not something you do by omission: a migration that would drop them lists them and asks for an explicit confirmation, and errors in CI unless you pass a flag that says you meant it.
Retiring an event is a social problem#
The technical half is handled, and in a way most people assume it is not. Removing an event type from your schema archives it rather than deleting it. An archived type still accepts publishes and still delivers. A wrong deploy does not drop traffic that is in flight, and redeploying the previous head restores the registry.
That protects you from the accident. It does nothing about the customer whose integration quietly stops receiving the event they built a workflow on, because nothing in the delivery layer can tell them. The only mechanism that works there is the one that has always worked: tell them, keep sending for a stated period, and watch who is still consuming it before you actually stop.
What none of this fixes#
Their deploy schedule. You can version perfectly, generate clients in six languages, and publish docs that rebuild themselves on every deploy. The customer who has not touched their handler in eight months still has not touched it. A v2 event type is not a migration, it is a second thing to maintain until they move, and some of them never will.
Strict consumers. If someone validates your payload with a schema that rejects unknown keys, your safest change (adding an optional field) breaks them. There is nothing you can do about that from your side except know it exists, which is a good reason to say in your docs that you add fields without notice.
The rule that survives all of it: treat the payload as append-only, and treat removal as a conversation rather than a deploy. Everything else is bookkeeping, and bookkeeping is for you, not for them.