quotastack Docs
Docs / Cookbook / Connect Dodo Payments

Connect Dodo Payments

Configure sandbox and live Dodo connections, register callbacks, map products, create hosted checkout, and recover payment events safely.

HOSTED CHECKOUT · VERIFIED PAYMENT EFFECTS

Mental model — Treat Dodo as the cashier and QuotaStack as the ledger and access system. Your application selects a configured connection and commercial target; Dodo collects money, then a signed event lets QuotaStack grant a top-up or advance a prepaid subscription exactly once.

Quick take

  • Configure separate sandbox and live connections; mappings and checkouts never cross environments
  • Map Dodo products to existing QuotaStack top-up packages or prepaid plan variants
  • Create checkout with a connection and target — never send price or credit amounts from the client
  • A return redirect is display-only; only a verified payment event changes credits or subscription state
1 · CONNECTION Create unverified 2 · DODO Callback + secret 3 · CATALOG Map products 4 · APPLICATION Create checkout SIGNED OUTCOME Verify + dedupe QUOTASTACK EFFECT Grant / renew Dodo webhook payment succeeded Redirect ≠ success Poll checkout status if needed

Connect Dodo Payments

This guide links Dodo checkout to QuotaStack. It works with top-up packs and prepaid plans. Dodo takes the funds. QuotaStack owns plan state, credits, usage, and access.

Each tenant gets this feature on its own. If Settings → Payment Connections is not shown, ask support to turn it on in sandbox.

Get set

First, add the goods in both tools:

  1. In QuotaStack, make each top-up pack and fixed prepaid plan.
  2. In Dodo, make a one-time or repeat-price item for each one.
  3. Make a Dodo API key for test and a new one for live.
  4. List the web app origins that may get checkout returns.

The connector uses these Dodo hosts:

EnvironmentDodo API baseQuotaStack key
Sandboxhttps://test.dodopayments.comqs_test_...
Livehttps://live.dodopayments.comqs_live_...

QuotaStack picks the Dodo host from the QuotaStack environment. Do not use a test key in live. Do not share maps across them.

Set up your own host

QuotaStack Cloud has all needed host settings. On your own host, set these server values first:

VariableValue
PAYMENT_CONNECTOR_ENCRYPTION_KEYBase64 key that decodes to 32 bytes; make one with openssl rand -base64 32
PAYMENT_CONNECTOR_ENCRYPTION_KEY_IDA stable name for that key, such as payments-v1
PUBLIC_API_URLPublic API origin used for callback URLs, with no path

The server will not start connector work if the key is wrong or not set. Keep the key in your secret store. Do not put it in Postgres or source code. In live use, Dodo must reach PUBLIC_API_URL by HTTPS.

1. Create an unverified connection

In Settings → Payment Connections:

  1. Choose Dodo Payments.
  2. Select Sandbox.
  3. Give the connection a clear name.
  4. Paste the Dodo sandbox API key.
  5. Add the browser return origins your application uses.
  6. Save the connection.

The new connection stays unverified. It gives you a callback URL for this link. You do not have the Dodo signing secret yet. Do not check the link until the next step.

Secret fields are write-only. The API and site show that a key is set. They may show a safe hint.

2. Register the Dodo callback and verify

Open the new connection and copy its Callback URL. It has this form:

https://api.quotastack.io/v1/payment-connectors/webhooks/{connection_id}

In Dodo, make a webhook with that exact URL. Turn on the event list shown by QuotaStack. Dodo will show the signing secret. Paste it into the QuotaStack link, save, and choose Verify.

The check needs both the API key and webhook secret. QuotaStack calls Dodo and checks the account and mode. It records the Dodo business ID and turns on the link. Each signed callback must have a business_id that matches it. QuotaStack rejects a mismatch before the durable inbox. A bad key, secret, mode, or business ID leaves the link off.

The returned required_events list is the source of truth for your build.

The first build uses these events:

