Checkouts
Create and read checkouts — the params, the capability gates on successUrl, expiresAt and customAmount, metadata, and status semantics.
A checkout is the hand-off from your app to the provider’s payment page. checkouts.create returns a
normalized Checkout whose url you redirect the customer to; checkouts.get reads one back.
Creating a checkout
const checkout = await client.checkouts.create({
items: [{ product: price.checkoutRef, quantity: 1 }],
customerEmail: '[email protected]',
successUrl: 'https://example.com/thanks',
metadata: { userId: 'user_123' },
});
redirect(checkout.url);
items?CheckoutItem[]
At least one { product, quantity? }. product is a Price.checkoutRef, never a Product.id.
CheckoutItem[]successUrl?string
Where the customer lands after paying. Requires the checkoutSuccessUrl capability.
stringcustomerId?string
Attach an existing provider customer.
stringcustomerEmail?string
Prefill the email for a new customer.
stringmetadata?Record<string, string | number | boolean>
Copied onto the resulting order/subscription where the provider supports it.
Record<string, string | number | boolean>customAmount?number
What to charge for a pay-what-you-want price, in minor units. Requires the checkoutCustomAmount capability.
numberexpiresAt?Date
When the checkout link stops working. Requires the checkoutExpiresAt capability.
Datesignal?AbortSignal
Abort the request.
AbortSignalThe client validates items before any request is made: an empty array, an empty product string, or a
quantity that isn’t a positive integer throws RevenueError with code validation.
Three params are capability-gated and throw unsupported rather than being silently dropped —
successUrl (checkoutSuccessUrl), expiresAt (checkoutExpiresAt) and customAmount
(checkoutCustomAmount). A few finer limits are enforced by the adapters instead: Polar rejects an item
quantity other than 1, and Lemon Squeezy rejects a second item as well as customerId (pass
customerEmail). Read the values off client.capabilities, or look them up in the
capability matrix:
await client.checkouts.create({
items: [{ product: price.checkoutRef }],
successUrl: client.capabilities.checkoutSuccessUrl
? 'https://example.com/thanks'
: undefined,
});
The Checkout model
id?string
Provider checkout identifier.
stringurl?string
Where to send the customer. Empty string when the provider did not return one.
stringstatus?'open' | 'complete' | 'expired' | null
null when the provider exposes no checkout status (Lemon Squeezy).
'open' | 'complete' | 'expired' | nullcustomerId?string
Provider customer, when known.
stringcustomerEmail?string
Customer email, when known.
stringsubscriptionId?string
The subscription the checkout created, when known.
stringmetadata?Record<string, string | number | boolean>
Metadata echoed back by the provider.
Record<string, string | number | boolean>expiresAt?Date
When the checkout link expires, when the provider says so.
Dateraw?unknown
The untouched provider payload.
unknownCheckout expiry
expiresAt is a Date, gated by the checkoutExpiresAt capability — only Lemon Squeezy and Stripe let
you choose when a link stops working; the other three apply a fixed lifetime server-side:
await client.checkouts.create({
items: [{ product: price.checkoutRef }],
expiresAt: client.capabilities.checkoutExpiresAt
? new Date(Date.now() + 60 * 60 * 1000) // one hour
: undefined,
});
An invalid Date or one at or before now throws validation; a provider-specific window (Stripe’s, for
instance) is left to the provider, whose rejection arrives as a validation error like any other.
Checkout.expiresAt is the read side of the same field, populated on Polar, Lemon Squeezy and Stripe —
Polar included, which reports the expiry it picked without letting you choose it.
What each provider does with a checkout's lifetime
| Provider | expiresAt |
Behavior |
|---|---|---|
| Polar | no | Fixed 24 hours, set server-side. |
| Lemon Squeezy | yes | expires_at, ISO 8601. Omit it and the link never expires. |
| Stripe | yes | expires_at, unix seconds. Stripe accepts 30 minutes to 24 hours from creation. |
| Paddle | no | Transactions have no expiry field. |
| Dodo Payments | no | Fixed 24 hours, or 15 minutes for a confirmed checkout. |
Pay what you want
A price whose model is custom has no fixed amount: the buyer picks one on the provider’s checkout
page. customAmount supplies that amount from your own UI instead — an integer in the currency’s
minor units (2500 is $25.00). It is gated by checkoutCustomAmount, which is true on Polar and
Lemon Squeezy only:
await client.checkouts.create({
items: [{ product: price.checkoutRef }],
customAmount: client.capabilities.checkoutCustomAmount ? 2500 : undefined,
});
A value that is not a positive integer throws validation, both checks happening before any request.
The upper bound belongs to the provider — only it knows the price’s own minimum and maximum.
Metadata
metadata is a flat Record<string, string | number | boolean> sent with the checkout and echoed back
on the resulting order or subscription — with one gap worth planning around: on Lemon Squeezy custom
data never reaches the subscription resource at all. It travels only in the webhook envelope, so
Subscription.metadata is populated on Lemon Squeezy webhook events and undefined when you fetch the
same subscription over the API.
Where metadata is written on each provider
| Provider | Written as | Reaches the subscription? |
|---|---|---|
| Polar | checkout metadata |
yes — Polar copies it onto the order/subscription |
| Lemon Squeezy | checkout_data.custom |
only via webhooks, as meta.custom_data |
| Stripe | session metadata and subscription_data[metadata] |
yes — the SDK writes both |
| Paddle | transaction custom_data |
yes — carried onto the subscription |
| Dodo Payments | checkout metadata |
yes |
Stripe does not copy session metadata onto the created subscription by itself, which is why the SDK
writes subscription_data[metadata] as well whenever the checkout is in subscription mode.
Checkout status semantics
Checkout.status is a three-value union plus null:
status |
Meaning |
|---|---|
open |
Not paid (yet). The link may still be used. |
complete |
Paid. Safe to fulfill. |
expired |
The checkout failed, was canceled, or timed out. |
null |
The provider exposes no checkout status (Lemon Squeezy). |
Which provider value produces which is in Status mapping.
Reading a checkout back
const checkout = await client.checkouts.get({ id: checkoutId });
if (checkout.status === 'complete') {
// Paid.
}
On Dodo Payments the checkout-status endpoint does not return the checkout URL, so a Checkout
fetched with checkouts.get has url: ''. Persist the URL from checkouts.create if you need it later.