Webhooks vs Polling: Which One Your SaaS Needs
A plain-English guide to webhooks vs polling: how each one works, what breaks, when polling is honestly the better call, and why most apps end up using both.
· Justin Boggs

Photo by David Arrowsmith on Unsplash
The choice between webhooks vs polling comes down to who does the work of noticing. With a webhook, the other service calls you the moment something happens. With polling, you call the other service on a schedule and ask whether anything changed. Webhooks are faster and cheaper to run, but they only work if your server is up and reachable at the exact moment the event fires. Polling is slower and wastes requests, but it's almost impossible to break. Most production apps run both: webhooks for speed, polling as the safety net that catches what the webhooks dropped.
TL;DR
- Webhooks are push: the other service POSTs to your URL when an event happens. Near-instant, one request per real event.
- Polling is pull: you ask on a timer. Latency equals half your interval on average, and you burn API calls even when nothing changed.
- Webhooks fail silently. If your server is down for 20 minutes, those events are gone unless the provider retries — and not every provider does.
- Polling is fine when latency of minutes is acceptable, the data set is small, or the provider has no webhooks at all.
- The reliable pattern is both: webhooks for the fast path, a low-frequency reconciliation poll to catch misses.
How does a webhook actually work?
A webhook is an HTTP request that another company's server makes to yours when something happens on their side. That's the entire concept. There's no special protocol, no persistent connection, no library you have to install. It's a POST request with a JSON body, hitting a URL you gave them.
Here's the sequence. You register a URL — say https://yourapp.com/api/webhooks/stripe — in the provider's dashboard. Something happens on their system: a customer's card gets charged, an invoice gets paid, a repository gets a new commit. Their server builds a JSON payload describing that event and POSTs it to your URL. Your server reads it, does something useful, and returns a 200.
The Standard Webhooks spec, an open specification adopted by OpenAI, Anthropic, and Supabase among others, describes webhooks as "a sort of a reverse API" — when you want something from a service you call their API, and when the service wants to tell you something it calls yours. That framing is the one that finally made it click for me. Your app isn't just a client anymore. For webhooks to work, it has to also be a server that a stranger can call.
That "stranger can call it" part is where all the difficulty lives. Three things follow from it, and every webhook headache traces back to one of them.
Your endpoint is public. Anyone who guesses the URL can POST to it. So you have to verify that the request actually came from the provider. Stripe does this by signing every event with an HMAC-SHA256 signature in the Stripe-Signature header, which you verify against a secret. Skip that step and someone can fake a payment_intent.succeeded and get free access to your product. I wrote about the specific ways this goes sideways in Stripe webhook hell.
Your endpoint has to be fast. GitHub gives you 10 seconds to return a 2XX before it terminates the connection and marks the delivery failed. Stripe's guidance is to return the 200 before any complex logic that could time out. This means you can't do the real work inside the handler. You acknowledge, then process in the background — which is why webhook handling and background job systems end up being the same conversation.
Your endpoint has to be up. If your deploy takes 90 seconds and three events fire during it, those three events hit a dead server. Whether you ever see them depends entirely on the provider's retry policy, which we'll get to.
How does polling work, and why is it still everywhere?
Polling means your app asks the other service, on a repeating schedule, whether anything has changed since last time. A cron job runs every five minutes, calls GET /orders?since=<timestamp>, and processes whatever comes back.
It's the older approach and it feels primitive, which is why founders often assume webhooks are the better option by default. They aren't always. Polling has one enormous advantage: the initiative is yours. You control when the request happens, you see the response code, and if the request fails you know immediately and can just try again. Nothing disappears while you weren't looking.
The cost is requests. If you poll every fifteen seconds, that's 5,760 requests per day per resource — and the vast majority of them return "nothing changed."

