Skip to content

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 more
  • 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.

Database
NeonSupabase
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.

    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.

    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.

    Placeholder
    test_mode
  • DODO_TAX_CATEGORYOptional

    The tax category bun run billing:sync-plans gives 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.

    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-plans creates it and prints this line. Test and live mode have different ids.

    Placeholder
  • BILLING_PRICE_PRO_YEARLYOptional

    The Dodo product (pdt_...) for pro-yearly, a yearly subscription product.

    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.

    Placeholder
  • BILLING_PRICE_TEAM_MONTHLYOptional

    The Dodo product (pdt_...) for team-monthly.

    Placeholder
  • BILLING_PRICE_TEAM_YEARLYOptional

    The Dodo product (pdt_...) for team-yearly.

    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:

FileJob
src/lib/billing/dodo.tsthe one lazy Dodo client (getDodo(), dodo)
src/lib/billing/dodo-config.tsenv reads: key, webhook key, DODO_PAYMENTS_ENVIRONMENT, placeholder check. Pure
src/lib/billing/provider.tsbillingProvider, the BillingProvider contract: customer, checkout, portal, verify, translate
src/lib/billing/dodo-events.tsHANDLED_EVENTS, signature check, event translation, success-page sync. Pure, I/O injected
src/lib/billing/dodo-objects.tspayment, subscription and product reads (status, refunds, trial). Pure, tested
src/lib/billing/dodo-fixtures.tsrecorded payloads for the tests and dodo:test-webhook
src/app/api/webhooks/dodo/route.tsthree lines into the shared processWebhook
scripts/billing/*.tssync-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. translate and syncCheckout return BillingEvent[]. applyBillingEvents in webhook.ts is 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, not entitlements.ts. A feature that needs Dodo data gets a method on BillingProvider or a field on a BillingEvent. Never a branch on billingProvider.id in shared code.
  • The client is built lazily, once. next build imports every module a page can reach, so new DodoPayments(...) at module scope throws on any deploy with no keys yet. Do not "simplify" getDodo() away. Only dodo-config.ts reads DODO_PAYMENTS_API_KEY.
  • Nothing Dodo reaches the browser. Checkout and the portal are hosted. Never import dodo.ts, provider.ts or @/lib/billing into a "use client" file. server-only makes 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. createCheckout sends the same session either way. A payment is one-time when it charges no subscription: subscription_ids empty, not subscription_id null 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 stores planSlug null, which unlocks nothing.
  • Statuses are normalized, and Dodo's word is kept. on_hold is unpaid (no access: Dodo stopped charging and waits for a new card). past_due is Dodo's grace period and keeps access. An active subscription inside trial_period_days is trialing.

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 with dodo.subscriptions.retrieve. Dodo retries for a day, so an old subscription.active can land after the subscription.cancelled that replaced it.
  • refund.succeeded, dispute.opened and dispute.lost re-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.succeeded or payment.failed is 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.

Show all 6

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.

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.