Back to Blog
Integration

The Webhook Payload You Trust Is Already Out of Date

Thin events are winning. Stripe now ships lightweight notifications that carry an ID instead of the object, and you fetch the current state yourself. Here is why the fetch-on-notify pattern is the right default, what staleness it trades away, and the traps it creates.

WebhookVault Team · Webhook Infrastructure Experts12 min read
Angled close-up of a dark monitor showing syntax-highlighted JavaScript, including a SQL SELECT string and a function that loops over datasets with forEach and parseFloat

The payload describes a world that no longer exists

A customer.subscription.updated lands. Your handler reads the object in the body, sees status: past_due, and fires the dunning email. Except the object in that body is a snapshot taken when the event was raised, and between then and the moment your worker actually processed it, the customer paid. The current status is active. You just emailed a paying customer to tell them their card failed.

Nothing broke. The signature verified. The JSON parsed. The status field said exactly what it said. The payload was simply describing a version of the world that had already been overwritten by the time you read it, and there is no field in the body that warns you about that. This is the quiet failure mode of trusting the payload: it is not wrong, it is late.

For years the answer was to shrug and accept it, because the object was right there in the body and refetching felt wasteful. That default is now flipping, and the platforms are doing the flipping for you.

Two jobs one payload was never good at

The clearest articulation of why came out in August 2026, when The Valley of Webhooks hit the top of Hacker News. The argument: providers quietly conflated two jobs into one delivery mechanism. One job is triggering a side effect, send the email, start the reconciliation, grant the access. The other is keeping a replica of the provider's data in sync with yours. Webhooks are good at the first and structurally bad at the second, because a fat payload is a point-in-time copy that goes stale the instant it is serialized.

Almost every workaround in the webhook ecosystem exists to paper over that mismatch. Deduplication tables, ordering buffers, bootstrap importers, nightly reconciliation jobs. You build them not because side-effect triggering is hard, but because you are trying to hold an accurate copy of someone else's database using a stream of stale snapshots that can arrive late, out of order, or not at all. If you have ever built the same reconciliation backstop three times, you already know the valley floor.

The payload is the root of it. Ship the object in the event and you have committed to a copy that was true once and is now a guess.

Thin events made the split official

Stripe's answer is to stop shipping the object. Its v2 event system delivers thin events: a lightweight notification that carries the event ID, the type, and a related_object hash with the affected object's ID, type, and a URL, and almost nothing else. No object body. No fields to go stale. The notification says "this changed, here is where to look," and looking is your job.

{
  "id": "evt_test_65UIRNU7G1XbhCfOim416TgmEI4ASQ3jHxXt8RFwXoeVwO",
  "object": "v2.core.event",
  "type": "v2.core.account.updated",
  "created": "2026-03-09T13:00:28.435Z",
  "related_object": {
    "id": "acct_1T93Q4Pmpb34Vto6",
    "type": "v2.core.account",
    "url": "/v2/core/accounts/acct_1T93Q4Pmpb34Vto6"
  }
}

The SDKs give you two moves once the notification arrives. fetchRelatedObject() makes one request and hands back the latest state of the affected object. fetchEvent() retrieves the complete event, which adds the data hash and the changes hash listing the previous values of whatever changed. You pick per event type: fetch the object, fetch the event, or process the notification as-is when the ID and type alone are enough. This split used to be v2-resources-only; as of the current private preview, thin events cover API v1 resources too, so you can adopt the model without rebuilding your endpoint configuration.

Fetch-on-notify is the pattern, not the workaround

Treat the webhook as a doorbell, not a delivery. The event tells you that something happened and which object it happened to. You get the what by asking the API, at the moment you process, which is the only moment whose answer matters.

This inverts the instinct most handlers were built on. The old shape parsed the body and acted on it. The new shape reads the ID, then fetches. It costs one extra round trip per event, and in return every decision you make is based on state that was current a few milliseconds ago instead of state that was current whenever the sender happened to serialize the payload. For anything where correctness depends on the present, subscription status, account flags, balance, order state, that trade is not close.

