---
id: api-checkout-sessions
title: Checkout sessions
summary: Create a session on your server and read it back.
faces: ["public", "agent"]
section: back-end
group: API reference
slug: checkout-sessions
order: 20
next: ["api-payments"]
---
# Checkout sessions

A session is one amount, for one shopper, with a 30-minute life. Create it on your server when you want the
amount to be verified, then hand the `client_secret` to your page and let the checkout do the rest. Never log
or store a client secret.

<!-- generated from docs/api/openapi.yaml — edit the spec, not this -->

### POST /v1/checkout/sessions

Create a checkout session

**Authentication.** Your secret key, in `X-Ripper-Api-Key`. Server only.

Creates a session for one amount. Send the `client_secret` from the response to your page and pass it
to the checkout; never log it or store it. The session expires 30 minutes after it is created. A
session created on your server counts as a verified amount.

Requires a secret key with the `create_payments` permission. The session uses your default
integration.

| parameter | in | required | what it is |
|---|---|---|---|
| `Rip-Idempotency-Key` | header | yes | A key you choose for this create, for example your order id plus an attempt number. Retrying with the same key and body returns the first response instead of creating a second session. Up to 255 printable ASCII characters. |

**The body you send**

```json
{
  "amount": 2500,
  "currency": "GBP",
  "customer": {
    "email": "shopper@example.com",
    "name": "Sam Shopper"
  },
  "metadata": {
    "order_id": "1042"
  },
  "statement_descriptor_suffix": "ORDER 1042",
  "order_reference": "ORDER-1042"
}
```

**Fields in the body you send**

| field | type | always sent | what it is |
|---|---|---|---|
| `amount` | integer | yes | The amount in the currency's minor unit. |
| `currency` | string | yes | A three-letter ISO 4217 code, for example `GBP`. |
| `items` | array | no | The basket, shown to the shopper on the payment page and returned on every read of this session and its payment. At most 100 lines. TWO RULES THAT CATCH PEOPLE OUT, both of which refuse the whole request: A line that carries its own `unit_amount` must also carry a `name`. A `sku` alone is enough for a line you are pricing from your catalogue, but not for one you are pricing yourself — the name is what the shopper reads on the page, and ripper will not invent it. The basket may not come to less than nothing. Discount lines are subtracted, so a basket whose discounts exceed everything else on it is refused rather than clamped to zero. This is the only rule here about the basket as a whole; every other rule is about one line. `amount` still decides what is charged. When the lines do not add up to it, the difference is shown to the shopper as one adjustment rather than the basket being refused. |
| `customer` | object | no | The shopper. Every payment needs an email; if you leave it out here, the checkout asks the shopper for it. |
| `metadata` | object | no | Up to 50 string values of your own, 8 KB in total. Keys come back exactly as you sent them. |
| `statement_descriptor_suffix` | string | no | Added to the name on the shopper's card statement. |
| `order_reference` | string | no | Your order reference, shown to the shopper on the confirmation page and stored on the payment. 1 to 64 characters with no control characters. A run of 13 to 19 digits that could be a card number is refused; use fewer digits or put a letter among them. |
| `confirmation_url` | string | no | Where to send the shopper after the payment, instead of your integration's setting. An absolute https URL on one of your default integration's allowed origins, with no user information, no fragment, no IP address as the host, and no `payment_id` or `order_reference` query parameter (ripper adds those). |

**`items` fields**

| field | type | always sent | what it is |
|---|---|---|---|
| `kind` | string | no | What this line is. Absent means `product`. Delivery is a `shipping` line and a reduction is a `discount` line carrying a POSITIVE `unit_amount` that is subtracted — never a negative amount. |
| `sku` | string | no | Your code for the item. A line needs this or a `name`. |
| `name` | string | no | What the shopper sees. A line needs this or a `sku`, and always needs it when the line carries its own `unit_amount`. |
| `quantity` | integer | yes | How many. At least 1. |
| `unit_amount` | integer | no | What the shopper pays per unit, tax INCLUSIVE, in the currency's minor unit. Not negative: a reduction is a line whose `kind` is `discount`. |
| `tax_amount` | integer | no | The tax INSIDE this line's total — that is, inside `quantity` x `unit_amount` — in minor units. Between 0 and that total. |
| `tax_rate` | integer | no | This line's tax rate in basis points, where 2000 is 20.00%. |
| `type` | string | no | Your own label for the kind of thing this is, at most 32 characters. |

