Webhook Design Patterns 2026 — Reliability and Security

Photo: cottonbro studio / Pexels
Webhook design patterns decide whether an integration survives its first bad night. A webhook is an HTTP POST that a service sends to your URL when something happens, and senders treat anything other than a fast success reply as a failure and try again. That means your endpoint will meet forged requests, duplicate deliveries, events that arrive out of order, and sudden traffic spikes. This guide covers the patterns that handle all four: signature verification, idempotency, fast acknowledgment, retries, dead-letter queues, and monitoring, using the limits the vendors publish.
What a webhook is and why production webhooks break
A webhook is the reverse of an API call. Instead of your code asking Stripe "any new payments?" every minute, Stripe sends your server a request the moment a payment succeeds. Polling is like walking to the mailbox every ten minutes. A webhook is a doorbell: you hear about the delivery immediately, but you now have to deal with whoever is at the door, including people who are not the courier.
Customer pays $99 in Stripe
|
Stripe sends POST to https://yourapp.com/webhooks/stripe
|
Body: {"id": "evt_...", "type": "payment_intent.succeeded", ...}
|
Your app verifies, records, and acts on it
The basics of calling and receiving APIs without code are covered in API basics for no-code automation. What matters here is the delivery contract. Every major sender promises "at least once," never "exactly once." Stripe says plainly that endpoints might occasionally receive the same event more than once, and that it does not guarantee delivery in the order events were generated (Stripe documentation). Every pattern below exists because of one of those two sentences.
What each sender expects from your endpoint
The contracts differ enough that you should check each vendor's current documentation before you design a handler. The table summarizes what the vendors state as of October 2026.
| Sender | Reply deadline | Retries | Signature header | Duplicate-detection field |
|---|---|---|---|---|
| Stripe | Return a 2xx "quickly," before complex logic (no number published) | Live mode: up to three days with exponential back-off. Sandbox: three attempts over a few hours | Stripe-Signature (timestamp plus v1 HMAC-SHA256) |
Event id |
| GitHub | 10 seconds | No automatic redelivery; redeliver manually or with a REST API script | X-Hub-Signature-256 (sha256= plus hex digest) |
X-GitHub-Delivery header |
| Shopify | 1 second to connect, 5 seconds total | 8 retries over about 4 hours; Admin API subscriptions are deleted after 8 consecutive failures | X-Shopify-Hmac-SHA256 (base64 HMAC-SHA256) |
X-Shopify-Webhook-Id header |
| Slack Events API | 3 seconds | Up to 3 retries, at about 0, 1, and 5 minutes | Signing secret | X-Slack-Retry-Num marks retries |
| Standard Webhooks (open spec) | 15 to 30 seconds recommended | Exponential back-off with jitter over multiple days | webhook-signature |
webhook-id header |
Sources: Stripe, GitHub, Shopify, and Slack developer documentation and the Standard Webhooks specification, checked October 19, 2026.
Two details in the table deserve attention. Slack temporarily disables a subscription if more than 95% of delivery attempts fail within 60 minutes, though apps receiving fewer than 1,000 events per hour are exempt (Slack documentation). Shopify deletes the subscription outright after eight consecutive failures. A webhook that fails silently for a few hours can therefore turn into an integration that no longer exists.
The reference pipeline: verify, record, acknowledge, process later
Think of a hotel loading dock. The dock worker checks the delivery slip (signature), writes the delivery in a ledger (dedupe record), signs for the box (2xx response), and puts it on a cart for the stockroom (queue). Nobody opens the box on the dock while the truck waits. The stockroom unpacks it later at its own speed.
That is the whole architecture, and the code is short. This Node.js example uses Express and Stripe's signing scheme.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
// The raw bytes must reach the verifier untouched, so register this route
// BEFORE any app.use(express.json()).
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body; // Buffer
const header = req.get('stripe-signature') ?? '';
// 1. Verify authenticity. Bad signature: 400 and stop.
if (!verifyStripeSignature(rawBody, header, process.env.STRIPE_WEBHOOK_SECRET)) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(rawBody.toString('utf8'));
try {
// 2. Record the event durably. A unique constraint on id ignores repeats.
await db.webhookEvents.insertIfNew({
id: event.id,
type: event.type,
payload: event,
status: 'received',
});
// 3. Hand off to a queue. Enqueueing every time is safe because the
// worker claims each event atomically (see below).
await queue.add('process-webhook', { eventId: event.id });
// 4. Only now acknowledge.
return res.status(200).send('ok');
} catch (err) {
// We could not persist or enqueue: ask the sender to retry later.
logger.error('webhook intake failed', { eventId: event.id, err });
return res.status(500).send('retry later');
}
}
);
The order of steps matters. Acknowledge only after the event is stored and queued, because a 2xx tells the sender to forget the event. If your database is down, a 500 is the correct answer: it is the only way to get another delivery. The queue worker then does the slow, risky work.
async function processWebhook({ eventId }) {
// Atomic claim: exactly one worker flips received -> processing.
const claimed = await db.webhookEvents.updateWhere(
{ id: eventId, status: 'received' },
{ status: 'processing', startedAt: new Date() }
);
if (!claimed) return; // already done, or another worker has it
try {
const { payload: event } = await db.webhookEvents.get(eventId);
await handleEvent(event);
await db.webhookEvents.update(eventId, { status: 'done', finishedAt: new Date() });
} catch (err) {
await db.webhookEvents.update(eventId, { status: 'received' }); // release the claim
throw err; // let the queue apply its backoff
}
}
One gap remains: a worker that crashes after claiming leaves the row stuck in processing. Give each claim a lease, for example by letting any worker reclaim rows whose startedAt is older than a few minutes. The rest of this guide takes each step in turn.
Pattern 1: verify the signature on the raw body
Anyone who learns your webhook URL can send it a request that claims to be a payment confirmation. Stripe's own guidance is blunt: without verification, an attacker could send fake events to trigger actions like fulfilling orders, granting account access, or modifying records. A signature turns "this request says it is from Stripe" into "this request was produced by someone who holds the shared secret."
The mechanism is an HMAC (hash-based message authentication code). Picture a wax seal pressed with a ring only you and the sender own. The sender computes a short fingerprint of the message mixed with the secret key. You recompute it from what arrived. If the fingerprints match, the message is unaltered and came from a key holder. For the broader idea of proving authenticity, see how digital signatures prove a file is authentic.
How Stripe signs events
Stripe puts a timestamp and one or more signatures in the Stripe-Signature header. To verify by hand, split the header on commas, take the t value and each v1 value, build the string timestamp + "." + raw body, compute an HMAC with SHA-256 using the endpoint secret, and compare. Stripe also sends a fake v0 signature on test events, and its documentation says to ignore every scheme other than v1 to prevent downgrade attacks. Stripe recommends its official libraries over hand-rolled code, and the version below exists to show what the libraries do.
function verifyStripeSignature(rawBody, header, secret, toleranceSec = 300) {
let timestamp = null;
const signatures = [];
for (const item of header.split(',')) {
const [key, value] = item.split('=');
if (key === 't') timestamp = value;
if (key === 'v1') signatures.push(value); // ignore v0 and anything else
}
if (!timestamp || signatures.length === 0) return false;
// Replay protection: reject stale (or far-future) timestamps.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return signatures.some((sig) => {
const given = Buffer.from(sig, 'hex');
// timingSafeEqual throws on unequal lengths, so check length first.
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
The length check in that last line fixes a bug common in tutorials. Node's timingSafeEqual throws an exception when the two buffers differ in length, so an attacker who sends a short signature can turn a rejected request into an unhandled error.
Why compare signatures in constant time?
An ordinary string comparison stops at the first mismatching character, so a response that is slightly faster or slower can leak how many leading characters were right. A constant-time comparison takes the same time regardless of where the strings differ. Stripe's and GitHub's documentation both tell receivers to use one, such as Node's crypto.timingSafeEqual.
What is replay protection and how long a window is safe?
A replay attack captures a valid request and sends it again later. Because Stripe's timestamp is part of the signed payload, an attacker cannot change it without breaking the signature, so you can reject old timestamps. Stripe's libraries default to a 5-minute tolerance, and the documentation warns against a tolerance of zero, which disables the recency check entirely. Keep your server clock synchronized with NTP, or legitimate events will start failing.
GitHub, Shopify, and the raw-body trap
GitHub signs the raw payload with HMAC-SHA256 and sends the result in X-Hub-Signature-256, prefixed with sha256=. An older X-Hub-Signature header uses SHA-1 and is kept only for legacy support. GitHub also warns that proxies or load balancers must not alter the payload or headers, or verification will fail.
function verifyGitHubSignature(rawBody, header, secret) {
if (!header || !header.startsWith('sha256=')) return false;
const expected = Buffer.from(
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
);
const given = Buffer.from(header);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
Shopify uses the same idea with a base64 digest in X-Shopify-Hmac-SHA256, keyed with the app's client secret. Its documentation tells developers to capture the raw body before any JSON parser runs and to place verification middleware ahead of body parsing. That is the most common cause of "signature verification failed" errors across all vendors. A JSON parser that reorders keys, strips whitespace, or changes encoding produces different bytes, and different bytes produce a different HMAC. In Express, express.json() must come after your webhook route, which is why the earlier example uses express.raw().
Secrets, rotation, and network allowlists
Treat the signing secret like a password. GitHub's guidance is to generate a random, high-entropy string, store it where your server can read it, and never commit it to a repository. Stripe recommends rolling secrets periodically or when you suspect a leak, and it lets the old secret stay valid for up to 24 hours so you can deploy the new one without dropping events. During that window Stripe sends one signature per active secret, which is why the verifier above accepts any matching v1 value.
A signature is the strong check, but sender IP allowlists add a second layer. Stripe recommends using both, and GitHub publishes its addresses through the GET /meta endpoint, noting that they change and need periodic updates. Do not rely on IP allowlists alone, since they are cheap to get wrong when providers add ranges.
Testing your verifier
GitHub publishes a test vector you can drop into a unit test: with the secret It's a Secret to Everybody and the payload Hello, World!, the expected header is sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17.
import assert from 'node:assert';
const secret = "It's a Secret to Everybody";
const header = 'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17';
assert.equal(verifyGitHubSignature(Buffer.from('Hello, World!'), header, secret), true);
assert.equal(verifyGitHubSignature(Buffer.from('Hello, World?'), header, secret), false);
assert.equal(verifyGitHubSignature(Buffer.from('Hello, World!'), 'sha256=00', secret), false);
The third assertion would crash a naive implementation, and that is exactly why it belongs in the suite.
Pattern 2: idempotency, because the same event will arrive twice
Idempotency means that doing something twice has the same effect as doing it once. Pressing an elevator button five times still summons one elevator. Your webhook handler needs the same property, because retries, network hiccups, and operator replays all create duplicates. Without it, one retried payment_succeeded event can send two receipts or grant two months of access. The same principle applies to automations in general, as described in how idempotency keys make automation safe.
Deduplicate on the event ID, not the timestamp
Stripe's advice is specific. Log the event IDs you have processed and skip any you have already seen. Do not use the created field to decide order or whether an event is new, because snapshot events record created in whole seconds and distinct events can share a timestamp. Stripe also notes that in some cases two separate event objects are generated for the same underlying change, and it suggests identifying those by combining the ID of the object in data.object with the event.type.
Shopify provides X-Shopify-Webhook-Id for the same purpose, and notes that X-Shopify-Event-Id is shared across subscriptions for the same merchant action, which makes it useful for correlation but not for per-subscription deduplication. GitHub's X-GitHub-Delivery header is unique per event and keeps its value when a delivery is redelivered. The Standard Webhooks specification generalizes the idea: webhook-id stays the same across retries, so receivers can use it as an idempotency key.
Make the check atomic
A common mistake is "read, then write": query for the event ID, and if it is missing, process and then insert. Two concurrent deliveries can both read "missing" and both process. The fix is to let the database arbitrate. Insert the event ID under a unique constraint and treat a constraint violation as "already seen," as the reference pipeline does. For work that must not overlap, use a compare-and-set claim, an UPDATE ... WHERE status = 'received' whose affected-row count tells you whether you won. A Redis lock with an expiry can do the same job when no database row is available.
Pass idempotency keys to the APIs you call
Your handler usually calls other APIs, and those calls can be retried too. Stripe supports an Idempotency-Key header on POST requests. For API v1, Stripe saves the status code and body of the first request made with a given key and returns the saved response for retries, including 500 errors. Keys can be up to 255 characters, V4 UUIDs or another high-entropy string are recommended, and keys may be pruned once they are at least 24 hours old. Stripe also compares the parameters on a retry to the original and errors out if they differ.
// Derive the key from the event, so a replayed event reuses the same key.
await stripe.refunds.create(
{ payment_intent: paymentIntentId },
{ idempotencyKey: `refund_${event.id}` }
);
Deriving the key from the event ID gives you stability across retries without storing anything extra. Do not derive keys from sensitive data such as email addresses, which Stripe explicitly advises against.
Pattern 3: acknowledge fast, work later
Senders do not wait long. GitHub gives your server 10 seconds to return a 2xx, then ends the connection and counts the delivery as a failure. Shopify allows 1 second to connect and 5 seconds in total. Slack allows 3 seconds. Stripe does not publish a number, but says to return a 2xx before any complex logic that could cause a timeout, and its debugging table lists "Timed out" as a failure status.
The consequence of slow handling is a feedback loop. A slow handler times out, the sender retries, the retry arrives while the first attempt is still running, and now two copies compete for the same database rows. Stripe also warns of volume effects: a large spike in deliveries, for example when all subscriptions renew at the start of the month, can overwhelm endpoint hosts that process events synchronously. A queue lets you absorb the burst and process at a rate your system can sustain.
Which queue should you use?
| Option | Examples | Best for |
|---|---|---|
| Redis-backed job queue | BullMQ, Sidekiq, Celery with Redis | Most web apps; fast, with built-in retry and backoff |
| Database-backed queue | pg-boss, Inngest, Trigger.dev | Teams that want one less moving part and transactional enqueue |
| Managed cloud queue | Amazon SQS, Google Pub/Sub, Cloudflare Queues | Spiky traffic and teams that prefer not to run Redis |
| In-memory array | none | Prototypes only; events vanish on restart |
The in-memory option deserves its warning. A queue that disappears when the process restarts breaks the promise you made when you returned the 2xx. The durable record in your database from the reference pipeline is what keeps even a simple setup safe.
Which status code to return
Use the status code to tell the sender what to do next. The table reflects how the vendors and the Standard Webhooks specification treat responses.
| Status | When to send it | What the sender typically does |
|---|---|---|
| 200 or other 2xx | Event verified, stored, and queued | Stops retrying |
| 400 | Signature invalid or payload malformed | Counts as failure; may retry |
| 410 Gone | Endpoint permanently removed (Standard Webhooks) | Disables the endpoint |
| 429 | You are overloaded | Should throttle and honor Retry-After |
| 500 or other 5xx | You could not store or queue the event | Retries later |
| 3xx | Never on purpose | Treated as failure by Stripe, Shopify, and the Standard Webhooks specification |
A related myth deserves correction. Some guides say to return 200 even when business logic fails. The better rule is to return 2xx only after the event is safely stored, and to handle business failures inside the worker where you control retries. Returning 500 for genuine intake failures does not cause infinite retries, since Stripe stops after three days in live mode and Shopify after eight attempts, but it does multiply load when you are already struggling. For a refresher on what these codes mean in general, see how HTTP status codes guide browsers.
Pattern 4: retries with exponential backoff and jitter
When a worker fails, retrying immediately usually fails again for the same reason. Exponential backoff waits longer after each failure, doubling the delay each time. Jitter adds randomness so that thousands of failed jobs do not all retry at the same instant, a pattern sometimes called a thundering herd. The Standard Webhooks specification recommends exponential backoff with random jitter, and gives an example schedule of immediately, 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours, and 24 hours, spanning roughly three days.
Most queue libraries do this for you. BullMQ's attempts option enables retries, and its exponential backoff waits 2 ^ (attempts - 1) * delay milliseconds, so a 1,000 ms delay retries after about 1, 2, and 4 seconds. A jitter setting between 0 and 1 randomizes the wait between zero and the computed delay.
await queue.add('process-webhook', { eventId: event.id }, {
attempts: 6,
backoff: { type: 'exponential', delay: 1000 },
// jitter: 0.5, // spreads retries when many jobs fail together
});
Retry transient failures, not permanent ones
Retries help only when the next attempt can succeed. A downstream service that returns 503 or a network timeout is transient. A payload that fails validation, or an event type your code does not handle, is permanent, and retrying it six times wastes capacity and delays the alert. Classify errors in the worker and send permanent ones straight to the dead-letter queue.
class PermanentError extends Error {}
async function handleEvent(event) {
if (!SUPPORTED_TYPES.has(event.type)) {
throw new PermanentError(`unsupported event type ${event.type}`);
}
// ... call downstream services; transient errors propagate as-is
}
// In the queue worker's catch block:
if (err instanceof PermanentError) {
await deadLetterQueue.add('dead', { eventId, reason: err.message });
await db.webhookEvents.update(eventId, { status: 'dead' });
return; // do not rethrow, so the queue does not retry
}
throw err; // transient: let the backoff policy handle it
Rate limits are the in-between case. A 429 from a downstream API means "retry, but later." If you hit those often, how rate limiting protects APIs explains the mechanisms on the other side of the call, and honoring a Retry-After header is better than any fixed guess.
Pattern 5: dead-letter queues and replay
A dead-letter queue (DLQ) is a holding area for events that exhausted their retries or failed permanently. It converts silent loss into a visible backlog that someone can inspect, fix, and replay. The idea is a close cousin of the practice described in why automation needs error queues: failures should land somewhere a person will see them.
A usable DLQ record keeps the event ID, the full payload, the error message, the stack trace, the number of attempts, and the time of the last failure. Alert when anything enters the queue, since a single dead event can mean a customer paid but never received access. After fixing the bug, replay from the DLQ through the same worker, relying on idempotency to make the replay safe.
Recover from outages with the sender's own tools
Senders keep their own delivery records, which gives you a second safety net. Stripe's Dashboard can resend an event for up to 15 days after it was created, and the CLI command stripe events resend works for up to 30 days. The List Events API accepts delivery_success=false to return only events that failed delivery to at least one endpoint, though it only returns events from the last 30 days. Stripe notes that manually resending an event does not cancel its automatic retries, so your handler must still tolerate duplicates.
GitHub takes a different approach. It does not automatically redeliver failed deliveries, so teams either redeliver by hand or run a scheduled script that lists recent deliveries through the REST API, treats any with a status other than "OK" as failed, and redelivers them. Shopify recommends a reconciliation job that periodically pulls data through its APIs to catch anything missed. The general lesson is to treat webhooks as a fast notification channel and an API poll as the source of truth for catching gaps.
Pattern 6: do not trust arrival order or payload freshness
Stripe states that it does not guarantee events arrive in the order they were generated. Creating a subscription, for instance, can produce customer.subscription.created, invoice.created, invoice.paid, and charge.created, and your endpoint may see invoice.paid first. A handler that assumes a customer record already exists will fail intermittently, and intermittent failures are the hardest kind to debug.
Two defenses work. First, make handlers independent of order, so each one can create what it needs if it is missing. Second, treat the payload as a hint and fetch the current state from the API when correctness depends on it. Stripe's guidance is to retrieve missing objects, for example fetching the invoice, charge, and subscription objects related to an invoice.paid event that arrives first. Its newer thin events take this idea further by carrying identifiers and requiring a fetch for the full object.
The payoff is resilience against stale data. An event describes what was true when it was generated, and by the time your worker runs, a refund or cancellation may already have changed things. Fetching the latest object before acting on it prevents you from reversing a correction.
Pattern 7: logging, metrics, and alerts
Webhook failures are quiet by nature. Nothing crashes in your user interface; an event just goes missing. Log every stage with the event ID as a correlation key: received, verified, queued, claimed, completed, failed, dead. A single query on the event ID should reconstruct the history of any delivery.
Track a few metrics and alert on them:
- Verification failures per hour, since a spike can mean a secret was rotated or someone is probing the endpoint
- Time from receipt to completion, which exposes slow downstream services
- Queue depth, which shows backlog building before senders time out
- Failure rate by event type, which shows one broken handler among many healthy ones
- Dead-letter queue size, which should normally be zero
These matter more than they seem because of the vendor thresholds above. A Shopify subscription that fails eight times in a row is deleted, and a Slack app with a failure rate above 95% for an hour is disabled. Monitoring is how you notice before the sender stops trying. The practical side of that work is covered in why webhooks fail silently and how to monitor them and web app monitoring tools.
Sending webhooks: what to build when you are the sender
If your service sends webhooks to customers, you inherit the other side of every pattern above. Customers will judge your reliability by how well you follow the contract that Stripe and GitHub have taught them to expect.
The central problem is the dual write: you update your database and also have to tell the customer, and either step can fail. The transactional outbox pattern solves it. You write the outgoing message to an outbox table in the same database transaction as the business change, and a separate relay reads the outbox and delivers it. Messages are sent only if the transaction commits. The relay can crash after sending but before recording that it did, which means occasional duplicates, so consumers must be idempotent (microservices.io, Transactional outbox). That is the same at-least-once contract described earlier, produced deliberately.
A sender checklist
- Sign every request, include a timestamp inside the signed content, and document the exact format. The Standard Webhooks specification signs
webhook-id.timestamp.payloadwith HMAC-SHA256 and sendsv1,followed by a base64 signature, and it supports multiple space-separated signatures for rotation without downtime. - Use a unique secret per endpoint. The specification recommends unique keys per endpoint and 24 to 64 bytes of random data.
- Keep the event ID constant across retries and refresh the timestamp on each attempt, so receivers can deduplicate and still enforce recency.
- Retry with exponential backoff and jitter over days, and notify the customer through another channel before disabling their endpoint.
- Treat responses precisely. Any non-2xx or timeout is a failure; do not follow 3xx redirects; stop sending on 410 Gone; throttle on 429, 502, and 504, and honor
Retry-After. - Keep payloads small. The specification suggests staying under about 20 KB and sending a link or resource identifier for large assets.
- Offer a replay tool and test events so customers can recover and develop without waiting for real traffic.
Senders also create a security risk of their own. Because your servers make requests to URLs that customers provide, an attacker can point a webhook at an internal address, a pattern known as server-side request forgery (SSRF). The Standard Webhooks specification advises routing webhook traffic through a filtering proxy and isolating the workers in a private subnet. Where the consumer is outside your control, the specification also recommends asymmetric signatures (ed25519), so receivers can verify with a public key and never hold a secret that could be leaked and used to forge your events. For how the surrounding event-driven design fits together, see how event-driven automation works.
Testing webhooks without waiting for real events
Local development needs a public HTTPS URL, because senders cannot reach localhost. Stripe requires webhook endpoints in live mode to be publicly accessible HTTPS URLs, and its documentation suggests a tunneling tool such as ngrok for temporary URLs. Other tunneling tools do the same job.
Stripe's CLI avoids the tunnel entirely for development. stripe listen --forward-to localhost:4242/webhook forwards events to your local server and prints a signing secret to use, and stripe trigger payment_intent.succeeded creates a test event. The CLI secret differs from the Dashboard endpoint secret, and mixing them up is the most common cause of signature errors in development. GitHub's webhook settings show recent deliveries and let you redeliver any of them, which is a quick way to test idempotency.
Beyond the happy path, write tests for the failures that cost money:
- A valid event delivered twice produces one side effect.
- A tampered body, a missing header, and a wrong-length signature are all rejected without throwing.
- A timestamp older than your tolerance is rejected.
- Events delivered in reverse order still converge on the correct state.
- A worker killed mid-processing is eventually reclaimed.
Common mistakes and what each one costs
| Mistake | What happens |
|---|---|
| Parsing JSON before verifying | Signature checks fail unpredictably, so teams disable them |
| Skipping verification "for now" | Anyone can forge events that trigger fulfillment |
| Doing the work before returning 2xx | Timeouts, retries, and duplicate processing |
| No unique constraint on event ID | Race conditions create double side effects |
| Assuming events arrive in order | Intermittent failures that vanish when you retry |
| No dead-letter queue | Failed events disappear silently |
| One shared secret for everything | A single leak compromises every endpoint |
Frequently asked questions
Q: Should I use webhooks or polling? A: Use webhooks when you need prompt notification, and pair them with occasional polling or a reconciliation job to catch missed events. Polling alone is simpler but wastes requests and adds delay. Webhooks alone can leave gaps when your endpoint is down longer than the sender keeps retrying.
Q: What happens if my server is down when a webhook arrives? A: The sender retries according to its own policy. Stripe retries live events for up to three days with exponential back-off, Shopify retries 8 times over about 4 hours, and Slack retries up to 3 times. GitHub does not redeliver automatically, so you must redeliver missed events yourself.
Q: How long should a webhook handler take to respond? A: Return the 2xx as fast as you can after storing the event. Published deadlines include 10 seconds for GitHub, 5 seconds for Shopify, and 3 seconds for Slack. Stripe publishes no figure but asks for a 2xx before complex logic. Do the real work in a queue worker.
Q: Do I need HTTPS for webhook endpoints? A: Yes in practice. Stripe requires publicly accessible HTTPS endpoints in live mode and supports only TLS 1.2 and 1.3. GitHub verifies SSL certificates by default and recommends keeping that on. Signatures prove authenticity but do not encrypt the payload, so HTTPS protects confidentiality.
Q: How do I send one event to multiple subscribers? A: Accept the event once, then fan it out through a queue, topic, or event bus so each subscriber has its own delivery and retry state. A single slow subscriber should never block the others. Keep the original event ID so each consumer can deduplicate independently.
Q: How do I scale webhook processing? A: Separate intake from processing. Intake stays small and stateless, needing only to verify, store, and enqueue. Workers scale horizontally behind the queue, and the atomic claim step prevents two workers from handling the same event. Watch queue depth to decide when to add workers.
The takeaway
Webhooks look like a simple POST, but the delivery contract hides four hard problems: forgery, duplication, disorder, and silent failure. Each pattern in this guide answers one of them, and none of them is exotic. The practical test is whether you can replay last week's events against your handler and get the same database state, with no duplicate side effects, from a queue that tells you when something dies. If you can, your integration is ready for the night when a sender retries every event at once.
Sources
- Stripe: Receive Stripe events in your webhook endpoint (retries, ordering, duplicates, signature verification, secret rotation)
- Stripe: Manage webhook endpoints (undelivered events, List Events API)
- Stripe: Idempotent requests
- GitHub: Best practices for using webhooks
- GitHub: Validating webhook deliveries
- GitHub: Handling failed webhook deliveries
- Shopify: HTTPS webhook subscriptions
- Slack: Events API
- Standard Webhooks specification
- BullMQ: Retrying failing jobs
- microservices.io: Transactional outbox
Vendor limits and behaviors were checked on October 19, 2026. Providers change them, so confirm against current documentation before relying on a number.
Read next:
Follow new articles
Add SciHub101 to your feed reader to see new science and technology stories.
About the Author
SciHub101 is an independent science and technology publication founded and edited by Buğra Sözeri, a software engineer and app developer based in Türkiye. AI tools assist with drafting and research. Source documentation in the existing archive is uneven, and articles are being reviewed and corrected. The Editorial Team byline identifies editorial responsibility; it does not certify specialist or independent human review. Where we compare products, anything built by our own team is labelled as such — see the editorial guidelines and About page for how corrections and disclosures work.
Related Articles

Sales Automation Tools 2026 — Outreach, Apollo, Salesloft
Sales automation tools in 2026: how data, sequencing, CRM, and call-analysis software fit together, with verified pricing, email rules, and a stack-sizing model.


API Basics for No-Code Automation — Webhooks Explained
APIs and webhooks explained for non-technical users. GET vs POST, authentication, real Stripe webhook example. Build advanced automations without coding.

How to Integrate Tools Without Coding — No-Code Guide
Connect your favorite apps: Gmail, Slack, Airtable, Salesforce. No API knowledge required. Zapier, Make, and native integrations compared.

API Integration Patterns 2026 — Webhooks, SSE, Streaming
How to choose between REST polling, webhooks, server-sent events, WebSockets, and gRPC streaming, with retry schedules, failure modes, and reliability rules.
