Webhook vs API: What's the Difference? (2026)
Webhook vs API, explained plainly — polling vs push, when to use each, and how founders wire up Stripe webhooks without losing events.
Table of contents8 sections
- 01The Night I Refreshed Stripe 400 Times
- 02An API Is You Asking. A Webhook Is Them Telling.
- 03Polling vs Push: Why You Can’t Just Loop the API
- 04The Founder Example: Getting Told a Payment Happened
- 05The Four Gotchas That Will Eat Your Events
- 06When Each One Actually Fits
- 07Testing Webhooks Without Losing Your Mind
- 08The Bottom Line
The Night I Refreshed Stripe 400 Times
It was 2024. I’d wired a payment page onto a little side project — nothing fancy, a one-time unlock for a Pro feature — and I was testing it end to end. Checkout worked. Money moved. And then I sat there staring at my own database, waiting for it to notice.
Because here was my genius plan: every thirty seconds, my server would call the payments API and ask, “Any new successful charges? No? Okay, ask again.” A cron job, hammering the API, pull, pull, pull. It worked in testing because I was the only customer and knew exactly when to check. But something felt off — I was refreshing the Stripe dashboard in one tab while my code polled the API in another, both racing to find out the same thing.
That’s the moment the difference between an API and a webhook finally clicked — not from a doc, but from the dumb feeling of asking a question over and over when the answer could have just arrived. If you’ve ever confused the two, or wondered why every payment provider on earth makes you set up this weird “endpoint URL,” this is the thing I wish someone had said to me that night.
An API Is You Asking. A Webhook Is Them Telling.
People frame “webhook vs API” like it’s a cage match. It isn’t. A webhook is HTTP, same as an API — it’s just pointed the other direction. The real split is about who starts the conversation.
An API call is you asking. Your code makes a request — GET /charges, POST /links — and the service hands back a response right then. You initiated it, you control when it happens, and nothing comes back unless you asked. That’s the request/response model, and it’s what most people mean by “an API.” (Want the full build — routes, validation, auth? I wrote how to stand up a Python API without losing a week; this post assumes you already have one.)
A webhook is the service telling you. You register a URL once — https://yourapp.com/webhooks/stripe — and from then on, whenever something happens on their side, they make an HTTP request to you. An event fires, they POST it to your endpoint. You didn’t ask. You weren’t even awake. The information just showed up on your doorstep.
That’s the whole thing:
- API → you pull. Request/response. You start it, you wait for the answer.
- Webhook → they push. Event-driven. They start it, you handle it whenever it lands.
That’s why a webhook is sometimes called a “reverse API” — it’s your server being the API for once, with the service as the client calling you.
Polling vs Push: Why You Can’t Just Loop the API
So why not do what naive-me did — just call the API on a loop and check? For a demo, you can. At any real scale, polling falls apart, and it’s worth understanding why, because it’s the entire reason webhooks exist.
Think about my thirty-second cron job, but as a real business with a thousand customers, and I want to know the instant any of them pays. Polling means asking “anything new?” every thirty seconds, forever, whether or not anything happened. Ninety-nine times out of a hundred the answer is “no, go away.” You’re burning requests, your rate limit, and the flat-fee box the cron runs on — all to hear no. (This is the exact “20,000 API calls every five minutes” trap I described in the deployment tier list — and if that cron is running on serverless, it’s exactly the kind of runaway meter you want a billing cap in front of.) And you still lose: if a payment lands one second after your last poll, you don’t find out for another twenty-nine. A push event has no lag — the payment succeeds, and within a second or two your endpoint is getting POSTed.
Here’s the clean rule I use: poll when you want the latest state on demand; use a webhook when you need to know the moment something changes. Fetching a user’s subscription to render their dashboard? API call, when they load the page. Knowing a subscription just got cancelled at 3 AM so you can revoke access? That’s a webhook. You cannot poll the entire payment network for every event on earth. So the network tells you instead.
The Founder Example: Getting Told a Payment Happened
This is where it stops being theory. The single most common webhook a solo founder will ever wire up is the one that tells you someone paid.
When a customer checks out with Stripe or Paddle, the payment doesn’t complete on your server — it completes on theirs, often on a page they host, sometimes after a bank redirect or a 3D Secure challenge. Your server isn’t in that loop. So how does your app find out the money cleared and it’s time to flip the account to Pro?
It gets told. Stripe fires an event — the classic one is checkout.session.completed — and POSTs it to the webhook URL you registered. The body is a JSON blob: which customer, which price, which session. Your endpoint reads it, finds the user, grants access. That’s the handshake — the customer never waits on your database, and you never poll for “did anyone pay in the last thirty seconds.”
This is also why you should never unlock the account purely from the “success” redirect in the browser. A user can close the tab before it fires, or the redirect can lie — anyone can visit your /success URL. The webhook is the source of truth because it comes signed, from Stripe’s server straight to yours. The redirect is for the human; the webhook is for your database. And the same event stream is what a proper billing system runs on — subscriptions, renewals, failed cards — a big part of why you should never build your own recurring billing.
The Four Gotchas That Will Eat Your Events
Here’s the part nobody warns you about, where I’ve watched founders (me included) lose real money. A webhook endpoint looks like a two-line function — read JSON, update database — and then reality shows up. Four things you must get right, and skipping any one is a bug that only appears in production.
1. Verify the signature — it’s a public URL
Your webhook endpoint is a URL sitting on the open internet. Anyone can POST to it. If you trust every request that says “trust me, this payment succeeded,” you’ve just built a free-money button for the whole internet.
So every provider signs its events. Stripe sends a Stripe-Signature header, and you verify it against a signing secret only you and Stripe know. Their SDK does the heavy lifting — you hand it the raw body, the header, and your secret, and it either confirms the event is genuinely from Stripe or throws. One gotcha that eats an afternoon: you must verify against the raw, unparsed body. The second your framework auto-parses the JSON into an object, the signature won’t match. Grab the raw bytes before any middleware touches them.
2. Return 200 fast — then do the work
When Stripe POSTs you an event, it’s waiting for a quick 2xx to know you got it. Their guidance is blunt: return the success status quickly, before any complex logic. If your handler takes too long — sending a welcome email, generating a PDF invoice, calling three other services inline — Stripe treats the slow response as a failure and retries. Now you’re processing the same event twice.
The right shape: verify the signature, drop the event in a queue or a table, return 200 immediately, and do the real work in a background job. Acknowledge fast, process later. Your endpoint’s only job in that first moment is to say “got it.”
3. Retries are a feature, not a bug
If your endpoint is down, or throws, or returns anything that isn’t a 2xx, Stripe doesn’t give up — it retries with exponential backoff for up to three days in live mode. That’s genuinely great: your server can be down for an hour during a deploy and you won’t permanently lose the “someone paid” event. It’ll keep knocking.
But it flips your mental model. A webhook isn’t a one-shot notification you either catch or miss. It’s a delivery that keeps arriving until you acknowledge it. Which leads straight to the fourth, and nastiest, gotcha.
4. Idempotency — the same event will arrive twice
Webhooks are at-least-once delivery. Because of those retries — and the occasional provider hiccup that redelivers after an incident — you will eventually receive the same event more than once. This is part of the contract, not an edge case. If your handler naively grants a credit or fulfills an order every time it sees checkout.session.completed, one duplicate delivery means you shipped twice, or credited twice.
The fix is simple, and build it in from day one: every Stripe event has a stable id (like evt_1Ox...) that stays identical across every retry. Log the IDs you’ve processed, and when one shows up again, return 200 and do nothing else. That’s idempotency — making “process this event” safe to run twice. GitHub gives you a delivery GUID for the same purpose; every serious provider hands you some unique ID so you can dedupe. Use it.
When Each One Actually Fits
Strip away the jargon and the decision is almost boring:
- Reach for an API call when you need something now, on your schedule. Rendering a page and need the user’s current plan? Fetching a list of their invoices? Looking up live inventory? You ask, you get an answer, you move on. You control the timing.
- Reach for a webhook when you need to react to something that happens on their schedule. A payment clears. A subscription renews or fails. A GitHub push triggers your CI. A file finishes transcoding. You can’t predict when, and polling for it would be wasteful and slow — so you let them tell you.
Most real apps use both, constantly. A very common pattern: the webhook wakes you up (“a payment event happened”), and then your handler makes an API call back to the provider to fetch the full, authoritative details before acting. The push is the doorbell; the pull is you opening the door to see who it is. And once you’re the one sending webhooks to your own customers, tools like Svix and Hookdeck handle the retries, verification, and queuing so you don’t rebuild all of this — but that’s a whole other post.
Testing Webhooks Without Losing Your Mind
One last practical thing, because it stumps everyone the first time. A webhook needs a public URL to POST to — but during development, your app is on localhost, which Stripe can’t reach. So how do you test?
The best answer for Stripe is their own CLI. You run stripe listen --forward-to localhost:3000/webhooks/stripe, and it opens a secure tunnel from Stripe straight to your local server, forwarding real events — and it prints a signing secret for local runs so you can test verification properly. For non-Stripe webhooks, ngrok does the same job generically: it gives your localhost a temporary public URL any provider can hit. Between the two, you can build and debug the whole flow without deploying.
Test the ugly paths, not just the happy one. Fire the same event twice and confirm your idempotency check holds. Send a bad signature and confirm you reject it. Make the handler throw and watch the retry show up. The failure modes are the feature — that’s the whole reason webhooks beat the polling loop I started with.
The Bottom Line
An API and a webhook aren’t rivals. They’re two directions of the same HTTP conversation. An API is you asking a question and waiting for the answer. A webhook is the service knocking on your door the instant something happens, so you never have to stand there refreshing a dashboard at midnight like I did.
The practical version is shorter: pull with the API when you want the current state, and let webhooks push you the events you can’t afford to miss — payments, first and foremost. Just respect the four rules that separate a webhook that works from one that quietly loses you money: verify the signature, return 200 fast, expect the retries, and make every handler idempotent. Get those right and you’ll never again write a cron job that asks the same question four hundred times, waiting for an answer that could have just arrived on its own.
This is the Broken Engineer Guide — I over-engineer everything, poll APIs I should’ve webhooked, and hand you the scars so you can skip them. Now go register that endpoint.
