Skip to content
Advertisement
Automation

How Webhooks Work: Verify, Dedupe, and Respond Fast

AI Tools Tutorial Team19 min readDocumentation and user reports

Pricing and features verified August 2026

Photograph of network cable

Photo by Lenharth Systems via stocksnap (CC0)

A webhook is one HTTP POST from someone else's server to a URL you published. That is the whole mechanism — and the moment you publish that URL, anyone on the internet can POST to it too. Everything hard about webhooks follows from that: verifying the signature over the exact raw bytes, rejecting replays, deduplicating on the delivery ID, and returning 2xx before you do any real work.

Key takeaways

  • There is no webhook protocol — only each sender contract, so copy-pasted verification code breaks when you switch senders
  • Verify over the RAW bytes; a global JSON parser silently invalidates every signature
  • crypto.timingSafeEqual throws on a length mismatch, turning a forged signature into a 500 and a retry storm
  • The delivery ID the sender already sends is your idempotency key — record it before you act
  • GitHub never retries, Shopify deletes your subscription after 8 failures. Read the contract before writing code
  • Return 2xx in milliseconds, then process in a worker that owns its own retries

What actually crosses the wire#

Trace one delivery. You register an HTTPS URL. Something happens in the sender's system, they open a TLS connection, POST a body with a handful of headers, wait for a status line, and close.

While waiting, their infrastructure decides three things: did you answer in time, was the status 2xx, does this go on the retry queue. That is the whole state machine you program against. Stripe's failure list makes the edges concrete — unable to connect, TLS error, 4xx, 5xx, timed out, redirects. It treats a 3xx as a failure outright, and accepts only TLS 1.2 and 1.3.

Advertisement

Webhooks versus polling, and why mature systems run both#

Polling's ceiling is its interval. Poll every 30 seconds and you learn about an event up to 30 seconds late, however fast your code is. Webhook latency is bounded by the sender's dispatch instead.

But "polling is wasteful" is lazier than the truth. GitHub documents that a conditional request returning 304 does not count against your primary rate limit when correctly authorized — a well-built poller is close to free there. GitHub still recommends webhooks, and says to poll on a fixed schedule and respect x-poll-interval when present.

The honest framing: webhooks are the low-latency path, polling is the reconciliation path. Stripe does not guarantee event ordering, and GitHub does not retry at all. Anything you missed, you can only find by asking.

Push versus pull, judged on the things that actually cost you money
DimensionWebhook (push)Polling (pull)
LatencyBounded by the sender dispatchBounded by your interval — a 30s poll means up to 30s late
Cost at low event volumeOne request per real eventEvery interval, event or not
Cost at high event volumeScales with events, and bursts land all at onceFlat and predictable
Your server is downEntirely governed by the sender retry contractYou catch up on the next successful poll
OrderingNot guaranteed — Stripe says so explicitlyYou read current state, so order is moot
Operational burdenPublic endpoint, signatures, dedupe, queueSchedule, cursors, rate-limit budget
Push versus pull, judged on the things that actually cost you money

What works

  • Near-real-time reaction without a scheduler
  • One request per event instead of thousands of empty polls
  • The sender absorbs the change-detection work
  • Rich payloads often remove a follow-up API read

What does not

  • You must operate a public, internet-facing write endpoint
  • Delivery is best-effort and the guarantees differ per sender
  • Ordering is not guaranteed, so you still need reconciliation
  • Local development needs a tunnel, a proxy, or vendor CLI tooling

The decision rule: webhooks for anything a person is waiting on, plus a slow reconciliation poll for anything with money or fulfillment attached. Never make webhooks your only source of truth.

The headers do the work#

The body is business data. The protocol lives in the headers, and they do four jobs.

Identity. GitHub sends X-GitHub-Delivery, which its docs describe as a globally unique identifier for the event. Shopify sends X-Shopify-Webhook-Id. Standard Webhooks calls it webhook-id. This is the header that makes deduplication possible, and it costs nothing to store.

Event type. GitHub uses X-GitHub-Event; Stripe puts the type in the body as event.type. Route on this before parsing anything domain-specific.

Authenticity. X-Hub-Signature-256 on GitHub, Stripe-Signature, X-Slack-Signature, X-Shopify-Hmac-SHA256, X-Twilio-Signature. Same idea, five different formats.

