Back to Blog
Best practices

Webhook Versioning: How to Evolve Your Event API Without Breaking Consumers

Versioning webhooks is harder than versioning REST APIs because you push data to consumers. Strategies for additive changes, envelope versioning, and safe migrations.

WebhookVault Team··7 min read

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.