**`customer` fields**

| field | type | always sent | what it is |
|---|---|---|---|
| `email` | string | no | The shopper’s email address. |
| `name` | string | no | The shopper’s name. |
| `account_id` | string | no | Your own identifier for this shopper, returned on the session read, the payment read and the webhook. Same rule as `order_reference`: 1 to 64 characters, no control characters, and a run of 13 to 19 digits that could be a card number is refused. It is yours and is not checked for uniqueness. |
| `metadata` | object | no | Up to 50 string values of your own about this shopper, 8 KB in total. This is `customer.metadata` and it has its OWN budget, separate from the session's top-level `metadata` — the two are measured apart, not against one shared limit. Keys come back exactly as you sent them. |

**201 —** The session was created, or a retry with the same idempotency key replayed it.

```json
{
  "id": "cs_0123456789abcdef01234567",
  "client_secret": "cs_0123456789abcdef01234567_secret_9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0",
  "status": "requires_payment",
  "mode": "test",
  "amount": 2500,
  "currency": "GBP",
  "public_key": "rip_pk_EXAMPLE-KEY_00000000abcZ",
  "config_url": "/v1/checkout/config/rip_pk_EXAMPLE-KEY_00000000abcZ",
  "created_at": "2026-09-16T10:15:00.0000000+00:00",
  "expires_at": "2026-09-16T10:45:00.0000000+00:00",
  "expires_in_seconds": 1800
}
```

**Fields in the response**

| field | type | always sent | what it is |
|---|---|---|---|
| `id` | string | yes | The session. Send its `client_secret` to your page; send this to us. |
| `client_secret` | string | yes | Pass this to the checkout on your page. It is returned only here. |
| `status` | string | yes | Where the session is in its life. A new session has taken no attempts yet. |
| `mode` | string | yes | `test` or `live`. A test session can take only test cards. |
| `amount` | integer | yes | What the shopper will be charged, in the currency’s minor unit. |
| `currency` | string | yes | A three-letter ISO 4217 code, for example `GBP`. |
| `public_key` | string | yes | Your publishable key, which the checkout loads the session with. |
| `config_url` | string | yes | The configuration read's path, relative to the API host. |
| `created_at` | string | yes | When the session was created. |
| `expires_at` | string | yes | When the session stops accepting attempts. |
| `expires_in_seconds` | integer | yes | How long the session has from now. |

**When it refuses**

- `400` — `idempotency_key_missing`: no usable `Rip-Idempotency-Key` (missing, empty, longer than 255 characters, or not printable ASCII). `invalid_request`: the body is not a JSON object; `amount` is not a positive whole number; `currency` is not three letters; `order_reference`, `customer.account_id` or `confirmation_url` breaks its rule; a basket line breaks one of the rules on `items`; the basket's discounts come to more than everything else on it; or the body carries a field this route does not accept. Since v1.13 an unrecognised field is refused rather than ignored, and the message names its JSON path — `items[2].tax_ammount` — so a typo tells you where it is instead of going quietly missing. The keys inside a `metadata` object are yours and are never checked this way. `metadata_too_large`: more than 50 metadata entries, metadata over 8 KB, a `customer.metadata` over its own separate budget of the same size, or a `statement_descriptor_suffix` over 22 characters. `card_data_rejected`: a value looks like a card number. The value is never echoed back.
- `401` — `unauthorized`: no key, or a key that is unknown, expired, or used from an address or origin the key does not allow. `key_revoked`: the key was revoked. `public_key_missing`: the key has no publishable key paired with it.
- `403` — `permission_denied`: the key does not have `create_payments`. `merchant_not_active`: your account cannot take payments right now. `key_live_only`: a live-only key would have created a test session.
- `409` — `idempotency_key_reused`: the key was already used with a different body or on a different route, or a request with this key is still running. Use a new key for a new session.
- `429` — Too many requests from your address in a minute. This answer comes from ripper's gateway, so its body is not the error shape above. Wait for `Retry-After` seconds.
- `500` — `internal_error`: something failed on ripper's side. The body carries no message. Quote `request_id` to support.
- `503` — `store_unavailable`: ripper could not check the idempotency key. Retry with the same key.

Operation id `createCheckoutSession`, for code generators.

