npx skills add ...
npx skills add dodopayments/skills --skill dodo-best-practices
Guide for initial Dodo Payments setup, including SDK installation, test and live environments, API keys, and the canonical checkout-to-webhook architecture.
npx skills add dodopayments/skills --skill dodo-best-practices
This skill covers the foundational concepts and setup for Dodo Payments. Use it when starting a new integration, setting up the SDK, or understanding the core payment flow.
Dodo Payments is a Merchant of Record (MoR). That means Dodo is the legal seller on every transaction, handles sales tax registration and calculation across all jurisdictions, remits taxes to authorities, and manages card-network disputes and chargebacks. As a developer, you don't build a sales-tax engine or dispute-handling system. You create checkout sessions, listen to webhooks, and grant access when payment succeeds.
Only two real base URLs exist:
https://live.dodopayments.comhttps://test.dodopayments.comNever use api.dodopayments.com — it has no DNS record and cannot be reached.
Keys have two formats:
dodo_test_...dodo_live_...The environment parameter defaults to live_mode if omitted. Always pass test_mode explicitly during development.
Constructor options:
| Option | Type | Default | Notes |
|---|---|---|---|
bearerToken | string | env DODO_PAYMENTS_API_KEY | Required for API calls |
environment | 'test_mode' | 'live_mode' | 'live_mode' | Explicit in dev |
webhookKey | string | env DODO_PAYMENTS_WEBHOOK_KEY | For webhooks.unwrap() |
baseURL | string | — | Override; mutually exclusive with environment |
Dodo publishes framework-specific packages under @dodopayments/*. Use the one matching your stack:
| Framework | Package | Use if |
|---|---|---|
| Next.js | @dodopayments/nextjs | Building with Next.js App Router |
| Nuxt | @dodopayments/nuxt | Building with Nuxt 3+ |
| Express | @dodopayments/express | Using Express.js |
| Fastify | @dodopayments/fastify | Using Fastify |
| Hono | @dodopayments/hono | Using Hono (Edge/Node) |
| Astro | @dodopayments/astro | Using Astro endpoints |
| SvelteKit | @dodopayments/sveltekit | Using SvelteKit server routes |
| Remix | @dodopayments/remix | Using Remix loaders/actions |
| TanStack Start | @dodopayments/tanstack | Using TanStack Start |
| Better Auth | @dodopayments/better-auth | Integrating with Better Auth |
| Convex | @dodopayments/convex | Using Convex backend |
| Bun | @dodopayments/bun | Using Bun.serve() |
Each adapter provides checkout, customer portal, and webhook handlers tailored to the framework's conventions.
Items you sell. Create in the dashboard or via API. Types:
Represent buyers. Can have multiple payment methods, subscriptions, and credit balances. Create explicitly or implicitly during checkout.
The primary payment collection method. Create a session server-side, redirect the customer to the hosted checkout URL, and listen for webhooks to confirm payment.
See the checkout-integration skill for detailed checkout configuration and the subscription-integration skill for recurring lifecycle management.
Recurring charges on a schedule. Managed through checkout sessions or the subscriptions API. See the subscription-integration skill for lifecycle, trials, plan changes, and on-demand charging.
Real-time event notifications. Dodo sends events like payment.succeeded, subscription.active, refund.succeeded, and credit.deducted. Webhook signature verification is covered in the webhook-integration skill.
Virtual balances (API calls, tokens, compute hours) attached to products. Configured per product with rollover, overage, and expiration rules. See the credit-based-billing skill.
checkout_url returned.payment.succeeded) to your endpoint.client.webhooks.unwrap().Never grant access based on the browser return_url redirect alone. The webhook is the authoritative confirmation.
All requests use Bearer token authentication:
List endpoints are page-numbered. They accept page_size and page_number, and the response
exposes the rows on items:
The SDK can also walk every page for you:
Dodo enforces rate limits. The SDK automatically retries 429 responses as described below.
The SDK throws typed errors. Catch and inspect:
The SDK retries twice by default with a short exponential backoff on connection errors and 408, 409, 429, and 5xx responses. Do not add an unconditional custom retry loop.
Override the default for all requests when constructing the client:
Or override it for one request:
Webhook signature verification is mandatory. Never trust the payload without verification.
Dodo implements the Standard Webhooks spec. The signed message is webhook-id.webhook-timestamp.raw_body (period-joined), HMAC-SHA256, base64-encoded.
Use the SDK helper:
The unwrap() method verifies the signature and parses the payload. If verification fails, it throws an error.
For detailed webhook setup, event types, and testing, see the webhook-integration skill.
return_url redirectThe browser redirect is not proof of payment. Always wait for the webhook.
Don't implement HMAC verification yourself. Use client.webhooks.unwrap().
The old approach of signing just the payload is wrong because Standard Webhooks signs webhook-id.webhook-timestamp.raw_body. Hand-rolled HMAC will never match.
Webhook verification requires the exact raw body. If you parse JSON and re-stringify it, the signature breaks.
Don't use client.payments.create() or client.subscriptions.create() for new integrations. Both are deprecated. Use client.checkoutSessions.create().
environment: 'test_mode'The default is live_mode. Always pass test_mode explicitly during development to avoid charging real cards.
Never hardcode keys. Always use environment variables.
webhook-timestamp headerThe timestamp prevents replay attacks. client.webhooks.unwrap() validates it automatically, but if you hand-roll verification, check that the timestamp is recent (within a few minutes).
unsafeUnwrap() in productionunsafeUnwrap() skips signature verification. Use it only for unsigned test payloads from dodo wh trigger. Never use it for production webhooks.
pip install dodopaymentsimport os
from dodopayments import DodoPayments
client = DodoPayments(
bearer_token=os.environ.get("DODO_PAYMENTS_API_KEY"),
environment="test_mode", # defaults to "live_mode"
)go get -u github.com/dodopayments/dodopayments-go@v1.110.0import (
"os"
"github.com/dodopayments/dodopayments-go"
"github.com/dodopayments/dodopayments-go/option"
)
client := dodopayments.NewClient(
option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
option.WithEnvironmentTestMode(), // defaults to live
)composer require "dodopayments/client:6.19.0"use Dodopayments\Client;
$client = new Client(
bearerToken: getenv('DODO_PAYMENTS_API_KEY') ?: 'My Bearer Token',
environment: 'test_mode',
);gem "dodopayments", "~> 2.22.0"dodo_payments = Dodopayments::Client.new(
bearer_token: ENV["DODO_PAYMENTS_API_KEY"],
environment: "test_mode"
)<dependency>
<groupId>com.dodopayments.api</groupId>
<artifactId>dodo-payments-java</artifactId>
<version>1.110.0</version>
</dependency>DodoPaymentsClient client = DodoPaymentsOkHttpClient.fromEnv();
// Reads DODO_PAYMENTS_API_KEY, DODO_PAYMENTS_WEBHOOK_KEY, DODO_PAYMENTS_BASE_URL<dependency>
<groupId>com.dodopayments.api</groupId>
<artifactId>dodo-payments-kotlin</artifactId>
<version>1.110.0</version>
</dependency>val client: DodoPaymentsClient = DodoPaymentsOkHttpClient.fromEnv()import express from 'express';
const app = express();
// 1. Create session
const session = await client.checkoutSessions.create({
product_cart: [{ product_id: 'pdt_example', quantity: 1 }],
customer: { email: 'customer@example.com' },
return_url: 'https://yoursite.com/success',
});
// 2. Redirect to session.checkout_url
// 3. Listen for webhook with the exact raw request bytes
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
try {
// 4. Verify signature
const webhookId = req.headers['webhook-id'] as string;
const event = client.webhooks.unwrap(req.body.toString(), {
headers: {
'webhook-id': webhookId,
'webhook-signature': req.headers['webhook-signature'] as string,
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
},
});
// 5. Suppress duplicates with an atomic unique insert and run side effects
// in the same database transaction.
const handled = await processWebhookOnce(webhookId, async () => {
if (event.type === 'payment.succeeded') {
// ONE-TIME purchases only. Subscription access starts on subscription.active.
const payment = event.data;
await grantOneTimeAccess(payment.customer.customer_id);
}
if (event.type === 'subscription.active') {
await grantSubscriptionAccess(event.data);
}
});
res.json({ received: true, duplicate: !handled });
} catch (error) {
res.status(401).json({ error: 'Invalid signature' });
}
});Authorization: Bearer dodo_live_...const payments = await client.payments.list({
page_size: 50,
page_number: 0,
});
for (const payment of payments.items) {
console.log(payment.payment_id);
}for await (const payment of client.payments.list()) {
console.log(payment.payment_id);
}try {
await client.checkoutSessions.create({...});
} catch (error) {
if (error instanceof DodoPayments.APIError) {
console.error(error.status, error.message);
}
}const clientWithoutRetries = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment: 'test_mode',
maxRetries: 0,
});await client.checkoutSessions.create(
{
product_cart: [{ product_id: 'pdt_example', quantity: 1 }],
customer: { email: 'customer@example.com' },
},
{ maxRetries: 0 },
);const event = client.webhooks.unwrap(req.body.toString(), {
headers: {
'webhook-id': req.headers['webhook-id'] as string,
'webhook-signature': req.headers['webhook-signature'] as string,
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
},
});// WRONG
app.get('/success', (req, res) => {
grantAccess(req.query.customer_id); // No verification!
});
// CORRECT
app.post('/webhook', async (req, res) => {
const event = client.webhooks.unwrap(...);
if (event.type === 'payment.succeeded') {
grantAccess(event.data.customer.customer_id);
}
});// WRONG
const body = JSON.parse(req.body);
const event = client.webhooks.unwrap(JSON.stringify(body), {...});
// CORRECT
const event = client.webhooks.unwrap(req.body.toString(), {...});