Skip to content

Create Stripe Checkout Session

Creates a tenant-scoped local payment order and a one-time Stripe Checkout Session for a fixed Limit Plan. The server is the source of truth for Plan, amount, currency, and Stripe metadata. Business fulfillment must happen after the verified Stripe webhook settles the payment, not from the browser redirect.

POST /admin/v1/tenants/:tenant_id/payments/stripe/checkout-sessions
Terminal window
Authorization: Bearer <api-key>
Content-Type: application/json
Idempotency-Key: checkout_attempt_xxx

This is an Admin API. It accepts the Billing service API key or a tenant admin API key bound to :tenant_id. Idempotency-Key is required, must be at most 255 characters, and identifies one user Checkout attempt. Retrying with the same key returns the same local order and Stripe Session while that Checkout remains active. Portal calls use the server-side proxy at POST /api/payments/stripe/checkout-sessions; the proxy preserves this header when forwarding the request to the tenant-scoped Admin route.

{
"plan_id": "launch"
}
Field Required Description
plan_id Yes One of launch, growth, or scale. enterprise is not available through Checkout.

The server-owned one-time prices are:

Plan Amount Currency
launch 19.00 USD
growth 199.00 USD
scale 499.00 USD

The request body does not accept an amount, currency, or metadata. The idempotency key is sent in the Idempotency-Key header and is also mapped to a stable Stripe request idempotency key. While a tenant has an active Checkout for one Plan, a request for another Plan returns 409 Conflict; retries for the same Plan reuse the active Checkout. One local order is permanently associated with at most one Stripe Checkout Session.

When the local order is first created, the API snapshots every mutable Stripe Checkout request field: success_url, cancel_url, product name, and the absolute expires_at timestamp. The timestamp is fixed at 36 minutes after the initial request: a five-minute local creation retry window, Stripe’s minimum 30-minute Session lifetime, and a one-minute transport safety margin. Every retry for that order reconstructs the Stripe request from the persisted snapshot, so a process restart or later configuration change cannot reuse the Stripe idempotency key with different parameters. The exact expiration returned by Stripe is then stored on the order. Once that timestamp is reached, the next Checkout request transitions the unpaid local order from pending to expired. Reusing that order’s request key returns 409 with code: "payment_checkout_expired"; start a new attempt with a new Idempotency-Key. The Portal handles this response by rotating its session-scoped key and retrying once. If local order creation succeeded but no Stripe Session was attached, the local creation attempt is treated as abandoned after five minutes. This guarantees that its fixed expires_at is still at least 31 minutes in the future throughout the permitted retry window.

The /month labels on the public Pricing page describe the Plan period; this endpoint creates a single payment, not a recurring Stripe subscription.

success_url and cancel_url are read from the Billing [stripe] configuration. They are intentionally not accepted from the request body.

Returns 201 Created for both the first successful creation and an idempotent replay.

{
"order_id": "pay_xxx",
"session_id": "cs_test_xxx",
"session_url": "https://checkout.stripe.com/c/pay/cs_test_xxx"
}
Field Type Description
order_id string Stable local payment order ID.
session_id string Stripe Checkout Session ID.
session_url string Hosted Stripe Checkout URL for browser redirect.

Common errors:

Status Condition
400 Missing/invalid Idempotency-Key, invalid Plan, or invalid configuration.
401 Authentication is missing.
403 The API key cannot administer this tenant.
409 Another Plan has an active Checkout, the request key conflicts, or its Checkout has expired.
502 Stripe could not create the Checkout Session.

An expired attempt has a machine-readable code:

{
"error": "payment checkout has expired; retry with a new Idempotency-Key",
"code": "payment_checkout_expired"
}

Configure Stripe to send payment_intent.succeeded to:

POST /v1/payments/stripe/webhook

The endpoint verifies Stripe-Signature, requires the PaymentIntent object to be succeeded, and strictly checks its amount, currency, tenant metadata, Plan metadata, and local payment provider. It handles payment settlement, the immutable Plan snapshot, and the Grant in one PostgreSQL transaction. Redis quota initialization runs only after that transaction commits. If Redis initialization fails, Stripe receives a retryable error; repeated delivery reloads the existing Grant and retries the idempotent Redis initialization without issuing another Grant. Repeated delivery of the same PaymentIntent is safe because the provider payment ID and Grant source are unique. If Stripe confirms payment after the local Checkout was marked expired, the successful payment still transitions that order to paid and grants the purchased entitlement; local expiry never suppresses fulfillment after money was received. Release projects that require dedicated Doris storage provision their project table when the Portal creates the project; this is separate from payment settlement. Clients should read the current entitlement endpoint instead of treating the Stripe redirect as activation. Stripe event payloads are not persisted in a separate webhook-events table.