Typical integration
- invoice.paid evt_1A
- invoice.paid evt_1A
Same event again. No dedupe.
credits 2 · emails 2
Stripe delivers webhooks at least once and in no guaranteed order. Between "test payment succeeded" and real customers, most integrations hit the same bugs: double-provisioning, a duplicate credit, a paid user without access.
Webhook-Proof Stripe is the billing layer built for client products, packaged for your codebase in an afternoon.
Launch price $59 for the first 50 buyers — enter code LAUNCH59 at checkout
Stripe delivers each event at least once, in no guaranteed order. Watch a typical handler on the left and Webhook-Proof Stripe on the right.
Stripe sends the same invoice.paid event twice. A typical handler credits twice. The kit ledger marks the second delivery as duplicate, so it is never queued twice.
Same event again. No dedupe.
credits 2 · emails 2
Second row conflicts. Marked duplicate.
duplicate · not queued twice
Acknowledge first. Workers claim events with a time-limited lease. Only the worker holding the lease can commit, so a stale or crashed attempt's database writes are discarded. Trust Stripe's live object, not the webhook payload.
01
Events arrive at least once, in no guaranteed order.
02
The endpoint verifies the Stripe signature, enforces a 1MB body cap, and checks livemode. It ignores event types the kit doesn't handle and replies 200.
03
It stores each event once in an event ledger: INSERT ... ON CONFLICT DO NOTHING.
04
Stripe gets its 200 as soon as the event is safely stored, before any processing.
05
3 workers by default (FastAPI); on Next, after() plus a cron drain. Events are claimed with FOR UPDATE SKIP LOCKED plus a lease. Events are deduplicated by Stripe event id, so a duplicate delivery is never queued twice.
06
Workers re-fetch the object from Stripe instead of trusting the payload or the arrival order. Synced state updates when a worker processes the event; failures retry with backoff.
07
Refund and dispute events are recorded and synced from Stripe.
08
hasAccess reads your synced rows. Disputes and fully refunded subscription periods revoke automatically; Payment Link refunds go to a review queue (REFUND_REVOKE_POLICY).
Failures retry with exponential backoff; after repeated failures, it moves to a dead-letter list. On Vercel, events are processed right after the response via after(), with a cron drain as backup. FastAPI runs in-process workers or python -m app.worker.
The kit is the billing layer, not a new app. You drop it in. You don't re-derive webhook correctness.
Signature verification and raw-body gotchas per framework
Included
Idempotent event ledger (INSERT … ON CONFLICT DO NOTHING)
Included
Lease-based worker queue with retries and a dead-letter list, claimed with FOR UPDATE SKIP LOCKED plus a lease
Included
Out-of-order handling by re-fetching from Stripe
Included
Retries, exponential backoff, and a dead-letter list
Included
Refund and dispute events are recorded in the ledger and synced from Stripe.
Included
Entitlements via hasAccess(customerId, feature?) / has_access from synced state
Included
Payment Links fulfilment
Included
Customer Portal
Included
Tests with real signed events against real Postgres
Included
The 16-chapter field guide
Included
| Capability | Build it yourself | Included |
|---|---|---|
| Signature verification and raw-body gotchas per framework | You write it | Included |
| Idempotent event ledger (INSERT … ON CONFLICT DO NOTHING) | You write it | Included |
| Lease-based worker queue with retries and a dead-letter list, claimed with FOR UPDATE SKIP LOCKED plus a lease | You write it | Included |
| Out-of-order handling by re-fetching from Stripe | You write it | Included |
| Retries, exponential backoff, and a dead-letter list | You write it | Included |
| Refund and dispute events are recorded in the ledger and synced from Stripe. | You write it | Included |
| Entitlements via hasAccess(customerId, feature?) / has_access from synced state | You write it | Included |
| Payment Links fulfilment | You write it | Included |
| Customer Portal | You write it | Included |
| Tests with real signed events against real Postgres | You write it | Included |
| The 16-chapter field guide | You write it | Included |
Left is the handler most apps ship. Right is the kit: verify, ledger, 200, then workers.
export async function POST(req: Request) {
const event = await req.json() // parses JSON before verifying
const invoice = event.data.object
await db.credits.add(invoice.customer, 1) // no dedupe
await mailer.sendWelcome(invoice.customer) // trusts the payload
await provisionAccount(invoice.customer) // slow work before 200
return Response.json({ received: true })
}/**
* Accept-fast Stripe webhook. Workers drain the ledger after the response.
*/
import { after, NextResponse } from 'next/server';
import { billingPolicy } from '@/lib/stripe/config';
import { acceptWebhook } from '@/lib/stripe/webhook-handler';
import { drainPendingEvents } from '@/lib/stripe/webhook-worker';
// Runs on the Node.js runtime (the default): raw body, Node crypto and the postgres driver.
// Don't add `export const runtime`: Next 16 rejects route segment config under
// `cacheComponents` (next.config.ts), and the Edge runtime can't run this route.
/**
* Chunked bodies carry no Content-Length: stop reading once the cap is passed, so memory
* stays bounded at the cap plus one chunk. Returns null when the body is too large.
*/
async function readBodyWithCap(req: Request, maxBytes: number): Promise<string | null> {
if (!req.body) {
return '';
}
const reader = req.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > maxBytes) {
await reader.cancel();
return null;
}
chunks.push(value);
}
return Buffer.concat(chunks).toString('utf8');
}
export async function POST(req: Request) {
const contentLengthHeader = req.headers.get('content-length');
if (
contentLengthHeader &&
Number(contentLengthHeader) > billingPolicy.maxWebhookBodyBytes
) {
return NextResponse.json({ error: 'payload_too_large' }, { status: 413 });
}
const body = await readBodyWithCap(req, billingPolicy.maxWebhookBodyBytes);
if (body === null) {
return NextResponse.json({ error: 'payload_too_large' }, { status: 413 });
}
const signature = req.headers.get('stripe-signature');
const accepted = await acceptWebhook(body, signature, billingPolicy.maxWebhookBodyBytes);
if (accepted.status === 200 && accepted.body.queued === true && process.env.VITEST !== 'true') {
after(() => {
void drainPendingEvents(billingPolicy.drainBatchSize);
});
}
return NextResponse.json(accepted.body, { status: accepted.status });
}
Boilerplates give you a whole new app: auth, landing page, blog, and a Stripe integration somewhere inside. That's great on day zero.
This kit is for everyone else: you already have an app (or a client does) and you need Stripe done properly inside it. No auth opinions, no UI framework lock-in, no rewrite.
INSERT ... ON CONFLICT DO NOTHING. Stripe gets its 200 as soon as the event is safely stored, before any processing.past_due gracehasAccess(customerId, feature?) / has_access, derived from synced stateLaunch price: $59 for the first 50 buyers — enter code LAUNCH59 at checkout
Prices in USD. Taxes calculated at checkout.
Prices in USD. Taxes calculated at checkout.
No. It's a billing module you add to an existing Next.js or FastAPI app. It has no auth, UI kit or landing page, so it fits whatever you already use.
Next.js 16 App Router, Python 3.12 with FastAPI, Postgres, and the Stripe API version pinned in the repo. Updates follow Stripe API changes for 12 months.
The ledger and queue use Postgres features (ON CONFLICT, FOR UPDATE SKIP LOCKED plus a lease); the DB layer is spread across several files, so swapping ORMs is real work.
Yes. It's plain Postgres.
The webhook route runs on the Node.js runtime on purpose. The guide explains why.
On the Agency tier, yes, unlimited. Solo is for one individual developer and their own projects.
A ZIP download immediately after purchase, plus GitHub repo access on request.
Email me within 14 days if it doesn't work as described and I'll refund you. See our refund policy.
The Agency tier includes one written review. For hands-on integration work, I take a limited number of contract projects: contact me.
I'm Ashwani Arya, a contract full-stack engineer (React, Next.js, Node.js, Python/FastAPI) based in Bangalore, India. I build products for startups, and Stripe is the part I get called in to make correct: PaymentIntents, Payment Links, Invoices, API integrations and webhook handling in production.
This kit is the pattern I reach for, written fresh and documented properly.
Launch price $59 for the first 50 buyers — enter code LAUNCH59 at checkout