The simplest possible answer
A webhook is an HTTP POST that an external service sends to your URL when an event happens on their side. Instead of you polling GET /orders every 30 seconds asking "anything new?", they fire POST https://your-app.com/webhooks/order the moment an order is created.
That's it. The whole concept.
Why webhooks exist
Polling has two problems:
- You ask too often → you waste calls and the API rate-limits you.
- You ask too rarely → events are stale by the time you act.
Webhooks resolve this by inverting the relationship. The provider already knows when something happened (they made it happen). They tell you immediately. You handle exactly one event per delivery, and you never have to guess the cadence.
What's actually in a webhook
A webhook is a normal HTTP request, with three things you usually care about:
- The body — a JSON payload describing what happened. Schema is provider-specific. Stripe sends
{ "type": "payment_intent.succeeded", "data": { ... } }. GitHub sends{ "action": "opened", "pull_request": { ... } }. Discord sends Ed25519-signed JSON with atypediscriminator. - A signature header — proof that the body came from the provider, not an attacker. Stripe uses
Stripe-Signature, GitHub usesX-Hub-Signature-256, Shopify usesX-Shopify-Hmac-Sha256. The signature is HMAC over the body using a shared secret. - A timestamp — to prevent replay attacks. Most providers reject requests older than ~5 minutes.
The handler shape
In any web framework:
app.post('/webhooks/stripe', (req, res) => {
// 1. Verify the signature using the raw body bytes (not parsed JSON).
const event = stripe.webhooks.constructEvent(req.rawBody, req.headers['stripe-signature'], SECRET)
// 2. Acknowledge fast — webhook providers retry on slow / failing handlers.
res.status(200).send()
// 3. Process async (queue, background worker) — never block the HTTP response.
enqueue(event)
})
The "verify before parse" rule comes up in /tools/webhook-signature-verifier and the Stripe debugging post — it's the #1 cause of "my signature doesn't match" bugs.
Reliability is on the provider
Every serious webhook provider retries delivery on failure: Stripe retries with exponential backoff for ~3 days; GitHub for 24h; Discord drops after a couple of minutes. This means your handler can be down for a while and you won't lose events — but it also means a 5xx response is expensive (it triggers retries; flapping handlers create thundering herds).
Best practice:
- Return 2xx as soon as the signature checks out, before doing any actual work.
- Push the event into a queue.
- Process the queue with idempotency (same
event.idproduces the same result twice).
Testing webhooks during development
This is where most of the friction lives. Webhooks need a public HTTPS URL with a real cert; localhost doesn't qualify. Three classic options:
- Deploy to staging — slow inner loop, hard to debug.
- Provider replay tools — Stripe CLI
stripe listenis excellent; GitHub has a "redeliver" button. Both work, both have limits. - Tunnel — run an agent on your laptop that gives you a public URL forwarding to localhost. ngrok was the original; lrok is the cheaper alternative with a reserved subdomain on the free plan.
The advantage of a tunnel: your handler's full code path runs locally with hot reload, you can step through a debugger, and the URL stays put across days of development if you reserve a subdomain.
Try it
$ lrok reserve stripe-dev
$ lrok http 4242 --hint stripe-dev
Forwarding https://stripe-dev.lrok.io → http://127.0.0.1:4242
Paste https://stripe-dev.lrok.io/webhook into the Stripe dashboard once. The URL doesn't rotate; the dev loop is now real-time.
Walkthroughs for specific providers: Stripe, GitHub, Discord, Twilio, Shopify.