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
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
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:
- In QuotaStack, make each top-up pack and fixed prepaid plan.
- In Dodo, make a one-time or repeat-price item for each one.
- Make a Dodo API key for test and a new one for live.
- List the web app origins that may get checkout returns.
The connector uses these Dodo hosts:
| Environment | Dodo API base | QuotaStack key |
|---|---|---|
| Sandbox | https://test.dodopayments.com | qs_test_... |
| Live | https://live.dodopayments.com | qs_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:
| Variable | Value |
|---|---|
PAYMENT_CONNECTOR_ENCRYPTION_KEY | Base64 key that decodes to 32 bytes; make one with openssl rand -base64 32 |
PAYMENT_CONNECTOR_ENCRYPTION_KEY_ID | A stable name for that key, such as payments-v1 |
PUBLIC_API_URL | Public 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:
- Choose Dodo Payments.
- Select Sandbox.
- Give the connection a clear name.
- Paste the Dodo sandbox API key.
- Add the browser return origins your application uses.
- 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 event | QuotaStack behavior |
|---|---|
payment.succeeded | May grant a mapped top-up or start or renew a prepaid plan |
payment.failed | Save the failed try; grant nothing |
refund.succeeded | Flag for review; do not take credits back on its own |
dispute.opened, dispute.expired, dispute.accepted, dispute.cancelled, dispute.challenged, dispute.won, dispute.lost | Flag for review; do not take credits back on their own |
subscription.active | Sync provider state and dates; grant nothing on its own |
subscription.updated | Sync state, dates, and end-at-term choice |
subscription.renewed | Sync renew facts; grant nothing on its own |
subscription.plan_changed | Sync plan change facts; grant nothing on its own |
subscription.on_hold | Move the bound subscription to overdue behavior; grant nothing |
subscription.failed | Mark a safely linked initial unbound checkout failed. For an existing binding, move it to overdue so it stays recoverable; grant nothing |
subscription.cancelled | Sync the end state |
subscription.expired | Sync 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 product | QuotaStack target | Requirement |
|---|---|---|
| One-time fixed-price product | topup_package | Package is active |
| Fixed recurring product | plan_variant | Variant 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": "[email protected]",
"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
localhostand127.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:
- QuotaStack creates a Dodo Checkout Session for one mapped product.
- Dodo hosts collection.
- Dodo sends a signed payment result.
- QuotaStack grants only on verified
payment.succeeded. - 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.succeededlets QuotaStack grant and start one locked term.payment.failedgrants nothing. One failed try does not cut off access.subscription.on_holdmoves 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}withcancel_at_next_billing_date=true; - now:
PATCH /subscriptions/{id}withstatus=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
- Create or rotate the endpoint secret in Dodo.
- Update the QuotaStack connection with the new
webhook_secret. - Confirm new deliveries in Payment Activity.
- 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:
- open the event and check its link, checkout, payment, map, and QuotaStack change;
- fix the bad link or map;
- choose Retry;
- 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:
| Operation | Required restricted-key scope |
|---|---|
POST /v1/payment-checkouts | payments: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_holdgrant 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:readandpayments: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
- Checkout Sessions API
- Webhook security and delivery
- Payment event payloads
- Subscription event payloads
- Dodo credit-based billing — why mapped goods keep Dodo credits and meters off
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
Loading…