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-sessionsAuthentication
Section titled “Authentication”Authorization: Bearer <api-key>Content-Type: application/jsonIdempotency-Key: checkout_attempt_xxxThis 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.
Request Body
Section titled “Request Body”{ "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.
Response
Section titled “Response”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"}Webhook
Section titled “Webhook”Configure Stripe to send payment_intent.succeeded to:
POST /v1/payments/stripe/webhookThe 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.