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

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);
PropType
items?CheckoutItem[]

At least one { product, quantity? }. product is a Price.checkoutRef, never a Product.id.

TypeCheckoutItem[]
successUrl?string

Where the customer lands after paying. Requires the checkoutSuccessUrl capability.

Typestring
customerId?string

Attach an existing provider customer.

Typestring
customerEmail?string

Prefill the email for a new customer.

Typestring
metadata?Record<string, string | number | boolean>

Copied onto the resulting order/subscription where the provider supports it.

TypeRecord<string, string | number | boolean>
customAmount?number

What to charge for a pay-what-you-want price, in minor units. Requires the checkoutCustomAmount capability.

Typenumber
expiresAt?Date

When the checkout link stops working. Requires the checkoutExpiresAt capability.

TypeDate
signal?AbortSignal

Abort the request.

TypeAbortSignal

The 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

PropType
id?string

Provider checkout identifier.

Typestring
url?string

Where to send the customer. Empty string when the provider did not return one.

Typestring
status?'open' | 'complete' | 'expired' | null

null when the provider exposes no checkout status (Lemon Squeezy).

Type'open' | 'complete' | 'expired' | null
customerId?string

Provider customer, when known.

Typestring
customerEmail?string

Customer email, when known.

Typestring
subscriptionId?string

The subscription the checkout created, when known.

Typestring
metadata?Record<string, string | number | boolean>

Metadata echoed back by the provider.

TypeRecord<string, string | number | boolean>
expiresAt?Date

When the checkout link expires, when the provider says so.

TypeDate
raw?unknown

The untouched provider payload.

Typeunknown

Checkout 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.

Last updated on August 8, 2026

Was this page helpful?