DEV Community

myougaTheAxo
myougaTheAxo

Posted on

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

Incoming webhooks from Stripe, GitHub, or any provider need three things: signature verification, idempotency protection, and fast responses. Get any of these wrong and you have security holes or double-processed payments. Claude Code generates the complete safe implementation.


CLAUDE.md for Webhook Handling

## Webhook Receiving Rules

### Security (required)
- Verify signatures on ALL incoming webhooks (reject without valid signature)
- Stripe: verify stripe-signature header with Webhook Secret
- GitHub: verify X-Hub-Signature-256 with HMAC-SHA256
- CRITICAL: verify against rawBody (not parsed JSON — parsing changes the bytes)

### Idempotency (required)
- All webhook handlers must be idempotent (safe to receive same event twice)
- Idempotency key: {provider}-{eventId} in DB processed events table
- Already-processed events: return 200 immediately, skip processing

### Response timing
- Return 200 within 5 seconds (providers retry if no response)
- Heavy processing goes to BullMQ queue BEFORE returning

### Retry safety
- Idempotency table prevents double-processing on retries
- Let sending provider handle retry logic
Enter fullscreen mode Exit fullscreen mode

Generating the Stripe Webhook Handler

Generate a secure Stripe webhook endpoint.

Events to handle:
- payment_intent.succeeded  mark order as completed
- payment_intent.payment_failed  mark order as failed
- customer.subscription.deleted  deactivate subscription

Requirements:
- Verify stripe-signature header
- Use rawBody for verification (skip JSON parser for this route)
- Record processed events in DB for idempotency
- Queue heavy processing via BullMQ, return 200 immediately

Generate:
- src/webhooks/stripeWebhook.ts
- src/webhooks/handlers/paymentHandler.ts
Enter fullscreen mode Exit fullscreen mode

Generated:

// src/webhooks/stripeWebhook.ts
import Stripe from 'stripe';
import express, { Router } from 'express';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export const stripeWebhookRouter = Router();

// IMPORTANT: Use express.raw() here — 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);
  }
);
Enter fullscreen mode Exit fullscreen mode

Payment Handler

// src/webhooks/handlers/paymentHandler.ts
export async function handlePaymentIntentSucceeded(event: Stripe.Event): Promise<void> {
  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');
}
Enter fullscreen mode Exit fullscreen mode

GitHub Webhook Verification

Generate GitHub webhook HMAC-SHA256 signature verification.

Requirements:
- Verify X-Hub-Signature-256 header
- Timing-safe comparison (prevent timing attacks)
- Handle events: push, pull_request
- Use timing-safe comparison for the signature

Save to: src/webhooks/githubWebhook.ts
Enter fullscreen mode Exit fullscreen mode
import crypto from 'crypto';

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
  }
}

githubWebhookRouter.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)) {
      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);
  }
);
Enter fullscreen mode Exit fullscreen mode

Webhook Idempotency Table (Prisma Schema)

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

  @@index([processedAt])
}
Enter fullscreen mode Exit fullscreen mode

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


Summary

Design webhook handling with Claude Code:

  1. CLAUDE.md — Signature verification mandatory, idempotency mandatory, 5-second response rule
  2. Raw body verification — Parse AFTER verifying, never before
  3. Idempotency table — Prevent double-processing on retries
  4. Return 200 fast — Queue heavy work, don't block the response

Security Pack (¥1,480) includes /security-check for webhook security — missing signature verification, absent idempotency, timing attack vulnerabilities.

👉 prompt-works.jp

Myouga (@myougatheaxo) — Claude Code engineer focused on payment and integration security.

Top comments (0)