The Push Problem: Why Webhook Versioning Is Harder
With a REST API, consumers pull data at their own pace. If you release a v2 endpoint, consumers migrate when they're ready. The v1 endpoint keeps working until you sunset it. The consumer controls the timeline.
Webhooks flip this relationship. You push data to consumer endpoints, and every consumer
receives whatever you send. There's no "I'll upgrade next quarter." If you rename a field
from user_id to account_id on a Tuesday afternoon, every consumer
that parses user_id breaks on that Tuesday afternoon. No grace period. No
gradual migration. Just broken integrations and angry support tickets.
I've seen teams underestimate this difference repeatedly. They treat webhook payloads like internal data structures, reshaping them whenever the database schema changes. The result is always the same: a flood of failed deliveries and frantic Slack messages from partners. The strategies below prevent that.
Strategy 1: Additive-Only Changes
The simplest versioning strategy is to never version at all. Instead, make a rule: you can add fields, but you never remove, rename, or change the type of existing ones. A consumer that ignores unknown fields (which any well-written JSON parser does) will keep working indefinitely.
Suppose your order.completed event currently looks like this:
{
"event": "order.completed",
"order_id": "ord_8xk2m",
"amount": 4999,
"currency": "usd",
"timestamp": "2026-01-15T10:30:00Z"
}
You want to add shipping information. The additive approach simply appends new fields:
{
"event": "order.completed",
"order_id": "ord_8xk2m",
"amount": 4999,
"currency": "usd",
"timestamp": "2026-01-15T10:30:00Z",
"shipping_address": {
"city": "Portland",
"country": "US"
},
"tracking_number": "1Z999AA10123456784"
}
Existing consumers ignore shipping_address and tracking_number.
New consumers can use them. No one breaks.
This works well for 80% of changes. But it falls apart when you need to fundamentally restructure a payload, change a field's type (say, from a string ID to a nested object), or remove fields that expose sensitive data. When additive changes aren't enough, you need explicit versioning.
Strategy 2: Envelope Versioning
Envelope versioning wraps every webhook payload in a standard outer structure that includes a version number. The envelope stays stable; the inner payload varies by version.
// Versioned webhook envelope
interface WebhookEnvelope {
id: string;
version: "1.0" | "2.0";
type: string;
created_at: string;
data: Record<string, unknown>;
}
// Dispatch based on version
function handleWebhook(envelope: WebhookEnvelope) {
switch (envelope.version) {
case "1.0":
return handleV1(envelope.type, envelope.data);
case "2.0":
return handleV2(envelope.type, envelope.data);
default:
console.warn(`Unknown version: ${envelope.version}`);
return { status: 400 };
}
}
The envelope gives consumers a reliable way to detect what shape the payload will be in before they try to parse it. I've found this pattern works well for platforms that send dozens of event types. You version the entire payload contract, not individual events.
One downside: you need to maintain transformation logic for every supported version. If your internal model evolves, you're writing mappers from the current model to v1 and v2 output formats. Over time, this becomes a maintenance burden if you support too many versions simultaneously. In practice, supporting 2 active versions is manageable. Three starts to hurt. Four is a mistake.
Strategy 3: URL-Based Versioning
URL-based versioning puts the version directly in the webhook registration URL. A consumer
subscribing to /webhooks/v1/events gets v1 payloads. A consumer on
/webhooks/v2/events gets v2 payloads. The version is explicit and visible in
every configuration screen and log entry.
import { Router, Request, Response } from "express";
const router = Router();
// v1 handler: original payload shape
router.post("/webhooks/v1/events", (req: Request, res: Response) => {
const event = req.body;
// v1 payloads use flat structure
processV1Event({
user_id: event.user_id,
action: event.action,
amount: event.amount,
});
res.sendStatus(200);
});
// v2 handler: restructured payload
router.post("/webhooks/v2/events", (req: Request, res: Response) => {
const event = req.body;
// v2 payloads nest user data under objects
processV2Event({
user: { id: event.user.id, email: event.user.email },
action: event.action,
amount: { value: event.amount.value, currency: event.amount.currency },
});
res.sendStatus(200);
});
The major advantage is clarity. When debugging a failed delivery, you can see the version in the URL immediately. There's no ambiguity about which payload format the consumer expects.
The drawback is that migrating consumers requires them to change their registered URL. For some platforms this means updating a config file and redeploying. For others, it means logging into a dashboard, deleting the old subscription, and creating a new one. That friction slows adoption, which is sometimes what you want (it prevents accidental upgrades) but often just means consumers stay on v1 forever.
Strategy 4: Header-Based Versioning
Header-based versioning sends a version indicator in the HTTP headers of each webhook delivery. The consumer reads the header and routes to the correct parser. This keeps URLs clean and lets you change versions without changing the subscription configuration.
On the provider side, you attach the version header when dispatching:
async function dispatchWebhook(
url: string,
payload: unknown,
version: string
): Promise<Response> {
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Version": version,
"X-Webhook-Id": crypto.randomUUID(),
},
body: JSON.stringify(payload),
});
}
// Consumer-side version negotiation
function handleIncomingWebhook(req: Request): void {
const version = req.headers["x-webhook-version"] || "1.0";
const parser = parsers.get(version);
if (!parser) {
console.error(`No parser for webhook version ${version}`);
return;
}
const event = parser.parse(req.body);
processEvent(event);
}
Header versioning pairs well with version negotiation during subscription setup. When a
consumer registers for webhooks, they can specify
"preferred_version": "2.0" in their subscription request. You record that
preference and use it for every delivery. This shifts the upgrade decision to the consumer
without requiring them to change URLs.
The risk with headers is that they're invisible in many debugging tools. If a consumer reports broken webhooks, they often paste the payload body but forget the headers. URL-based versions are self-documenting in a way that headers are not.
Consumer-Driven Contracts
The most flexible approach is to let consumers define exactly what they want. Instead of "you get v1 or v2," consumers specify which events they care about, which fields they need, and which version of the schema to use. This is consumer-driven contract testing applied to webhooks.
A subscription configuration might look like this:
// Consumer subscription with contract
const subscription = {
url: "https://api.consumer.com/webhooks",
version: "2.0",
events: ["order.completed", "order.refunded"],
fields: ["order_id", "amount", "currency", "customer.email"],
};
// Contract test: run in CI to catch breaking changes
function testWebhookContract(
subscription: Subscription,
samplePayload: Record<string, unknown>
): boolean {
const delivered = buildPayload(subscription, samplePayload);
// Verify all requested fields are present
for (const field of subscription.fields) {
const value = getNestedField(delivered, field);
if (value === undefined) {
console.error(`Contract violation: missing field "${field}"`);
return false;
}
}
return true;
}
The contract test runs in your CI pipeline. Before any release, it checks every active consumer contract against the current payload builder. If a code change removes a field that any consumer depends on, the build fails. You catch the break before it reaches production, not after.
This pattern requires more infrastructure than simpler approaches. You need a subscription store, a contract test runner, and a payload builder that can filter fields. For platforms with fewer than 50 webhook consumers, this is usually overkill. For platforms with thousands of integrations, it's the only way to ship confidently.
The Deprecation Timeline
No version lives forever. At some point you need to retire old versions, and how you communicate that matters as much as the technical implementation. I've found that a 6-month deprecation window works well for most B2B integrations. Consumer engineering teams need time to plan, implement, test, and deploy, and they have their own roadmaps.
Start by adding sunset headers to every webhook delivery on the deprecated version. The
Sunset header is an HTTP standard (RFC 8594) that tells consumers exactly
when a resource will stop being available.
function addDeprecationHeaders(
headers: Record<string, string>,
version: string
): Record<string, string> {
const deprecationSchedule: Record<string, string> = {
"1.0": "2026-07-01T00:00:00Z",
"1.5": "2026-10-01T00:00:00Z",
};
const sunsetDate = deprecationSchedule[version];
if (sunsetDate) {
headers["Sunset"] = sunsetDate;
headers["Deprecation"] = "true";
headers["Link"] =
'<https://docs.example.com/webhooks/migration>; rel="successor-version"';
}
return headers;
}
Beyond headers, send email notifications at 6 months, 3 months, 1 month, and 1 week before
sunset. Include the exact date, a link to the migration guide, and a list of which
subscriptions are affected. Make it painfully specific: "Your subscription
sub_3kx9m at https://api.acme.com/hooks uses version 1.0,
which will stop receiving events on July 1, 2026."
The Migration Playbook
When you need to roll out a breaking change, follow a 4-phase process. Skipping phases is what causes outages.
Phase 1: Announce (Day 0)
Publish the new version's schema in your documentation. Send the first deprecation notice to all consumers on the old version. Open the new version for new subscriptions but don't force anyone to switch yet.
Phase 2: Dual-Write (Day 1 to Month 5)
For every event, generate both v1 and v2 payloads. Deliver the version each consumer has selected. This is the expensive phase because you're maintaining two output formats simultaneously. Monitor delivery success rates for both versions. If v2 deliveries have higher failure rates than v1, something is wrong with your new schema or documentation.
Phase 3: Monitor (Month 5 to Month 6)
Track how many consumers are still on v1. Reach out directly to any large consumers who haven't migrated. Offer migration support. This is where most of your effort goes: not in the technical implementation, but in getting people to actually move.
Phase 4: Sunset (Month 6+)
Stop delivering v1 webhooks. For any remaining v1 subscribers, either auto-upgrade them to v2 (if the mapping is safe) or pause their subscriptions and notify them. Do not silently drop events. That's worse than sending them in the wrong format.
A common mistake is trying to compress this timeline. I've seen teams announce a breaking change with 2 weeks notice. The result was predictable: half the consumers didn't migrate, their integrations broke, and the team had to roll back the change and start the deprecation cycle over. The 6 months feels slow, but it's far faster than a failed migration followed by a do-over.
Picking the Right Strategy
For most teams, start with additive-only changes and enforce them through code review. You'll get surprisingly far before you need explicit versioning. When additive changes aren't sufficient, envelope versioning with a version field in the payload is the most practical choice. It works with any transport, is easy to debug, and doesn't require consumers to change their URLs.
URL-based versioning is better when you have a self-service platform where consumers manage their own subscriptions through a dashboard. Header-based versioning is better when you need version negotiation and want to keep URLs stable. Consumer-driven contracts are for platforms at scale where the cost of breaking any single consumer is high enough to justify the engineering investment.
Whatever strategy you pick, the deprecation timeline matters more than the versioning mechanism. A perfect versioning scheme with a 1-week sunset window will still break consumers. A simple version field with a 6-month migration path will not.