webhook-safety

git-tao's avatarfrom git-tao

Knowledge for safe webhook handling. Apply when working on webhook handlers, payment processing, or subscription events.

⭐0stars🔀0forks📁View on GitHub🕐Updated Jan 9, 2026⬇️1downloads

When & Why to Use This Skill

This Claude skill provides essential patterns and best practices for safe webhook handling, focusing on idempotency, signature verification, and race condition prevention. It is specifically designed for developers working on payment processing (like Stripe), subscription management, and asynchronous event-driven architectures to ensure data integrity and system reliability.

Use Cases

  • Implementing idempotent Stripe webhook handlers to prevent duplicate credit allocation or double-billing during network retries.
  • Securing webhook endpoints with cryptographic signature verification to protect against spoofing and unauthorized data injection.
  • Managing race conditions in asynchronous workflows, such as ensuring a subscription record is created before processing its associated invoice payment.
  • Designing fail-safe error handling strategies that leverage webhook retry mechanisms to prevent lost payments or missed status updates.
namewebhook-safety
description"Knowledge for safe webhook handling. Apply when working on webhook handlers, payment processing, or subscription events."

Webhook Safety Skill

Safe webhook handling patterns for Stripe and other payment/event webhooks.

When to Apply

  • Modifying webhook handlers
  • Processing Stripe events
  • Credit allocation from payments
  • Subscription status updates
  • Any async event processing

Core Invariants

1. Idempotency (CRITICAL)

Webhooks may be delivered multiple times. Every handler MUST be idempotent.

// CORRECT: Idempotent handling
async function handleCheckoutCompleted(event) {
    // Check if already processed
    const existing = await db.billingEvents.findFirst({
        where: { stripeEventId: event.id }
    });

    if (existing) {
        return { status: 'already_processed' };
    }

    // Process and record
    await processCheckout(event.data.object);
    await db.billingEvents.create({
        data: { stripeEventId: event.id, eventType: event.type }
    });
}

// WRONG: Will double-credit on retry
async function handleCheckoutCompleted(event) {
    user.credits += 100; // DOUBLES ON RETRY!
}

2. Signature Verification

app.post('/webhooks/stripe', async (req, res) => {
    const sig = req.headers['stripe-signature'];
    let event;

    try {
        event = stripe.webhooks.constructEvent(
            req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
        );
    } catch (err) {
        return res.status(400).send('Invalid signature');
    }

    // Process event...
});

3. Fail-Fast for Critical Events

// CORRECT: Re-throw to trigger retry
async function handlePaymentSucceeded(event) {
    try {
        await allocateCredits(event);
    } catch (error) {
        console.error('Credit allocation failed:', error);
        throw error; // Re-throw for retry
    }
}

// WRONG: Silent failure = lost payment
async function handlePaymentSucceeded(event) {
    try {
        await allocateCredits(event);
    } catch {
        // Silent catch = LOST PAYMENT
    }
}

Event Types to Handle

Event Action
checkout.session.completed Allocate credits, create order
customer.subscription.created Create subscription record
customer.subscription.updated Update subscription
customer.subscription.deleted Mark inactive
invoice.paid Allocate monthly credits
invoice.payment_failed Notify user, pause service

Race Condition Prevention

// Webhooks can arrive out of order!
// invoice.paid may arrive before subscription.created

async function handleInvoicePaid(event) {
    const subscriptionId = event.data.object.subscription;

    // Wait for subscription with timeout
    let subscription;
    for (let i = 0; i < 5; i++) {
        subscription = await db.subscriptions.findFirst({
            where: { stripeSubscriptionId: subscriptionId }
        });
        if (subscription) break;
        await sleep(1000);
    }

    if (!subscription) {
        throw new Error('Subscription not yet created - retry');
    }

    await allocateCredits(subscription);
}

Billing Events Table

CREATE TABLE billing_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    stripe_event_id VARCHAR(255) UNIQUE NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ON billing_events(stripe_event_id);

Common Bugs

Bug Fix
Double credits Check billing_events before processing
Lost payments Re-throw errors to trigger retry
Race conditions Wait for prerequisites with timeout
Wrong customer Use customer field, not customer_email