InstaWebhook
September 6, 2026By InstaWebhook TeamWebhook Security

Webhook Versioning: How to Stop Schema Changes From Breaking Your Integrations

Webhook Versioning: How to Stop Schema Changes From Breaking Your Integrations Webhooks look simple on the surface — a provider POSTs some JSON to your URL when something happens.

Webhook Versioning How To Stop Schema Changes From Breaking Your Integrations

Webhook Versioning: How to Stop Schema Changes From Breaking Your Integrations

Webhooks look simple on the surface — a provider POSTs some JSON to your URL when something happens. But unlike a REST API call, where your client asks for data and can retry or renegotiate the format on the spot, a webhook is a notification pushed to you on the provider's schedule. You don't get to ask questions before it arrives, and you rarely get advance warning before the shape of that JSON changes.

When a provider renames a field, nests a previously flat property, or swaps a string for an integer, a naive webhook consumer can throw uncaught exceptions, silently drop events, or corrupt downstream state without anyone noticing until a customer complains. This guide covers how real API providers version their webhooks, what actually counts as a breaking change, how to write a parser that survives schema drift, and how to build a replay pipeline so you can recover the data you lost while your parser was broken.

1. Why Webhook Versioning Is Harder Than REST Versioning

With a REST API, the client controls the request and can pin a version per call. With webhooks, the provider controls delivery, and thousands of independent third-party endpoints are all listening to the same event stream. A provider can't force every consumer to redeploy on the same day, so it needs a way to change its data model without silently breaking everyone who hasn't upgraded yet. In practice, providers have converged on a handful of strategies to solve this.

2. Four Webhook Versioning Strategies, With Real Examples