### GET /v1/checkout/sessions/{session_id}

Retrieve a checkout session

**Authentication.** Your secret key, in `X-Ripper-Api-Key`. Server only.

Returns one of your sessions. A session past its expiry reads as `expired` straight away. The
session's `status` is the checkout's own progress; `succeeded` means the shopper paid, and the
payment itself is read with `payment_id`.

Requires a secret key with the `read_transactions` permission.

| parameter | in | required | what it is |
|---|---|---|---|
| `session_id` | path | yes | The session's id. |

**200 —** The session.

```json
{
  "id": "cs_0123456789abcdef01234567",
  "status": "succeeded",
  "mode": "test",
  "amount": 2500,
  "currency": "GBP",
  "customer": {
    "email": "shopper@example.com",
    "name": "Sam Shopper",
    "account_created": null,
    "account_id": "your-customer-4821",
    "metadata": {
      "tier": "gold"
    }
  },
  "metadata": {
    "order_id": "1042"
  },
  "attempt_count": 1,
  "last_decline": null,
  "payment_id": "pay_89abcdef0123456789abcdef",
  "settlement": null,
  "created_at": "2026-09-16T10:15:00.0000000+00:00",
  "expires_at": "2026-09-16T10:45:00.0000000+00:00",
  "expires_in_seconds": 1612,
  "integration": "Default",
  "minted_by": "server",
  "amount_verified": true,
  "amount_verification_source": "server_minted",
  "amount_verification_detail": null,
  "hold": null
}
```

**Fields in the response**

| field | type | always sent | what it is |
|---|---|---|---|
| `id` | string | yes | The session. Send its `client_secret` to your page; send this to us. |
| `status` | string | yes | Where the session is in its life, from created to a finished payment or an expiry. |
| `mode` | string | yes | `test` or `live`. A test session can take only test cards. |
| `amount` | integer | yes | What the shopper will be charged, in the currency’s minor unit. |
| `currency` | string | yes | A three-letter ISO 4217 code, for example `GBP`. |
| `items` | array or null | no | The basket, as sent on the server create. `null` when the session carried no lines — never an empty array, so "no basket" and "an empty basket" stay different answers. |
| `basket_total` | integer or null | no | What the lines add up to, in minor units, tax inclusive: products, shipping and fees added, discounts subtracted. Computed by ripper and never accepted in a request. `null` when there are no lines. When it differs from `amount`, `amount` is what was charged and the difference is shown to the shopper as one adjustment. |
| `tax_total` | integer or null | no | The tax across the lines, in minor units. `null` when no line carries a `tax_amount`. |
| `customer` | object or null | yes | The shopper, as you or the checkout supplied them. |
| `metadata` | object | yes | The metadata you sent when you created this session, keys exactly as you sent them. |
| `attempt_count` | integer | yes | Payment attempts opened, at most 3. |
| `last_decline` | object or null | yes | The most recent decline on this session, or `null` when there has not been one. |
| `payment_id` | string or null | yes | The payment this session produced, or `null` while it has not produced one. |
| `settlement` | string or null | yes | `awaiting_reconciliation` while ripper confirms the outcome of an attempt the acquirer did not answer. Money may already have moved, so do not release goods and do not treat the payment as failed while it is set — reconciliation resolves it, not a clock in the checkout. |
| `created_at` | string | yes | When the session was created. |
| `expires_at` | string | yes | When the session stops accepting attempts. |
| `expires_in_seconds` | integer | yes | Seconds left; 0 once expired. |
| `integration` | string or null | yes | The name of the integration the session belongs to. |
| `minted_by` | string or null | yes | `server` when your server created the session, `client` when the checkout did. |
| `amount_verified` | boolean | yes | Whether ripper could check the amount against your price list before charging it. When it could not, an approved payment is held for you to review rather than captured. |
| `amount_verification_source` | string | yes | What ripper checked the amount against: the server that created it, your integration’s default, your price list, nothing, or a mismatch. |
| `amount_verification_detail` | string or null | yes | Why the amount did not verify, when the source is a mismatch. `null` otherwise. |
| `hold` | object or null | yes | Why this session’s payment is on hold and what was decided, or `null` when it was never held. |

**`items` fields**

