| name | stripe-webhook-idempotency |
| description | Server-side Stripe webhook idempotency patterns. PROACTIVELY activate for: (1) Stripe webhook handler design, (2) Transactional dedup via stripe_processed_events, (3) credit_transactions.idempotency_key UNIQUE partial indexes, (4) Idempotency-Key header priority (header > body > server UUID), (5) Idempotency key format/charset/length validation at the handler edge, (6) FOR UPDATE row locking when a UPDATE depends on a prior SELECT, (7) Webhook signature verification (stripe.webhooks.constructEventAsync, tolerance, raw-body reading), (8) Retry-safe endpoints with randomUUID fallback, (9) Durable checkpoint row ordering (checkpoint FIRST, mutation SECOND). Provides: complete webhook handler skeleton, Idempotency-Key validator, dedup SQL, FOR UPDATE pattern, signature verification example. |
Quick Reference
| Concept | Source of truth | Default |
|---|
| Signature verification | stripe.webhooks.constructEventAsync(rawBody, sig, secret, tolerance) | tolerance = 300s |
| Event dedup (short-term) | stripe_processed_events (30d retention) | primary key: event_id |
| Balance-change dedup (durable) | credit_transactions.idempotency_key UNIQUE partial index | retention = forever |
| Idempotency key priority | Idempotency-Key header > body.idempotency_key > crypto.randomUUID() | validate len<=128, [A-Za-z0-9_-] |
| Checkpoint ordering | Checkpoint INSERT -> user UPDATE | never reverse |
| Row lock | .for("update") on the snapshot row | required when UPDATE depends on prior SELECT |
When to Use This Skill
Use for every Stripe webhook handler and every client-retry-safe mutation endpoint (any POST route that creates a billable entity, e.g., /api/v1/orders, /api/v1/jobs, or your project's equivalent).
Related skills:
- For refund/dispute state:
stripe-billing-master:stripe-refund-dispute-lifecycle
- For the audit-row invariant:
stripe-billing-master:stripe-credit-audit-trail
- For post-signature invoice-line / refund pagination in the handler body:
stripe-billing-master:stripe-list-pagination-previous-attributes
Core Rules
G1: Durable checkpoint BEFORE state mutation
await db.transaction(async (tx) => {
const [inserted] = await tx.insert(creditTransactions).values({ })
.onConflictDoNothing({ target: creditTransactions.idempotencyKey })
.returning({ id: creditTransactions.id });
if (!inserted) return;
await tx.update(users).set({ }).where();
});
Idempotency-Key header priority + validation
const MAX_LEN = 128;
const VALID = /^[A-Za-z0-9_-]+$/;
export function validateIdempotencyKey(key: string | null): { valid: true; key: string | null } | { valid: false; error: string } {
if (!key || key.trim() === "") return { valid: true, key: null };
if (key.length > MAX_LEN) return { valid: false, error: "Idempotency-Key too long" };
if (!VALID.test(key)) return { valid: false, error: "Idempotency-Key invalid charset" };
return { valid: true, key };
}
const headerKey = req.headers.get();
bodyKey = body.;
raw = headerKey ?? bodyKey ?? ;
v = (raw);
(!v.) (, , v.);
key = v. ?? crypto.();
Signature verification
Use constructEventAsync — synchronous constructEvent blocks the worker on large payloads.
const event = await stripe.webhooks.constructEventAsync(
rawBody,
sigHeader,
env.STRIPE_WEBHOOK_SECRET,
300,
);
Complete webhook handler skeleton
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature) return apiError(400, "VALIDATION_ERROR", "Missing stripe-signature");
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
rawBody,
signature,
env.STRIPE_WEBHOOK_SECRET,
300,
);
} catch (err) {
logEvent("stripe_webhook_bad_signature", { err: String(err) });
return apiError(400, "INVALID_SIGNATURE", "Signature verification failed");
}
const dedup = await db.insert(stripeProcessedEvents)
.values({ eventId: event.id, type: event.type })
.({ : stripeProcessedEvents. })
.({ : stripeProcessedEvents. });
(dedup. === ) ({ : });
(event);
({ : });
}
FOR UPDATE on prior-read snapshot
const [user] = await tx.select().from(users).where(eq(users.id, userId)).for("update");
const prev = user.stripeSubscriptionStatus;
await tx.insert(creditTransactions).values({ metadata: { previousStatus: prev } });
await tx.update(users).set({ stripeSubscriptionStatus: "past_due" });