Freshness. X-Slack-Request-Timestamp, the t= component inside Stripe-Signature, webhook-timestamp in Standard Webhooks. Without one of these you cannot detect a replay.

One storage note: GitHub caps payloads at 25 MB and will not deliver anything larger.

Advertisement

Verifying the signature, and what it must fail closed on#

The mechanic is simple. You and the sender share a secret; the sender builds a base string from the request, HMACs it, and puts the result in a header; you recompute and compare.

Every word of that hides a per-sender decision, which is why no portable verification function exists. Here is a complete Stripe-shaped handler with the traps handled.

import express from 'express';
import crypto from 'node:crypto';

const app = express();
const TOLERANCE_SECONDS = 300;

// express.raw is scoped to this route and mounted BEFORE any global JSON parser.
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('stripe-signature') || '';
  const secret = process.env.STRIPE_WEBHOOK_SECRET;

  // Header looks like: t=1492774577,v1=5257a869...,v0=6ffbb59b...
  const parts = Object.create(null);
  for (const pair of header.split(',')) {
    const [k, v] = pair.split('=');
    if (!k || !v) continue;
    (parts[k.trim()] ||= []).push(v.trim());
  }

  const timestamp = Number(parts.t?.[0]);
  // Ignore every scheme that is not v1. v0 is a deliberately fake signature.
  const received = parts.v1 || [];
  if (!Number.isFinite(timestamp) || received.length === 0) {
    return res.status(401).send('invalid signature');
  }

  // Freshness check, before any expensive work.
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
    return res.status(401).send('invalid signature');
  }

  // req.body is a Buffer here: the exact bytes Stripe signed.
  const signedPayload = Buffer.concat([
    Buffer.from(String(timestamp) + '.', 'utf8'),
    req.body
  ]);
  const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');

  // Accept ANY matching v1 entry: during secret rotation there are several.
  if (!received.some((candidate) => safeEqual(expected, candidate))) {
    return res.status(401).send('invalid signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Record event.id, enqueue, THEN answer. See the next two sections.
  res.status(200).send('ok');
});

