Skip to content

Checkout Flow

The checkout endpoint creates a payment session with the configured provider and returns a URL or token for the user to complete payment.

Two-Phase Flow

Subscribing and paying are two separate processes that do not have to happen together:

  1. Subscribe — create a pending subscription record (POST /api/subscriptions).
  2. Checkout — initiate a provider payment session (POST /api/checkout).

For static plans both phases can be collapsed into a single call by passing planId directly to the checkout endpoint — Qelos creates the pending subscription internally.

For dynamic plans step 1 is mandatory and an admin must set the dynamicAmount before step 2 can proceed. Users cannot set their own checkout amount.

Static Plan Flow (one step)

User  →  POST /api/checkout { planId, billingCycle }   subscription created + checkout initiated

Dynamic Plan Flow (two steps + admin)

User  →  POST /api/subscriptions                       creates pending subscription
Admin →  PUT  /api/subscriptions/:id/dynamic-amount    sets the charge amount
User  →  POST /api/checkout { subscriptionId }         initiates payment

Provider checkout page (redirect or SDK)

POST /api/payments/webhooks/:providerKind               payment confirmed

Subscription status → active, invoice created

API

Initiate Checkout

POST /api/checkout

Either subscriptionId or planId is required.

FieldTypeRequiredDescription
subscriptionIdstringconditionalID of an existing pending subscription. Required for dynamic plans. All plan/entity data is read from the subscription — no other fields are needed.
planIdstringconditionalPlan ID for inline checkout of static plans (no prior subscribe call needed). Ignored when subscriptionId is provided.
billingCycle"monthly" | "yearly"conditionalRequired when using the planId path.
billableEntityType"user" | "workspace"noAdmin-only override. Defaults to the authenticated user's entity type.
billableEntityIdstringnoAdmin-only override. Defaults to the authenticated user's entity ID.
couponCodestringnoDiscount coupon code.
successUrlstringnoOverride the redirect URL on payment success. Falls back to tenant payment configuration.
cancelUrlstringnoOverride the redirect URL on payment cancellation.
amountnumbernoAdmin only. For dynamic plans: creates a pending subscription with this amount and immediately initiates checkout. Ignored for non-admins.

Response:

json
{
  "subscriptionId": "sub-123",
  "checkoutUrl": "https://checkout.paddle.com/...",
  "clientToken": null
}

checkoutUrl is used for redirect-based providers (Paddle, PayPal). clientToken is used for SDK-based providers (Sumit).

Cancel a Checkout Subscription

PUT /api/checkout/:subscriptionId/cancel

Cancels the subscription at the payment provider and marks it locally as canceled. Non-admins can only cancel their own entity's subscriptions.

Error Responses

CodeStatusDescription
PLAN_NOT_FOUND404Plan does not exist
PLAN_NOT_ACTIVE400Plan is deactivated
DYNAMIC_PLAN_REQUIRES_SUBSCRIPTION400Dynamic plan checkout requires a pre-created subscription with dynamicAmount set by an admin
DYNAMIC_AMOUNT_NOT_SET400The subscription's dynamicAmount is missing or ≤ 0
SUBSCRIPTION_NOT_PENDING400The referenced subscription is not in pending status
ACTIVE_SUBSCRIPTION_EXISTS409The entity already has an active or trialing subscription
PAYMENTS_NOT_CONFIGURED500No payment provider configured for this tenant
MISSING_EXTERNAL_PRICE_ID400Plan has no external price ID for the configured provider
UNSUPPORTED_PROVIDER400Configured payment provider is not supported
COUPON_NOT_FOUND400Coupon code does not exist
COUPON_EXPIRED400Coupon has passed its expiry date
COUPON_NOT_YET_VALID400Coupon is not yet valid
COUPON_MAX_REDEMPTIONS400Coupon has reached its redemption limit
COUPON_NOT_APPLICABLE400Coupon does not apply to this plan

SDK Usage

See the Payments SDK guide for complete examples.

Static plan (user)

typescript
const { checkoutUrl } = await sdk.payments.checkout({
  planId: 'plan-pro',
  billingCycle: 'yearly',
  couponCode: 'WELCOME',
  successUrl: 'https://myapp.com/success',
  cancelUrl: 'https://myapp.com/cancel',
});

window.location.href = checkoutUrl;

Dynamic plan (user side)

typescript
// Step 1: subscribe (user)
const sub = await sdk.payments.subscribeToPlan('plan-enterprise', 'monthly');

// Step 2: admin sets amount — see admin SDK
// Step 3: user completes checkout (once admin has set the amount)
const { checkoutUrl } = await sdk.payments.checkout({
  subscriptionId: sub._id,
  successUrl: 'https://myapp.com/success',
});

window.location.href = checkoutUrl;

Dynamic plan (admin convenience shortcut)

typescript
// Admin creates subscription + initiates checkout in one call
const { checkoutUrl } = await adminSdk.managePayments.checkout({
  planId: 'plan-enterprise',
  billingCycle: 'monthly',
  amount: 149.00,
  billableEntityType: 'workspace',
  billableEntityId: 'workspace-id',
  successUrl: 'https://myapp.com/success',
});

Webhooks

Each provider sends webhooks to confirm payment. The endpoint is:

POST /api/payments/webhooks/:providerKind

Where :providerKind is paddle, paypal, or sumit.

Idempotency

All webhook events are tracked by their external event ID. Duplicate events are safely ignored and return { status: "already_processed" }.

Paddle Events

EventAction
subscription.activatedActivate subscription, set billing period
subscription.canceledCancel subscription
subscription.past_dueMark as past due
transaction.completedCreate invoice
transaction.payment_failedMark as past due

PayPal Events

EventAction
BILLING.SUBSCRIPTION.ACTIVATEDActivate subscription
BILLING.SUBSCRIPTION.CANCELLEDCancel subscription
BILLING.SUBSCRIPTION.EXPIREDMark as expired
PAYMENT.SALE.COMPLETEDCreate invoice
BILLING.SUBSCRIPTION.PAYMENT.FAILEDMark as past due

Sumit Events

EventAction
payment_success / RecurringPaymentChargedActivate subscription + create invoice
payment_failed / RecurringPaymentFailedMark as past due
recurring_canceled / RecurringPaymentCanceledCancel subscription

Build SaaS Products Without Limits.