---
title: Connect Dodo Payments
description: Configure sandbox and live Dodo connections, register callbacks, map products, create hosted checkout, and recover payment events safely.
order: 10
---

# 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.

**Pattern:** 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

## Diagram

Setup proceeds from creating an unverified connection with a Dodo API key, to registering QuotaStack’s connection-specific callback, saving the revealed signing secret and verifying, mapping Dodo products, and creating hosted checkout. Dodo then sends a signed event back to QuotaStack; after verification and deduplication, a successful payment grants a top-up or activates or renews a prepaid subscription.

```mermaid
sequenceDiagram
    participant Admin
    participant QS as QuotaStack
    participant Dodo
    participant App as Tenant application
    Admin->>QS: Create unverified connection (API key)
    QS-->>Admin: Connection-specific callback URL
    Admin->>Dodo: Register callback
    Dodo-->>Admin: Webhook signing secret
    Admin->>QS: Save secret and verify connection
    Admin->>QS: Map Dodo products to QS targets
    App->>QS: POST /v1/payment-checkouts
    QS->>Dodo: POST /checkouts (mapped product)
    Dodo-->>App: Hosted checkout
    Dodo->>QS: Signed payment/subscription event
    QS->>QS: Verify, inbox, deduplicate
    QS->>QS: Grant or renew only on payment success
```

## 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:

| 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**:

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:

```text
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:

```bash
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:

```jsonc
{
  "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:

```bash
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:

```jsonc
{
  "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:

```bash
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:

```http
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:

```text
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:

```text
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

- [Checkout Sessions API](https://docs.dodopayments.com/api-reference/checkout-sessions/create)
- [Webhook security and delivery](https://docs.dodopayments.com/developer-resources/webhooks)
- [Payment event payloads](https://docs.dodopayments.com/developer-resources/webhooks/intents/payment)
- [Subscription event payloads](https://docs.dodopayments.com/developer-resources/webhooks/intents/subscription)
- [Dodo credit-based billing](https://docs.dodopayments.com/features/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

- [Payment Connectors](/docs/concepts/payment-connectors)
- [Topups & Wallets](/docs/concepts/topups-and-wallets)
- [Subscriptions](/docs/concepts/subscriptions)
- [Idempotency](/docs/concepts/idempotency)

## Related

- [End-to-End Billing Setup](/docs/cookbook/end-to-end-billing) — Create the QuotaStack catalog and customer records that connector mappings reference.
- [Low-Balance Alerts](/docs/cookbook/low-balance-alerts) — Notify customers before their connector-funded credit balance runs out.