function safeEqual(expected, candidate) {
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(candidate, 'utf8');
  // timingSafeEqual throws when byte lengths differ, so guard first.
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

The Python equivalent for Slack shows how differently the same idea gets expressed. Note the colon-delimited base string and hmac.compare_digest, which is constant-time and safe on unequal lengths.

import hashlib, hmac, os, time
from flask import Flask, request

app = Flask(__name__)
SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"].encode()
TOLERANCE_SECONDS = 60 * 5

@app.post("/webhooks/slack")
def slack_events():
    timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
    signature = request.headers.get("X-Slack-Signature", "")
    if not timestamp.isdigit():
        return "", 401
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return "", 401

    # get_data() returns raw bytes. Do NOT touch request.json before this line.
    base = b"v0:" + timestamp.encode() + b":" + request.get_data()
    expected = "v0=" + hmac.new(SIGNING_SECRET, base, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, signature):
        return "", 401

    # Verified. Record the delivery, enqueue, and answer inside three seconds.
    return "", 200

Both use a constant-time comparison, and both senders insist on it. GitHub's guidance is blunt: never use a plain == operator, prefer secure_compare or crypto.timingSafeEqual. A naive comparison short-circuits on the first wrong byte, so response time leaks how many leading bytes of a forged signature were right — enough to grind out a valid one.

Four ways verification silently breaks#

1. The parsed body is not the signed body. Stripe states that any manipulation of the raw body causes verification to fail. A global express.json() re-serializes, so the bytes no longer match. OWASP's draft adds that reordering fields, changing whitespace or converting encodings all invalidate the signature. Fix: a raw parser scoped to the webhook route, ahead of every other parser.

2. Length mismatch turns a 401 into a 500. On Node v24.12.0, crypto.timingSafeEqual(Buffer.from('abc'), Buffer.from('abcd')) throws, with err.code === 'ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH' and the message "Input buffers must have the same byte length". An attacker POSTing a two-character signature header gets an unhandled exception and a 500.

3. The base string is not the body. Stripe signs timestamp + "." + body. Slack signs "v0:" + timestamp + ":" + body. Standard Webhooks signs msg_id.timestamp.payload. Twilio does not sign the body at all for form-encoded POSTs — it signs the full URL with the scheme, port and query string, sorts the POST parameters alphabetically and appends each name and value to that string, then hashes it with HMAC-SHA1 and base64-encodes the result. Encodings differ too: GitHub hex, Shopify base64.

A real standard exists — RFC 9421, Standards Track since February 2024, with a signature base that can cover derived components such as @method, @target-uri and @path. None of the senders in this article uses it; every one ships its own header format. Assume bespoke.

4. Rotation breaks single-signature code. Stripe keeps a previous secret active for up to 24 hours and emits one v1 entry per active secret during that window. Standard Webhooks makes the same point structurally: its signature header is a space-delimited list so rotation is zero-downtime. Code that checks only the first signature fails for a day every time you rotate.

Advertisement

Replay attacks: a valid signature on a request you should refuse#

A signature proves the payload came from someone holding the secret. It proves nothing about when. Anyone who captures a signed delivery — a log, a compromised proxy, a TLS-terminating gateway — can send those bytes again and your check passes.

That is why senders sign a timestamp. Stripe puts t= inside the signed payload so it cannot be edited without breaking the signature; Slack puts version and timestamp in the base string.

The tolerances: Stripe's libraries default to five minutes, and the docs warn against a tolerance of 0 because it disables the recency check entirely. Slack's own sample rejects anything where the difference exceeds 60 * 5. The OWASP draft says plus or minus five minutes. Standard Webhooks says only "some allowable tolerance".

Two things people get wrong. Stripe generates a new timestamp and signature on every retry, so a tight window does not reject legitimate ones. And the check is only as good as your clock, which is why Stripe tells you to run NTP.

Idempotency: the delivery ID is the key, and you already have it#

Every sender hands you a stable identifier: X-GitHub-Delivery, X-Shopify-Webhook-Id, the Stripe event ID, webhook-id in Standard Webhooks. Record it before you act, and skip anything you have seen.

The senders are candid that duplicates happen. Stripe says webhook endpoints occasionally receive the same event more than once. Shopify says it minimizes duplicates but your app might still receive the same webhook more than once, after a network timeout or a retry. Neither is a bug report. Both are the contract.

  1. Verify first, dedupe second

    Never write a delivery ID before the signature passes, or an attacker can poison the dedupe table with IDs that block legitimate deliveries.

  2. Record the ID and the side effect atomically

    The ID insert and the business write must land in one transaction, or a crash between them leaves you deduped against work that never happened.

  3. Match retention to the sender retry horizon

    Standard Webhooks suggests Redis for five minutes; OWASP says at least the validation window. For payments or fulfillment, cover the full horizon — Stripe retries up to three days and can be resent for up to 30 days from the CLI.

  4. Return 200 for known duplicates

    OWASP is explicit: answer a duplicate with 200 so the sender stops redelivering. A 500 on a duplicate is the worst of both worlds.

One Stripe-specific trap: the docs warn that two separate Event objects are sometimes generated for the same underlying change. Event ID alone misses that pair, so add the ID in data.object plus the event type as a second guard.

Key derivation, atomic write-plus-record and dead-lettering are covered in the automation error handling guide. This article stops the moment you return 200.

Advertisement

Every sender's retry contract is different#

Same word, four incompatible contracts. All numbers below come from the senders' own documentation.

Published delivery and retry behavior for four common webhook senders
SenderResponse deadlineAutomatic retriesManual redeliveryAfter the last failure
GitHubNot published in these docsNone at allPast 3 days, via UI or REST APINothing automatic — recovery is manual or nothing
StripeTimeouts logged as a failureUp to 3 days, exponential backoff (live mode)Dashboard 15 days, CLI 30 daysEndpoint keeps receiving new events
Shopify1s connect, 5s total request8 retries over 4 hoursNot covered in this docAdmin API subscription is auto-deleted
Slack3 seconds3 retries: near-immediate, 1 min, 5 minNot applicableNo further attempts; subscriptions temporarily disabled past 95% failures in 60 min
Published delivery and retry behavior for four common webhook senders

GitHub says it plainly: "GitHub does not automatically redeliver failed deliveries." Your only recovery is the three-day manual window, by hand or through the REST API.

Shopify's is the one that ends integrations. After eight consecutive failures the subscription is automatically deleted if it was configured through the Admin API — a bad deploy that outlasts four hours does not pause your integration, it removes it.

Slack gives the most feedback: x-slack-retry-num says which attempt this is, x-slack-retry-reason says why the last one failed with values like http_timeout and ssl_error, and x-slack-no-retry: 1 on a non-200 stops further attempts. Slack's own limit is worth reading twice: an app that fails more than 95% of delivery attempts in 60 minutes has its event subscriptions temporarily disabled, so a bad deploy costs you the subscription, not just the events.

Three consequences. Your dedupe retention must cover the longest horizon you subscribe to. A 500 you return is not free — it is a request the sender will send again. And you need an alert on consecutive failures per endpoint, not per event.

Respond fast, work later#

Stripe states it directly: return a 2xx before any complex logic that could time out — specifically, return 200 before marking an invoice paid in your accounting system. Shopify frames queuing as how you answer inside five seconds. Slack gives you three.

The whole handler is five steps: read the raw body, verify the signature, check-and-record the delivery ID, enqueue, return 200. Every slow thing — database writes, API calls, emails, PDF generation — happens in a worker that owns its own retries.

Stripe's reason for the queue is capacity, not just latency: it warns about spikes at the start of the month when all subscriptions renew, exactly when a synchronous handler falls over.

Two failure modes deserve naming. Returning 200 before you have durably enqueued loses the event on a crash with no retry coming — OWASP says return 200 only after acknowledgment. And returning 500 because your worker is broken makes the sender replay every event at you while you are already struggling.

Advertisement

Debugging: see the bytes before you guess#

Reach for these three in order.

The sender's own delivery log. Stripe shows the status of each attempt and when the next retry is due, on the event destination's Event deliveries tab. GitHub's delivery view shows the request headers, the payload and your response. Stripe's own status table names the usual culprits behind "the webhook never fired": a 404, a 3xx redirect, a TLS error, or a timeout — all visible there before you touch your code.

A request bin, to see what a sender actually sends before you have a handler. Webhook.site gives a free unique URL that displays headers and body instantly. Honest limits: free URLs and their data are removed after 7 days, and the URL stops accepting new requests after 100 of them. Never point a production webhook carrying customer data at a public bin.

A tunnel plus a replay loop for the real fix cycle. The ngrok agent runs a local inspection interface whose API sits at http://127.0.0.1:4040/api and can replay a captured request against your local endpoint, so one genuine signed payload can hit a breakpoint dozens of times. Two constraints ngrok states plainly: capture and replay are documented for HTTP requests only, and because the API is served on a local interface it has no authentication — it is a single-machine developer tool, not something to expose. Stripe's CLI does the same job natively: stripe listen --forward-to localhost:4242/webhook prints a signing secret and stripe trigger payment_intent.succeeded fires an event. Its catch is that those are test-mode events, and Stripe attaches an extra deliberately invalid v0 signature to test-mode deliveries.

When you cannot expose a public URL#

Corporate firewall, no ingress budget, or a security team that will not approve an internet-facing endpoint. Four real options, each with its actual constraint.

Outbound-only tunnel. Cloudflare Tunnel's cloudflared daemon uses an outbound-only connection model, so you can allow only those outbound connections and block all inbound traffic. Its zero-setup mode, cloudflared tunnel --url http://localhost:8080, needs no account and no domain and generates a random trycloudflare.com subdomain. Cloudflare's own limitation, verbatim: "Quick Tunnels are intended for testing and development only." It also states there is no SLA or uptime guarantee.

A WebSocket instead of an endpoint. Slack's Socket Mode removes the public URL and needs no signature verification, because events arrive pre-authenticated. Three limits: Socket Mode apps are not currently allowed in the public Slack Marketplace, it requires granular permissions, and you get at most 10 open connections. You still acknowledge each event so Slack knows whether to retry.

A managed bus instead of HTTP. Stripe can send events straight to Amazon EventBridge or Azure Event Grid as event destinations, moving the trust boundary inside your cloud account. The limitation is reach: of the senders covered here, only Stripe documents it, so it solves one integration rather than the pattern.

A forwarding proxy, for development. GitHub's testing docs walk through smee.io, with npm install --global smee-client and smee --url WEBHOOK_PROXY_URL --path /PATH --port PORT. GitHub presents it as a way to test webhooks against a local server, and every payload travels through a third-party relay on the way, so keep production traffic off it.

The fallback nobody wants: if none of those clear review, poll. Conditional requests, fixed schedule, poll-interval honored. Slower, and it works.

Who this is not for#

Skip this if you are only sending webhooks — those failure modes are different. Skip it if you are wiring a personal automation that posts to a Slack channel and a duplicate costs nothing. Skip it if the tool ships a first-party connector that verifies signatures for you, because reimplementing that by hand adds risk and buys nothing.

Read it if a webhook triggers a write to a system of record — orders, invoices, tickets, CRM, user permissions. That is where a forged, replayed or duplicated request costs real money.

Commit to this#

If your handler writes to a system of record, do not enable the endpoint until six things are true: raw body preserved ahead of any parser; signature verified constant-time with a length guard that returns 401 and never 500; timestamp checked against a bounded tolerance with NTP running; delivery ID recorded and duplicates short-circuited to 200; work durably enqueued; 2xx returned before anything slow. That is an afternoon.

The second afternoon is the rest: secrets in a manager with a dual-key rotation window, strict schema validation after a valid signature, POST only with a 405 for every other method, the CSRF exemption scoped to the webhook route rather than disabled globally, dedupe retention matched to the retry horizon, and logs carrying event ID, type, status and latency — never the body or the secret.

What the worker does after that 200 is the automation error handling guide. Before a vendor gets an endpoint inside your network, run it through the AI tool security checklist — and for a webhook-triggered workflow end to end, see the inbox triage build.

Frequently asked questions

What is the difference between a webhook and an API?

A webhook is a push: the sender POSTs to a URL you published the moment something happens. A normal API call is a pull, where you ask and wait. Most mature integrations run both — webhooks for low latency, API reads for reconciliation, because senders like Stripe do not guarantee event ordering.

Why does webhook signature verification keep failing?

Usually because your framework parsed and re-serialized the body before you hashed it. Stripe verifies against the exact raw bytes, so any change to whitespace, field order or encoding breaks the check. Mount a raw body parser scoped to the webhook route, ahead of any global JSON middleware.

Do webhooks need authentication, and is HTTPS enough on its own?

HTTPS protects bytes in transit but proves nothing about who sent them. Your endpoint is a public write path, so verify the HMAC signature the sender publishes and check the signed timestamp. The OWASP draft cheat sheet treats IP allowlisting as a supporting control, never as the only one.

What happens if your webhook endpoint is down when an event fires?

That depends entirely on the sender. GitHub does not retry at all and gives you a three-day manual redelivery window. Stripe retries for up to three days with exponential backoff. Shopify retries eight times over four hours, then deletes an Admin API subscription after eight consecutive failures.

How do you test a webhook on localhost?

Reach for the sender tooling first. The Stripe CLI forwards events to a local port and prints a signing secret, and GitHub's testing docs walk through smee.io with the smee-client package. A tunnel also works for senders that sign the body, but Twilio signs the full URL including scheme, port and query string, so a rewritten host breaks it.

How do you stop a webhook from being processed twice?

Record the delivery ID the sender already gives you — X-GitHub-Delivery, X-Shopify-Webhook-Id, or the Stripe event ID — before you act, and short-circuit anything you have already seen to a 200. Keep those IDs at least as long as the sender retry horizon, not just your replay window.

Sources

  1. Stripe docs — Receive Stripe events in your webhook endpoint
  2. Stripe docs — Webhook IP addresses
  3. GitHub Docs — Validating webhook deliveries
  4. GitHub Docs — Webhook events and payloads
  5. GitHub Docs — Redelivering webhooks
  6. GitHub Docs — Testing webhooks
  7. GitHub Docs — Best practices for using the REST API
  8. Slack API docs — Verifying requests from Slack
  9. Slack API docs — Events API
  10. Slack API docs — Using Socket Mode
  11. Shopify dev docs — Subscribe to webhooks with HTTPS
  12. Twilio docs — Security and webhook request validation
  13. Standard Webhooks specification
  14. OWASP Cheat Sheet Series (draft) — Webhook Security Guidelines
  15. RFC 9421 — HTTP Message Signatures
  16. Node.js crypto docs — timingSafeEqual
  17. ngrok docs — Agent
  18. ngrok docs — Agent API
  19. Cloudflare docs — Cloudflare Tunnel
  20. Cloudflare docs — TryCloudflare quick tunnels
  21. Webhook.site documentation
  22. n8n docs — Webhook node
Advertisement

AI Tools Tutorial Team

Editorial

The editorial team behind aitoolstutorial.com. Every tool is checked against its vendor's own pricing and docs before anything is published, every source is linked at the foot of the article, and every recommendation names at least one thing the tool gets wrong.