Dodo eventQuotaStack behavior
payment.succeededMay grant a mapped top-up or start or renew a prepaid plan
payment.failedSave the failed try; grant nothing
refund.succeededFlag for review; do not take credits back on its own
dispute.opened, dispute.expired, dispute.accepted, dispute.cancelled, dispute.challenged, dispute.won, dispute.lostFlag for review; do not take credits back on their own
subscription.activeSync provider state and dates; grant nothing on its own
subscription.updatedSync state, dates, and end-at-term choice
subscription.renewedSync renew facts; grant nothing on its own
subscription.plan_changedSync plan change facts; grant nothing on its own
subscription.on_holdMove the bound subscription to overdue behavior; grant nothing
subscription.failedMark a safely linked initial unbound checkout failed. For an existing binding, move it to overdue so it stays recoverable; grant nothing
subscription.cancelledSync the end state
subscription.expiredSync expiry and stop more renewals

Dodo signs each call with three Standard Webhooks headers. They are webhook-id, webhook-signature, and webhook-timestamp. QuotaStack checks the raw body before it reads JSON. It saves a valid event, sends a fast reply, and does the work in a job. Do not let web code change the body.

Run the same two setup steps for live only after test checks pass. Use a new live key, Dodo hook, and HTTPS return origins.

3. Map products

Under Product Mappings, map each Dodo item to one QuotaStack target:

Dodo productQuotaStack targetRequirement
One-time fixed-price producttopup_packagePackage is active
Fixed recurring productplan_variantVariant uses billing_mode: prepaid and has no configured free trial

QuotaStack checks the Dodo item before it saves the map. It rejects the wrong price type or a price that can vary. It also rejects packs that are off, postpaid plans, and plans with a free trial.

Keep Dodo credit and usage tools off

Do not create a connector mapping that uses any of these:

  • native credit_entitlements;
  • usage-based or meter pricing;
  • a Dodo-managed trial;
  • a QuotaStack plan variant with a configured free trial.

Those tools would make Dodo and QuotaStack own the same facts. Keep this clear split:

  • Dodo item = price, checkout, payment rule, and cash;
  • QuotaStack target = credits, usage, access, and plan state.

The check may find Dodo credits, meters, or usage prices. It may find a Dodo-managed trial. It may find a QuotaStack plan variant with a free trial. Remove that setting. You can also pick a fixed-price item and a prepaid plan with no trial. Then save the map again.

4. Create checkout

Use a tenant API key and a stable retry key:

curl -X POST https://api.quotastack.io/v1/payment-checkouts \
  -H "X-API-Key: $QS_API_KEY" \
  -H "Idempotency-Key: checkout:order_01JABC" \
  -H "Content-Type: application/json" \
  -d '{
    "connection_id": "019f1a2b-3c4d-7000-8000-000000000001",
    "external_customer_id": "user_abc",
    "target_type": "topup_package",
    "target_id": "019f1a2b-3c4d-7000-8000-000000000002",
    "payer": {
      "email": "buyer@example.com",
      "name": "Buyer Name"
    },
    "return_url": "https://app.example.com/billing/success",
    "cancel_url": "https://app.example.com/billing"
  }'

These routes are planned and are not in the current public OpenAPI file yet. The response will have an ID, state, hosted URL, and end time:

{
  "id": "019f1a2b-3c4d-7000-8000-000000000003",
  "status": "open",
  "hosted_url": "https://test.checkout.dodopayments.com/session/...",
  "expires_at": "2026-07-30T13:30:00Z"
}

Send the user to hosted_url.

Optional payer

payer.email and payer.name fill the Dodo form. If you send payer, its email is required. You may omit payer. Dodo will then ask the user for those fields. QuotaStack learns the Dodo customer link from a checked reply or event.

QuotaStack does not copy payer fields to the QuotaStack user. It also does not log them. It reuses a saved Dodo user link for more checkouts on that connection.

Send one of customer_id or external_customer_id, not both. The server finds the mapped Dodo item. The browser cannot set the amount, cash type, item, count, or credits.

