Home / Webhook Best Practices

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

· 9 min read

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:

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:

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:

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?

FAQ: Webhook Best Practices

What are webhook best practices?
The six webhook best practices are: (1) respond with 200 immediately and process asynchronously to avoid platform timeouts, (2) verify the platform's HMAC signature on every request using the raw body, (3) make handlers idempotent by storing and checking the event ID before processing, (4) don't rely on platform retries as your error recovery strategy, (5) set up active monitoring to detect failures within minutes rather than hours, (6) test against real platform events — including signature verification and idempotency — before going live.
Why should you respond to a webhook with 200 immediately?
Webhook platforms measure delivery success by your HTTP response time. Shopify times out after 5 seconds; Stripe after 30 seconds. If your handler does slow work — database writes, external API calls, email sends — before returning 200, you'll frequently time out and the platform marks the delivery as failed, triggering retries. The correct pattern: enqueue the event payload (to Redis, a database job table, or a message queue) and return 200 immediately. A separate worker handles the actual processing without any response-time constraint.
How do you make a webhook handler idempotent?
Every platform assigns a unique ID to each event (Stripe: evt_xxx, Shopify: X-Shopify-Webhook-Id header, GitHub: X-GitHub-Delivery header). Before processing, attempt to insert that ID into a processed_events table with a unique constraint. If the insert succeeds, process the event. If it fails with a unique violation, you've already handled it — return 200 and skip. This prevents duplicate processing when platforms retry a delivery you've already handled.

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.