Skip to content

Dodo test mode and live mode are two separate worlds

Different API keys, webhook keys, product ids and customers. Nothing carries over, and the mismatch that hurts most is silent.

Dodo Payments4 min readships at docs/solutions/dodo/test-mode-vs-live-mode.md

Tags: dodo · test-mode · live-mode · environments · launch · webhooks

You ship. Checkout loads. A customer pays. Money appears in the dashboard. Their account does not unlock, and nothing anywhere is red.

Almost every time, the cause is one of two things: a value in production still points at test mode, or something that exists in test mode was never created in live mode.

Dodo keeps the two apart: separate API keys and hosts, separate webhook endpoints and signing keys, separate products with separate ids, separate customers. DODO_PAYMENTS_ENVIRONMENT picks the host. Live mode is a second setup, not a switch.

The failure modes, from loud to silent

Test key with live_mode, or the reverse. Every API call answers 401. Checkout lands on /billing with "Checkout could not start". Loud, fixed in a minute, and bun run verify names it.

Right key, right mode, test product ids. The BILLING_PRICE_* values still point at test-mode products, which do not exist in live mode. Checkout fails the same loud way. verify names the env vars.

Right everything, wrong webhook key. The expensive one. Checkout works. The card is charged. Every webhook delivery gets a 400 from your endpoint. A buyer who comes back to the success page still gets their plan (the page asks Dodo directly), so it looks fine when you test it. But nothing else is ever recorded: not the buyer who closed the tab, not a renewal, not a cancellation, not a refund. Only the endpoint's delivery log shows it.

Right everything, endpoint missing events. Same symptom, different cause: the endpoint answers 200 to the events it was subscribed to and never sees the rest. A cancellation from the portal never arrives, and the customer keeps paid features.

The environment variables

DODO_PAYMENTS_API_KEY=<mode-specific>
DODO_PAYMENTS_WEBHOOK_KEY=<endpoint-specific>
DODO_PAYMENTS_ENVIRONMENT=test_mode | live_mode
BILLING_PRICE_PRO_MONTHLY=pdt_...   (one per catalogue price, mode-specific)
  • The default is test_mode (src/lib/billing/dodo-config.ts). The worst case for a misconfigured clone should be a checkout that charges nobody.
  • The webhook key is per endpoint. The CLI relay endpoint, your tunnel endpoint and production each have their own.
  • Set production values in your host's environment, scoped to production. Preview deployments often inherit production variables by default, which means a preview URL can take real money. Give previews the test-mode set.

One database, one mode

Customers are mode-specific too, and this repo stores one Dodo customer per user in billing_customers. Point a live deploy at a database that holds test-mode mappings and the first checkout fails with an unknown customer. Use a separate database per mode (you almost certainly do), or clear billing_customers when you switch a database from test to live.

Going live, in order

  1. Finish business verification and add the payout account. Dodo is the merchant of record and a person reviews it. Start days ahead.
  2. Create a live API key, set it with DODO_PAYMENTS_ENVIRONMENT=live_mode.
  3. Create the live products: bun run billing:sync-plans with the live key. It creates one product per catalogue price, with the tax category in DODO_TAX_CATEGORY, and prints the live BILLING_PRICE_* lines. Put them in production's environment.
  4. Create the live webhook endpoint at https://<your-domain>/api/webhooks/dodo, subscribed to every event in HANDLED_EVENTS (src/lib/billing/dodo-events.ts). Copy its signing key.
  5. Deploy, then run bun run verify against production's values. It checks the key works in the configured mode, the webhook key is a real whsec_ key, and every price points at a live product with the catalogue's amount and interval.
  6. Buy your own lifetime product with a real card. Check, in order: the payment in the Dodo dashboard, a 200 in the endpoint's delivery log, a row in billing_purchases, the plan on /billing. Refund yourself and confirm the plan goes away.

Step 6 is the only one that proves the other five.

Debugging when it is already broken

Go to the delivery log first: Developer -> Webhooks -> your endpoint. It shows every attempt, the response code and the body, and you can resend any of them.

  • 400 on every delivery: wrong DODO_PAYMENTS_WEBHOOK_KEY. The body says "Invalid signature".
  • 401 or 403 from something else: deployment protection or a WAF in front of the route. Dodo cannot log in.
  • 404: wrong path, or the deploy with the route never shipped.
  • 500: the handler threw. "Webhook not configured" means the key is unset; anything else, read the server log (a missing migration is the classic).
  • 200 with "outcome":"ignored": the event type is not in HANDLED_EVENTS, so it was acknowledged and dropped.

Once it is fixed, resend the failed deliveries or run bun run billing:reconcile. The handlers re-read state from Dodo, so both are safe and enough. You do not rebuild anything by hand.