Back to Blog
Best Practices

The Webhook You Never Got Is Sitting in an API You Forgot to Call

When a webhook evaporates between the sender and you, nothing notices until a customer complains. But the event was never lost. It is still in the provider's event log. Here is why pull-based Events APIs are becoming the backstop every serious webhook consumer needs.

WebhookVault Team · Webhook Infrastructure Experts10 min read
A curved monitor showing a terminal process monitor with rows of coloured process entries, CPU and memory figures across the top, and a scatter of green activity dots in the lower left

The webhook that evaporated

A customer cancels their subscription. Stripe fires customer.subscription.deleted. Somewhere between their edge and your load balancer the delivery dies. A TLS handshake that stalled, a deploy that dropped connections for four seconds, a 502 from a proxy your app never saw. The retry lands during the same deploy window and dies too, and after a few attempts the sender gives up and marks the endpoint unhealthy.

Nothing in your system noticed. Your logs are clean because your handler never ran, your monitoring is green because there was nothing to fail. The customer keeps getting billed for a plan they cancelled until they open a support ticket three weeks later, angry, and now it is a refund and an apology instead of a log line.

Here is the part that should bother you. The event was never lost. It has been sitting in Stripe's event log the whole time, retrievable with one authenticated GET. You just never asked for it.

Push is only half of an event system

Webhooks are a push mechanism, and push has one structural weakness that no amount of retry logic fixes. The sender decides when you find out, and if the sender's attempts all fail, you find out never. A stream of events with three missing from the middle looks exactly like a stream with nothing missing. You cannot alert on the absence of a message you were never told to expect.

This is the observation at the centre of a piece that made the rounds in early August 2026, The Valley of Webhooks, which argued that webhooks are a fine tool for triggering side effects and a poor tool for keeping two datasets in sync. The failure mode it opens with is the one above: a subscription event that evaporated between Stripe and the receiver, and nothing anywhere was capable of noticing.

The fix is not a better retry schedule. Retries are the sender trying harder to push. The fix is giving yourself a way to pull.

What a pull feed gives you that a stream cannot

A pull-based event feed flips the control around. Instead of waiting for the provider to tell you what happened, you ask it: give me everything since the last event I acknowledged. The provider keeps an ordered log of events and hands you a cursor. You store the cursor. Next time you ask, you send the cursor back and get everything after it.

That one change fixes three things at once. You get ordering, because the log has a defined sequence and the cursor walks it in order. You get gap detection for free, because if you ask for everything after cursor X and there are forty events, you get all forty. A dropped webhook cannot hide when you are enumerating the log instead of waiting for pushes. And you get replay, because the cursor is just a position. Move it backwards and you reprocess.

None of this replaces webhooks. Push is still how you get low-latency reaction, the receipt emailed within seconds rather than on the next poll. Pull is how you guarantee you eventually see everything.

Stripe already ships this, and you probably ignored it

You do not need a new protocol to start. Stripe has had the pull half the whole time, and most integrations never touch it.

curl -G https://api.stripe.com/v1/events \
  -u "$STRIPE_SECRET_KEY:" \
  -d limit=100 \
  -d "starting_after=evt_1NG8Du2eZvKYlo2CUI79vXWy"

GET /v1/events returns the same event objects your webhook endpoint receives, in a paginated list. Retention is 30 days. Anything older has aged out of the log, which sets a hard bound on how far behind you can fall before a pull can no longer save you. Pagination is cursor-based. starting_after and ending_before both take an event ID and define your place in the list, and limit runs from 1 to 100 with a default of 10, so set it explicitly or you will page through ten at a time.

Two filters make this a real reconciliation tool rather than a firehose. delivery_success=false returns only events that are still pending or have failed every delivery attempt to your endpoints. That is your dropped-webhook list, handed to you directly. And type or types narrows to the event names you actually process, so you are not paging through thousands of events you would ignore anyway.

One subtlety catches people. Each event is rendered according to the API version in effect when it was created, exposed as the api_version field on the event, not according to your account's current version. If you handle two API versions in the same code path, read that field and branch on it, or you will chase an old payload shape for an afternoon.

WorkOS went further and called it a migration path

Stripe frames its events list as a debugging and recovery aid. WorkOS went further and positioned its Events API as a first-class alternative to webhooks for syncing data across SSO and directory connections.

const { data, listMetadata } = await workos.events.listEvents({
  events: ['dsync.user.created', 'dsync.user.updated'],
  after: storedCursor,
})
// persist listMetadata.after for the next call

The cursor parameter is after, and the detail that matters is this. WorkOS states the event IDs in webhook bodies are the same IDs returned by the Events API. That is what makes the two interchangeable. You can dedupe across both channels on a single ID, migrate an integration from push to pull without a flag day, or run both and treat the pull as the source of truth. Their own guidance is that you can migrate to the Events API if you already use webhooks. The pull path is not a lesser sibling. It is the one with the stronger guarantees. When a provider tells you the push and pull channels share an ID space, take the hint. The log is the real system and the webhook is a notification about it.

