# Checkout Sessions

Create one-off payments programmatically — the only public payments endpoint today.

# Checkout Sessions

A **Checkout Session** is the primary API endpoint for taking a payment. You create it from your server, redirect the customer to `checkout_url`, and receive a webhook when it's paid.

Base URL: `https://api.southbill.com/v1`
Auth: `Authorization: Bearer sk_live_…`

## Create a session

`POST /v1/checkout/sessions`

```bash
curl -X POST https://api.southbill.com/v1/checkout/sessions \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order_12345" \
  -d '{
    "amount": 4990,
    "currency": "eur",
    "customer_name":  "Jane Doe",
    "customer_email": "jane@example.com",
    "reference": "ORDER-12345",
    "description": "Order #12345",
    "line_items": [
      { "name": "Sneaker Runner Pro", "quantity": 1, "amount": 4990, "image_url": "https://…/sneaker.jpg", "price_id": "price_1Tu…" }
    ],
    "success_url": "https://shop.example.com/thanks?o=12345",
    "cancel_url":  "https://shop.example.com/cart"
  }'
```

### Body

| Field | Type | Required | Notes |
|---|---|---|---|
| `amount` | integer | ✅ | Minor units (cents). Minimum **250** (= 2.50 in the currency). |
| `currency` | string | ✅ | 3-letter ISO, lowercase. |
| `customer_name` | string | ✅ | 2–120 chars. Needed to attribute the payment. |
| `customer_email` | string | ✅ | Valid email. Receipt is sent here. |
| `mode` | string | ➖ | `payment` (default) or `subscription`. For `subscription`, pass `line_items[0].price_id` pointing at a recurring price and omit `amount`. |
| `reference` | string | ➖ | Your order ID. Echoed on receipts and webhooks. |
| `description` | string | ➖ | Short description shown on the checkout. |
| `line_items` | array | ➖ | Display only. Fields: `name`, `quantity`, `amount`, `image_url`, `price_id`, `product_id`. |
| `success_url` / `cancel_url` | string | ➖ | Where the browser is sent after the session ends. |
| `metadata` | object | ➖ | Free-form key/value returned in webhooks. |

`line_items[].price_id` is optional — pass it when the item comes from a product you created in the dashboard, so it appears in receipts and analytics with its catalog link. The **actual amount charged is `amount`** — the price is not fetched from the catalog.

### Response

```json
{
  "id": "cs_01H…",
  "object": "checkout.session",
  "mode": "payment",
  "livemode": true,
  "status": "open",
  "amount": 4990,
  "currency": "EUR",
  "client_secret": "cs_01H…_secret_…",
  "checkout_url": "https://payments.southbill.com/c/cs_01H…?cs=…",
  "embed_url":    "https://payments.southbill.com/embed/cs_01H…?cs=…",
  "expires_at": 1735776000,
  "created": 1735689600
}
```

Redirect the buyer to `checkout_url`, or mount `embed_url` in an iframe.

## Hosted vs embedded — your choice, every time

Every Checkout Session response returns **three** integration handles, for one-time **and** subscription sessions:

- `client_secret` — for a fully custom SDK UI.
- `checkout_url` — hosted page on `payments.southbill.com`. Redirect here → the buyer **leaves** your site.
- `embed_url` — the same session, rendered for an iframe. Mount it → the buyer **stays on your site**.

Southbill does **not** use a `ui_mode` parameter. The session is always both hosted **and** embeddable; you choose at render time:

- **Redirect / hosted:** `window.location = session.checkout_url`
- **Embedded:** `<iframe src="{{embed_url}}" allow="payment *">`, or the SDK: `southbill.checkout({ mode: 'embed', mount: '#southbill-checkout' })`

This is identical for `mode: "subscription"` — pass `line_items[0].price_id` pointing at a recurring price, then mount `embed_url` to keep the subscription checkout inline. Do **not** redirect to `checkout_url` if you want it on-site.

> A browser redirect or iframe `postMessage` is never proof of payment. Always confirm server-side via webhooks (`checkout.session.completed`, or for subscriptions `invoice.paid` / `subscription.created`).

## List, retrieve, update & expire

```
GET   /v1/checkout/sessions            # newest first: ?limit= &starting_after= &status=
GET   /v1/checkout/sessions/{id}
PATCH /v1/checkout/sessions/{id}      # or POST /v1/checkout/sessions/{id}/update
POST  /v1/checkout/sessions/{id}/expire
```

### Update an open session

