Orders
Read billing history back — listing and fetching payments, the Order model, refund semantics, and on-demand invoice URLs.
An order is one payment: a one-off purchase, or a single charge against a subscription. The
order.paid webhook tells you money arrived; client.orders is how you read the same payments back —
for an invoice history page, a reconciliation job, or anything that happened before your endpoint
existed.
const { items, cursor } = await client.orders.list({ limit: 20, customerId: 'CUSTOMER_ID' });
const order = await client.orders.get({ id: 'ORDER_ID' });
const invoiceUrl = await client.orders.getInvoiceUrl({ id: 'ORDER_ID' });
Orders are read-only: creating a charge is checkouts.create, and refunding one is a dashboard or
provider-native operation.
A billing history page
The use case orders exist for: date, amount, status, and a receipt link the customer can open.
import type { Order } from 'revenue-sdk';
function formatMoney(amount: number, currency: string): string {
// Minor units per major unit vary by currency — 100 for USD, 1 for JPY, 1000 for KWD.
const format = new Intl.NumberFormat('en-US', { style: 'currency', currency });
return format.format(amount / 10 ** format.resolvedOptions().maximumFractionDigits);
}
function describe(order: Order): string {
// refundStatus is read before status: on Stripe a refunded invoice still reports `paid`,
// and on Paddle and Dodo Payments a refunded payment still reports `completed`/`succeeded`.
if (order.refundStatus !== undefined) {
return order.refundStatus === 'full' ? 'Refunded' : 'Partially refunded';
}
return order.status === 'paid' ? 'Paid' : order.status;
}
export async function billingHistory(customerId: string) {
const rows = [];
for await (const order of client.orders.listAll({ customerId })) {
rows.push({
id: order.id,
date: order.createdAt,
amount:
order.amount !== undefined && order.currency !== undefined
? formatMoney(order.amount, order.currency)
: '—',
status: describe(order),
});
}
// Lemon Squeezy drains orders before subscription invoices, so sort rather than
// trusting the page order.
return rows.sort((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0));
}
The receipt link belongs in a second route that calls getInvoiceUrl per click and redirects — never
rendered into the table, because most of those URLs expire. See
getInvoiceUrl below.
The Order model
id?string
Provider identifier, coerced to a string. What get and getInvoiceUrl take.
stringstatus?OrderStatus
One of 'pending' | 'paid' | 'failed' | 'refunded' | 'partially_refunded' | 'void'. Always set.
OrderStatusamount?number
What the customer was charged, in the currency's minor units — after discounts and credits, including tax.
numbercurrency?string
Lowercase ISO 4217 code.
stringcustomerId?string
The provider's customer identifier.
stringcustomerEmail?string
The email on the payment, where the provider reports one.
stringsubscriptionId?string
Set when the payment belongs to a subscription. Absent on one-off purchases — and on every Lemon Squeezy order, which carries no subscription reference.
stringcreatedAt?Date
When the payment record was created. On Paddle this is billed_at, falling back to created_at.
DaterefundStatus?'full' | 'partial'
Set once any of the order was refunded. Never set on Stripe.
'full' | 'partial'metadata?Record<string, string | number | boolean>
Provider metadata, where available.
Record<string, string | number | boolean>raw?unknown
The untouched provider payload.
unknownamount is deliberately what the customer was charged, not what was billed: where a credit absorbs
part of the total, the charged figure wins (an unpaid invoice reports the billed total, having no other).
Line items, tax and discount breakdowns are not normalized — read raw for those.
Which resource each provider maps to
“An order” is a different object on every provider, with a different ID space:
| Provider | Underlying resource | orders.list reads |
List filter |
|---|---|---|---|
| Polar | Order |
GET /v1/orders/ |
every status except draft, excluded server-side |
| Lemon Squeezy | Order ∪ Subscription Invoice |
GET /v1/orders, then GET /v1/subscription-invoices |
the store, minus initial invoices |
| Stripe | Invoice (not Charge) |
GET /v1/invoices |
drafts dropped client-side |
| Paddle | Transaction |
GET /transactions |
status=completed, ordered by billed_at[DESC] |
| Dodo Payments | Payment |
GET /payments |
status=succeeded |
Order.id is always an ID from that provider’s own space, so it round-trips through orders.get and
orders.getInvoiceUrl — and matches the event.order.id you receive on order.paid. Paddle and Dodo
Payments filter to a settled status server-side, so every order they list is paid. Lemon Squeezy is
the one union, which also makes its pages not globally chronological — sort after draining; see
Lemon Squeezy.
Statuses
OrderStatus is a closed union of six values: paid, pending, failed, refunded,
partially_refunded and void. Every provider enum is treated as open — an unrecognized status
maps to pending rather than throwing, so a provider adding a state can never crash a list call. Branch
on paid and on the refund states; treat everything else as “not settled yet”.
Stripe, Paddle and Dodo Payments never report a refund through status — read refundStatus instead.
Per-provider status mapping
| Unified | Polar | Lemon Squeezy | Stripe | Paddle | Dodo Payments |
|---|---|---|---|---|---|
paid |
paid |
paid |
paid |
completed |
succeeded |
pending |
pending, draft¹ |
pending |
open, draft¹ |
billed, ready, draft¹ |
processing, requires_* |
failed |
— | failed, fraudulent |
uncollectible |
past_due |
failed, cancelled |
refunded |
refunded |
refunded |
— | — | — |
partially_refunded |
partially_refunded |
partial_refund |
— | — | — |
void |
void |
void² |
void |
canceled |
— |
¹ Drafts never reach orders.list — Polar, Stripe and Paddle all exclude them. The mapping only applies
to an order fetched directly by ID, or one arriving on a webhook.
² Only Lemon Squeezy subscription invoices carry void — a bill cancelled while the subscription was
paused. Orders never do.
Refunds
refundStatus is set as soon as any of the order was refunded, and says whether the refund covered
the whole amount ('full') or part of it ('partial').
How refundStatus is derived per provider
| Provider | Derived from |
|---|---|
| Polar | status refunded → full; status partially_refunded, or any refunded_amount > 0 → partial |
| Lemon Squeezy | status refunded → full; otherwise refunded_amount compared against total |
| Stripe | never set |
| Paddle | adjustments_totals.breakdown.refund compared against the order amount |
| Dodo Payments | refund_status on the payment (full / partial) |
Lemon Squeezy’s refunded boolean is deliberately not read: it is true only for a full refund, so a
partial one leaves it false with a non-zero refunded_amount. Paddle’s refund totals only exist when
the request asks for them, so the SDK always sends include=adjustments_totals.
getInvoiceUrl is a call, not a field
The invoice link lives behind a method because its lifetime differs wildly per provider — Polar’s is an S3 presign valid for 10 minutes, Paddle’s for 1 hour, while Lemon Squeezy’s never expires. Fetch one when the customer clicks, the same rule as a portal session.
It throws RevenueError { code: 'not_found' } when the provider has no invoice for the order: on
Stripe before the invoice is finalized, on Polar when none was ever generated (Polar mints them
through an async job the SDK deliberately does not trigger), on Paddle for a transaction that was
never billed or is zero-value, on Lemon Squeezy while a subscription invoice is still pending, and
on Dodo Payments when the payment carries no invoice_url.
Invoice URL source and lifetime per provider
| Provider | Source | Lifetime |
|---|---|---|
| Lemon Squeezy | urls.receipt / urls.invoice_url on the resource |
documented as not expiring |
| Dodo Payments | invoice_url on the payment |
stable |
| Stripe | hosted_invoice_url, falling back to invoice_pdf |
~30 days after the due date, capped at 120; an expired PDF answers 400 |
| Paddle | an extra GET /transactions/{id}/invoice |
1 hour |
| Polar | an extra GET /v1/orders/{id}/invoice |
10 minutes (an S3 presign) |
Filtering by customer
orders.list and orders.listAll accept a customerId, gated by the
listOrdersByCustomer capability — false on Lemon
Squeezy only, whose /v1/orders filters by store, user email and order number rather than customer
ID. Passing one there throws unsupported before any request is made; listing unfiltered works
everywhere.
// On Lemon Squeezy, list everything and filter locally.
const orders = client.capabilities.listOrdersByCustomer
? client.orders.listAll({ customerId })
: client.orders.listAll();
Getting notified
order.paid carries a fully mapped Order — status, createdAt and refundStatus included — so a
handler can record the payment without a follow-up read, and it fires once per payment, renewals
included:
if (event.type === 'order.paid') {
await recordPayment(event.order);
}
Webhooks are the live signal; orders.list is the backfill. Deliveries can be missed, and only the list
is authoritative about what actually happened — see Webhooks.
Testing
The in-memory provider seeds orders and exposes them on state.orders:
const provider = createInMemoryProvider({
orders: [
{ id: 'ord-1', customerId: 'cus-1', amount: 2900, currency: 'usd', status: 'paid' },
{ id: 'ord-2', customerId: 'cus-1', amount: 2900, currency: 'usd', refundStatus: 'partial' },
],
});
const { items } = await createClient({ provider }).orders.list({ customerId: 'cus-1' });
See Testing provider for the full seed shape.