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.
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 →Related posts
Monitoring Webhook Health: The Metrics That Actually Matter
Uptime doesn't tell you whether webhooks are working. A short list of the signals that actually indicate a healthy webhook pipeline, and what each one catches that the others miss.
Alerting on Webhook Failures Without Alert Fatigue
Notify on every failed delivery attempt and you'll train people to ignore the channel. A pattern for alerting on what actually needs a human, and staying quiet about what doesn't.
IP Allowlisting vs Signature Verification: What Actually Secures a Webhook Endpoint
IP allowlisting feels like a solid first line of defence for a webhook endpoint. It isn't, and understanding why makes the case for signature verification much clearer.