Webhook Handling with Claude Code: Signature Verification, Idempotency, and Retry Safety

Published: (March 11, 2026 at 12:51 AM EDT)
4 min read
Source: Dev.to

Source: Dev.to

Webhook Receiving Rules

Security (required)

  • Verify signatures on all incoming webhooks (reject without a valid signature).
  • Stripe: verify stripe-signature header with the webhook secret.
  • GitHub: verify X-Hub-Signature-256 with HMAC‑SHA256.
  • CRITICAL: verify against the raw request body (not parsed JSON — parsing changes the bytes).

Idempotency (required)

  • All webhook handlers must be idempotent (safe to receive the same event twice).
  • Idempotency key format: {provider}-{eventId} stored in a WebhookEvent table.
  • Already‑processed events: return 200 immediately and skip further processing.

Response timing

  • Return 200 within 5 seconds (providers retry if no response).
  • Heavy processing should be delegated to a queue (e.g., BullMQ) before returning.

Retry safety

  • The idempotency table prevents double‑processing on retries.
  • Let the sending provider handle its own retry logic.

Stripe Webhook Implementation

Endpoint (src/webhooks/stripeWebhook.ts)

import Stripe from 'stripe';
import express, { Router } from 'express';
import { prisma } from '../prisma'; // adjust import as needed
import { webhookQueue } from '../queues'; // BullMQ queue
import { logger } from '../logger';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export const stripeWebhookRouter = Router();

// IMPORTANT: Use express.raw() — not express.json()
// Stripe signature verification requires the raw request body
stripeWebhookRouter.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const sig = req.headers['stripe-signature'] as string;

    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
    } catch (err) {
      logger.warn({ err }, 'Stripe webhook signature failed');
      return res.status(400).send('Signature verification failed');
    }

    // Idempotency check
    const alreadyProcessed = await prisma.webhookEvent.findUnique({
      where: { id: `stripe-${event.id}` },
    });

    if (alreadyProcessed) {
      logger.info({ eventId: event.id }, 'Already processed, skipping');
      return res.status(200).json({ received: true });
    }

    // Record as processed (before processing to prevent race conditions)
    await prisma.webhookEvent.create({
      data: {
        id: `stripe-${event.id}`,
        type: event.type,
        processedAt: new Date(),
      },
    });

    // Return 200 immediately — don't block on processing
    res.status(200).json({ received: true });

    // Queue for async processing
    await webhookQueue.add(event.type, event);
  }
);

Handlers (src/webhooks/handlers/paymentHandler.ts)

import Stripe from 'stripe';
import { prisma } from '../../prisma';
import { emailQueue } from '../../queues';
import { logger } from '../../logger';

export async function handlePaymentIntentSucceeded(
  event: Stripe.Event
): Promise {
  const paymentIntent = event.data.object as Stripe.PaymentIntent;
  const orderId = paymentIntent.metadata.orderId;

  if (!orderId) {
    logger.warn(
      { paymentIntentId: paymentIntent.id },
      'No orderId in metadata'
    );
    return;
  }

  await prisma.order.update({
    where: { id: orderId },
    data: {
      status: 'completed',
      paidAt: new Date(),
      paymentIntentId: paymentIntent.id,
    },
  });

  await emailQueue.add('order-confirmation', { orderId });
  logger.info({ orderId }, 'Order completed via webhook');
}

export async function handlePaymentIntentFailed(
  event: Stripe.Event
): Promise {
  const paymentIntent = event.data.object as Stripe.PaymentIntent;
  const orderId = paymentIntent.metadata.orderId;

  if (!orderId) {
    logger.warn(
      { paymentIntentId: paymentIntent.id },
      'No orderId in metadata'
    );
    return;
  }

  await prisma.order.update({
    where: { id: orderId },
    data: {
      status: 'failed',
      failedAt: new Date(),
    },
  });

  logger.info({ orderId }, 'Order marked as failed via webhook');
}

export async function handleSubscriptionDeleted(
  event: Stripe.Event
): Promise {
  const subscription = event.data.object as Stripe.Subscription;
  const customerId = subscription.customer as string;

  await prisma.subscription.updateMany({
    where: { stripeCustomerId: customerId },
    data: { active: false, cancelledAt: new Date() },
  });

  logger.info(
    { customerId },
    'Subscription deactivated via webhook'
  );
}

GitHub Webhook Implementation (src/webhooks/githubWebhook.ts)

import crypto from 'crypto';
import express, { Router } from 'express';
import { githubQueue } from '../queues';
import { logger } from '../logger';

const router = Router();

function verifyGitHubSignature(body: Buffer, signature: string): boolean {
  const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!);
  hmac.update(body);
  const expected = `sha256=${hmac.digest('hex')}`;

  // Timing‑safe comparison prevents timing attacks
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch {
    return false; // Different lengths
  }
}

router.post(
  '/webhooks/github',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-hub-signature-256'] as string;

    if (!signature || !verifyGitHubSignature(req.body, signature)) {
      logger.warn('Invalid GitHub webhook signature');
      return res.status(401).send('Invalid signature');
    }

    const event = req.headers['x-github-event'] as string;
    const payload = JSON.parse(req.body.toString());

    // Return fast, process async
    res.status(200).json({ received: true });

    await githubQueue.add(event, payload);
  }
);

export default router;

Webhook Event Model (Prisma)

model WebhookEvent {
  id          String   @id // "{provider}-{eventId}"
  type        String
  processedAt DateTime

  @@index([processedAt])
}

Maintenance

  • Clean up old events after 30 days with a scheduled cron job.

For more details on webhook security best practices, see the guide at .

0 views
Back to Blog

Related posts

Read more »