Payments
Next.js boilerplate with Dodo Payments
Merchant of record in 220+ countries and regions. Sales tax is Dodo's job.
Subscriptions and one-time payments on Dodo Payments, a merchant of record that sells worldwide and remits sales tax for you. Hosted checkout, the customer portal, refunds and disputes that revoke access, and a Standard Webhooks endpoint with delivery-id idempotency. Plans live in src/lib/pricing.ts and /pricing renders with no keys.
What Dodo Payments adds to the agent layer: 2 rules · 2 skills · 6 solution docs
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick Dodo Payments?
Pick it if
Founders outside the US and EU, India especially, selling software worldwide. Dodo takes local payment methods: UPI in India, cards, Apple Pay and Google Pay. It remits sales tax as the seller, so you never register for VAT abroad.
Watch out for
- Dodo is the seller on the customer's statement and invoice. The tax liability moves to Dodo, which is the point, but your brand is not what shows on the card line.
- Younger than Stripe or Paddle, and the SDK changes more often. The version is pinned; read the release notes before upgrading.
Show 3 moreShow fewer
- The flat rate costs more than a direct card processor because it includes the tax work. At high volume, compare it with Stripe plus a tax vendor.
- Fewer billing tools than Stripe. Failed renewals are not retried unless you turn Payment Retries on. Check that the reports and dunning you need exist before you commit.
- Payouts follow a collect-then-remit cycle, not a card processor's rolling schedule. Plan your runway on the payout date, not the charge date.
What it costs
4% + 40c per US card or wallet payment, no monthly fee. Non-US payments add 1.5% and subscriptions add 0.5%. Standard payouts are free, with a $5 fee under $1,000. USD SWIFT payouts for non-US businesses cost $25. Sales tax is included: Dodo is the seller.
Prices change. Check with Dodo Payments before you commit.
registry/tested.yaml
Tested with Dodo Payments
Each pair was installed, typechecked, linted, built and booted together.
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
What it adds
What Dodo Payments adds to the repo
Read straight from the dodo manifest, so it is exactly what lands in your repo.
Environment variables
DODO_PAYMENTS_API_KEYRequired
Server-side API key from Developer -> API Keys in the Dodo dashboard. Test mode and live mode keys are issued separately and only work against their own mode. Never expose it to the browser: it can issue refunds and read every customer's payment history. Until it is set, /pricing still renders and every buy button explains that billing is not set up.
- Where to get it
- https://docs.dodopayments.com/api-reference/introduction
- Placeholder
- dodo_test_replace_me
DODO_PAYMENTS_WEBHOOK_KEYRequired
Signing key for the endpoint at /api/webhooks/dodo, from Developer -> Webhooks -> your endpoint. Dodo signs with Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature). Every endpoint has its own key, so the local listener's key and production's differ.
- Where to get it
- https://docs.dodopayments.com/developer-resources/webhooks
- Placeholder
- whsec_replace_me
DODO_PAYMENTS_ENVIRONMENTOptional
"test_mode" or "live_mode". Picks the API host. Defaults to test_mode, so a fresh clone cannot take a real payment by accident. Set live_mode in production together with the live key.
- Where to get it
- https://docs.dodopayments.com/api-reference/introduction
- Placeholder
- test_mode
DODO_TAX_CATEGORYOptional
The tax category
bun run billing:sync-plansgives the products it creates: saas, digital_products, e_book, edtech or live_tutoring. Dodo remits sales tax as the seller and works the rate out from it.- Where to get it
- https://docs.dodopayments.com/features/products
- Placeholder
- saas
BILLING_PRICE_PRO_MONTHLYOptional
The Dodo product (pdt_...) sold as the catalogue price
pro-monthly, a monthly subscription product.bun run billing:sync-planscreates it and prints this line. Test and live mode have different ids.- Where to get it
- https://app.dodopayments.com/products
- Placeholder
BILLING_PRICE_PRO_YEARLYOptional
The Dodo product (pdt_...) for
pro-yearly, a yearly subscription product.- Where to get it
- https://app.dodopayments.com/products
- Placeholder
BILLING_PRICE_PRO_LIFETIMEOptional
The Dodo product (pdt_...) for
pro-lifetime, a single payment product. One payment grants the Pro plan for good; a refund or dispute takes it away.- Where to get it
- https://app.dodopayments.com/products
- Placeholder
BILLING_PRICE_TEAM_MONTHLYOptional
The Dodo product (pdt_...) for
team-monthly.- Where to get it
- https://app.dodopayments.com/products
- Placeholder
BILLING_PRICE_TEAM_YEARLYOptional
The Dodo product (pdt_...) for
team-yearly.- Where to get it
- https://app.dodopayments.com/products
- Placeholder
Dependencies
- dodopayments~2.52.0
- server-only^0.0.1
- standardwebhooks^1.1.1
Scripts
- bun run billing:prune-events
bun --conditions=react-server scripts/billing/prune-events.ts
- bun run billing:reconcile
bun --conditions=react-server scripts/billing/reconcile.ts
- bun run billing:sync-plans
bun --conditions=react-server scripts/billing/sync-plans.ts
- bun run dodo:listen
dodo wh listen http://localhost:3000/api/webhooks/dodo
- bun run dodo:test-webhook
bun scripts/billing/dodo-test-webhook.ts
Files it writes
14 files, at these exact paths.
scripts/4 files
billing/4 files
- dodo-test-webhook.ts
- prune-events.ts
- reconcile.ts
- sync-plans.ts
src/9 files
app/1 file
api/1 file
webhooks/1 file
dodo/1 file
- route.ts
lib/8 files
billing/8 files
- dodo-config.ts
- dodo-events.test.ts
- dodo-events.ts
- dodo-fixtures.ts
- dodo-objects.test.ts
- dodo-objects.ts
- dodo.ts
- provider.ts
tests/1 file
e2e/1 file
- billing-webhook.spec.ts
Stack slots it fills
The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.
- @slot env-required
- @slot legal-processors
- @slot verify-checks
The differentiator
What Dodo Payments teaches your agent
Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.
Rules (2)
Loaded when the agent opens a matching file.
Dodo translates, the shared billing core writes, and nothing leaves the server
Loads onsrc/lib/pricing.tssrc/lib/billing/**scripts/billing/**src/app/api/webhooks/dodo/**.claude/rules/dodo-billing-discipline.md
Billing here is one shared layer plus one adapter. This battery ships only the adapter:
| File | Job |
|---|---|
src/lib/billing/dodo.ts | the one lazy Dodo client (getDodo(), dodo) |
src/lib/billing/dodo-config.ts | env reads: key, webhook key, DODO_PAYMENTS_ENVIRONMENT, placeholder check. Pure |
src/lib/billing/provider.ts | billingProvider, the BillingProvider contract: customer, checkout, portal, verify, translate |
src/lib/billing/dodo-events.ts | HANDLED_EVENTS, signature check, event translation, success-page sync. Pure, I/O injected |
src/lib/billing/dodo-objects.ts | payment, subscription and product reads (status, refunds, trial). Pure, tested |
src/lib/billing/dodo-fixtures.ts | recorded payloads for the tests and dodo:test-webhook |
src/app/api/webhooks/dodo/route.ts | three lines into the shared processWebhook |
scripts/billing/*.ts | sync-plans, reconcile, prune-events, dodo-test-webhook |
Everything else under src/lib/billing, plus /pricing, /billing and
src/components/billing, is the stack's shared code. Its rule is "Billing is
one shared layer with one provider adapter". Hold the line:
- The adapter never writes billing state and never sends mail.
translateandsyncCheckoutreturnBillingEvent[].applyBillingEventsinwebhook.tsis the only writer and runs them inside the idempotency claim. The one store call the adapter makes is a read:getUserIdForCustomer. - Nothing outside the adapter imports
dodopayments. Not a page, not a component, notentitlements.ts. A feature that needs Dodo data gets a method onBillingProvideror a field on aBillingEvent. Never a branch onbillingProvider.idin shared code. - The client is built lazily, once.
next buildimports every module a page can reach, sonew DodoPayments(...)at module scope throws on any deploy with no keys yet. Do not "simplify"getDodo()away. Onlydodo-config.tsreadsDODO_PAYMENTS_API_KEY. - Nothing Dodo reaches the browser. Checkout and the portal are hosted.
Never import
dodo.ts,provider.tsor@/lib/billinginto a"use client"file.server-onlymakes that a build error.
Products, prices and one-time
The catalogue is src/lib/pricing.ts. Dodo holds one price per product, so
each catalogue price is its own Dodo product, tied by an env var named after
the price id:
pro-monthly -> BILLING_PRICE_PRO_MONTHLY=pdt_... (subscription product)
pro-lifetime -> BILLING_PRICE_PRO_LIFETIME=pdt_... (single payment product)
- No
pdt_...in code. Test and live mode have different ids. - Never trust an amount, interval or trial from the client. The buy button
sends a catalogue id; the product comes from env; the trial comes from the
catalogue (
subscription_data.trial_period_days, 0 overrides a trial set on the product). - One-time or subscription is Dodo's product type.
createCheckoutsends the same session either way. A payment is one-time when it charges no subscription:subscription_idsempty, notsubscription_idnull alone (a multi-subscription payment leaves that null). - The plan comes from the product, never the metadata. For a
subscription, a portal plan change swaps the product and leaves the checkout
metadata naming the old plan. For a one-time payment, a static payment link
copies any
metadata_*query parameter the buyer types into the metadata. An unmapped product storesplanSlugnull, which unlocks nothing. - Statuses are normalized, and Dodo's word is kept.
on_holdisunpaid(no access: Dodo stopped charging and waits for a new card).past_dueis Dodo's grace period and keeps access. An active subscription insidetrial_period_daysistrialing.
billing:sync-plans creates missing products (metadata price_id is the
catalogue id) and exits 1 on drift. verify runs with bun, outside
Next.js: it may import only the pure files (catalog, price-refs, format,
dodo-config, dodo-objects) and the SDK, never dodo.ts, provider.ts or
the store.
Customers
createCustomer makes one Dodo customer per user before the first checkout,
with metadata.userId, and the core stores it with linkCustomer, which never
overwrites. Test-mode and live-mode customers are different objects: point one
database at one mode, or clear billing_customers when you switch.
Verify every Dodo webhook, keep every handler idempotent
Loads onsrc/app/api/webhooks/dodo/**src/lib/billing/provider.tssrc/lib/billing/dodo-events.tssrc/lib/billing/dodo-objects.tssrc/lib/billing/webhook.ts.claude/rules/dodo-webhook-integrity.md
The webhook route is an unauthenticated public endpoint. The signature is the only thing between a Dodo event and a forged POST that grants a lifetime plan.
The route is three lines: processWebhook(billingProvider, request). The
pipeline in src/lib/billing/webhook.ts is shared by every provider. The Dodo
parts are verifyDodoWebhook and translateDodoEvent in dodo-events.ts.
Keep it that way: nothing Dodo-specific in the route.
Read the body as text. processWebhook hands the exact bytes of
request.text() to verifyWebhook. request.json() reorders keys and every
signature fails. Never put a body parser or middleware in front of it.
Verify the Standard Webhooks way, with the library. new Webhook(key)
.verify(rawBody, headers) from standardwebhooks, with all three headers:
webhook-id, webhook-timestamp, webhook-signature. The timestamp is inside
the signed string and must be within five minutes, which is what stops a
captured delivery being replayed. Never hand-roll the HMAC. A missing or
placeholder DODO_PAYMENTS_WEBHOOK_KEY throws WebhookConfigError (500, Dodo
retries until you set it). A bad signature, a missing header or a stale
timestamp throws WebhookSignatureError (400).
Only HANDLED_EVENTS do work. Everything else answers 200 ignored and
writes nothing. Adding an event means adding it to the array, handling it in
translateDodoEvent, adding a fixture test, and subscribing both Dodo
endpoints (test and live) to it.
The claim comes before any side effect. processWebhook inserts
dodo:<webhook-id> into billing_processed_events first. A duplicate answers
200 duplicate. On a throw the claim is released and the answer is 500, so
Dodo's retry runs the work again. Never key on a payment id or a timestamp:
webhook-id is the value Dodo keeps stable across retries.
Re-read what can be stale, trust what is signed and monotonic.
subscription.*and renewal payments re-read the subscription withdodo.subscriptions.retrieve. Dodo retries for a day, so an oldsubscription.activecan land after thesubscription.cancelledthat replaced it.refund.succeeded,dispute.openedanddispute.lostre-read the payment: the refund or dispute payload does not say what was bought, and the payment carries every refund and dispute so far.- A one-time
payment.succeededorpayment.failedis written from the signed payload. The purchase upsert only moves status forward, so a stale "paid" can never undo a refund, and the path needs no network.
One-time is decided by the payment, not the event name.
payment.succeeded fires for renewals too. Only a payment whose
subscription_ids is empty (and that is not a card-change charge,
is_update_payment_method) is a purchase. Treating a renewal as a purchase
would hand out a lifetime plan for a monthly charge.
Receipts and warnings are events, not calls. translate returns
payment.receipt (paid, above zero) and payment.failed (a declined renewal
whose subscription is now past_due or unpaid). applyBillingEvents sends
them inside the claim and logs a failure instead of throwing.
A payment with no local user is skipped loudly, not guessed. The user comes
from metadata.userId (our checkout writes it), then from
billing_customers. With neither, translate reports it and returns no events.
Fix the mapping, then resend the event from the Dodo dashboard, or run
bun run billing:reconcile.
Status codes are the retry protocol. 400 bad signature, 200 processed, duplicate or ignored, 500 for anything transient. Never swallow an error into a 200: that drops a payment on the floor.
Test with signed deliveries only. bun run dodo:listen forwards real
test events, and bun run dodo:test-webhook signs a fixture with your own
key. Never add an "unsafe unwrap" path for Dodo CLI mock events, which are
unsigned.
Skills (2)
Invoked by name.
- /add-product
Add or change a plan or price on Dodo Payments (monthly, yearly or one-time lifetime). Edit src/lib/pricing.ts, create the Dodo product, wire its env var, and prove checkout and the webhook end to end.
.claude/skills/add-product/SKILL.md
- /test-webhook
Exercise the Dodo webhook endpoint. Send a signed fixture offline, forward real test-mode events with the Dodo CLI, buy a subscription and a one-time product, refund one, assert idempotency, and debug signature failures.
.claude/skills/test-webhook/SKILL.md
Solution docs (6)
Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.
- Selling software from outside the US, and why a merchant of record solves the payout problemIf your company is registered in India, Nigeria, Brazil or most of the world, the hard part is not accepting cards. It is getting paid, and staying compliant in a hundred countries you never visit.docs/solutions/dodo/merchant-of-record-payouts-outside-the-us.md
- One-time payments and subscriptions on Dodo Payments, side by sideDodo decides one-time or recurring from the product, and payment.succeeded fires for both. Tell them apart by the payment's subscriptions, store purchases separately, and let a refund take access back.docs/solutions/dodo/one-time-payments-with-dodo.md
- Refunds and chargebacks on Dodo, and what they should do to accessA refund or dispute webhook says which payment, not what was bought. Re-read the payment, derive the status from all its refunds and disputes, and never let a late event undo a refund.docs/solutions/dodo/refunds-and-disputes-bookkeeping.md
- Verifying Standard Webhooks signatures, the three headers and the two mistakesDodo signs id.timestamp.body with HMAC-SHA256. Verify the raw bytes, pass all three headers, and never write the comparison yourself.docs/solutions/dodo/standard-webhooks-signature-verification.md
- The Dodo subscription lifecycle, which event means what and who keeps accessactive, past_due, on_hold, paused, cancelled, failed, expired. Map each to access on purpose, re-read the subscription on every event, and let a grace period decide how failed renewals feel.docs/solutions/dodo/subscription-lifecycle-events.md
How it fits
What Dodo Payments needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Requires
- A database battery. The resolver adds the default one for you and tells you why.
- An auth battery. The resolver adds the default one for you and tells you why.
Pairs well with
- An email battery. Suggested, never added for you.
Cannot be combined with
Compared with the alternatives
Build a repo with Dodo Payments
Free and MIT. The builder opens with Dodo Payments picked. You download the zip right away, and we email you the link too.