Return URL rules

The return and cancel URL origins must match an allowed origin:

  • live connections require HTTPS;
  • sandbox also lets you use HTTP on localhost and 127.0.0.1;
  • scheme, host, and set port must match;
  • a different path is allowed because the allowlist is origin-based;
  • URLs with user info are blocked.

5. Handle the browser return safely

The return page can say that the payment is being checked. It must not grant credits or mark a plan paid. A redirect is not proof.

Poll the QuotaStack checkout to show its state:

curl https://api.quotastack.io/v1/payment-checkouts/019f1a2b-3c4d-7000-8000-000000000003 \
  -H "X-API-Key: $QS_API_KEY"

The state can be creating, open, unknown, succeeded, failed, expired, or cancelled. If checkout timed out after it was sent, repeat the identical POST body with the same Idempotency-Key. QuotaStack returns the saved reply, not a new charge try. Use the returned checkout ID and poll this route. Never use a new idempotency key or another provider while the first result is not known.

Your app can use QuotaStack credit, plan, and common payment events. The Dodo callback is only for Dodo and QuotaStack.

6. One-time top-up flow

For target_type: topup_package:

  1. QuotaStack creates a Dodo Checkout Session for one mapped product.
  2. Dodo hosts collection.
  3. Dodo sends a signed payment result.
  4. QuotaStack grants only on verified payment.succeeded.
  5. Credits come from the mapped pack. The paid amount and cash type are audit facts.

Calls that come twice or out of order still make one sale and one grant.

Do not also call POST /v1/topups/grant for this sale. That route is for manual payment flows. A second grant path could grant twice.

7. Initial subscription and renewal

To start a plan, change the target. This planned request shape is not in the current public OpenAPI file:

{
  "connection_id": "019f1a2b-3c4d-7000-8000-000000000001",
  "external_customer_id": "user_abc",
  "target_type": "plan_variant",
  "target_id": "019f1a2b-3c4d-7000-8000-000000000004",
  "return_url": "https://app.example.com/billing/success",
  "cancel_url": "https://app.example.com/billing"
}

The first paid charge makes one pending QuotaStack subscription. Each first grant has a stable key. QuotaStack turns on the plan only after all grants work. A second checkout gets 409 Conflict while that same plan link is live, late, or canceling. It does not make a new payment rule.

After that:

  • Dodo sets the next charge date and owns SCA and retries.
  • payment.succeeded lets QuotaStack grant and start one locked term.
  • payment.failed grants nothing. One failed try does not cut off access.
  • subscription.on_hold moves the QuotaStack plan to late status.
  • A later paid charge can turn it back on and grant once.

Do not call POST /v1/subscriptions/{id}/renew for a connector plan. It gets 409 Conflict. The QuotaStack job also skips new paid terms for this plan.

8. Cancel a subscription

Use the normal QuotaStack route. This keeps local and Dodo state linked:

curl -X POST https://api.quotastack.io/v1/subscriptions/$SUBSCRIPTION_ID/cancel \
  -H "X-API-Key: $QS_API_KEY" \
  -H "Idempotency-Key: cancel:$SUBSCRIPTION_ID" \
  -H "Content-Type: application/json" \
  -d '{"cancel_immediately": false, "reason": "Customer requested cancellation"}'

For a Dodo-bound plan, QuotaStack sends this to Dodo:

  • term end: PATCH /subscriptions/{id} with cancel_at_next_billing_date=true;
  • now: PATCH /subscriptions/{id} with status=cancelled.

If Dodo accepts, the QuotaStack plan moves to cancelling. A signed last event sets canceled or expired. If Dodo says no, local state does not change.

To change plans or providers, cancel and start a new checkout. The first build does not move a live mandate. It does not split fees or retry on a new connection.

9. Rotate credentials without dropping callbacks

API key

Make the new Dodo key and add it to the connection. Then check the connection. The new key takes effect at once. Keep the old key until the check works, then revoke it in Dodo.

