stripe-webhook-idempotency — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited stripe-webhook-idempotency (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Stripe guarantees at-least-once delivery — the same event may be sent multiple times. Without idempotency guards, duplicate events cause:
Idempotency is not optional. Every webhook handler must check before executing.
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;Record the event ID after (or atomically with) the business logic. Never before.
// 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 lossconst 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.
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);
}When a webhook delivery fails (non-2xx response or timeout):
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.
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 AMWhen 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.
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.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.