Checkout
Checkout produces a provider-hosted checkout session that the application redirects the customer to. Payable supports two modes: subscription checkout (start a subscription through the hosted page) and payment checkout (a one-time payment). Both run through one pipeline and return the same DTO.
Two builders, two entry points
Subscription-mode checkout - newSubscription(name).checkout(urls)
SubscriptionBuilder is reached via payable.customer(billable).newSubscription(name). Its
checkout() method builds a subscription-mode session. The same builder can instead create() a
subscription directly without a hosted page - see 10-subscriptions.
Fluent options on SubscriptionBuilder:
| Method | Effect |
|---|---|
price(priceId) | Sets the primary price (required before checkout()). |
trialDays(days) | Adds a trial; forwarded as trialDays. |
coupon(code) | Applies a coupon; forwarded as coupon. |
quantity(n) | Sets the primary line-item quantity (default 1). |
addItem(priceId, qty) | Adds extra line items - used by create(), not by checkout(). |
checkout(request) takes a CheckoutRequest ({ successUrl, cancelUrl, reference?, authorization? }) and:
- Calls
assertAuthorizedwithCanCreateCheckoutPolicy, gated whendeps.authorizationEnabledis true (otherwise a no-op). - Throws
PayableError(CHECKOUT_PRICE_REQUIRED) if no price was set. - Delegates to
CreateCheckoutPipelinewithmode: 'subscription', a single line item{ priceId, quantity }, the URLs, thesubscriptionName, and the optionaltrialDays/coupon.
Note: in subscription-mode checkout, only the primary price becomes the line item - addItem(...)
entries are not forwarded by checkout() (they apply to direct create()).
const session = await payable
.customer(billable)
.newSubscription('default')
.price('price_pro')
.trialDays(14)
.checkout({
successUrl: 'https://app.test/success',
cancelUrl: 'https://app.test/cancel',
});
return redirect(session.url);
Payment-mode checkout - checkout()
CheckoutBuilder is reached via payable.customer(billable).checkout(). It defaults to
mode: 'payment'.
| Method | Effect |
|---|---|
mode(mode) | 'payment' or 'subscription' (default 'payment'). |
addPrice(priceId, qty) | Appends a line item (default qty 1). At least one is required. Throws CHECKOUT_INVALID_QUANTITY for a non-positive-integer quantity. |
create(request) | Asserts CanCreateCheckoutPolicy (gated by authorizationEnabled), then builds the session; throws CHECKOUT_LINE_ITEMS_REQUIRED if no line items. |
CheckoutBuilder does not accept trialDays or coupon; those are only set through the
subscription builder. Its subscriptionName is fixed to 'default'.
const session = await payable
.customer(billable)
.checkout()
.mode('payment')
.addPrice('price_one')
.create({
successUrl: 'https://app.test/success',
cancelUrl: 'https://app.test/cancel',
});
Redirect/amount checkout - redirectCheckout(amount)
Catalog-less providers (SISP) have no prices, so the checkout()/create() builders (which require
line items and a provider customer) do not fit. payable.customer(billable).redirectCheckout(amount)
is a separate, amount-based entry for hosted-redirect providers:
| Method | Effect |
|---|---|
redirectCheckout(amount: Money) | Starts an amount-based, payment-mode redirect checkout. |
create(request?) | Ensures a local customer, records a pending Payment, and returns the session. |
create(request) takes an optional RedirectCheckoutRequest (all fields optional):
export interface RedirectCheckoutRequest {
successUrl?: string;
cancelUrl?: string;
reference?: string;
authorization?: AuthorizationContext;
}
referenceis folded into the idempotency key and stored on the pendingPaymentfor callback reconciliation.authorizationis checked againstCanCreateCheckoutPolicyonly when authorization is enabled.successUrl/cancelUrldefault to''when omitted. Redirect providers may ignore them: a hosted-form provider (SISP) returns its own auto-submit form and does not honor caller-supplied success/cancel URLs.
const session = await payable
.customer(billable)
.redirectCheckout(Money.of(150000, 'CVE'))
.create({ reference: 'order-42' });
res.send(session.html); // hosted-form providers return a ready auto-submit form
Unlike the catalog pipeline, redirect checkout conditionally synchronizes the provider customer. When
the selected provider advertises the customers capability, it explicitly syncs the local customer
and passes the resulting provider customer id into checkout. Providers without that capability (such
as SISP) receive an empty provider customer id. The builder then records a pending Payment keyed by
the session id so the later callback can reconcile it. The provider-specific callback is processed
with payable.receiveRedirectCallback(...). See SISP.
The CheckoutSessionDTO
CheckoutSessionDTO is { id, url, html? }. Stripe and Paddle return id + url (a hosted page to
redirect to). Redirect-form providers (SISP) additionally return html - a ready auto-submit form to
send to the browser, which POSTs to the gateway in url.
The pipeline and action
CreateCheckoutPipeline (the catalog checkout() path) composes two steps:
- Sync the customer.
SyncCustomerWithProviderActionresolves (and persists) theproviderCustomerIdfor theBillable- see 08-customers-billable. - Create the session.
CreateCheckoutSessionActioncallsprovider.createCheckoutSession(input, ctx).
The pipeline builds a deterministic idempotency key with IdempotencyKey.forCheckout:
checkout:${providerName}:${billableType}:${billableId}:${firstPriceId}:${subscriptionName}. Repeated
calls with the same Billable, first price, and subscription name reuse the key.
sequenceDiagram
participant App
participant Builder as Subscription/CheckoutBuilder
participant Pipeline as CreateCheckoutPipeline
participant Sync as SyncCustomerWithProviderAction
participant Action as CreateCheckoutSessionAction
participant Provider
App->>Builder: checkout(urls) / create(urls)
Builder->>Pipeline: handle(CreateCheckoutInput)
Pipeline->>Sync: handle(billable)
Sync-->>Pipeline: providerCustomerId
Pipeline->>Action: handle({ input, idempotencyKey })
Action->>Provider: createCheckoutSession(input, ctx)
Provider-->>App: CheckoutSessionDTO { id, url }
Inputs and outputs
The provider receives CreateCheckoutSessionInput:
export interface CreateCheckoutSessionInput {
providerCustomerId: string;
mode: 'payment' | 'subscription';
lineItems: { priceId: string; quantity: number }[];
successUrl: string;
cancelUrl: string;
trialDays?: number;
coupon?: string;
amount?: Money;
}
The output is a CheckoutSessionDTO:
export interface CheckoutSessionDTO {
id: string;
url: string;
html?: string;
}
The application redirects the customer to url. The actual subscription or payment record is created
locally later, when the provider’s webhook is received and reconciled - see
13-webhooks.
Business rules
- Subscription-mode checkout requires a price (
CHECKOUT_PRICE_REQUIRED). - Payment-mode checkout requires at least one line item (
CHECKOUT_LINE_ITEMS_REQUIRED). - The customer is always synced to the provider before the session is created.
trialDaysandcouponflow only through the subscription builder.- The provider’s
checkoutcapability is not asserted in the checkout path itself; the pipeline assumes the bound provider supportscreateCheckoutSession(it is a required method on thePaymentProvidercontract).
Policy
CanCreateCheckoutPolicy authorizes against an AuthorizationContext (allowed === true and a
non-empty actorId). All three builders - CheckoutBuilder, RedirectCheckoutBuilder, and
SubscriptionBuilder - call assertAuthorized with CanCreateCheckoutPolicy before doing any work,
gated when deps.authorizationEnabled is true. When authorization is disabled the call is a no-op, so
integrators that do not opt in see no behavior change. See
11-charges-refunds for the same wiring across the other CRUD policies.
Edge cases
- No price / no line items. Explicit
PayableErrors as above. addItemin subscription checkout. Ignored bycheckout(); only the primary price is sent. Multi-item plans go throughcreate()(direct subscription).- Tenancy / provider resolution. Inherited from
payable.customer(...)- see 08-customers-billable. - Idempotent retries. Re-issuing the same checkout reuses the deterministic key, so the provider can dedupe the session.