An **open, unconfirmed** session (flow version 5+) can be updated instead of recreated — useful when a cart changes quantity. Updatable fields: `amount`, `line_items`, `description`, `reference`. Requires `checkout:write`.

```bash
curl -X PATCH https://api.southbill.com/v1/checkout/sessions/cs_123 \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"amount": 5900, "reference": "ORD-1042"}'
```

```json
{ "id": "cs_123", "object": "checkout.session", "status": "open", "flow_version": 5, "revision": 3, "amount": 5900, "currency": "eur" }
```

Every successful update increments `revision`. The call returns `409` when the session cannot be changed: `immutable_session` (flow version below 5), `confirmation_started` / `confirmation_in_progress` (a payment is already running) or `revision_conflict` (changed concurrently — re-read and retry).

Sessions expire automatically 24 hours after creation. An expired session emits `checkout.session.expired`.

## Idempotency

Send `Idempotency-Key: <your-key>` on every `POST`. Keys are scoped to `(merchant_id, method, path)` and expire after 24 hours.

- **Same key + same body** → the original response is replayed verbatim (same status, same body).
- **Same key + different body** → **`409 Conflict`** with:

```json
{
  "error": {
    "type": "idempotency_error",
    "code": "idempotency_key_reused",
    "message": "Idempotency-Key reused with a different request body"
  }
}
```

See [Error reference](/docs/api/errors) for the full model.

## Fees

Fees are calculated in **EUR** on your active plan and deducted from the merchant's balance in the transaction currency (converted at live FX). See **Fees, currency & minimum amounts**.

## Errors

| HTTP | `error.type` | `error.code` | Meaning |
|---|---|---|---|
| 400 | `invalid_request` | field-specific | Missing / malformed field. |
| 400 | `amount_too_small` | `amount_below_minimum` | `amount` below the 2.50 minimum. |
| 401 | `authentication_error` | `invalid_api_key` | Bad, revoked or wrong-mode key. |
| 402 | `card_error` | `card_declined` | Buyer's card was declined. |
| 409 | `idempotency_error` | `idempotency_key_reused` | Same key sent with a different body. |
| 409 | `resource_conflict` | `merchant_not_ready` | Merchant onboarding incomplete. |
| 429 | `rate_limit_error` | `rate_limited` | Slow down. Respect `Retry-After`. |


## Button label & wallets

The pay button text is not an API field: it comes from a fixed list in **Dashboard → Checkout builder** (`Pay {amount}`, `Pay securely`, `Complete purchase`, `Buy now`, `Place order`, `Donate {amount}` for one-time; `Subscribe`, `Subscribe now`, `Start subscription`, `Pay subscription` for subscriptions). Apple Pay / Google Pay are shown on supporting devices for one-time **and** subscription sessions; for subscriptions the wallet sheet displays the recurring terms and stores the mandate. See [Hosted Checkout](/docs/integrations/hosted-checkout).


## Variants (catalog options)

A line item can reference a **product variant** instead of a Stripe price. The variant owns its own mirrored price, so the amount is always resolved server-side — a variant checkout can never be mispriced by the caller.

```json
POST /v1/checkout/sessions
{
  "mode": "payment",
  "line_items": [{ "variant": "b0f4…-uuid", "quantity": 2 }],
  "success_url": "https://example.com/thanks"
}
```

Rules:

- `variant` must be a Southbill variant id (uuid) belonging to your merchant account. Mixing `variant` and `price` in one session is rejected (400).
- The variant, its price and its product must all be active, and the price must be one-time. Recurring prices are rejected — use `price_id` for subscriptions; variants are not supported in `mode: "subscription"`.
- `amount` and `currency` are derived from the variant price. If you send them and they do not match, the request fails with `amount_mismatch` / `currency_mismatch`.
- Each variant may appear only once — use `quantity`.

### Stock-tracked variants

If the variant has inventory tracking enabled, creating the session **reserves** the units. If the stock is gone the request fails with `409 out_of_stock` and no session is created. A stock-tracked variant must be the only line item of the session; for multi-item carts use the Storefront checkout.

The reservation is consumed when the payment succeeds and released automatically when the session is cancelled, fails or expires. A full refund restores the stock.

The resolved `variant_id`, `product_id` and `sku` are returned on the session line items and included in `checkout.session.*` webhooks.

## Declined attempts in sandbox

A declined test card records a failed payment with `failure_code` and `failure_message` and emits `payment_intent.payment_failed`. The hosted Checkout Session remains open for another card attempt; do not fulfil unless `checkout.session.completed` is received.

