Corteksa
GuidesBilling

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

  1. Treat redirect_url as opaque. Just send the browser to it. Don't assume the hosted-page domain and don't branch on the subscription.gateway string (it's "dodo").
  2. On the /checkout return page, don't rely on payment_id. The card/subscription flow returns subscription_id (not payment_id). Confirm success by polling GET /usage, not payment-status.
  3. One-time add-on packs redirect. A one-time pack purchase returns a redirect_url you must open; the balance is credited after the webhook → poll /usage on 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:

FlowQuery 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 status from the query for a quick optimistic hint, then poll GET /usage for the truth. Call payment-status only if a payment_id query 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_urlredirect 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 via POST /checkout extends it to 30 days; Dodo delays the first charge until trial end, then auto-charges and the status flips to active (poll /usage around trial end).
  • Monthly / yearly: pass period: "monthly" | "annual" to /change-plan; the recurring cadence is handled by Dodo. recurring_total in /usage reflects it.
  • Renewals: fully automatic on Dodo — no action from the frontend; /usage reflects each new period after the renewal webhook.

Checklist

  • Treat redirect_url as opaque; no gateway/domain checks.
  • /checkout callback → poll GET /usage; call payment-status only when a payment_id query param exists.
  • One-time add-on pack → if redirect_url is 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.

On this page