payable payable Docs
Coming soon

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:

MethodEffect
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 }) and:

  1. Throws PayableError (CHECKOUT_PRICE_REQUIRED) if no price was set.
  2. Delegates to CreateCheckoutPipeline with mode: 'subscription', a single line item { priceId, quantity }, the URLs, the subscriptionName, and the optional trialDays/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'.

MethodEffect
mode(mode)'payment' or 'subscription' (default 'payment').
addPrice(priceId, qty)Appends a line item (default qty 1). At least one is required.
create(request)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',
  });

The pipeline and action

CreateCheckoutPipeline composes two steps:

  1. Sync the customer. SyncCustomerWithProviderAction resolves (and persists) the providerCustomerId for the Billable - see 08-customers-billable.
  2. Create the session. CreateCheckoutSessionAction calls provider.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;
}

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.
  • trialDays and coupon flow only through the subscription builder.
  • The provider’s checkout capability is not asserted in the checkout path itself; the pipeline assumes the bound provider supports createCheckoutSession (it is a required method on the PaymentProvider contract).

Policy

CanCreateCheckoutPolicy authorizes against an AuthorizationContext (allowed === true and a non-empty actorId). It is not yet wired into the checkout pipeline or builders - no checkout code references it. Treat it as an available building block for integrators, not an enforced gate. See 11-charges-refunds for the same status across the other CRUD policies.

Edge cases

  • No price / no line items. Explicit PayableErrors as above.
  • addItem in subscription checkout. Ignored by checkout(); only the primary price is sent. Multi-item plans go through create() (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.