What to Put in a Webhook

Most of what gets written about webhooks is about delivery: signing the events, retrying them, and backing off from an endpoint that’s struggling. There’s a decision that comes before any of that, though, and it tends to get made by whoever writes the first event type. When something changes, do you send the receiver the whole object, or do you tell them what happened and give them an ID to go look it up?

I’ll call the first one event-based (the payload carries the full object as of the event) and the second one reference-based (the payload carries the event type and the IDs involved, and the receiver fetches the rest from your API). Both work. They fail in different places, and the places aren’t always the ones people expect.

The Two Shapes#

Say a customer changes their plan, and a subscription moves from one price to another. An event-based webhook for that might look like this:

{
  "id": "evt_01JD7Q4M2X",
  "type": "subscription.updated",
  "created_at": "2025-05-26T18:04:11Z",
  "data": {
    "object": "subscription",
    "id": "sub_123",
    "version": 7,
    "customer_id": "cus_456",
    "status": "active",
    "plan": "team",
    "seats": 12,
    "current_period_end": "2025-06-26T00:00:00Z"
  }
}

The reference-based version of the same event is much shorter:

{
  "id": "evt_01JD7Q4M2X",
  "type": "subscription.updated",
  "created_at": "2025-05-26T18:04:11Z",
  "data": {
    "object": "subscription",
    "id": "sub_123"
  }
}

The receiver of the second one makes a GET /subscriptions/sub_123 call before it can do anything useful. Almost every difference between the two comes down to that one extra request, and to the fact that it happens later, somewhere else, against whatever your API returns at that moment.

Consistency#

An event-based payload is a snapshot. If the sender builds it from the write that caused the event, the receiver gets exactly the state the event describes, no matter when the delivery lands. (The “if” matters. A payload built before the transaction commits, or read back from somewhere that lags the write, has its own version of every problem below.)

A reference-based receiver gets whatever the API says when it asks, and that can be wrong in two directions.

The common one is newer. The object changed again between the event and the fetch, so the receiver sees state from after the event it’s handling. For most receivers this is fine, and arguably what they wanted in the first place. If you’re syncing a subscription into your own database, the latest state is the state you want to store, and a second updated event that arrives a moment later will just fetch the same thing again.

The one that actually hurts is older. If the sender’s API is eventually consistent (reads served from a replica, a cache in front of the database, or a read model that’s populated asynchronously), the webhook can go out as soon as the write commits on the primary, and the receiver’s fetch can land on a copy that hasn’t seen it yet. For a created event, that’s a 404 for an object the webhook just told you exists. For an updated event, it’s worse, because the fetch succeeds. The receiver gets the state from before the change, compares it to what it already has, decides nothing happened, and acknowledges the webhook. Nothing errors, and the update is silently lost until something else touches that object.

Newer state is a receiver seeing a little more than it was told about. Older state is a receiver being told about a change and then shown evidence that it didn’t happen, with no way to tell those apart from the response alone.

There are a few ways to close that gap. The sender can serve webhook-driven reads from the primary, or otherwise guarantee read-your-writes for them. Receivers can treat a 404 right after a created event as “retry shortly” rather than “ignore”. But the most general fix is to give the receiver something to compare against, which is where the next section ends up.

Ordering and Duplicates#

Most webhook delivery retries on failure, and a delivery that timed out on the sender’s side may well have succeeded on the receiver’s. That makes delivery at-least-once, and once there are retries, events for the same object can arrive out of order. Version 6 fails, version 7 is delivered, and then the retry of version 6 shows up.

This is where event-based receivers carry the burden. If the handler writes whatever arrives, the late retry overwrites newer data with older data. The receiver needs a guard: track a version or sequence number per object, and only apply a payload that’s newer than what’s stored. With a table like this on the receiving side:

CREATE TABLE subscriptions (
    id text PRIMARY KEY,
    version integer NOT NULL,
    status text NOT NULL,
    plan text NOT NULL,
    seats integer NOT NULL
);

an upsert that refuses to go backwards handles both late and duplicate deliveries:

INSERT INTO subscriptions (id, version, status, plan, seats)
VALUES ('sub_123', 7, 'active', 'team', 12)
ON CONFLICT (id) DO UPDATE
SET version = EXCLUDED.version,
    status = EXCLUDED.status,
    plan = EXCLUDED.plan,
    seats = EXCLUDED.seats
WHERE subscriptions.version < EXCLUDED.version;

That only works if the sender puts a version in the payload in the first place. A timestamp can stand in for one, but two writes in the same millisecond, or clocks on two different hosts, make it a weaker guard. The guard only protects the write, though. Anything else the handler does (sending an email, provisioning seats) needs idempotency on the event ID, so that a duplicate delivery doesn’t send the email twice.

Reference-based receivers are much more relaxed about all of this. The event is a nudge to go look, and every fetch returns the latest state, so a late retry or a duplicate costs one more request and changes nothing.1 That’s a real advantage, and it’s the flip side of the consistency section: reference-based receivers are tolerant of delivery order, but not of a read path that lags the write.

The Real Cost Is Versions#

A fetch by ID returns the current state of the object, and only the current state. For a created event that’s usually enough. For an updated event, it often isn’t.

Plenty of receivers don’t care what the state is so much as what changed. Did the plan change, or just the seat count? Did the status move from trialing to active, or was that an unrelated update? With a reference and a fetch, the receiver can’t answer that. It sees where the object is now, and if two updates happened before it fetched, it sees the result of both. To work out what a given update did, the receiver either keeps its own copy and diffs against it (and inherits every ordering problem from the last section in doing so), or it needs the sender to hand it more than an ID.

