The 6 webhook best practices are: (1) respond with 200 immediately and process asynchronously, (2) verify the platform's signature on every request, (3) make handlers idempotent using the event ID, (4) never rely on platform retries as your safety net, (5) set up active monitoring to detect failures within minutes, (6) test against real platform events before go-live.
Webhook Best Practices: A Developer's Guide to Reliable Webhook Handling
Webhooks are deceptively simple to set up and surprisingly easy to get wrong. The basics — expose an endpoint, parse the payload, do something with it — take 20 minutes. But the failure modes that bite you in production take much longer to diagnose: duplicate order processing, missed payment events, silently deleted Shopify subscriptions.
This guide covers the six practices that separate a webhook handler that works in development from one that holds up in production.
Why Do Webhooks Need Special Handling?
Webhooks are HTTP POST requests fired by a third party (Stripe, Shopify, GitHub) into your server. Unlike a database query or an API call you initiate, you don't control when they arrive, how fast your handler must respond, whether they arrive exactly once, or whether the event you receive was actually sent by the platform claiming to send it.
Each of those constraints requires deliberate handling:
- Response time constraints — Stripe times out at 30 seconds; Shopify at 5 seconds
- Delivery guarantees — platforms retry on failure, so your handler may receive the same event multiple times
- No authentication by default — anyone can POST to your endpoint; signature verification is your responsibility
- Silent failure modes — platforms retry for hours before notifying you, and some (Shopify) delete subscriptions without warning
How Should You Respond to a Webhook Request?
Return HTTP 200 as fast as possible — before doing any work. This is the single most common webhook implementation mistake.
The correct pattern:
// Express example
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Verify signature first (fast — just crypto)
const event = verifySignature(req.body, req.headers['stripe-signature']);
if (!event) return res.status(400).send('Bad signature');
// 2. Enqueue for async processing
await queue.add('process-webhook', { event });
// 3. Return 200 immediately — before any business logic
res.status(200).send('ok');
}); A separate worker reads from the queue and runs the actual business logic — database writes, API calls, emails — without any response-time pressure. If the worker fails, the job stays in the queue for retry. The webhook delivery itself has already succeeded.
Shopify's 5-second timeout is tight enough that any database query or external API call risks exceeding it. Even with Stripe's 30-second window, database contention, cold starts, or slow downstream calls can cause timeouts at the worst possible time.
How Do You Verify a Webhook Signature?
Every major platform signs its webhook payloads with an HMAC-SHA256 digest using a secret known only to you and the platform. Verifying this signature proves the request came from the platform — not from a third party who discovered your endpoint URL.
See also: Webhook signature glossary entry.
Stripe example — use the official SDK, don't reimplement this yourself:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
function verifyStripeSignature(rawBody: Buffer, signature: string) {
try {
return stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return null; // Bad signature
}
} Critical detail: verify the raw request body, not a parsed JSON object. Parsing JSON and re-serializing it changes byte order and whitespace, invalidating the signature. Middleware like express.json() must not run before signature verification — use express.raw() on your webhook route instead.
Shopify uses X-Shopify-Hmac-Sha256; GitHub uses X-Hub-Signature-256. The pattern is the same for all three — platform-specific implementations are in the integration docs at Stripe, Shopify, and GitHub.
What Is Webhook Idempotency and Why Does It Matter?
See also: Webhook idempotency glossary entry.
Idempotency means processing the same event twice produces the same result as processing it once. This matters because webhook platforms retry on failure — if your server returns a 500, Stripe will re-send the event. If your endpoint times out, Stripe assumes failure and retries. If you deploy mid-retry window, you may process an event twice.
Without idempotency, retries cause: duplicate charges, double email sends, double provisioning, duplicate order fulfillment.
Implementation pattern using a processed-events table:
-- Migration: create idempotency table
CREATE TABLE processed_webhook_events (
event_id TEXT PRIMARY KEY, -- e.g. evt_xxx from Stripe
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- In your handler
async function handleStripeEvent(event) {
// Attempt to record this event ID
try {
await db.query(
'INSERT INTO processed_webhook_events (event_id) VALUES ($1)',
[event.id]
);
} catch (err) {
if (err.code === '23505') return; // Unique violation — already processed
throw err;
}
// Safe to process now — this path runs exactly once per event ID
await processEvent(event);
}
For Shopify, use the X-Shopify-Webhook-Id header as the idempotency key. For GitHub, use the X-GitHub-Delivery header.
What Is the Right Way to Handle Webhook Retries?
Platform retries exist as a delivery mechanism, not as your error recovery strategy. Here's what each platform does:
- Stripe — retries up to 11 times over 72 hours on exponential backoff. You receive an email notification only after all retries are exhausted.
- Shopify — retries 19 times over 48 hours with no notification. After 19 failures, Shopify deletes the webhook subscription without alerting you.
- GitHub — does not retry failed deliveries at all. One attempt, no notification on failure.
The mistake is building business logic that assumes retries will eventually succeed. If your endpoint is down for 73 hours, all pending Stripe retries expire and you lose the events. If your endpoint is consistently returning 500s, Shopify deletes your subscription after 48 hours.
The correct approach: make your handler reliable enough that retries are rare, monitor for failures so you know when retries are happening, and never build recovery flows that depend on platform retries completing.
How Do You Test Webhooks Before Going Live?
See the detailed guide at How to Test Webhooks. The short version:
- Local development — use the Stripe CLI (
stripe listen --forward-to localhost:3000/webhook) or ngrok to expose your local server. Both give you real event payloads. - Signature verification — test that bad signatures are rejected (your handler returns 400, not 200).
- Idempotency — send the same event twice and verify your handler processes it once.
- Slow handlers — simulate a slow database and verify you still return 200 within the platform timeout. If you're using the async-queue pattern, this should be automatic.
- Unknown event types — verify your handler returns 200 for event types it doesn't recognize (don't return 400 for unknown events — you'll be sending a failure signal for events you simply don't care about).
How Do You Monitor Webhooks in Production?
Testing catches the obvious bugs. Production monitoring catches the failures that happen after go-live: your endpoint returning 500s because of a bad deploy, a database outage, a dependency going down, or an upstream API change that breaks your handler.
Each platform maintains a delivery log showing the result of every delivery attempt. Active monitoring means reading these logs automatically and alerting on failures — rather than waiting for a customer to report a problem or for Stripe to exhaust its 72-hour retry window before emailing you.
Webhook Guardian polls Stripe, Shopify, and GitHub delivery logs every 5 minutes via read-only OAuth and sends a Slack or email alert within 5 minutes of any failure — with the event type, error code, retry count, payload, and a one-click replay link. No changes to your endpoint URLs or infrastructure.
What Are the Most Common Webhook Mistakes?
- Synchronous processing — running slow business logic before returning 200, causing timeouts that Stripe or Shopify count as failures
- No signature verification — accepting any POST request to your endpoint without verifying the HMAC signature, making you vulnerable to replay attacks and spoofed events
- Non-idempotent handlers — processing events without checking for duplicates, leading to double-charges, double-sends, and double-provisioning on retries
- Returning 400 for unknown event types — causing the platform to treat unsubscribed events as failures and triggering unnecessary retries
- Parsing JSON before signature verification — invalidating the HMAC check because JSON parsers don't preserve byte-for-byte fidelity
- No production monitoring — relying on platform retry emails (which arrive after 72 hours at best, never at worst) instead of actively watching the delivery log
FAQ: Webhook Best Practices
What are webhook best practices?
Why should you respond to a webhook with 200 immediately?
How do you make a webhook handler idempotent?
Add production monitoring to your webhook setup. Start a free 14-day trial of Webhook Guardian and get alerted within 5 minutes of any failed delivery — across Stripe, Shopify, and GitHub. Or read the full guide to webhook monitoring.