It also quietly fixes a problem you may have spent real effort on. Events arrive out of order all the time, and if you apply payloads in arrival order you write yesterday's snapshot over today's. Fetch-on-notify sidesteps the whole category: it does not matter which order two updated events arrive in if both of them cause you to fetch the same current object. Last write wins, and the winner is always the truth. You stop needing an ordering buffer for the replication case, because you never trust the order, only the fetch.

The staleness you traded away, and the one you kept

Here is the honest part. Fetch-on-notify does not eliminate staleness, it moves it to a place where it hurts less. The window between "event raised" and "you read the object" shrinks from however long delivery and retries took down to the latency of one API call. That is a huge reduction, and for most integrations it is enough.

But you have not reached zero, and you cannot. Between your fetch returning and your worker finishing its logic, the object can change again. More subtly, the object you fetch can be newer than the event that told you to fetch it. A subscription.updated for a plan change arrives, you fetch, and the object you get back already reflects a cancellation that happened two events later. You reacted to the plan change using an object that has moved on past it. For pure replication that is fine, you want the latest. For reacting to a specific transition, it is a landmine, because the object alone cannot tell you which transition you were called about.

When "latest" is the wrong answer

The trap is assuming the current object is a substitute for the event. It is not, and three cases prove it.

First, the field that changed. If you need to know what changed rather than what the value is now, the object is useless, it only shows the present. That is what the changes hash on the full event is for, and it is why fetchEvent() exists alongside fetchRelatedObject(). Reacting to "the email address changed" requires the old value; the object only has the new one.

Second, collapsed intermediate states. Two rapid updates can both fetch the same latest object, so a value that flipped A → B → A looks to you like nothing happened at all. If your side effect must fire on every transition, not every distinct end-state, fetching the object silently drops the middle.

Third, deletions. When the event is a delete, the fetch returns a 404 or a soft-deleted stub. A handler that assumes the fetch always succeeds will throw, retry, throw again, and eventually get itself treated as a failing endpoint. The absence of the object is the information, and your code has to read a 404 as a valid answer rather than an error.

Snapshot payloads do not save you either

The reflex defence is to keep using fat snapshot events so the object is in the body and no fetch is needed. Stripe's own documentation closes that door: because the embedded snapshot can be stale by the time you process it, they recommend fetching the latest version of the resource from the API anyway. The fat payload does not spare you the round trip if you care about correctness. It just tempts you into skipping it.

Snapshots carry a second cost that thin events delete outright: versioning. A snapshot event is serialized against an API version, so the shape of the embedded object is pinned to whatever version your endpoint is configured for, and upgrading means migrating the endpoint and the consumer together. Thin events are unversioned. There is no object in the body to version, so you upgrade your integration client-side, on your schedule, without touching the webhook configuration or coordinating a cutover. For anyone who has run a webhook version deprecation, that alone is worth the migration.

Rate limits are the failure mode you inherit

Fetch-on-notify is not free, and the bill arrives as API traffic. Every event now produces at least one API call, and the events that spike hardest are exactly the ones that spike your fetches. Stripe warns that the start of the month, when every subscription renews at once, produces an event surge; with fetch-on-notify that is also an API-call surge, aimed at endpoints that have their own rate limits. Handle it badly and you turn a webhook spike into a wave of 429s, then into a backlog, then into the stale processing you were trying to escape.

The mitigations are ordinary and you should build them in from day one. Coalesce: if you get five events for the same object ID inside a short window, fetch once. Cache the fetched object briefly so a burst about one resource does not become five identical reads. Respect the Retry-After on a 429 and back off instead of hammering. And keep the fetch on a worker, off the request path, so a slow or throttled API never delays the acknowledgement the sender is waiting for.

Build the handler around the fetch

