TrueSpec

Secure Webhook Handler

Signature verification, idempotency, retry handling, and queue processing

What You’ll Build

After following this guide, you will have a working implementation of secure webhook handler in your project. Handle third-party webhooks (Stripe, GitHub, etc.) securely and reliably. Covers HMAC signature verification to prove the webhook is authentic, idempotency to handle retries without double-processing, and background queue processing for reliability.

Use Cases & Problems Solved

  • Build reliable server endpoints that clients can consume consistently
  • Handle common backend patterns like routing, middleware, and error handling
  • Provide a clean interface between your frontend and data layer

Prerequisites

  • Node.js 18+
  • Express.js
  • Understanding of HMAC signatures

Step-by-Step Implementation

Verify webhook signature (Stripe example)

The following snippet shows how to verify webhook signature (stripe example). Copy this into your project and adjust the values for your environment.

const crypto = require('crypto');

// IMPORTANT: Use raw body, not parsed JSON
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const sig = req.headers['stripe-signature'];
    const secret = process.env.STRIPE_WEBHOOK_SECRET;

    // Verify HMAC signature
    const payload = req.body.toString();
    const expectedSig = crypto
      .createHmac('sha256', secret)
      .update(payload)
      .digest('hex');

    if (sig !== \`sha256=\${expectedSig}\`) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(payload);
    await processWebhookEvent(event);
    res.status(200).json({ received: true });
  }
);

Idempotency — prevent double processing

The following snippet shows how to idempotency — prevent double processing. Copy this into your project and adjust the values for your environment.

async function processWebhookEvent(event) {
  // Check if already processed
  const existing = await db.webhookEvent.findUnique({
    where: { eventId: event.id },
  });
  if (existing) return; // Already processed, skip

  // Mark as processing
  await db.webhookEvent.create({
    data: { eventId: event.id, type: event.type, status: 'processing' },
  });

  try {
    switch (event.type) {
      case 'payment_intent.succeeded':
        await fulfillOrder(event.data.object);
        break;
      case 'customer.subscription.deleted':
        await cancelSubscription(event.data.object);
        break;
    }
    await db.webhookEvent.update({
      where: { eventId: event.id }, data: { status: 'completed' },
    });
  } catch (err) {
    await db.webhookEvent.update({
      where: { eventId: event.id }, data: { status: 'failed', error: err.message },
    });
    throw err; // Return 500 so the webhook provider retries
  }
}

⚠️ Don’t Do This

❌ Processing webhooks without signature verification

// Anyone can POST fake events to this endpoint!
app.post('/webhooks/stripe', express.json(), (req, res) => {
  const event = req.body; // No verification!
  fulfillOrder(event.data.object); // Attacker triggers fake fulfillment
});

✅ Always verify the signature before processing

// Verify the request actually came from Stripe
const sig = req.headers['stripe-signature'];
try {
  const event = stripe.webhooks.constructEvent(req.body, sig, secret);
  // Now safe to process
} catch (err) {
  return res.status(401).send('Invalid signature');
}

Testing

Add these tests to verify your API endpoints work correctly:

// __tests__/api.test.ts
import { describe, it, expect } from 'vitest';

describe('Secure Webhook Handler', () => {
  it('should return 200 for valid requests', async () => {
    const res = await fetch('/api/endpoint', { method: 'GET' });
    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data).toBeDefined();
  });

  it('should return 400 for invalid input', async () => {
    const res = await fetch('/api/endpoint', {
      method: 'POST',
      body: JSON.stringify({}),
    });
    expect(res.status).toBe(400);
  });

  it('should handle errors gracefully', async () => {
    const res = await fetch('/api/endpoint/nonexistent');
    expect(res.status).toBe(404);
  });
});

Verification

# Use Stripe CLI to test locally:
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded
# Check your logs for successful processing

Related Specs

Intermediate

Production-Ready REST API (Express)

Error handling, Zod validation, rate limiting, CORS, and structured logging

API & Backend
Intermediate

Real-Time Updates with SSE

EventSource API, reconnection, broadcasting, and Express/Fastify SSE

API & Backend
Intermediate

API Rate Limiting Strategies

Token bucket, sliding window, Redis-backed, and per-user limits

API & Backend