All posts
webhooksapi-designbackendreliability

Webhook Payload Versioning: Handling Provider Schema Changes

Providers change their webhook payloads without always telling you. How to write consumer code that survives a field being renamed, removed, or quietly added.

9 September 20264 min read

A field you depend on gets renamed in a provider's payload, your parser silently reads undefined instead of throwing, and three weeks later someone notices a report is missing data. This is a slower, quieter failure mode than a webhook outright failing, and it's arguably worse, because nothing alerts you when it happens.

Why this is different from an API breaking change

When a REST API changes its response shape, you notice quickly: your next request either errors or the code that parses the response throws. Webhooks don't have that feedback loop in the same way. The provider sends whatever shape it sends, your handler either copes or it doesn't, and unless you're explicitly checking for unexpected shapes, a silently broken parse looks identical to a successful one from the outside.

// This "succeeds" even if payload.customer.email no longer exists
async function handleEvent(payload: any) {
  const email = payload.customer.email; // undefined, no error thrown
  await sendReceipt(email); // fails somewhere downstream, unrelated-looking error
}

The bug doesn't announce itself at the point where the schema actually changed. It shows up wherever the now-undefined value eventually gets used, which can be several function calls and a deploy or two away from the actual cause.

Parse defensively, but don't over-reject

The goal isn't to reject anything unexpected. A provider adding a new field to a payload is not a breaking change, and treating it as one means your integration breaks every time the provider ships an unrelated feature. The goal is to notice when a field you actually depend on has changed shape or vanished.

A schema validation library makes this explicit instead of implicit:

import { z } from 'zod';

const OrderCreatedSchema = z.object({
  id: z.string(),
  customer: z.object({
    email: z.string().email(),
  }),
  total: z.number(),
  // Anything not listed here is simply ignored, not rejected
});

function handleOrderCreated(rawPayload: unknown) {
  const result = OrderCreatedSchema.safeParse(rawPayload);
  if (!result.success) {
    logger.error('Payload did not match expected shape', { errors: result.error.issues });
    // Store the raw payload for inspection rather than silently dropping it
    return;
  }
  // result.data is now typed and validated
  return processOrder(result.data);
}

By default, most schema libraries ignore extra fields rather than rejecting them, which is the behaviour you want: you fail loudly on the fields you depend on changing, and stay silent on additions you don't care about yet.

When validation fails, store the raw payload rather than just logging an error and moving on. Once you've fixed the parsing code, you'll want to reprocess whatever arrived while it was broken, and that's only possible if the original data wasn't discarded.

Pin to a version where the provider offers one

Some providers version their webhook payload shape explicitly. Stripe is the clearest example: each of your webhook endpoints is pinned to the API version active when you created it, and Stripe continues sending that shape even after their own API moves on, until you deliberately upgrade.

// Stripe includes the API version the event was generated under
if (event.api_version !== EXPECTED_API_VERSION) {
  logger.warn(`Unexpected Stripe API version: ${event.api_version}`);
}

Not every provider does this. Where a provider doesn't offer explicit versioning, the defensive-parsing approach above is the only real protection you have, since there's no contract to pin against.

Testing against real historical payloads, not just fixtures

Hand-written test fixtures tend to represent the payload shape as understood at the time they were written, which drifts from reality as a provider's actual payloads evolve. Keeping a handful of real payloads (with sensitive fields redacted) as test fixtures, updated periodically, catches drift that a fixture written eighteen months ago won't.

describe('handleOrderCreated', () => {
  it('parses a real payload captured 2026-08-01', () => {
    const payload = loadFixture('shopify-order-created-2026-08.json');
    expect(() => OrderCreatedSchema.parse(payload)).not.toThrow();
  });
});

Watching for silent parsing failures in production

Schema validation only helps if failures are visible somewhere you'll actually see them. A validation error logged at info level, buried among thousands of routine log lines, might as well not exist. Route schema mismatches to whatever channel actually gets attention: an error tracker, a dedicated alert, a metric with an alert threshold.


Provider payloads aren't static contracts, even when they feel like they should be. Defensive parsing on the fields you depend on, explicit rejection of only what actually breaks your logic, and keeping the raw payload around for reprocessing cover most of what changes underneath you over time.

That last part is where a durable event store earns its keep: Webhook Watchtower keeps every event's raw payload regardless of whether your own parsing succeeded, so once you've fixed a schema mismatch, you can replay historical events against the new code and confirm it handles what actually arrived rather than what your fixtures assumed. See the replaying events guide.

Frequently asked questions

Do webhook payloads actually change without warning?

Yes, more often for smaller or newer providers than for Stripe or GitHub, but it happens across the board. A field gets renamed during a provider's own refactor, a nested object gains a new required property, or an enum gains a new value your switch statement doesn't handle. None of these are usually announced with the urgency of a breaking API change.

Should I use strict schema validation on incoming webhooks?

Validate the fields you actually use, strictly. Don't reject the whole payload just because it contains fields you don't recognize. A provider adding a new field shouldn't break your integration; only a field you depend on changing shape or disappearing should.

How do Stripe and similar providers handle this for their own API?

Stripe pins each of your webhook endpoints to a specific API version at the time you create it, and payload shape stays consistent for that version indefinitely. Not every provider offers this, so it's worth checking whether yours has an equivalent versioning mechanism before assuming payloads are stable forever.

Share this post

Webhook Watchtower

Stop debugging webhooks in logs.

Capture every event, replay failures, and get alerted when deliveries fail, without changing your existing setup.

Try it free →