Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

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

PropType
id?string

Provider identifier, coerced to a string. What get and getInvoiceUrl take.

Typestring
status?OrderStatus

One of 'pending' | 'paid' | 'failed' | 'refunded' | 'partially_refunded' | 'void'. Always set.

TypeOrderStatus
amount?number

What the customer was charged, in the currency's minor units — after discounts and credits, including tax.

Typenumber
currency?string

Lowercase ISO 4217 code.

Typestring
customerId?string

The provider's customer identifier.

Typestring
customerEmail?string

The email on the payment, where the provider reports one.

Typestring
subscriptionId?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.

Typestring
createdAt?Date

When the payment record was created. On Paddle this is billed_at, falling back to created_at.

TypeDate
refundStatus?'full' | 'partial'

Set once any of the order was refunded. Never set on Stripe.

Type'full' | 'partial'
metadata?Record<string, string | number | boolean>

Provider metadata, where available.

TypeRecord<string, string | number | boolean>
raw?unknown

The untouched provider payload.

Typeunknown

amount 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 refundedfull; status partially_refunded, or any refunded_amount > 0partial
Lemon Squeezy status refundedfull; 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 Orderstatus, 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.

Last updated on August 8, 2026

Was this page helpful?