Back to Blog
webhooksschema evolutionAPI designversioningreliability

Webhook Consumer Versioning: Deploying Payload Changes Without Breaking Live Integrations

When a provider changes their webhook payload, your consumers break. Here's how to write webhook handlers that survive schema evolution — and how to ship breaking changes on your own platform without a maintenance window.

F
Finn Eriksson
Payments Engineer
April 10, 2026
9 min read

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:

json
// 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:

go
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:

go
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.json

Your consumer test suite runs against every fixture, including old ones:

go
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:

go
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:

go
// 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

PhaseDurationWhat happens
Beta4 weeksNew version available behind opt-in flag. Early adopters test.
GA + dual delivery8 weeksNew version is default for new endpoints. Old endpoints continue on pinned version.
Deprecation notice12 weeksEmail + dashboard notice that old version sunsets on date X.
Sunset warning4 weeksWarning header added to every delivery on the old version: Webhook-Deprecation: 2026-07-01.
SunsetOld 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:

json
{
  "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:

sql
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.

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.