Webhook signing secret

  1. Create or rotate the endpoint secret in Dodo.
  2. Update the QuotaStack connection with the new webhook_secret.
  3. Confirm new deliveries in Payment Activity.
  4. After the overlap window, retire the old Dodo secret.

QuotaStack keeps the old locked hook secret for 24 hours. It tries the new secret first, then the old one. The API never sends either secret back.

10. Fix a dead-letter event

Short faults get a set number of retries. A bad map or setup can still fail each time. That event then moves to the dead-letter list with a clear cause.

Checked raw provider bodies stay for 90 days. After that, QuotaStack drops them. The common payment links and audit log stay.

In Payment Activity:

  1. open the event and check its link, checkout, payment, map, and QuotaStack change;
  2. fix the bad link or map;
  3. choose Retry;
  4. check that the same event and payment make one top-up or one paid term.

The corresponding admin routes are:

GET  /v1/admin/provider-events
GET  /v1/admin/provider-events/{id}
POST /v1/admin/provider-events/{id}/retry

Retry needs an owner or admin login. It also needs the right X-Environment and an Idempotency-Key. Only a dead_letter event on a live connection can run again. Other states get 409 Conflict.

An already-paid checkout has an immutable mapping snapshot. Its event can replay after the old map’s soft deletion. Without that immutable checkout snapshot, a current provider-product mapping is required before replay. Retry checks the map source it must use. It cannot make proof of payment or a new charge. Do not fix a dead letter with a manual credit grant. A later replay could then grant twice.

Refunds and disputes need a different path. The first connector marks them as reconciliation-required. It does not automatically reverse credits. Check the user’s ledger. Add a clear credit change if your policy calls for one.

11. Migrate restricted API keys

Checkout endpoints add two tenant API-key scopes:

OperationRequired restricted-key scope
POST /v1/payment-checkoutspayments:write
GET /v1/payment-checkouts/{id}payments:read

Give a locked-down key only the scopes the app needs. An app that starts and polls checkout often needs both:

payments:write
payments:read

Old keys with an empty scope list still have full access. For those keys, empty does not mean deny all. Make a locked-down key with the payment scopes. Ship it and test checkout start and read. Then revoke the old broad key.

12. Multiple connections and gateways

Each checkout sends a connection_id. Your app still picks the route:

customer/region/product → selected connection → mapped target → hosted checkout

Keep these rules:

  • never reuse sandbox IDs in live;
  • map each target independently per connection;
  • do not retry a charge with an unknown result on a new connection;
  • moving a user to a new provider needs a new checkout and mandate;
  • all connections feed the same QuotaStack plan, credit, usage, and access tools.

Sandbox acceptance checklist

Before you make the live connection, prove these facts:

  • one-time top-up grants once after successful payment;
  • a prepaid plan starts only after a paid charge and all first grants;
  • recurring renewal advances one period;
  • the same event sent twice does not grant twice;
  • failed renewal and subscription.on_hold grant nothing;
  • a later successful payment recovers and grants once;
  • end-of-term and now cancel flows stay in sync;
  • a fixed dead-letter event can run again;
  • restricted keys work with payments:read and payments:write;
  • the return page does not treat its URL as proof.

Then make the live connection, hook, and maps. Do not copy test IDs for links, goods, users, checkouts, or plans into live.

Dodo references

Common mistakes

Don't grant from the checkout return URL

The customer can reach a redirect without a final successful payment. Read checkout status for UI and wait for QuotaStack's verified event processing for the financial effect.

Don't map a Dodo product with native credits, meters, usage pricing, or a Dodo-managed trial—or target a QuotaStack plan variant with a free trial

Those features would create two competing ledgers or lifecycle clocks. Keep Dodo focused on money collection and QuotaStack authoritative for credits, usage, and entitlements.

Don't retry a charge through another connection automatically

Provider timeout or failure can be ambiguous. Automatic cross-gateway retry risks charging twice; switching gateways requires an explicit new checkout and, for subscriptions, a new mandate.

Concepts used