The cursor is the whole trick

Everything above rests on one primitive: an opaque, monotonic cursor that marks a position in an ordered log.

Store the cursor transactionally with the work it represents. The failure you are guarding against is advancing the cursor past events you have not durably processed. If you fetch a page, process it, then crash before saving the cursor, you reprocess on restart. Annoying, but safe, because your handlers are idempotent. If you save the cursor first and crash before processing, you have just skipped events and reintroduced the exact gap you built this to close. Persist the cursor in the same transaction that commits the processed work, or after it. Never before.

Treat the cursor as opaque even when it looks like a timestamp or an incrementing ID. Providers change the encoding, and code that parses a cursor to do arithmetic on it breaks the day they do. It is a bookmark, not a number.

Using the feed as a backstop, not a rewrite

You do not have to rearchitect around pull to get most of the benefit. The pragmatic pattern is a scheduled sweep that runs alongside your existing webhook handler.

Every few minutes, pull everything since your stored cursor and run it through the same processing path your webhooks use. That path is already idempotent, the way duplicate-safe webhook consumers have to be, so replaying an event you already handled via webhook is a no-op. The dropped events get processed for the first time. The ones that arrived normally are seen twice and ignored. Steady state costs a cheap idempotency check per event and nothing else.

This is the same instinct behind the reconciliation jobs in why webhooks don't keep your state in sync, but sharper. A reconciliation job compares your database against the provider's current state and infers what changed. A pull sweep reads the event log directly and gets the actual sequence of changes, tombstones and all. You are not guessing what you missed. You are enumerating it.

Bootstrapping is the problem push was never going to solve

There is one thing a webhook stream cannot do, and no retry policy changes it: give you the state that existed before you subscribed. The day you turn on an integration, the provider has years of customers, subscriptions, and directory users that generated events you were not listening for. Webhooks only carry you forward.

A pull feed folds bootstrap and live sync into one motion. You start with an empty cursor, page forward through the historical log until you catch up to the present, then keep paging as new events land. The same endpoint serves the initial import and the ongoing tail, so there is no separate backfill script drifting out of sync with your real handler. This is the property the SCROLL draft that came out of the same August discussion is trying to standardise: unified bootstrap and live modes over one cursor-addressed change log, with full-state upserts and tombstones for deletes. Whether or not that proposal wins, it names the shape the good implementations already have.

When you genuinely do not need this

Not every webhook consumer needs a pull backstop, and pretending otherwise is how you gold-plate a Slack notifier. If the events trigger fire-and-forget side effects with no lasting state, say post a message, bust a cache, kick a rebuild, then a dropped event costs you one missed notification and the next one papers over it. A reconciliation sweep there is pure overhead.

The line is whether a missed event leaves your system in a wrong state that stays wrong. Billing, entitlements, inventory, access control, anything where the events accumulate into a record of truth, those need the pull. A single drop is a silent, permanent divergence, and the ordering guarantees you don't get from push alone make it worse. Decide per event type, not per integration, and spend the effort where a gap turns into a support ticket.

Frequently asked questions

If I already retry failed deliveries, why do I still need to pull? Retries are the sender pushing harder, and they share a fate. If the sender's attempts all fail, or it never fired the event because of a bug on its side, no number of retries produces the event on your end. Pulling asks the log directly, so it catches events that were dropped, delayed, or never pushed at all, which retries cannot reach because retries are still push.

How often should the reconciliation sweep run? Match it to how long you can tolerate a wrong state and to your provider's retention window. Every few minutes is common and keeps you well inside Stripe's 30-day event window. The sweep is cheap when your handlers are idempotent, because replaying already-processed events is a no-op, so err toward more frequent rather than less. You pay a few idempotency checks and get a smaller blast radius when a push channel breaks.

Does pulling mean I can drop webhooks entirely and just poll? You can, and for pure data-sync use cases some teams do, but you lose latency: webhooks react in seconds and a poll reacts on its interval. The strongest setup keeps both and dedupes across them on the shared event ID, so the same event arriving on both channels is processed once.

What happens if I fall further behind than the provider's retention window? Then the pull can no longer recover the missing events, and you are back to comparing your state against the provider's current snapshot to repair the divergence. Retention sets a hard deadline: with Stripe's 30 days, a cursor that has not advanced in a month is a gap you can no longer close from the log. Alert on cursor age, not just on handler errors, so a stalled sweep surfaces before the window closes.

Should I store one cursor or many? Store a cursor per independent stream you consume, per collection, per event-type group, or per tenant, depending on how the provider partitions its log. A single global cursor is simplest but couples unrelated streams, so a slow consumer for one type holds back another. Persist each cursor transactionally with the work it represents, and never advance it past events you have not durably processed.

Related posts