Stripe webhook idempotency
Marketplace of plugins and skills for Claude Code
npx -y skills add RadOrigin-LLC/RAD-Claude-Skills --skill stripe-webhook-idempotencyAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 5 stars5 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
This skill should be used when implementing idempotent webhook processing, handling "duplicate webhook events", "processed_events table", "idempotency key", "at-least-once delivery", "webhook deduplication", "event ID tracking", "reconciliation cron", "Stripe retry behavior", "crash-safe event processing", "atomic event recording", "webhook reliability patterns", or when the user asks how to prevent double-processing of Stripe webhook events.
SKILL.md
5.8 KB, as published. Nobody here has run it
Idempotent Webhook Processing
Why Idempotency Is Mandatory
Stripe guarantees at-least-once delivery — the same event may be sent multiple times. Without idempotency guards, duplicate events cause:
- Double-charging customers (if creating invoices)
- Double-sending emails
- Double-provisioning resources
- Corrupted subscription state
Idempotency is not optional. Every webhook handler must check before executing.
The Processed Events Table
Track every successfully processed event by its Stripe event ID:
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY, -- Stripe event ID (evt_...)
event_type TEXT NOT NULL, -- e.g., 'invoice.paid'
processed_at TEXT NOT NULL DEFAULT (datetime('now')),
snapshot_event_id TEXT, -- For thin events migration correlation
payload_hash TEXT -- Optional: hash of key fields for debugging
);
CREATE INDEX idx_processed_events_type ON processed_events(event_type);
CREATE INDEX idx_processed_events_snapshot ON processed_events(snapshot_event_id)
WHERE snapshot_event_id IS NOT NULL;
The Critical Ordering Rule
Record the event ID after (or atomically with) the business logic. Never before.
Wrong — Record First, Process Later
// DANGEROUS: If we crash after INSERT but before updateSubscription,
// the event is marked "done" forever. Stripe retries will be ignored.
await db.run('INSERT INTO processed_events (event_id, event_type) VALUES (?, ?)',
[event.id, event.type]);
await updateSubscription(event); // Crash here = permanent data loss
Correct — Atomic Transaction
const processEvent = db.transaction((event: Stripe.Event) => {
// Check idempotency inside the transaction
const existing = db.prepare(
'SELECT 1 FROM processed_events WHERE event_id = ?'
).get(event.id);
if (existing) return { skipped: true };
// Execute business logic
updateSubscriptionSync(event);
// Record event ID — only if business logic succeeded
db.prepare(
'INSERT INTO processed_events (event_id, event_type) VALUES (?, ?)'
).run(event.id, event.type);
return { skipped: false };
});
// Execute atomically
const result = processEvent(event);
if (result.skipped) {
request.log.info({ eventId: event.id }, 'Duplicate event skipped');
}
For SQLite, use BEGIN IMMEDIATE to prevent the read-to-write upgrade deadlock. The db.transaction() helper in better-sqlite3 does this automatically.
For PostgreSQL, wrap the check-and-process in a single transaction with INSERT ... ON CONFLICT DO NOTHING or use advisory locks.
Thin Events Deduplication
During migration from snapshot to thin events, the same logical event may arrive twice — once as a snapshot event and once as a thin event. Deduplicate using the snapshot_event correlation field:
function isAlreadyProcessed(
eventId: string,
snapshotEventId?: string
): boolean {
if (snapshotEventId) {
const row = db.prepare(
'SELECT 1 FROM processed_events WHERE event_id = ? OR event_id = ? OR snapshot_event_id = ?'
).get(eventId, snapshotEventId, snapshotEventId);
return !!row;
}
return !!db.prepare(
'SELECT 1 FROM processed_events WHERE event_id = ?'
).get(eventId);
}
Stripe Retry Behavior
When a webhook delivery fails (non-2xx response or timeout):
- Stripe retries for up to 3 days using exponential backoff
- First retry: ~1 hour after initial failure
- Subsequent retries: increasing intervals
- After 3 days of failures, Stripe marks the endpoint as disabled
Return 200 OK within a few seconds to prevent retries. If the handler needs more time, return 200 immediately and process asynchronously in a queue.
Reconciliation Cron
Webhooks are push-based and can be lost (network issues, server downtime, bugs). Run a daily pull-based reconciliation:
async function reconcileEvents() {
// Fetch recent events from Stripe API
const events = await stripe.events.list({
created: { gte: Math.floor(Date.now() / 1000) - 86400 }, // Last 24 hours
limit: 100,
});
for (const event of events.data) {
const existing = db.prepare(
'SELECT 1 FROM processed_events WHERE event_id = ?'
).get(event.id);
if (!existing) {
log.warn({ eventId: event.id, type: event.type }, 'Missed event detected');
await dispatchEvent(event);
}
}
}
// Schedule daily
// cron: '0 3 * * *' — run at 3 AM
Idempotency for Stripe API Calls (Outbound)
When creating Stripe objects from the application, use the Idempotency-Key header to prevent duplicate charges from retried requests:
const session = await stripe.checkout.sessions.create(
{
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: '...',
cancel_url: '...',
},
{
idempotencyKey: `checkout_${userId}_${priceId}_${Date.now()}`,
}
);
Stripe caches the response for 24 hours. If the same idempotency key is sent again, Stripe returns the original response without creating a duplicate.
Cleanup
Periodically clean up old processed event records to prevent unbounded table growth:
DELETE FROM processed_events
WHERE processed_at < datetime('now', '-90 days');
Run this in a scheduled job, not during webhook processing.