There are a few ways for the sender to do that, with increasing cost:

  • Put the object’s version in the reference. The receiver can at least tell whether the fetch is older than the event (a stale read, so retry), the same, or newer (something else happened since).2
  • Put the changed field names in the reference. This tells the receiver what kind of change it was, but not the values on either side, so it’s still reading current state for those.
  • Keep versioned objects, and let the receiver fetch a specific one, for example GET /subscriptions/sub_123/versions/7. Now the reference is complete: the receiver can read the state as of the event, and the version before it, whenever it gets around to it.

A reference with both of those in it looks like this:

{
  "id": "evt_01JD7Q4M2X",
  "type": "subscription.updated",
  "created_at": "2025-05-26T18:04:11Z",
  "data": {
    "object": "subscription",
    "id": "sub_123",
    "version": 7,
    "changed": ["plan", "seats"]
  }
}

The last option is the one that actually makes reference-based webhooks equivalent to event-based ones, and it’s the expensive one. The sender has to store every version of every object it sends events about, decide how long to keep them, and serve them through the API with the same access checks as the current object. Deletes need the same treatment, since a deleted reference otherwise fetches a 404, and the receiver can’t see what was removed. Audit history, where a trigger writes the before and after state of every changed row to a companion table, is one way a sender could keep those versions (I’ve since written up how I do that in Postgres), but it’s still a commitment to keep them, and an API to put in front of them.

That, more than anything else, is the cost of choosing references. The payload is smaller because the information has moved somewhere else, and “somewhere else” has to keep existing for as long as an event might reference it.

Payload Size#

A reference-based payload is smaller, and stays the same size no matter how large the object gets. An event-based payload for an object with a lot of fields, or with related objects embedded in it, can get big enough that receivers start hitting request body limits, and the sender has to decide which relations to include and how deep to go.

The smaller payload isn’t the whole bill, though. Every reference-based webhook that the receiver acts on turns into a second request, and the response to that request is the full object anyway. So the total bytes moved are usually higher, not lower, and there’s a second round trip in the latency before the receiver can act. The fetch can also fail on its own, which means the receiver needs its own retries for it, separate from the sender’s delivery retries.

The part that’s easy to miss is the burst. Something like a bulk import or a batch job can produce a large number of events in a short window, and with references, every receiver turns those events into API reads at roughly the same moment. The sender’s API sees a read spike that it caused itself, and receivers start running into rate limits while trying to do exactly what the webhook asked them to do. Senders already have to think about backing off from a struggling receiver. Reference-based webhooks add the same problem in the other direction, and the sender needs to budget API capacity for the reads its own webhooks will trigger.

Security#

Signing matters for both shapes. The receiver should verify a signature over the body (usually an HMAC with a shared secret) and reject anything with a timestamp outside a small window, so that a captured request can’t be replayed later.3 Neither shape gets to skip that.

What changes is what an attacker gets when verification is missing or broken. With an event-based webhook, the payload is the data. A forged or replayed request that gets through writes whatever it contains into the receiver’s system. With a reference-based webhook, a forged request can only make the receiver go and fetch an object from the sender’s API, using the receiver’s own credentials. The response is whatever the API says is true, so there’s no data to inject,4 and a replay just causes a fetch of the current state.

The fetch also checks access again at read time. If the customer has revoked the integration, or narrowed its scopes, since the event was created, the read fails. An event-based payload was built and authorized when the event was created, and it’s pushed to whatever URL is configured for it. That URL could be misconfigured, behind a proxy that logs request bodies, or simply pointing at a system that shouldn’t have that object at all, and the payload also lives in the delivery logs on both sides. For objects that carry personal or financial data, that’s a lot of copies, most of them in places the sender doesn’t control.

The cost on the reference-based side is that every receiver needs API credentials with enough scope to read what the events refer to. That’s a credential to issue, store, and rotate, and for a lightweight receiver (a notification sink, or a small automation that only needs to know that something happened) it may be more integration than it wanted to take on.

What to Actually Do#

Put a version on every event, whichever shape you pick. With full objects, it’s what lets the receiver throw away late and duplicate deliveries. With references, it’s the only way the receiver can tell that what it fetched is older than what it was told about. There’s no version of this design where leaving it out is the better choice.

If you’re not sure which shape you need, start with a reference that carries the version and the names of the fields that changed. It’s small, it’s tolerant of delivery order, it doesn’t push your data to wherever an endpoint happens to point, and it gives every receiver enough to notice a stale read and to know what kind of change it’s looking at. Serve the reads those webhooks cause from somewhere that doesn’t lag the write, and budget the API capacity for them.

Add versioned objects when a receiver needs the state as of the event, rather than the state now. That’s the expensive part, so wait until someone actually needs it.

Send full objects instead when your receivers are lightweight and shouldn’t need API credentials at all. In that case the receiver owns the version guard and the idempotency, and you’re accepting that the data is copied to wherever the endpoint points.

Always send a version. Start with a reference, a version, and the changed fields, and add versioned objects when a receiver needs the state as of the event.


Hopefully this works out for you, or was at least helpful. If you have any questions, don’t hesitate to shoot me an email, or follow me on twitter @nrmitchi.


  1. For the stored state, anyway. A handler that sends an email or provisions seats still needs to deduplicate on the event ID, whichever shape the payload has. ↩︎

  2. This assumes the API returns the version with the object, so there’s something to compare against. ↩︎

  3. The timestamp has to be part of what’s signed. If it’s a separate header, a replay can just update it. ↩︎

  4. The event type and the IDs are still the attacker’s to choose, though. A handler that acts on a forged subscription.deleted without looking at what the fetch returned can still be fooled, so the fetched state is the thing to trust. ↩︎

Read more