Repository navigation
DodoPayments API
Client configuration and endpoint methods for
@tundraconnect/dodo-payments.
import { DodoPayments } from '@tundraconnect/dodo-payments';
const client = new DodoPayments({
auth: { type: 'BEARER', token: 'YOUR_API_KEY', prefix: 'Bearer' },
mode: 'test', // the default
});| Option | Type | Required | Default | Description |
|---|---|---|---|---|
auth |
DodoPaymentsAuth |
yes | — |
{ type: 'BEARER', token, prefix? }. |
mode |
'test' | 'live' |
no | 'test' |
Which environment to talk to. |
baseURL |
string |
no | derived from mode
|
Overrides mode entirely. |
timeout |
number |
no | 30 |
Request timeout in seconds. |
mode defaults to 'test'. This client moves real money, so the safe
environment is the one you get by omission. API keys are
environment-specific — a test key will not work against the live host.
| Mode | Host |
|---|---|
'test' |
https://test.dodopayments.com |
'live' |
https://live.dodopayments.com |
| Getter | Type | Description |
|---|---|---|
vendor |
string |
Always 'DodoPayments'. |
mode |
'test' | 'live' |
The environment in use. |
Every request body is checked before it is sent and fails with
REQUEST_VALIDATION_ERROR. Unknown fields are rejected, not dropped,
and numbers and booleans must not arrive as strings. See
Schemas.
Every amount is an integer in the currency's smallest unit — cents for
USD, yen for JPY, fils for KWD. 1999 is $19.99.
POST /payments — initialize a one-time payment.
Required: product_cart, customer, billing. Pass payment_link: true
for a hosted checkout URL.
const created = await client.createPayment({
product_cart: [{ product_id: 'prd_1', quantity: 2 }],
customer: { email: 'buyer@example.com', name: 'Ada' }, // or { customer_id }
billing: { country: 'DE' },
payment_link: true,
return_url: 'https://example.com/thanks',
});Returns payment_id, total_amount, client_secret, customer, and
payment_link when requested.
client_secretis a credential. Never log it or expose it beyond the buyer's own session.
GET /payments/{payment_id} — the full payment record.
This is the authoritative post-checkout check. Never trust the browser
redirect: the buyer controls it, and ?status=success proves nothing.
getPayment reduced to a boolean. true only when
status === 'succeeded'.
| Status | isPaid |
Meaning |
|---|---|---|
succeeded |
true |
Funds captured. |
processing |
false |
In flight — nothing settled yet. |
requires_* |
false |
Awaiting an action. |
failed / cancelled
|
false |
Will not complete. |
partially_captured |
false |
Only part captured. |
absent / null
|
false |
Unknown — never assume success. |
GET /payments — one page of payment summaries. Pass customerId for a
single customer's order history.
| Option | Query param |
|---|---|
customerId |
customer_id |
subscriptionId |
subscription_id |
productId / brandId
|
product_id / brand_id
|
status |
status |
createdAtGte / createdAtLte
|
created_at_gte / created_at_lte
|
pageNumber / pageSize
|
page_number / page_size
|
Returns the items array directly. List records are lighter than
getPayment's: no refunds, disputes or product cart.
Auto-paging counterpart to listPayments — an AsyncGenerator that walks
every page.
for await (const p of client.listAllPayments({ customerId })) {
console.log(p.payment_id, p.status);
}
const all = await Array.fromAsync(client.listAllPayments({ customerId }));Takes the same filters as listPayments minus pageNumber (it manages
that), plus maxPages.
| Option | Default | Description |
|---|---|---|
pageSize |
100 |
Requested per page; the vendor may clamp it. |
maxPages |
1000 |
Safety ceiling — see below. |
Dodo numbers pages from 0. The first request omits page_number
(which Dodo reads as page 0), and later requests send 1, 2, … Dodo's
own SDK sends 2 after the first page and so skips page 1.
Iteration stops on the first empty page, not a short one. The vendor
may clamp page_size below what was asked for, and treating a clamped
page as the last would silently truncate the history.
maxPages exists for the one case that cannot terminate on its own: an
endpoint that ignored page_number and kept returning the same full page
would spin forever against a rate-limited, money-handling API.
Prefer
listPaymentswhen one page is enough — the iterator issues one request per page.
A refund gives a succeeded payment's money back, whole or in part, to the payment method it came from.
POST /refunds. Required: payment_id.
// A full refund.
const refund = await client.createRefund({
payment_id: 'pay_1',
reason: 'Charged twice',
});
// A partial refund: $5.00 of one product line, and one add-on in full.
await client.createRefund({
payment_id: 'pay_2',
items: [
{ item_id: 'prd_1', amount: 500 },
{ item_id: 'adn_1' },
],
metadata: { ticket: 'T-42' },
});| Field | Meaning |
|---|---|
payment_id |
The payment to refund. It must have succeeded. |
items |
Omitted: a FULL refund. Listed: a PARTIAL refund of those lines only. |
items[].item_id |
The line: the product_id of a product in the payment's cart, or an add-on id. |
items[].amount |
Minor units to give back on that line. Omitted or null: the whole line. |
items[].tax_inclusive |
Whether amount includes tax. Dodo's default is true. |
reason |
Recorded with the refund and shown on the customer's receipt. At most 3000 characters. |
metadata |
String, number or boolean values, the same shape as product metadata. |
The request is checked locally before it is sent: unknown fields are
rejected (a misspelt item dropped silently would turn a partial
refund into a full one), amounts must be positive integers, items lists
at least one line and each line once.
Dodo's own rules, which it checks and this client does not:
- the payment succeeded, and is within the refund window (30 days by
default; vendor code
REFUND_WINDOW_EXPIRED); - together with earlier refunds, no more than was paid
(
REFUND_AMOUNT_EXCEEDS_PAID_AMOUNT,LINE_ITEM_REFUND_AMOUNT_TOO_HIGH); - only one refund in flight per payment: one
pendingorreviewmust finish first (EXISTING_REFUND_REQUEST_PROCESSING); - the business's Dodo balance covers it. Dodo pays refunds from the balance, not from the payment, so a refund right after a payout can be refused.
These fail with INVALID_REQUEST, the reason in vendorCode. An unknown
payment is NOT_FOUND (checked in test mode on 2026-10-08).
The refund usually comes back pending. Only succeeded means the money
went back: read it again with getRefund, or act on the
refund.succeeded / refund.failed webhook. is_partial says whether it
gave back part of the payment.
createRefundis not idempotent. After a timeout, read the payment'srefunds(orlistRefunds) before sending it again.
GET /refunds/{refund_id} — the full record: status, amount, currency,
reason, is_partial, the customer, metadata, and network_reference. The
network reference (with its kind in network_reference_type: ARN, STAN,
RRN or other) is what the customer quotes to their bank; it arrives 1 to
3 business days after the refund succeeds, and no webhook announces it.
GET /refunds — refund summaries, lighter than getRefund's record: no
brand, customer or metadata.
| Option | Query param |
|---|---|
customerId |
customer_id |
subscriptionId |
subscription_id |
status |
status |
createdAtGte / createdAtLte
|
created_at_gte / created_at_lte
|
pageNumber / pageSize
|
page_number / page_size
|
Dodo has no filter by payment. A payment's refunds are on its own
record: getPayment(id).refunds, each entry the list item's shape (parse
it with RefundListItemSchemaObject), with refund_status ('partial' or
'full', absent while nothing succeeded) beside it. Both pass through
untyped on PaymentSchema.
listAllRefunds pages the same way as listAllPayments.
GET /customers/{customer_id} — the full customer record.
const customer = await client.getCustomer('cus_1');
customer.email;
customer.created_at;
customer.blocked_at; // set only when the merchant blocked themRicher than the customer summary embedded in a payment or subscription: it
adds business_id, created_at and the blocklist fields.
blocked_atis resolved only by this single-customer route — the vendor leaves it empty on list responses, so absent means "not reported", not "not blocked".
Where does customer_id come from? createPayment and
createSubscription return it on .customer even when you identified the
customer only by email. Store it then — it is the only place it is handed
to you.
POST /customers/{customer_id}/customer-portal/session — a sign-in link to
Dodo's hosted portal, where the customer manages their subscriptions,
payment methods and invoices.
const { link } = await client.createCustomerPortalSession('cus_1', {
returnUrl: 'https://example.com/account',
});| Option | Effect |
|---|---|
sendEmail |
true also emails the link to the customer. |
returnUrl |
Where the portal sends them back; overrides your default. |
The link signs the customer in. Send it only to them and never log it.
Both list methods take customerId, and status comes back inline — no
follow-up fetch per record.
const [customer, payments, subscriptions] = await Promise.all([
client.getCustomer(customerId),
client.listPayments({ customerId, pageSize: 20 }),
client.listSubscriptions({ customerId }),
]);Filter server-side rather than paging and discarding:
await client.listPayments({ customerId, status: 'succeeded' });
await client.listSubscriptions({ customerId, status: 'active' });Charge history for one subscription:
await client.listPayments({ subscriptionId: 'sub_1' });Payment status is optional; subscription status is not.
p.status; // IntentStatus | null | undefined ← handle absent
s.status; // SubscriptionStatus ← always presentNever infer success from "not failed". Absent is unknown, and only
'succeeded' means money moved.
The list records differ in weight. listSubscriptions returns the
full subscription object — identical to getSubscription, so no
follow-up is ever needed. listPayments returns a lighter record,
dropping refunds, disputes, product_cart, billing and
error_code / error_message. Fetch detail only for the rows that need
it:
const failed = payments.filter((p) => p.status === 'failed');
const detailed = await Promise.all(
failed.map((p) => client.getPayment(p.payment_id)),
);
detailed[0]?.error_message; // 'The card was declined.'POST /subscriptions. Required: product_id, quantity, customer,
billing.
A created subscription is not yet active. When
payment_method_required is true, the customer must still complete
checkout at payment_link; the subscription sits in pending until then.
GET /subscriptions/{subscription_id}.
GET /subscriptions. Same filter style as listPayments
(customerId, productId, brandId, status, date bounds, paging).
Auto-paging counterpart to listSubscriptions. Same paging, termination
and maxPages rules as listAllPayments.
for await (const s of client.listAllSubscriptions({ customerId })) {
console.log(s.subscription_id, s.status);
}PATCH /subscriptions/{subscription_id}.
| Option | Effect |
|---|---|
| (default) | Cancels immediately — sends status: 'cancelled', revoking the mandate. |
atPeriodEnd |
Sends cancel_at_next_billing_date: true; access lasts the paid period. |
comment |
Stored as cancellation_comment. |
Reading the result of a period-end cancellation: the vendor leaves
statusas'active'until the date arrives and records the intent incancel_at_next_billing_date. Checkingstatusalone will tell you the cancellation did not take.
PATCH /subscriptions/{subscription_id} with
cancel_at_next_billing_date: false. Withdraws a period-end cancellation,
so the subscription renews as normal. An immediate cancellation cannot be
undone.
PATCH /subscriptions/{subscription_id} with status: 'paused' or
status: 'active', sent alone because Dodo rejects either combined with
another field. Resuming also restarts an on_hold subscription that has an
unpaid pause invoice, and voids that invoice.
resumeSubscription is for a paused subscription. To keep one that is set
to cancel at period end, use undoScheduledCancellation: its status is
still active.
POST /subscriptions/{subscription_id}/change-plan — move a subscription to
another product, for an upgrade, a downgrade or a quantity change.
await client.changePlan('sub_1', {
product_id: 'prd_pro_monthly',
quantity: 1,
proration_billing_mode: 'prorated_immediately',
});| Field | Meaning |
|---|---|
product_id, quantity
|
The new plan. Required. |
proration_billing_mode |
Required. See below. |
effective_at |
'immediately' (default) or 'next_billing_date'. |
on_payment_failure |
'prevent_change' keeps the old plan until payment succeeds; 'apply_change' (Dodo's default) switches anyway. |
collect_via_payment_link |
Charge through a hosted checkout instead of the saved card. Needs an immediate change and prevent_change. |
addons |
Addons for the new plan. An empty list removes existing ones. |
discount_codes |
Replaces the subscription's discounts; [] removes them. See Discounts. |
proration_billing_mode |
Billing |
|---|---|
prorated_immediately |
Credits the unused part of the current cycle, then charges a full cycle of the new plan. |
full_immediately |
Charges the new plan in full, with no credit for the current cycle. |
difference_immediately |
An upgrade charges the difference now; a downgrade keeps the remainder as renewal credit. |
do_not_bill |
Switches with no charge or credit; the billing cycle is unchanged. |
For a change charged to the saved payment method, every field of the result
is null; with collect_via_payment_link, payment_link is the checkout to
send the customer to. The outcome arrives as subscription.plan_changed
and payment.succeeded or payment.failed webhooks.
A second change while one is still pending fails with CONFLICT (vendor
code PendingPlanChangeExists).
DELETE /subscriptions/{subscription_id}/change-plan/scheduled — withdraw a
change made with effective_at: 'next_billing_date'. NOT_FOUND when
nothing is scheduled.
A product is what a customer pays for: a one-time price (a credit pack) or
a recurring price (a plan). Payments and subscriptions refer to it by
product_id.
POST /products. Required: name (at most 100 characters),
tax_category, price.
const pack = await client.createProduct({
name: 'Credit pack (500)',
tax_category: 'saas',
price: { type: 'one_time_price', price: 4900, currency: 'USD' },
metadata: { pack_code: 'credits_500' },
});price is either { type: 'one_time_price', price, currency, … } or
{ type: 'recurring_price', price, currency, payment_frequency_count, payment_frequency_interval, subscription_period_count, subscription_period_interval, trial_period_days?, … }. Usage-based prices
can be read but not created here.
The subscription period is how long a subscription runs, not how often it bills. When the two are equal (1 month billed monthly), the subscription runs one cycle and then expires. For an ongoing plan, set a long period such as
20+'Year'.
tax_category is one of digital_products, saas, e_book, edtech,
live_tutoring.
GET /products/{id} — the full record, archived or not. price is a union
discriminated on type.
GET /products. Filters: archived, recurring, brandId, plus paging.
Without archived, only live products are listed; archived: true lists
only archived ones. Each item carries the base amount in price and
currency, and the full price in price_detail.
listAllProducts pages the same way as listAllPayments.
Every product whose metadata contains all entries of match. Dodo cannot
filter by metadata, so this pages through the catalogue: one request per
page, and a second pass over archived products with
includeArchived: true. Values compare strictly, so { seats: 5 } does not
match { seats: '5' }.
const [existing] = await client.findProductsByMetadata(
{ plan_code: 'pro_monthly' },
{ includeArchived: true },
);Metadata values are strings, numbers or booleans, and keep their type.
PATCH /products/{id}. Every field is optional; an update that changes
nothing is rejected locally. Dodo returns no body, so this resolves to
nothing; call getProduct for the new record.
To change a price without moving existing subscribers, create a new
product and archive the old one instead of updating price.
DELETE /products/{id} takes a product off sale; Dodo calls this archiving.
POST /products/{id}/unarchive puts it back, and fails with CONFLICT for
a product that is not archived.
A discount code takes money off a payment or subscription. Codes are
redeemed through discount_codes on createPayment, createSubscription
and changePlan (up to 20, applied in order).
POST /discounts. Required: type, amount.
// 20% off the first payment, new customers only, once each.
const discount = await client.createDiscount({
type: 'percentage',
amount: 2000, // basis points: 2000 = 20%
code: 'WELCOME20',
subscription_cycles: 1,
per_customer_usage_limit: 1,
customer_eligibility: 'first_time',
metadata: { partner_id: 'acme' },
});
// $5 off orders of $20 or more, until the end of the year.
await client.createDiscount({
type: 'flat',
amount: 500,
currency_options: [
{
currency: 'USD',
is_default: true,
max_amount_possible: 500,
minimum_subtotal: 2000,
},
],
expires_at: '2026-12-31T23:59:59Z',
});| Field | Meaning |
|---|---|
type |
'percentage' or 'flat'. Dodo blocks flat_per_unit for new codes. |
amount |
Integer, at least 1. Basis points for percentage (1500 = 15%, at most 10000). For flat, see below. |
currency_options |
Per currency: max_amount_possible (the flat deduction, or the percentage cap), minimum_subtotal, and is_default. |
code |
3–16 characters. Dodo upper-cases it, and generates a random 16-character code when omitted. |
name |
Display name. |
usage_limit |
Total redemptions, at least 1. Unlimited when omitted. |
per_customer_usage_limit |
Redemptions per customer; at most usage_limit when both are set. |
subscription_cycles |
Billing cycles a subscription keeps the discount: 1 is the first payment only. Omitted or null is indefinite. |
restricted_to |
Product ids the code is limited to. |
starts_at, expires_at
|
RFC 3339 date-times with an offset. expires_at must be after starts_at. |
customer_eligibility |
'any' (default), 'first_time', 'existing' or 'specific' (only customers on the allow list). |
preserve_on_plan_change |
true keeps the discount through changePlan. Defaults to false. |
metadata |
String, number or boolean values, the same shape as product metadata. |
Flat codes: the deduction is each currency's
currency_options[].max_amount_possible, in minor units. Aflatcode needs at least one currency option, and every option needsmax_amount_possible(Dodo answers a missing one with 422DISCOUNT_CURRENCY_OPTION_INVALID). Dodo also requiresamountand stores it, but it does not set the deduction: in test mode,amount: 1withmax_amount_possible: 250took 250 off. Setamountto the default currency's deduction so the record reads sensibly. A lone currency option becomes the default.
The request is checked locally before it is sent. Unknown fields are
rejected, not dropped, because a misspelt usageLimit dropped silently
would create a code with no limit. Numbers and booleans must be real
numbers and booleans, not strings. The rules above (percentage range, flat
needs a currency option, each currency once with at most one default,
per-customer limit within the total, window order) fail with
REQUEST_VALIDATION_ERROR.
A taken code fails with INVALID_REQUEST (vendor code
DISCOUNT_CODE_ALREADY_EXISTS).
GET /discounts/{discount_id}. NOT_FOUND for an unknown or deleted
discount.
GET /discounts/code/{code}. Dodo gives lookup by code its own path, so it
never has to guess whether a value is an id or a code. Matching ignores
case.
This is also a redemption check: an expired or used-up code fails with
INVALID_REQUEST (Dodo answers 422), not with the record. To read such a
code, list with listDiscounts({ code }) and compare code exactly; the
code filter is a partial, case-insensitive match.
GET /discounts. Deleted discounts are never listed. Each item is the full
record, the same as getDiscount's.
| Option | Query param |
|---|---|
code |
code (partial match) |
discountType |
discount_type |
active |
active |
productId |
product_id |
pageNumber / pageSize
|
page_number / page_size
|
listAllDiscounts pages the same way as listAllPayments.
PATCH /discounts/{discount_id}: a partial update. Omitted fields are left
as they are, and the updated record is returned. An update that changes
nothing is rejected locally.
| To | Send |
|---|---|
| Make the per-customer limit unlimited | per_customer_usage_limit: null |
| Make the code valid immediately | starts_at: null |
| Remove the product restriction |
restricted_to: [] (the list is replaced, not merged) |
| Remove every currency option |
currency_options: [] (the list is replaced, not merged) |
type, amount, code, customer_eligibility,
preserve_on_plan_change, restricted_to, currency_options and
metadata cannot be null: Dodo would ignore it. Dodo does not document
whether null clears name, usage_limit, subscription_cycles or
expires_at, so check the returned record. A usage_limit below
times_used is rejected by Dodo.
Changing a code does not change the discount on subscriptions that already redeemed it.
DELETE /discounts/{discount_id}. Dodo soft-deletes the code: it stops
working and leaves every list and lookup, and it cannot be restored.
NOT_FOUND when it is already deleted.
A customer_eligibility: 'specific' code is redeemable only by the
customers on its allow list, and a new one starts with an empty list.
| Method | Route |
|---|---|
addDiscountCustomers(discountId, customerIds) |
POST /discounts/{discount_id}/customers |
listDiscountCustomers(discountId, options?) |
GET /discounts/{discount_id}/customers |
listAllDiscountCustomers(discountId, options?) |
Auto-paging counterpart. |
removeDiscountCustomer(discountId, customerId) |
DELETE /discounts/{discount_id}/customers/{customer_id} |
await client.addDiscountCustomers('dsc_1', ['cus_1', 'cus_2']);addDiscountCustomers takes 1 to 1000 ids, returns only the customers
added by that call, and is safe to repeat. Dodo rejects the whole call
(INVALID_REQUEST) when any id is not a customer of yours.
removeDiscountCustomer fails with NOT_FOUND for a customer not on the
list.
A subscription record lists the discounts applied to it in discounts,
in stack order. discount_id and discount_cycles_remaining are Dodo's
deprecated single-discount fields.
| Read | Each discounts entry carries |
|---|---|
getSubscription, and the cancel / pause / resume results |
The discount record plus position and cycles_remaining (billing cycles still to go; null for a discount without a cycle limit). |
listSubscriptions |
Only discount_id and discount_cycles_remaining. |
No field gives the date a subscription's discount ends. The entry's
expires_at is when the CODE stops being redeemable. Work the end out from
cycles_remaining and the billing period.
Dodo has no field or route for this on the subscription itself;
PATCH /subscriptions/{id} takes no discount. The documented route is a
plan change to the same product and quantity, with discount_codes
and do_not_bill:
const sub = await client.getSubscription('sub_1');
await client.changePlan(sub.subscription_id, {
product_id: sub.product_id,
quantity: sub.quantity,
proration_billing_mode: 'do_not_bill', // no charge, cycle unchanged
discount_codes: [
// discount_codes REPLACES the stack: keep the current codes.
...(sub.discounts ?? []).flatMap((d) => (d.code ? [d.code] : [])),
'LOYAL10',
],
});-
discount_codesreplaces every discount on the subscription;[]removes them all. Omitted, only discounts created withpreserve_on_plan_change: truesurvive a plan change. - Checked in test mode on 2026-10-08: Dodo accepts the same-product change, takes no charge (it records a $0 payment), adds the new code with its full cycle count, and leaves the codes already applied with the cycles they had left. Sending only the new code removed the old one.
- Dodo's documented failures here include
DISCOUNT_ALREADY_USED_ON_SUBSCRIPTION,DISCOUNT_NOT_APPLICABLE_TO_NEW_PRODUCTandDISCOUNT_NOT_AVAILABLE_FOR_ON_DEMAND(asvendorCode).
createPayment, createSubscription and changePlan take
discount_codes: string[] (up to 20, applied in order). The single
discount_code field is deprecated by Dodo and cannot be combined with
discount_codes; a request with both is rejected locally.
Not an HTTP call — a method on the client (the signing secret is passed per call; it is not the API key). The HMAC stays on Web Crypto because Standard Webhooks keys the MAC with the base64-decoded secret, which @tundralibs/crypt's string-keyed signHMAC cannot express; the constant-time comparison does come from crypt. DodoPayments.webhookSignedContent(id, ts, payload) exposes the exact signed string. See
the README and
Errors.
| Option | Type | Default | Description |
|---|---|---|---|
payload |
string |
— | The raw body. Never re-serialized. |
headers |
Headers | object |
— | Case-insensitive lookup. |
secret |
string |
— | With or without the whsec_ prefix. |
toleranceSeconds |
number |
300 |
Replay window, checked both directions. |
nowMs |
number |
Date.now() |
Clock override for tests. |
Returns the parsed payload on success — so the verified object is the natural thing to act on, and there is no unverified copy to reach for by mistake.