Stripe Payments in Next.js Without Webhook Pain: stripe-no-webhooks and the DIY Sync Pattern
By Arash Latifi
Stripe webhooks are the root of most payment bugs: duplicate events, undefined ordering, silent failure. Two real solutions: the open-source stripe-no-webhooks library, and a scheduled-sync pattern you can build yourself. With Next.js and Postgres code.
TL;DR — Most Stripe payment bugs come from webhooks: duplicate events, undefined ordering, silent endpoint failures. The open-source
stripe-no-webhookslibrary (featured on HN in late 2026) removes the webhook layer entirely and syncs Stripe data straight into your Postgres — or you can build the same idea yourself with a cron job.
Why Stripe webhooks are so painful
Stripe's own documentation (docs.stripe.com/webhooks) is explicit about three behaviors, each a bug source on its own:
- Duplicate events: "Webhook endpoints might occasionally receive the same event more than once" — your endpoint must handle duplicates; Stripe leaves that to you.
- Retries for up to three days: in live mode, Stripe retries failed deliveries with exponential backoff for up to three days. If your endpoint is down for a while, a cascading retry queue comes back.
- Fast 2xx before any logic: the docs require you to "return a 200 response before updating a customer's invoice as paid" — meaning every handler practically needs an internal queue.
Add the API rate limit: the author of stripe-no-webhooks noted in the HN thread (news.ycombinator.com/item?id=46963177) that Stripe's API caps at 100 requests per minute, which real applications feel. Result: every Stripe project needs a dedicated "webhook infrastructure" layer that has nothing to do with the business.
Option 1: the stripe-no-webhooks library
The open-source project (github.com/pretzelai/stripe-no-webhooks) launched on Hacker News in late 2026. Its approach: instead of writing a listener per event, Stripe data is continuously synced into your own Postgres, and your app reads from its own database — the same approach as dj-stripe in the Django world.
Per the repo README, setup is six steps:
Plans are defined in code (billing.config.ts), not manually in the dashboard:
The billing client connects to your auth via resolveUser — for example with Clerk in a Next.js app:
Per the README it supports: subscriptions, credits, wallet balances, usage-based billing (trackUsage: true), seat-based billing, plan upgrades/downgrades with sane credit handling, and optional callbacks like onSubscriptionCreated.
Option 2: roll your own scheduled sync
If you don't want a library dependency, implement the same idea more simply:
| Approach | What it does | Best for |
|---|---|---|
| Classic webhook | Public endpoint + signature verify + idempotency + internal queue | Time-critical events (cutting access the moment a subscription is canceled) |
| Cron polling | Every 5-15 minutes, pull new events from the Stripe API into Postgres | Subscriptions, invoices, billing portal — anything tolerant of a few minutes' delay |
| stripe-no-webhooks | The same polling, plus ready-made plan/credit/wallet handling | SaaS apps on Next.js + Postgres that don't want to build infrastructure |
The polling pattern needs only two pieces: a route that reads events with stripe.events.list({ created: { gte: lastSync } }) (log each processed event ID in a table — that's exactly the idempotency Stripe asks for), and a cron that calls the route. On Next.js you can wire the cron with Vercel Cron or an external worker.
One security note from Stripe's docs that applies in both approaches: verify the webhook signature with whsec and the Stripe-Signature header against the raw body — parsing the body before verification breaks the signature check. And return the 2xx status code before any heavy logic.
When do you still need a webhook?
Going fully webhook-free isn't right for everything. These cases still want one:
- Immediate action: revoking a user's access on
customer.subscription.deleted— polling has minutes of delay. - Real-time metering: per-request usage tracking that must be observed live.
The common healthy combination: polling as the source of truth in Postgres, plus one lightweight webhook for time-sensitive events. If the webhook burns down, no data is lost — the next polling run catches up.
Implementation checklist
- [ ] Webhook endpoint (if you have one): verify the signature on the raw body with
whsecbefore anything else. - [ ] Idempotency: store every processed event ID and check before processing — Stripe's docs list this as a best practice.
- [ ] Fast 2xx, heavy logic later: respond before updating invoices or the database.
- [ ] Respect the 100 rpm API limit: queue bulk operations instead of hitting the API directly.
- [ ] Never infer subscription state from webhooks alone — run a periodic sync to reconcile.
- [ ] Test live and sandbox modes separately — sandbox retries (3 times over a few hours) differ from live (up to 3 days).
FAQ
Q: Does stripe-no-webhooks fully replace webhooks?
For data sync and plan/credit management, yes — the library handles the webhook layer itself (it generates a ready app/api/stripe/[...all]/route.ts). But if you have time-critical real-time logic (like instantly cutting access), add that part via a callback or your own lightweight webhook.
Q: How often is safe for polling?
For subscriptions and invoices, every 5-15 minutes is fine in practice — real users won't notice. For cancellation events that must immediately block access, polling alone isn't enough; add a webhook or a per-request server-side check.
Q: My app uses a local payment provider, not Stripe. Is this still relevant?
The architecture pattern is identical: instead of betting everything on the provider's webhook, periodically verify transaction status via the provider's verification API and build your own source of truth in your database. Webhooks buy you speed; sync buys you correctness.