Agent SkillsAgent Skills
git-tao

webhook-safety

@git-tao/webhook-safety
git-tao
0
0 forks
Updated 3/31/2026
View on GitHub

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

Installation

$npx agent-skills-cli install @git-tao/webhook-safety
Claude Code
Cursor
Copilot
Codex
Antigravity

Details

Path.claude/skills/webhook-safety/SKILL.md
Branchmain
Scoped Name@git-tao/webhook-safety

Usage

After installing, this skill will be available to your AI coding assistant.

Verify installation:

npx agent-skills-cli list

Skill Instructions


name: webhook-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

EventAction
checkout.session.completedAllocate credits, create order
customer.subscription.createdCreate subscription record
customer.subscription.updatedUpdate subscription
customer.subscription.deletedMark inactive
invoice.paidAllocate monthly credits
invoice.payment_failedNotify 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

BugFix
Double creditsCheck billing_events before processing
Lost paymentsRe-throw errors to trigger retry
Race conditionsWait for prerequisites with timeout
Wrong customerUse customer field, not customer_email