DocsAPI ReferenceCheckout Sessions
Checkout Sessions API
A Stripe-style HTTP API for accepting payments from your own apps and websites. Create a session on your server, let the customer pay on the hosted page or embedded in your frontend, then verify before fulfilling.
Basics
| Base URL | https://saleonix.com/api/v1 |
| Authentication | Bearer key in the Authorization header (or X-Api-Key). Keys in query strings are rejected. |
| Currency | EUR only — there is no currency parameter; sending one returns currency_unsupported. |
| Units | All amounts are integer cents: 4318 = €43.18. Floats and numeric strings are rejected. |
| Format | JSON bodies with Content-Type: application/json (required on POST; missing or wrong type returns 415 unsupported_media_type). |
Integration style
| Style | Flow | Best for |
|---|---|---|
| Hosted checkout | Your server creates a session with your secret key and redirects the customer to the returned url. Saleonix renders the payment page (simulated outcome in TEST, real 3DS in LIVE). | The only supported path today — works with the React SDK's redirectToCheckout too. |
Paying a session from your frontend with an API call carrying the card (POST …/confirm) is permanently disabled — 3-D Secure requires the card to be entered on Saleonix's own page in both TEST and LIVE. See "Confirm a session" below.
Create a checkout session
/api/v1/checkout/sessionsSecret keyA session charges in exactly one of three modes:
- Price mode — pass
price_id(+ optionalquantity); the amount is read from your catalog on the server. - Line items mode (WooCommerce, Shopify, and other cart-plugin integrations) — pass
line_items, an array of up to 100{ price_id, quantity }entries; each is resolved from your catalog exactly like price mode, andamount_totalis the sum. Use this when a cart contains several distinct registered products — Saleonix is merchant of record and needs to know what was actually sold, not just the total. - Amount mode (off by default) — pass
amount(+ requireddescription); your server states the total directly, with no catalog link. This requires the Amount-mode checkout permission, which Saleonix grants per account on request; without it, requests containingamountare rejected with400 amount_mode_unsupported. Useprice_idorline_itemsinstead.
Passing more than one (or none) of price_id / amount / line_items is rejected. In all three modes the customer's browser never carries an amount — only your server decides what is charged.
Amount mode is off unless it was enabled for you
amount returns 400 amount_mode_unsupported. It is a per-account permission Saleonix grants on request — and it is enforced in TEST as well as LIVE, so what you can build against in test mode is exactly what will work in live. If you need it, contact support; otherwise use price_id or line_items.What is required
No single parameter is required on its own. What the endpoint requires is that the body carries exactly one mode selector — either price_id or line_items (or amount, which is disabled). Sending none of them, or more than one, returns 400 parameter_invalid. Choose by what the customer is buying:
| What you are selling | Use | Notes |
|---|---|---|
| One product | price_id | Optionally with quantity for several units of that same product. |
| Several different products (a cart) | line_items | The only way to sell more than one product in one session. Each entry carries its own price_id and quantity; do not send a top-level quantity. |
| An opaque total | amount | Off by default — returns 400 amount_mode_unsupported unless the Amount-mode checkout permission was granted to your account. |
Inside line_items, each entry's price_id is required and its quantity is optional (defaults to 1). Every parameter outside the mode selector is optional. The smallest valid request is therefore a single field:
Minimal request body
{
"price_id": "clpr1ce..."
}The Content-Type: application/json header is required on this endpoint; the Idempotency-Key header is optional but strongly recommended (see below). There is no currency parameter — sending one returns currency_unsupported.
| Field | Type | Required | Description |
|---|---|---|---|
| price_id | string | Conditional | Required only when you are not sending line_items — one product per session. A price in your store (find it on the product page in the dashboard). A price_ prefix is tolerated. Must be active, one-time and EUR — recurring prices return price_mode_unsupported, unknown or another store's ids return 404 resource_missing. Mutually exclusive with line_items and amount. |
| quantity | integer | No | Integer 1–100, default 1. Price mode only — sending it alongside line_items or amount is rejected with parameter_invalid (set quantity per entry inside line_items instead). |
| line_items | array | Conditional | Required when the session sells more than one product — this is the only way to put several products in one session. 1–100 entries, each { price_id, quantity? }. price_id is required per entry and resolved exactly like the top-level one; quantity is optional (integer 1–100, default 1). All prices must be EUR (the only supported currency) and belong to your store. Mutually exclusive with price_id and amount. |
| amount | integer | Permission | Requires the Amount-mode checkout permission, which is off by default. Without it, any body containing amount is rejected with 400 amount_mode_unsupported in test and live alike — use price_id or line_items. When granted: total in cents, minimum 50 (€0.50), maximum €10,000 by default. |
| description | string | Conditional | Required with amount, and valid only there — so it is usable only on accounts that hold the Amount-mode checkout permission. Sending it in price or line items mode is rejected with parameter_invalid. 1–255 chars, plain text; shown to the customer on the hosted checkout page. |
| success_url | string | No | Absolute URL, max 2000 chars; the customer is redirected here after payment with ?session_id=cs_... appended. https is required in live mode (http is allowed in test mode for local development); anything else returns invalid_success_url. |
| cancel_url | string | No | Stored for client-side use. Validated exactly like success_url (absolute URL, max 2000 chars, https in live mode); anything else returns invalid_cancel_url. |
| customer_email | string | No | A valid email address, max 254 chars. Prefills the customer record and is echoed back on reads. |
| metadata | object | No | Up to 20 keys (1–40 chars each). Values may be strings, numbers or booleans (coerced to strings, ≤500 chars each). Echoed back on reads. See Working with metadata below. |
| expires_at | integer | No | Epoch seconds. Clamped to 30 min – 24 h from now; default 24 h. A value outside that window is clamped, not rejected. |
| allow_order_bumps | boolean | No | Default false. When true, the order bumps you configured for the products in this session are offered on the hosted checkout, and a ticked bump raises amount_total. Price and line items mode only. See Order bumps and upsells. |
| allow_upsells | boolean | No | Default false. When true, your post-purchase upsells may be offered after payment. Each one is a separate order, and the session's amount never changes. The buyer returns to your success_url afterwards. Price and line items mode only. |
Working with metadata
metadata is a free-form key/value bag for your own bookkeeping — your internal order id, cart id, customer reference, campaign, whatever lets you match a Saleonix session back to a record in your system. Saleonix never interprets it; it is stored with the session and echoed back verbatim on every read, on the hosted checkout result and in webhook payloads.
Creating metadata
{
"price_id": "clpr1ce...",
"metadata": {
"order_id": "1066",
"cart_id": "c_9f2a",
"customer_ref": "CUST-4471",
"source": "woocommerce",
"items": 3,
"gift": true
}
}Rules, all enforced at create time:
- It must be a flat JSON object — arrays, strings and nested objects are rejected with
metadata_invalid. - At most 20 keys. Each key must be 1–40 characters.
- Values may be strings, numbers or booleans only. Numbers and booleans are coerced to strings, so
{ "qty": 3 }reads back as{ "qty": "3" }— compare as strings on your side.null, arrays and objects as values are rejected. - Each value is at most 500 characters after coercion.
- Omitting
metadata(or sendingnull) is fine — reads then return an empty object,{}, nevernull. - Metadata is part of the idempotency request hash: replaying an
Idempotency-Keywith different metadata returns409 idempotency_key_reused.
Never put secrets in metadata
Price mode
Create session — price mode
curl -X POST https://saleonix.com/api/v1/checkout/sessions \
-H "Authorization: Bearer sk_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-12345" \
-d '{
"price_id": "clpr1ce...",
"quantity": 1,
"success_url": "https://example.com/thanks",
"customer_email": "buyer@example.com",
"metadata": { "internal_ref": "12345" }
}'Line items mode (carts / WooCommerce / Shopify)
Create session — line items mode
# A cart of 2 distinct registered products (WooCommerce/Shopify-style)
curl -X POST https://saleonix.com/api/v1/checkout/sessions \
-H "Authorization: Bearer sk_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: wc-order-1066" \
-d '{
"line_items": [
{ "price_id": "clpr1ce1...", "quantity": 2 },
{ "price_id": "clpr1ce2...", "quantity": 1 }
],
"success_url": "https://shop.example/thanks",
"customer_email": "buyer@example.com",
"metadata": { "order_id": "1066" }
}'Amount mode (requires the Amount-mode checkout permission)
Create session — amount mode
# A cart of 3 items + shipping − coupon = €43.18 → amount: 4318 (cents)
curl -X POST https://saleonix.com/api/v1/checkout/sessions \
-H "Authorization: Bearer sk_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: wc-order-1066" \
-d '{
"amount": 4318,
"description": "Order #1066 — example-shop.com (3 items incl. shipping)",
"success_url": "https://shop.example/thanks",
"customer_email": "buyer@example.com",
"metadata": { "order_id": "1066" }
}'Response 201
checkout.session
{
"id": "cs_test_aBcD...",
"object": "checkout.session",
"url": "https://saleonix.com/checkout?sid=...&exp=...&sig=...",
"client_secret": "cs_test_aBcD..._secret_XyZ...",
"status": "open",
"payment_status": "unpaid",
"livemode": false,
"currency": "eur",
"amount_subtotal": 10000,
"amount_tax": 1800,
"amount_total": 11800,
"quantity": 1,
"price_id": "clpr1ce...",
"product_id": "clxyz...",
"description": null,
"order_id": "clabc...",
"customer_email": "buyer@example.com",
"customer_country": null,
"success_url": "https://example.com/thanks",
"cancel_url": null,
"line_items": [
{
"object": "line_item",
"description": "Ebook",
"price_id": null,
"product_id": "clxyz...",
"quantity": 1,
"amount_subtotal": 10000,
"amount_tax": 1800,
"amount_total": 11800,
"amount_discount": 0,
"tax_rate": 18,
"order_bump": false
}
],
"allow_order_bumps": false,
"allow_upsells": false,
"metadata": { "internal_ref": "12345" },
"created": 1780000000,
"expires_at": 1780086400
}url— the hosted checkout page (a signed link, valid until the session expires).client_secret— returned only here. Store it server-side; pass it to your frontend only for embedded checkout. Treat it like a password for this one payment.customer_country(added 2026-09-30) — the country the buyer selected on the hosted checkout, as an ISO 3166-1 alpha-2 code (e.g."XK","DE").nulluntil the buyer submits payment. Response-only — there is no request parameter, and existing integrations need no change. See the changelog.line_items(added 2026-10-07) — what the session charges, line by line. There is always at least one line, and the line totals add up toamount_total. A line the buyer added by ticking an order bump hasorder_bump: true.allow_order_bumps/allow_upsells(added 2026-10-07) — the values you created the session with, orfalseif you didn't send them.
Idempotency
Idempotency-Key header with a unique value per order (max 191 characters). Retries with the same key replay the original session (including its client_secret) with HTTP 200 instead of creating and charging twice. Reusing a key with a different body returns 409 idempotency_key_reused.By default, the buyer pays exactly what you created
allow_order_bumps or allow_upsells, an API session shows no order bumps and no upsells, so the buyer pays exactly amount_total. Saleonix coupons are never applied to API sessions. Apply your own discounts before you create the session.Order bumps and upsells (opt-in)
You can show the order bumps and post-purchase upsells you configured in Store → Checkout offers on sessions you create through the API. Both are off unless you ask for them on each session:
Opt in
{
"line_items": [
{ "price_id": "clpr1ce_ebook...", "quantity": 1 },
{ "price_id": "clpr1ce_course...", "quantity": 1 }
],
"success_url": "https://example.com/thanks",
"allow_order_bumps": true,
"allow_upsells": true
}Which offers are shown
- Offers are triggered by the products in the session. In price mode that is the price's product. In line items mode it is every product in the cart: if any line has a bump configured, that bump is offered.
- A product the buyer is already buying is never offered again, and nor is a second downloadable or licensed product when the cart already has one.
- Amount mode has no catalog products, so it cannot have offers. Sending either flag with
amountreturns400 parameter_invalid. - The flags must be JSON booleans.
"true"as a string is rejected. Omitting a flag is the same asfalse. - Turning a flag on with no offers configured is harmless. Nothing extra is shown.
Order bumps change the session's amount
A ticked bump becomes a new line on the same order, paid in the same card payment. So amount_total in the retrieve response and in checkout.session.completed can be higher than the amount you created the session for. Bump lines have order_bump: true in line_items.
- Fulfil every line in
line_items, not only the products you put in the session. - If you check the paid amount, check it against
amount_total(or the sum of the lines). Don't compare it with your original cart total. - The buyer can tick or untick a bump until they press Pay. After that the amount is locked and the bump list can no longer change.
Upsells are separate orders
After the payment succeeds, the session is completed straight away and checkout.session.completed is sent. Its amount never includes an upsell. Then the buyer may see up to two offers on a Saleonix page before returning to your success_url, with the same session_id. If they decline, the offer expires, or the upsell payment fails, they are still sent to your success_url.
- An accepted upsell is charged as its own order. You receive
order.paidfor it, withparent_order_idset to the order it was sold after, andupsell.payment_succeeded, whosecheckout_session_idis the session you created. - Subscribe to
order.paidorupsell.payment_succeededto fulfil upsells. They never appear on the session itself. - The original purchase never depends on the upsell. A failed upsell leaves the session and its order paid.
Turn these on only if your integration handles them
allow_order_bumps only if your system fulfils from line_items and accepts a paid amount above the cart total. Turn on allow_upsells only if you handle orders you didn't create through the API. Try both in test mode first. Test payments send the same events as live ones.Retrieve a session
/api/v1/checkout/sessions/{id}Secret keySame shape as create, with client_secret: null and url: null once the session is no longer open (paid or expired). Fulfill the order when status is "complete" and payment_status is "paid". If you created the session in amount mode, also check that amount_total equals the total you expect before fulfilling. With allow_order_bumps, the expected total is your own lines plus any order_bump: true lines in line_items.
Retrieve & verify
curl https://saleonix.com/api/v1/checkout/sessions/cs_test_aBcD... \
-H "Authorization: Bearer sk_test_xxx"| status | meaning |
|---|---|
open | awaiting payment |
complete | payment succeeded |
expired | deadline passed; create a new session |
| payment_status | meaning |
|---|---|
unpaid | no successful attempt yet |
paid | settled |
failed | an attempt was declined — the session cannot be retried; create a new one |
Confirm a session (legacy — permanently disabled)
/api/v1/checkout/sessions/{id}/confirmPublishable key + client_secretAlways rejected, in TEST and LIVE alike
400 three_d_secure_required. This is deliberate: TEST rejects the same way LIVE does, so an integration built and tested against TEST behaves identically the moment you flip to LIVE instead of discovering the break in production.Use the hosted checkout flow instead, for both TEST and LIVE. For the hosted flow, redirect the customer to the session's url from create. The card is entered on Saleonix's hosted page, which simulates the outcome in TEST (via the documented test cards below) and runs a real 3DS authentication against the gateway in LIVE. Then verify with GET /checkout/sessions/{id} before fulfilling, same as any hosted-checkout flow.
Test cards (entered on the hosted page in TEST mode — not sent to this endpoint)
| Card | Result |
|---|---|
4242 4242 4242 4242 | Approved |
4343 4343 4343 4343 | Declined |
Building checkout with the SDK?
redirectToCheckout, plus safe-redirect scheme checking. Use it if you can.Errors
All errors share one envelope:
Error shape
{ "error": { "type": "card_error", "code": "card_declined", "message": "..." } }| HTTP | type | Typical codes |
|---|---|---|
| 400 | invalid_request_error | parameter_invalid, invalid_json, session_expired, invalid_success_url, invalid_cancel_url, unsupported_test_card, price_inactive, price_mode_unsupported, currency_unsupported, amount_mode_unsupported, metadata_invalid, idempotency_key_invalid, three_d_secure_required (confirm endpoint, LIVE sessions only) |
| 401 | authentication_error | missing_api_key, invalid_api_key, wrong_key_type, invalid_client_secret, environment_mismatch |
| 402 | card_error | card_declined, invalid_card_number, invalid_expiry, expired_card, invalid_cvc |
| 403 | invalid_request_error | capability_disabled — the Payments API channel is turned off for this account (live mode only). Contact support to have it re-enabled. |
| 404 | invalid_request_error | resource_missing |
| 409 | invalid_request_error | session_already_complete, session_payment_failed, order_already_processed, idempotency_key_reused |
| 415 | invalid_request_error | unsupported_media_type |
| 429 | rate_limit_error | rate_limited, too_many_attempts (honor Retry-After) |
| 500 | api_error | internal_error, payment_outcome_unknown, settlement_failed |
Security model
- No browser-facing request ever carries an amount: price mode derives amounts from the catalog; amount mode accepts them only on the secret-key endpoint. The confirm endpoint ignores injected amount fields.
- Currency is never client-settable — EUR is pinned server-side.
- Keys are verified by SHA-256 hash; raw keys are never stored or logged.
client_secretis hashed at rest and returned exactly once.- Concurrent confirms cannot double-charge: the order is claimed atomically before the gateway is called.
- Card data is forwarded to the payment gateway only — never persisted or logged.
- TEST and LIVE are fully isolated: keys, sessions, and money never cross.
- Per-IP, per-key and per-session rate limits; all auth failures are audited.