| field | type | always sent | what it is |
|---|---|---|---|
| `kind` | string | yes | What this line is: `product`, `shipping`, `discount` or `fee`. A discount carries a positive `unit_amount` that is subtracted. |
| `sku` | string or null | yes | Your code for the item, or `null` if the line had none. |
| `name` | string or null | yes | What the shopper was shown, or `null` if the line was priced from your catalogue and carried no name. |
| `quantity` | integer | yes | How many. |
| `unit_amount` | integer or null | yes | What the shopper paid per unit, tax inclusive, in the currency’s minor unit. `null` on a line your catalogue has not priced. |
| `tax_amount` | integer or null | yes | The tax inside this line’s total, in minor units, or `null` when the line carries no tax. |
| `tax_rate` | integer or null | yes | This line’s tax rate in basis points, where 2000 is 20.00%, or `null` when none was set. |
| `type` | string or null | yes | Your own label for the kind of thing this is, or `null`. |

**`customer` fields**

| field | type | always sent | what it is |
|---|---|---|---|
| `email` | string or null | yes | The shopper’s email address, or `null` when none was given. |
| `name` | string or null | yes | The shopper’s name, or `null` when none was given. |
| `account_created` | string or null | yes | When the shopper's account with you was created, if a browser session sent it. |
| `account_id` | string or null | yes | Your own identifier for this shopper, exactly as you sent it. `null` when none was sent. Set from a browser session it is whatever the page sent, so treat it as the page's claim rather than as something ripper has checked. |
| `metadata` | object or null | yes | Your own values about this shopper, keys exactly as you sent them. `null` when none were sent. This is the CUSTOMER's metadata; the session's own `metadata` is a separate field with a separate budget, and the two are never merged. |

**`last_decline` fields**

| field | type | always sent | what it is |
|---|---|---|---|
| `code` | string | yes | The decline code. |
| `category` | string or null | yes | How to treat the decline: `soft`, `hard`, `fraud`, `network` or `provider_error`. Since v1.13 the word is the same snake_case everywhere — these routes, the webhook and the checkout — where the merchant routes and webhooks previously spelled it `Soft`, `Hard`, `ProviderError` and so on. It is the payment platform's own category, relayed unchanged rather than re-classified here. |
| `reason` | string or null | yes | The acquirer's own words, passed on as received. |

**`hold` fields**

| field | type | always sent | what it is |
|---|---|---|---|
| `status` | string | yes | Where the hold is. `pending_review` and `escalated` are both still waiting on a decision — escalated means a person is looking at it rather than a rule. `captured` means the money was taken. `voided` means the hold was released and nothing was taken, and it is final: it is not another kind of waiting. DO NOT RELEASE GOODS until this says `captured`. That holds for all four values, including escalated, which can still end either way. |
| `reason` | string | yes | `no_price_list`: there was nothing to check the amount against. `mismatch`: the amount did not match. |
| `detail` | string or null | yes | More about why, when there is more to say. |
| `deadline` | string | yes | When the hold must be decided by. |
| `decided_by` | string or null | yes | Who decided: you, or ripper on your rules. |
| `decided_at` | string or null | yes | When the decision was made, or `null` while it is still waiting. |
| `operation_refusal` | object or null | yes | The last capture or void the acquirer refused, if any. |

**`hold.operation_refusal` fields**

| field | type | always sent | what it is |
|---|---|---|---|
| `kind` | string | yes | Which operation was refused. |
| `sequence` | integer | yes | Which attempt this refusal belongs to. |
| `reason` | string | yes | Why it was refused. |
| `decline_code` | string or null | yes | The decline code, when the refusal came from the acquirer. |
| `at` | string | yes | When the refusal happened. |

**When it refuses**

- `401` — `unauthorized`: no key, or a key that is unknown, expired, or used from an address or origin the key does not allow. `key_revoked`: the key was revoked. `public_key_missing`: the key has no publishable key paired with it.
- `403` — `permission_denied`: the key does not have `read_transactions`. `merchant_not_active`: your account is not active.
- `404` — `resource_not_found`: there is no such id on your account. Another account's id answers the same.
- `429` — Too many requests from your address in a minute. This answer comes from ripper's gateway, so its body is not the error shape above. Wait for `Retry-After` seconds.
- `500` — `internal_error`: something failed on ripper's side. The body carries no message. Quote `request_id` to support.

Operation id `getCheckoutSession`, for code generators.

<!-- end generated -->
