Breaking webhook changes are one of the least visible ways to take down a production system. Unlike a REST API where you control the client call, a webhook consumer is passive — the payload arrives when the provider decides, in whatever shape the provider sends it. If you've upgraded your field expectations without a migration plan, you discover the problem in the worst possible way: a silent null pointer, a missing required field, or a handler that silently drops events.
This post covers two sides of the problem: how to write consumers that are resilient to upstream payload changes, and how to roll out breaking changes to your own webhook payloads without forcing every subscriber to deploy simultaneously.
The Core Versioning Problem
Providers introduce breaking changes for legitimate reasons: renaming an ambiguous field, collapsing redundant nested objects, removing deprecated values. The problem is that "breaking" is relative to what each consumer expects.
Consider a provider renaming amount to amount_cents for clarity:
// Old payload
{
"id": "evt_123",
"type": "payment.completed",
"data": {
"amount": 4999,
"currency": "usd"
}
}
// New payload (after provider change)
{
"id": "evt_123",
"type": "payment.completed",
"data": {
"amount_cents": 4999,
"currency": "usd"
}
}Consumers accessing data.amount now receive null (or a zero-value, or a panic, depending on your language and how you parsed it). A payment for $0.00 gets recorded. Accounting breaks. Alerts fire. You scramble.
The fix is not faster deploys — it's defensive consumer code and provider-side versioning coordination.
Writing Resilient Webhook Consumers
Use Strict Deserialization with Explicit Defaults
Avoid deserializing webhook payloads into fully typed structs on the first pass. Instead, parse into a flexible intermediate representation and validate field-by-field with explicit fallbacks.
In Go, this looks like accepting json.RawMessage for the data envelope and decoding sub-fields only after type inspection:
type WebhookEnvelope struct {
ID string `json:"id"`
Type string `json:"type"`
APIVersion string `json:"api_version"`
CreatedAt time.Time `json:"created_at"`
Data json.RawMessage `json:"data"`
}
func handlePaymentCompleted(raw json.RawMessage) error {
var data struct {
// Accept both old and new field names during migration window
AmountCents *int64 `json:"amount_cents"`
AmountLegacy *int64 `json:"amount"` // deprecated in api_version 2026-01
Currency string `json:"currency"`
}
if err := json.Unmarshal(raw, &data); err != nil {
return fmt.Errorf("unmarshal payment data: %w", err)
}
var amountCents int64
switch {
case data.AmountCents != nil:
amountCents = *data.AmountCents
case data.AmountLegacy != nil:
// tolerate old field during transition
amountCents = *data.AmountLegacy
log.Warn("received legacy 'amount' field; provider may not have migrated")
default:
return fmt.Errorf("payment.completed missing amount: event %s", "...")
}
return recordPayment(amountCents, data.Currency)
}This handler correctly processes both old and new payloads for the duration of the provider's migration window. When the migration is complete and you've confirmed no old-format events are still in flight, you remove the AmountLegacy fallback.
Validate the api_version Field First
If the provider sends an api_version header or envelope field, branch on it before touching the data:
func routeByVersion(env WebhookEnvelope) error {
switch env.APIVersion {
case "2026-01", "": // "" = legacy pre-versioning
return handlePaymentV1(env.Data)
case "2026-04":
return handlePaymentV2(env.Data)
default:
// Log and skip unknown future versions rather than crash
log.Warn("unknown api_version", "version", env.APIVersion, "event_id", env.ID)
return nil
}
}The default: return nil is deliberate. A future version you can't handle is better silently skipped than noisily crashed — you'll see it in your unmatched-version metrics and can deploy a handler before the provider removes backward compatibility.
Test Against Saved Fixture Payloads
Every time you integrate a new provider or update an existing integration, save the raw webhook payload to a fixtures directory under version control:
testdata/
webhooks/
stripe/
payment_intent.succeeded_v2023-08-16.json
payment_intent.succeeded_v2025-10-01.json
shopify/
orders-create_v2024-01.jsonYour consumer test suite runs against every fixture, including old ones:
func TestPaymentIntentSucceeded_AllVersions(t *testing.T) {
fixtures, err := filepath.Glob("testdata/webhooks/stripe/payment_intent.succeeded_*.json")
require.NoError(t, err)
for _, path := range fixtures {
t.Run(filepath.Base(path), func(t *testing.T) {
raw, err := os.ReadFile(path)
require.NoError(t, err)
err = handleStripeEvent(raw)
assert.NoError(t, err, "handler must not error on %s", path)
})
}
}When a provider announces a payload change, add the new fixture before you ship the handler update. The failing test is your signal to implement support. When you drop an old fixture from the suite, you're explicitly committing to dropping backward compatibility with it.
Shipping Breaking Changes on Your Own Platform
If you operate a webhook platform and need to change your payload shape, the challenge is symmetric: your subscribers are consumers with the same versioning problem. You can't force a synchronized deploy across every subscriber.
The api_version Pinning Pattern
The safest approach is to pin each subscriber endpoint to an api_version at subscription time. When you're ready to release a new schema version, you do not migrate existing endpoints — you let subscribers opt in.
Your delivery layer reads the destination's pinned version and transforms the event before delivery:
func deliverEvent(ctx context.Context, event Event, dest Destination) error {
payload := event.Payload
// Normalize to the destination's pinned version
if dest.APIVersion != currentAPIVersion {
var err error
payload, err = transformPayload(payload, event.Type, currentAPIVersion, dest.APIVersion)
if err != nil {
return fmt.Errorf("transform to %s: %w", dest.APIVersion, err)
}
}
return forward(ctx, dest, payload)
}The transformPayload function maintains a chain of version adapters:
// transformPayload downgrades from `from` to `to` by applying adapters in reverse
func transformPayload(payload []byte, eventType, from, to string) ([]byte, error) {
if from == to {
return payload, nil
}
adapter, ok := adapters[eventType][from][to]
if !ok {
return nil, fmt.Errorf("no adapter from %s to %s for %s", from, to, eventType)
}
return adapter(payload)
}This pattern allows you to ship breaking payload changes on your schedule while giving subscribers a 6–12 month window to migrate at their own pace.
Version Sunset Timeline
| Phase | Duration | What happens |
|---|---|---|
| Beta | 4 weeks | New version available behind opt-in flag. Early adopters test. |
| GA + dual delivery | 8 weeks | New version is default for new endpoints. Old endpoints continue on pinned version. |
| Deprecation notice | 12 weeks | Email + dashboard notice that old version sunsets on date X. |
| Sunset warning | 4 weeks | Warning header added to every delivery on the old version: Webhook-Deprecation: 2026-07-01. |
| Sunset | — | Old version adapters removed. Endpoints still pinned to old version receive the new shape (with a migration guide in the dashboard). |
Four weeks of advance notice is the minimum; 6 months is industry standard for non-trivial changes. Stripe, GitHub, and Shopify all use deprecation windows of 6+ months for breaking webhook changes.
Communicating Deprecations in the Payload
During the sunset warning phase, include a deprecations array in the envelope so consumers can detect the warning programmatically — not just by reading email:
{
"id": "evt_01JQNR7KX...",
"type": "payment.completed",
"api_version": "2025-10",
"deprecations": [
{
"field": "data.amount",
"sunset_date": "2026-07-01",
"replacement": "data.amount_cents",
"docs": "https://docs.yourapp.com/webhooks/migration/2026-01"
}
],
"data": {
"amount": 4999,
"amount_cents": 4999,
"currency": "usd"
}
}During the transition window, both amount and amount_cents are present. Consumers who parse deprecations can alert internally before the sunset date; consumers who ignore it still get the old field until sunset.
Tracking Version Distribution Across Your Subscriber Base
Before you sunset an old version, you need to know how many subscribers are still pinned to it. A simple SQL query against your delivery table tells you:
SELECT
d.api_version,
COUNT(DISTINCT d.id) AS destination_count,
COUNT(da.id) AS deliveries_last_30d,
MAX(da.created_at) AS most_recent_delivery
FROM destinations d
LEFT JOIN delivery_attempts da
ON da.destination_id = d.id
AND da.created_at >= NOW() - INTERVAL '30 days'
WHERE d.account_id = $1 -- or omit to aggregate across all tenants
GROUP BY d.api_version
ORDER BY deliveries_last_30d DESC;Run this query weekly during the deprecation window. When deliveries_last_30d for the old version drops to zero, you can safely sunset the adapters. If it never reaches zero, you need to directly contact the remaining subscribers — they're either unresponsive endpoints that have silently stopped working, or integrations that were forgotten.
GetHook's event and delivery_attempts tables give you this data out of the box. Every delivery is recorded with the destination, outcome, and timestamp, which means version distribution is a straightforward aggregation over your existing delivery log.
Common Mistakes
1. Adding required fields without a migration window. A new required field is a breaking change. Treat it with the same ceremony as renaming or removing a field — mark it optional for at least one release cycle, then require it.
2. Changing field types. Converting created_at from a Unix timestamp (integer) to an ISO 8601 string is a silent breaking change in typed languages. Always add a new field with the new type and deprecate the old one; never change a field's type in place.
3. Not testing old fixtures after a refactor. Internal refactors change internal models. Unless you have fixture-based tests, you won't know the serialized output changed until subscribers start filing bugs.
4. Treating additionalProperties: false as safe. If your JSON Schema validation rejects payloads with unknown fields, and the provider adds a field, your consumer breaks even though you didn't depend on that field. Webhook consumers should accept unknown fields by default — additionalProperties: false is appropriate for request validation, not webhook receipt.
Webhook payload changes are inevitable. The teams that handle them gracefully are the ones who treat their consumer code as a versioned compatibility layer from the start — not as a direct mapping of the current payload shape.
If you're building a platform that delivers webhooks to your own customers, GetHook's delivery infrastructure handles the routing and retry layer so you can focus on payload transformation and versioning logic rather than the mechanics of reliable delivery.