The endpoint's job shrinks to almost nothing: verify the signature, capture the ID, acknowledge. The fetch and the real work happen after you have already returned, on a worker that can be slow, throttled, or retried without the sender ever knowing.

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const notification = verifyThinEvent(req.body, req.headers['stripe-signature'])

  // The body has an ID, not an object. Enqueue the ID and get out.
  enqueue('webhook-events', {
    eventId: notification.id,
    objectId: notification.related_object.id,
    type: notification.type,
  })

  res.status(202).end()
})

async function worker({ eventId, objectId, type }) {
  // Fetch at processing time, so you act on current state, not a stale copy.
  const object = await stripe.v2.core.accounts.retrieve(objectId).catch((err) => {
    if (err.statusCode === 404) return null // deleted: absence is the answer
    throw err // real failure: let the retry happen
  })

  // Need the old value or the exact transition? Fetch the full event instead.
  if (needsChangeSet(type)) {
    const event = await stripe.v2.core.events.retrieve(eventId)
    return applyChange(event.changes, object)
  }

  return reconcile(object)
}

Notice the signature verification still runs over the raw body, and the body is now small, which is a minor security win in itself: less to parse, less to get wrong. The verification rules do not change because the payload got thinner.

Keep the notification, keep questioning the object

Fetch-on-notify raises your correctness floor. It does not make the stream complete. A thin event that never arrives is still a change you never heard about, and no amount of fetching helps if you did not know to fetch. That is why the pattern pairs with, rather than replaces, a pull-based reconciliation job: the webhook makes you fast, the periodic sweep makes you complete. Stripe keeps events retrievable through its List and Retrieve APIs for 30 days, which is exactly the window a reconciliation sweep needs to catch what the push channel dropped.

The mental shift is small and it changes everything downstream. Stop asking "what does this payload tell me" and start asking "what does this ID point at right now." The event becomes a trigger and a pointer, never a source of truth. Once the object in the body stops being data you trust and starts being a hint about where to look, most of the workarounds in the valley stop being necessary, because you were only ever building them to defend a copy that was stale the moment it was sent.

Frequently asked questions

Does fetch-on-notify make my handler idempotent for free? No, but it makes idempotency easier. Because you fetch and converge on current state, replaying the same event usually lands you at the same result, which is close to idempotent for pure replication. It breaks down the moment your handler has a side effect, sending an email, charging a card, where doing the work twice is not harmless. You still need an idempotency key keyed on the event ID so a redelivery or a duplicate does not fire the side effect again. Fetching current state and not repeating side effects are two separate guarantees, and you need both.

If I always fetch the latest object, why keep the event ID at all? Because the object cannot tell you what changed or which transition you were called about, only its present value. The event ID lets you retrieve the full event with its changes hash when you need the previous value, and it is the natural key for deduplication and for reconciliation against the provider's event list. Fetching the related object answers "what is true now"; the event ID answers "what happened and have I already handled it." Keep both.

Will thin events increase my API bill or trip rate limits? They can, because every event now becomes at least one API call, and the biggest event spikes are also the biggest fetch spikes. The fix is to coalesce fetches for the same object within a short window, cache the fetched object briefly, respect 429 responses with backoff, and run all of it on a worker off the request path. Done well the extra traffic is modest; done naively a month-start renewal surge turns into a wave of throttled requests and a growing backlog.

Should I switch all my existing fat webhooks to thin events? Only where correctness depends on current state, which is most replication and status-driven work. Where you genuinely need a point-in-time record of what a field was at the moment of the event, an audit log, a compliance trail, the snapshot payload with its previous attributes is the better fit and saves a fetch. Many integrations end up consuming both: thin events for the things they act on live, snapshot events for the things they archive.

What happens when the fetch returns a deleted or missing object? Treat the 404 as a valid answer, not an error. For a delete event the absence of the object is the information you were sent to collect, so your worker should branch on it and reconcile the deletion rather than throw. A handler that treats every failed fetch as retryable will loop on deletes, burn retries, and eventually look like a failing endpoint to the sender. Distinguish "this object is gone" from "the API call itself failed," and only retry the second.

Related posts