Strategy A — Account/Endpoint-Pinned Versioning (Stripe's model)

Stripe pins each webhook endpoint to a specific dated API version at the time it's created (for example, an endpoint created today might be pinned to a version like 2026-08-26). When an event fires, Stripe's delivery engine runs it through a version transformer so the JSON matches whatever version that specific endpoint — or the account's default, if the endpoint doesn't override it — was pinned to. Stripe lets you set the endpoint's own API version at creation time, so events sent to it use that version instead of the account's default, and if you don't set one explicitly, deliveries fall back to the account's default API version.

Stripe also draws a distinction between two kinds of releases: monthly releases that only ever contain backward-compatible changes, and twice-yearly named releases (like "Acacia" or "Clover") that can include breaking changes and require code updates. To move an account to a new named release safely, Stripe's own migration guide recommends standing up a second webhook endpoint on the new version, running both endpoints in parallel so every event is delivered twice, validating the new code path in production, and only then decommissioning the old endpoint — with a 72-hour rollback window built in.

Takeaway: this model gives consumers total control over when they upgrade, at the cost of the provider having to maintain transformer logic across many historical versions indefinitely.

Strategy B — Delivery Header Versioning (Shopify's model)

Shopify versions its entire Admin API — REST, GraphQL, and webhooks — on a quarterly, date-based release train (2025-01, 2025-04, 2025-07, and so on), with three channels: a stable channel recommended for production that's guaranteed not to change for its supported lifetime, a release-candidate channel published alongside the current stable release that may still include breaking changes, and an unstable channel for early testing only.

Every webhook delivery carries this in a header: Shopify includes an X-Shopify-Api-Version header on every webhook so you can tell which API version generated that specific payload — if it doesn't match the version you selected, your chosen version is no longer supported and Shopify has fallen back to a different one. Consumers are expected to branch their handler logic on that header. For delivery via Google Cloud Pub/Sub or Amazon EventBridge, the version is embedded in the message payload instead of an HTTP header. Each version is supported for roughly a year before deprecation, giving app developers a real migration window.

Takeaway: the header (or payload field, for non-HTTP transports) tells your router which parser to invoke — but you still have to read it before you touch the body.

Strategy C — Envelope Versioning (the CloudEvents pattern)

CloudEvents is a CNCF specification for describing event data consistently across HTTP, Kafka, AMQP, and other transports, and it directly inspired the "envelope" pattern many companies use for internal event buses. Every CloudEvent carries a required specversion attribute identifying which version of the CloudEvents spec the event conforms to, and compliant producers must set it to "1.0". The specification itself has had patch-level clarifications since — v1.0.2 shipped in February 2022 with minor clarifications and stayed backward-compatible with the original v1.0 spec — but the specversion string consumers check hasn't changed, which is exactly the point: it versions the envelope contract, not your business payload.

For your own data's schema, CloudEvents leaves room for an optional dataschema field — a URI you can bump whenever your payload structure changes, independent of the envelope version. This is the cleanest way to combine "the transport contract is stable" with "the business object versioned separately."

Strategy D — Additive-Only Evolution (GitHub's model)

GitHub doesn't publish schema version numbers for webhook payloads at all. Instead, every delivery carries an X-GitHub-Event header naming which of GitHub's event types triggered it, plus a unique X-GitHub-Delivery identifier and an HMAC signature header for verification. That header tells you what happened, not what shape the JSON is in — GitHub's implicit contract is that it will keep adding fields to existing payloads but won't rip out or restructure the ones you already depend on. With more than 70 distinct event types across the platform, this additive-only discipline is what makes it feasible for so many independent integrations to keep working without per-consumer version negotiation.

Takeaway: additive-only evolution has near-zero overhead for the provider and near-zero coordination cost for consumers, but it only works if the provider has the discipline to genuinely never remove or restructure a field — one broken promise and every downstream parser is at risk.

The security layer most versioning schemes forget: Standard Webhooks

Versioning gets the payload shape right, but almost every real provider also has to solve signing and replay protection, and until recently every one of them invented their own header names for it. The Standard Webhooks specification — co-authored by Svix, a webhooks-infrastructure vendor, and adopted by a growing list of API providers — standardizes this: a Webhook-Id header uniquely identifies a message and stays the same across retries, a Webhook-Timestamp header carries the send time in seconds since epoch, and a Webhook-Signature header carries one or more space-delimited, Base64-encoded HMAC signatures. The signature is computed over the concatenation of the delivery ID, the timestamp, and the raw payload, joined by periods, and multiple version-prefixed signatures can be present at once so secrets can be rotated without downtime. On the replay-protection window, the specification recommends rejecting any webhook whose timestamp is more than 300 seconds removed from server time — Stripe's own libraries apply that same 300-second default. Several providers, including Stripe, layer their own vendor-specific header name on top of this same underlying scheme so older integrations keep working.

3. Categorizing Breaking vs. Non-Breaking Webhook Payload Changes

Change typeNon-breaking (safe)Breaking (unsafe)
Field additionNew optional key, top-level or nested, with a sane defaultA new required key that consumers must read or acknowledge
Field removalDeprecate a key but keep populating it, even with a placeholderHard-deleting an existing key
Field renamingAdd the new key alongside the old one during a transition windowRenaming in place (user_idaccount_id) with no alias
Data typesWidening numeric precision in languages that handle large numbers nativelyChanging a type — string "123" to integer 123, or scalar to array
Structural nestingWrapping new metadata in a sub-objectMoving an existing flat key into a child object (emailcustomer.email)
EnumsAdding a new enum value, provided consumers have a fallback pathRemoving a value, or silently changing its casing/format
TimestampsAdding an epoch field alongside an existing ISO 8601 stringSwapping the format outright (ISO string → Unix integer)

This is the same taxonomy every mature API provider ends up publishing in some form in their changelog — the categories above map closely to how Stripe and Shopify describe their own "backward-compatible vs. breaking" release criteria.

4. Building a Backward-Compatible Webhook Parser

Following Martin Fowler's Tolerant Reader pattern, a resilient consumer extracts only the fields it needs, ignores keys it doesn't recognize, and degrades gracefully instead of throwing when the shape shifts slightly.

Principle 1 — permissive validation. Configure your schema validator (Zod, Pydantic, JSON Schema) to pass through unknown fields instead of rejecting the payload outright.

Principle 2 — safe coercion, never assume presence. Don't assume an optional field exists, or that a nested object is populated, before you read it.

Code example
import { z } from 'zod';

// ---------------------------------------------------------------------------
// 1. Versioned, tolerant schemas
// ---------------------------------------------------------------------------

// Legacy payload shape (v1)
const LegacyUserPayloadSchema = z.object({
  user_id: z.string(),
  full_name: z.string(),
  user_email: z.string().email(),
  status: z.string().default('active'),
}).passthrough(); // never throw on unknown keys

// Current payload shape (v2)
const ModernUserPayloadSchema = z.object({
  id: z.string(),
  profile: z.object({
    name: z.string(),
    email: z.string().email(),
  }).passthrough(),
  account_status: z.enum(['active', 'suspended', 'pending']).catch('active'), // fallback for unknown enum values
}).passthrough();

// Unified shape the rest of your app works with
export interface NormalizedUserEvent {
  userId: string;
  name: string;
  email: string;
  status: string;
  rawVersion: string;
}

// ---------------------------------------------------------------------------
// 2. Parser with version discrimination and graceful fallback
// ---------------------------------------------------------------------------

export class ResilientWebhookParser {
  public parseUserEvent(headers: Record<string, string | undefined>, rawBody: string): NormalizedUserEvent {
    let jsonBody: unknown;
    try {
      jsonBody = JSON.parse(rawBody);
    } catch (err) {
      throw new Error(`Invalid JSON payload received: ${(err as Error).message}`);
    }

    // Detect version from a header first, then fall back to an envelope field
    const versionHeader = headers['x-webhook-version'] ?? headers['X-Webhook-Version'];
    const payloadVersion =
      typeof jsonBody === 'object' && jsonBody !== null && 'version' in jsonBody
        ? String((jsonBody as Record<string, unknown>).version)
        : 'v1';

    const effectiveVersion = versionHeader ?? payloadVersion;

    if (effectiveVersion === '2026-01-01' || effectiveVersion === 'v2') {
      return this.parseV2(jsonBody, effectiveVersion);
    }
    return this.parseV1(jsonBody, effectiveVersion);
  }

  private parseV1(json: unknown, version: string): NormalizedUserEvent {
    const result = LegacyUserPayloadSchema.safeParse(json);
    if (!result.success) {
      console.warn('V1 parsing failed, attempting defensive extraction:', result.error);
      return this.fallbackExtraction(json, version);
    }
    const data = result.data;
    return { userId: data.user_id, name: data.full_name, email: data.user_email, status: data.status, rawVersion: version };
  }

  private parseV2(json: unknown, version: string): NormalizedUserEvent {
    const result = ModernUserPayloadSchema.safeParse(json);
    if (!result.success) {
      console.warn('V2 parsing failed, falling back to V1 parser:', result.error);
      return this.parseV1(json, version); // attempt backward compatibility
    }
    const data = result.data;
    return { userId: data.id, name: data.profile.name, email: data.profile.email, status: data.account_status, rawVersion: version };
  }

  /** Last-resort defensive extraction when neither known schema matches */
  private fallbackExtraction(json: unknown, version: string): NormalizedUserEvent {
    if (typeof json !== 'object' || json === null) {
      throw new Error('Payload is not a valid object');
    }
    const obj = json as Record<string, any>;
    const userId = String(obj.id ?? obj.user_id ?? obj.uuid ?? '');
    const email = String(obj.email ?? obj.user_email ?? obj?.profile?.email ?? '');
    const name = String(obj.name ?? obj.full_name ?? obj?.profile?.name ?? 'Unknown User');
    const status = String(obj.status ?? obj.account_status ?? 'active');

    if (!userId || !email) {
      throw new Error('Critical failure: unable to extract required fields from unknown schema variant');
    }
    return { userId, name, email, status, rawVersion: `fallback(${version})` };
  }
}

5. Verifying Signatures the Standard Webhooks Way

Whatever versioning strategy a provider uses, you still need to confirm the payload actually came from them before you parse it. Here's a minimal verifier for the Standard Webhooks scheme described above (Node.js):

Code example
import crypto from 'crypto';

const TOLERANCE_SECONDS = 300; // matches the spec's default replay window

export function verifyStandardWebhook(
  rawBody: string,
  headers: { 'webhook-id': string; 'webhook-timestamp': string; 'webhook-signature': string },
  secret: string
): boolean {
  const { 'webhook-id': id, 'webhook-timestamp': timestamp, 'webhook-signature': signatureHeader } = headers;

  // Reject stale or future-dated deliveries to block replay attacks
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > TOLERANCE_SECONDS) return false;

  const signedContent = `${id}.${timestamp}.${rawBody}`;
  const expected = crypto.createHmac('sha256', secret).update(signedContent).digest('base64');

  // A delivery may include multiple space-delimited, version-prefixed signatures (for secret rotation)
  return signatureHeader
    .split(' ')
    .some((sig) => {
      const [, value] = sig.split(',');
      return value && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
    });
}

Verify the signature against the raw request bytes, before any JSON parsing — re-serializing the body will change its byte content and break the comparison.

6. The "Receive Fast, Process Safe" Ingestion Architecture

Most schema-change outages happen because the HTTP handler does everything synchronously: parse, validate, write to the database, call downstream services — all inside the request/response cycle. One malformed payload throws inside that handler, the provider sees a 5xx, and it retries with exponential backoff, hammering your endpoint with the same broken event over and over.

Decouple receiving from processing instead:

Code example
[ Incoming Webhook POST ]
          │
          ▼
┌───────────────────────────────────────────┐
│ 1. HTTP Ingest Endpoint                    │
│  - Verify signature against the raw body   │
│  - Extract metadata (headers, version)     │
│  - Write the RAW payload to an event log   │
│  - Return HTTP 200 immediately (<50ms)     │
└──────────────────┬──────────────────────────┘
                    ▼
┌───────────────────────────────────────────┐
│ 2. Durable event store                     │
│    (Postgres / Redis / SQS / Kafka)        │
│  event_id | raw_body | headers | status    │
└──────────────────┬──────────────────────────┘
                    ▼
┌───────────────────────────────────────────┐
│ 3. Async background worker                 │
│  - Pull unprocessed event                  │
│  - Run through the tolerant parser         │
│  - Execute business logic / DB writes      │
│  - Mark status = PROCESSED                 │
└───────┬─────────────────────────┬───────────┘
        │ on schema error         │
        ▼                         ▼
┌────────────────────┐  ┌───────────────────────────┐
│ 4. status = FAILED  │  │ 5. Alert + log the schema  │
│  Kept in DLQ store   │  │    diff (Sentry/Datadog)   │
└────────────────────┘  └───────────────────────────┘

The ingest layer's only job is to verify the signature against the raw bytes, persist the payload untouched, and acknowledge receipt — GitHub, Shopify, and Stripe all expect a 2xx response within seconds, and any status code outside that range is treated as a delivery failure that triggers a retry. Everything that can fail — parsing, business logic, downstream calls — happens later, in a worker you control, where a bug doesn't cost you the provider's retry budget.

7. Implementing Replay for Recovering From Breaking Changes

Even a tolerant parser will eventually meet a change it can't reconcile. Having the raw payload stored means you don't need the provider to resend anything once you've fixed the parser — most providers only guarantee retries for a limited window anyway before they give up.

Step 1 — Quarantine. When the worker hits a schema it can't parse, mark the record FAILED_SCHEMA, log the structural diff to your monitoring platform, and stop retrying it automatically so it doesn't clog the queue.

Step 2 — Patch the parser. Update your validation schema for the new field names, types, or nesting, and run it against the actual stored raw payloads as regression tests — not synthetic fixtures.

Step 3 — Replay idempotently. Re-run every quarantined event through the fixed parser and re-execute the business logic using upserts, so replaying the same event twice never double-applies a side effect.

Event log schema

Code example
CREATE TABLE incoming_webhook_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    provider VARCHAR(64) NOT NULL,               -- e.g. 'stripe', 'github', 'shopify'
    event_id VARCHAR(255) NOT NULL,              -- provider's own event ID, for dedup
    event_type VARCHAR(128) NOT NULL,            -- e.g. 'user.updated'
    api_version VARCHAR(64),                     -- version header or envelope field
    headers JSONB NOT NULL,
    raw_payload JSONB NOT NULL,                  -- full, untouched JSON body
    status VARCHAR(32) NOT NULL DEFAULT 'PENDING', -- PENDING, PROCESSED, FAILED_SCHEMA, ERROR
    error_log TEXT,
    processed_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT unique_provider_event UNIQUE (provider, event_id) -- idempotent inserts
);

CREATE INDEX idx_webhooks_replay
  ON incoming_webhook_events (provider, status)
  WHERE status = 'FAILED_SCHEMA';

Replay script (Node.js / PostgreSQL)

Code example
import { Pool } from 'pg';
import { ResilientWebhookParser } from './parser';

const db = new Pool({ connectionString: process.env.DATABASE_URL });
const parser = new ResilientWebhookParser();

export async function replayFailedWebhooks(providerName: string): Promise<void> {
  const client = await db.connect();
  try {
    const { rows } = await client.query(
      `SELECT id, event_id, headers, raw_payload
       FROM incoming_webhook_events
       WHERE provider = $1 AND status = 'FAILED_SCHEMA'
       ORDER BY created_at ASC
       FOR UPDATE SKIP LOCKED`,
      [providerName]
    );

    for (const event of rows) {
      try {
        await client.query('BEGIN');

        const normalized = parser.parseUserEvent(event.headers, JSON.stringify(event.raw_payload));
        await upsertUser(client, normalized); // idempotent by design

        await client.query(
          `UPDATE incoming_webhook_events
           SET status = 'PROCESSED', error_log = NULL, processed_at = NOW()
           WHERE id = $1`,
          [event.id]
        );
        await client.query('COMMIT');
      } catch (err) {
        await client.query('ROLLBACK');
        await client.query(
          `UPDATE incoming_webhook_events SET error_log = $1 WHERE id = $2`,
          [`[Replay failure ${new Date().toISOString()}] ${(err as Error).message}`, event.id]
        );
      }
    }
  } finally {
    client.release();
  }
}

async function upsertUser(client: any, data: any): Promise<void> {
  await client.query(
    `INSERT INTO users (id, name, email, status, updated_at)
     VALUES ($1, $2, $3, $4, NOW())
     ON CONFLICT (id) DO UPDATE SET
       name = EXCLUDED.name, email = EXCLUDED.email,
       status = EXCLUDED.status, updated_at = NOW()`,
    [data.userId, data.name, data.email, data.status]
  );
}

8. How the Major Providers Compare

ProviderVersioning unitWhere the version livesBreaking-change cadence
StripeDated release (e.g. 2026-08-26) pinned per endpointSet at webhook-endpoint creation; falls back to account defaultMonthly releases are additive-only; named releases (roughly twice a year) can break
ShopifyQuarterly dated release (2025-01, 2025-04…)X-Shopify-Api-Version header on every deliveryNew stable release quarterly; ~12 months of support before deprecation
GitHubNone (additive-only contract)X-GitHub-Event names the event type, not a schema versionRare; GitHub aims never to remove or restructure existing fields
CloudEvents / Standard WebhooksEnvelope spec version (specversion) + optional dataschemaTop-level JSON fieldEnvelope itself is stable at 1.0.x; your own dataschema versions independently

9. Best Practices Checklist

For API providers

  • Treat existing keys as permanent; add fields, don't repurpose or remove them without a formal deprecation window.
  • Send an explicit version — as a header or an envelope field — with every delivery, not just in documentation.
  • Let customers pin a version per endpoint or account, and upgrade on their own timeline.
  • Sign every payload (HMAC-SHA256 over the raw body is the de facto standard) and include a timestamp for replay protection.
  • Publish changelogs and give real notice — Shopify's ~12-month deprecation window is a reasonable benchmark.

For API consumers

  • Store the raw payload before you parse anything.
  • Build a tolerant parser: allow unknown fields, provide enum fallbacks, never assume a nested object exists.
  • Return 2xx the moment the signature checks out; do real processing in a background worker.
  • Make your business-logic handlers idempotent so replays and provider retries can never double-apply an effect.
  • Keep a durable, queryable log of failed events so a parser bug is a re-run, not a data-loss incident.

Sources