Payments (Dodo) — Frontend
Payments (Dodo) — Frontend
The active payment gateway is Dodo Payments (a merchant-of-record that also handles global tax). This page covers the payment-specific things the frontend must handle for Dodo. Everything else about the billing screen lives in the Frontend Integration guide and is unchanged by the gateway.
The billing API contract is gateway-agnostic. All endpoint paths, the
{ message, data } envelope, subscription statuses, read_only/access gating,
GET /usage as the source of truth, the "poll /usage after any charge" golden rule,
the realtime usage:updated events, the 14→30-day trial, and monthly/annual plans work
the same regardless of gateway. Free-trial and monthly/yearly subscriptions behave
normally.
TL;DR — 3 things to get right
- Treat
redirect_urlas opaque. Just send the browser to it. Don't assume the hosted-page domain and don't branch on thesubscription.gatewaystring (it's"dodo"). - On the
/checkoutreturn page, don't rely onpayment_id. The card/subscription flow returnssubscription_id(notpayment_id). Confirm success by pollingGET /usage, notpayment-status. - One-time add-on packs redirect. A one-time pack purchase returns a
redirect_urlyou must open; the balance is credited after the webhook → poll/usageon return.
1. The gateway is Dodo
subscription.gateway (in /usage and the /change-plan response) is "dodo".
- Do:
window.location = redirect_url— treat it as an opaque URL. - Don't: branch on
gateway, sniff the hosted-page domain (checkout.dodopayments.com), or show gateway-specific copy.
2. Return-URL query params
When the browser returns to your callback_url, Dodo appends:
| Flow | Query params on return |
|---|---|
POST /checkout (add card / subscription / recover) | ?subscription_id=…&status=… |
| One-time add-on pack | ?payment_id=…&status=… |
Impact: on the /checkout callback page there is usually no payment_id, so
GET /payment-status?payment_id=… can't be used for that flow. Confirm the outcome by
polling GET /usage until has_payment_method: true and the status flips.
GET /payment-status?payment_id=… still works when a payment_id is present (the
one-time pack return).
Callback-page rule: read
statusfrom the query for a quick optimistic hint, then pollGET /usagefor the truth. Callpayment-statusonly if apayment_idquery param is present.
3. One-time add-on packs return a redirect_url
POST /user/billing/addons for a one-time pack (billing_mode: "one_time") returns a
hosted redirect_url — redirect the user to complete the purchase, like a checkout.
The pack balance is credited only after the charge webhook confirms → poll /usage on
return.
// One-time pack response
{
"message": "One-time pack purchase initiated",
"data": {
"billing_mode": "one_time",
"addon": null,
"redirect_url": "https://checkout.dodopayments.com/..." // ← OPEN IT
}
}Recurring add-ons are different: no redirect (redirect_url: null), capacity granted
immediately, folds into the next renewal.
4. Plan-change upgrades (no UI change)
An active upgrade returns { "charged": true } and the new plan applies via the async
webhook a moment later — poll /usage. Downgrades return { "charged": false } and
apply immediately. This is the standard /change-plan contract; nothing gateway-specific
to handle.
Free trial & subscriptions (unchanged, for reference)
- Free trial: a workspace starts on a 14-day trial (
status: "trial",trial_ends_at). Adding a card viaPOST /checkoutextends it to 30 days; Dodo delays the first charge until trial end, then auto-charges and the status flips toactive(poll/usagearound trial end). - Monthly / yearly: pass
period: "monthly" | "annual"to/change-plan; the recurring cadence is handled by Dodo.recurring_totalin/usagereflects it. - Renewals: fully automatic on Dodo — no action from the frontend;
/usagereflects each new period after the renewal webhook.
Checklist
- Treat
redirect_urlas opaque; no gateway/domain checks. -
/checkoutcallback → pollGET /usage; callpayment-statusonly when apayment_idquery param exists. - One-time add-on pack → if
redirect_urlis present, redirect; on return, poll/usage. - Keep driving the whole billing screen from
GET /usage.
The hosted-page redirect and the immediate response are never proof of success — the real state lands via the async webhook. Always confirm with
GET /usage.