That number matters because API providers meter you. Once you're polling aggressively across multiple customers or multiple resources, you start bumping into rate limits, and the limit is usually per-account, not per-resource — so one noisy integration starves the others.
There's a well-established fix that most people building their first integration have never heard of: conditional requests. Most GitHub endpoints return an ETag header, a short fingerprint of the response. Send it back as If-None-Match on your next request, and if nothing changed you get a 304 Not Modified with no body. GitHub's docs state that a conditional request returning 304 doesn't count against your primary rate limit when properly authenticated. That single header turns "polling is too expensive" into "polling is basically free" for a lot of use cases.
Not every API supports conditional requests. But enough do that it's worth checking before you write off polling. Cursor and Claude Code both know this pattern well — ask your assistant "does this API support ETags or a since cursor?" before it writes the polling loop, and you'll usually get a correct answer with a link to the relevant docs page.
Webhooks vs polling: the honest comparison
Here's how the two approaches stack up on the dimensions that actually affect a small SaaS.
| | Webhooks | Polling | | --- | --- | --- | | Latency | Near-instant | Half your interval, on average | | Requests when nothing happens | Zero | Every interval, forever | | Who initiates | The provider | You | | Failure mode | Silent — you don't know what you missed | Loud — the request errors and you retry | | Requires public HTTPS endpoint | Yes | No | | Requires signature verification | Yes, always | No | | Works behind a firewall / on localhost | No (needs a tunnel like ngrok) | Yes | | Ordering guaranteed | No | Yes, if you paginate by cursor | | Duplicate deliveries possible | Yes — dedupe by event ID | No | | Setup complexity | Higher | Lower | | Good default when | Events are rare and latency matters | Events are frequent or latency doesn't matter |
Two rows there deserve more than a table cell.
Ordering is not guaranteed. Stripe is explicit about this: it doesn't guarantee delivery of events in the order they were generated. Creating a subscription can generate customer.subscription.created, invoice.created, and invoice.paid — and you might receive invoice.paid first. Stripe also warns against using the created timestamp to determine order, because snapshot events record created in whole seconds and distinct events can share one. If your handler assumes a sequence, it will break in production and work fine in every test you write.
Duplicates are normal, not a bug. The same event can arrive twice. Stripe's recommendation is to log the event IDs you've processed and skip ones you've already seen. The Standard Webhooks spec formalizes this by requiring a webhook-id header explicitly intended to be used as an idempotency key. GitHub's equivalent is X-GitHub-Delivery. Whatever the header is called, store it and check it.
Polling has neither problem. You read a page of results in order, you track your cursor, you never see the same record twice. That reliability is the real reason polling refuses to die.
What actually happens when a webhook delivery fails?
This is the question that separates "I set up a webhook" from "I have a working integration," and it's the one almost nobody asks until something breaks.
When your server is down or slow, the provider's delivery attempt fails. What happens next is entirely up to the provider, and the variation is wide.
Stripe attempts delivery for up to three days with exponential backoff in live mode. In a sandbox, it retries three times over a few hours — which is worth internalizing, because it means your test environment is quietly more forgiving than production. Stripe also lets you manually resend an event from the Dashboard for up to 15 days after creation, or 30 days via the CLI.
The Standard Webhooks spec recommends a schedule spanning multiple days with jitter added, so that a batch of failures doesn't retry in lockstep and take your server down again the moment it recovers. Its reference schedule runs ten attempts: immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours, and 24 hours — roughly three days end to end.
| | Retry window | Response deadline | Manual replay | | --- | --- | --- | --- | | Stripe (live mode) | Up to 3 days, exponential backoff | Return 2xx before slow logic | 15 days via Dashboard, 30 via CLI | | Stripe (sandbox) | 3 attempts over a few hours | Same | Same | | GitHub | Redelivery available | 10 seconds, hard cutoff | Yes, via the deliveries UI or API | | Standard Webhooks (recommended) | ~3 days across 10 attempts, with jitter | 15–30 second timeout suggested | Recommended as a provider feature |
Now the part that catches people. Retries only help if the provider retries. Plenty of smaller APIs fire once and forget. And even with a generous retry policy, some failure modes defeat retries entirely:
- Your endpoint was disabled. Stripe notes that if a destination is disabled or deleted when a retry is attempted, it prevents future retries of that event.
- Your endpoint returned a
200but the background job that was supposed to do the work crashed. From the provider's side, that's a successful delivery. Nothing gets retried, and nothing looks wrong. - Your endpoint redirected. Stripe treats
3xxresponses as failures, not as "follow this." - Your TLS certificate expired. Stripe requires TLS 1.2 or higher and treats a handshake failure as a delivery failure.
That second bullet is the one that has bitten me hardest. A 200 is a promise you made about work you hadn't done yet. If you break that promise, no retry system in the world will tell you.
The mitigation is a reconciliation job, which brings us to the actual answer.
When is polling honestly the right call?
Since webhooks look strictly better on the marketing page, here's the list of cases where I'd pick polling without hesitating.
The provider doesn't offer webhooks. This is more common than you'd expect outside the big names. No amount of architectural preference helps.
You're building locally and don't want the tunnel. Webhooks need a publicly reachable HTTPS URL. Stripe's answer is the CLI's stripe listen --forward-to localhost:4242/webhook, and ngrok works too, but that's another moving part in your dev loop. If you're prototyping, a poll is one function you can run and step through.
Latency of minutes is genuinely acceptable. Syncing a customer list into a CRM. Refreshing an analytics dashboard. Pulling yesterday's numbers into a report. Nobody notices a four-minute delay on any of these, and you've now skipped signature verification, replay protection, idempotency keys, and a public endpoint.
The data set is small and bounded. Polling a hundred records once an hour costs you 24 requests a day. That is nothing. Reaching for webhooks here is over-engineering.
The event rate is very high. Counterintuitive, but if a resource changes hundreds of times a minute, a webhook per change means hundreds of inbound HTTPS requests a minute against your endpoint — each with signature verification and a queue insert. A single poll that returns a batch of changes is cheaper and simpler. Providers know this, which is why high-volume event APIs cap deliveries per hour: past a certain rate, push stops being the efficient option for either side.
You need a guaranteed-complete view. Anything financial, anything you'd have to explain to an accountant. A poll over a time range gives you every record in that range. A stream of webhooks gives you every record that was successfully delivered, which is a subtly different thing.
The pattern I'd actually recommend
Use both, with clearly separated jobs.
The webhook is the fast path. It fires, you verify the signature, you check the event ID against your processed table, you enqueue the work, you return 200. Fast, boring, does one thing.
The poll is the reconciliation path. Once an hour — or once a day for low-stakes data — a cron job asks the provider for everything in the last window and compares it against what you've recorded. Anything the webhook missed gets picked up here. Anything processed twice gets caught by the same idempotency key you're already storing.
The reconciliation job costs you 24 API calls a day and it will, eventually, be the thing that saves you. Stripe supports this directly with a documented flow for processing undelivered webhook events, and most mature APIs have some equivalent list-with-a-timestamp endpoint.
If you're wiring this into a Next.js app, both halves have a natural home. The webhook handler is a route handler that reads the raw body — and it must read the raw body, because Stripe requires the unmodified bytes for signature verification and any framework middleware that parses and re-serializes the JSON will break it. The reconciliation job is a scheduled function; I walk through the setup in cron jobs on Vercel.
Frequently asked questions
Are webhooks faster than polling?
Yes, substantially. A webhook arrives within a second or two of the event. Polling introduces latency equal to roughly half your interval on average — a five-minute poll means a two-and-a-half-minute average delay, with a worst case of five minutes. If your product's value depends on reacting quickly, that gap is the whole argument for webhooks.
Do I need webhooks for Stripe payments?
For most flows, yes. Card payments can complete asynchronously — a customer's bank confirms after the checkout page has already closed — so relying on the browser redirect alone means you'll miss payments. The webhook is what tells you the money actually moved. See the Stripe tutorial for the specific events worth listening to.
What happens to webhooks while my site is deploying?
It depends on the provider's retry policy. Stripe retries for up to three days with backoff, so a 60-second deploy window is a non-event. Providers that fire once and forget will drop them permanently. This is exactly the gap a reconciliation poll fills, and it's the reason I'd never run webhooks alone for anything involving money.
How do I test webhooks without deploying?
Use the provider's CLI if it has one. stripe listen --forward-to localhost:4242/webhook forwards live events to your local server and prints a signing secret to use while testing. Failing that, a tunnel like ngrok gives you a temporary public HTTPS URL that routes to localhost.
Can I use both webhooks and polling for the same integration?
That's the recommended setup, not a compromise. The webhook handles the common case in near-real-time and the poll catches the misses. The one requirement is that both paths write through the same idempotency check — usually a table of processed event IDs — so that an event arriving twice does the work once.
Is polling bad practice?
No. Polling is bad practice when you poll aggressively against an API that supports webhooks or conditional requests, and you're burning rate limit for data that hasn't changed. Polling every fifteen minutes with an ETag header against a small resource is a perfectly good engineering decision that will still be working in three years.
Conclusion
The webhooks vs polling decision isn't really a decision about which technology is better. It's a decision about what happens when things go wrong. Webhooks give you speed and cost you a silent failure mode: events that vanish while your server was restarting, 200s you returned for work that never happened, duplicates you processed twice. Polling gives you certainty and costs you latency and request volume — a cost that conditional requests can shrink almost to nothing.
Pick webhooks when latency matters and you're willing to build the signature verification, the idempotency check, and the async queue that make them trustworthy. Pick polling when minutes are fine, and stop feeling bad about it. Then, once the integration matters enough that being wrong would hurt, run a slow reconciliation poll underneath your webhooks and sleep better.
If you're building a SaaS with AI coding tools and want the webhook plumbing already wired up correctly — raw-body signature verification, event-ID deduplication, background processing — Coding Capybaras is the free boilerplate I built for exactly this. The marketplace has copy-paste prompts for the integrations mentioned above.