Checkout sessions
A checkout session is one payment for one order. Create it on your server, send the buyer to its page or start it for your own checkout, and read its payment to fulfil.
On this page
The checkout session object
| Field | Type | Description |
|---|---|---|
id | string | The session's id, pay_…. The same id identifies its payment |
object | string | "checkout_session" |
url | string | The hosted checkout page for this session. Send the buyer here |
status | string | open, pending, paid, held, failed or expired. See statuses |
livemode | boolean | false for Test mode, true for Live mode |
amount | string | The price, as set at creation: "49.00" |
currency | string | USD, EUR, GBP, CAD or AUD |
description | string | What the buyer is paying for, shown on the checkout |
reference | string or null | Your own id for the order |
customer_email | string or null | The buyer's email, lowercased |
created | integer | When it was created, in Unix seconds |
expires_at | integer | When it stops accepting new payments: 24 hours after creation |
started_at | integer or null | When the buyer (or your server) started it. Set once |
metadata | object or null | Your key-value pairs, returned unchanged |
order_id | string or null | The order this session is a payment attempt for, ord_…. Every session has one (null only on records from before orders existed) |
Create a checkout session
POST/v1/checkout_sessions
Creates a session for one order. Nothing is charged and no provider is contacted until the buyer starts paying. Send an Idempotency-Key so a retry returns the same session.
| Field | Type | Description |
|---|---|---|
amount | string Required | The price as a decimal string with at most two decimals, from "1.00" to "100000.00". "49" and "49.0" become "49.00". Numbers, exponents and negative values are refused |
currency | string Required | USD, EUR, GBP, CAD or AUD, in any letter case |
description | string Required | What the buyer is paying for, up to 120 characters |
customer_email | string or null | The buyer's email. Optional here, but needed before the session can start |
reference | string or null | Your order id, up to 120 characters. Returned on the payment and every webhook. Not unique: Railbed doesn't stop two sessions sharing one |
metadata | object or null | Up to 20 keys (1–40 characters, not starting with __) with string values up to 500 characters. For your own ids, never secrets or card details |
success_url | string or null | Where the checkout sends the buyer once the payment is confirmed. {PAYMENT_ID} and {REFERENCE} are filled in. Up to 1,000 characters |
cancel_url | string or null | Where the checkout's "Cancel and return to …" and "Back to …" links lead (they name your business). Up to 1,000 characters |
customer | object or null | Who is buying: name, phone and your customer id. See Orders and customers |
order | object or null | What they're buying: items, discount, shipping, tax, addresses and your order id. See Orders and customers |
Return URLs must be full http:// or https:// addresses (Live mode: https:// only) with no username or password. In Live mode your account needs a payout wallet first, or the request gets 409 no_payout_wallet.
curl https://pay.railbed.io/v1/checkout_sessions \
-H "Authorization: Bearer $RAILBED_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_1042" \
-d '{
"amount": "49.00",
"currency": "USD",
"description": "Pro Membership",
"reference": "order_1042",
"customer_email": "buyer@example.com",
"metadata": { "user_id": "player_1042" },
"success_url":
"https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}",
"cancel_url": "https://yourstore.com/cart"
}'const res = await fetch('https://pay.railbed.io/v1/checkout_sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order_1042',
},
body: JSON.stringify({
amount: '49.00',
currency: 'USD',
description: 'Pro Membership',
reference: 'order_1042',
customer_email: 'buyer@example.com',
metadata: { user_id: 'player_1042' },
success_url:
'https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}',
cancel_url: 'https://yourstore.com/cart',
}),
});
if (!res.ok) throw new Error((await res.json()).error.code);
const session = await res.json();r = requests.post(
"https://pay.railbed.io/v1/checkout_sessions",
headers={
"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}",
"Idempotency-Key": "order_1042",
},
json={
"amount": "49.00",
"currency": "USD",
"description": "Pro Membership",
"reference": "order_1042",
"customer_email": "buyer@example.com",
"metadata": {"user_id": "player_1042"},
"success_url": (
"https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}"
),
"cancel_url": "https://yourstore.com/cart",
},
timeout=15,
)
r.raise_for_status()
session = r.json()<?php
$ch = curl_init('https://pay.railbed.io/v1/checkout_sessions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order_1042',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => '49.00',
'currency' => 'USD',
'description' => 'Pro Membership',
'reference' => 'order_1042',
'customer_email' => 'buyer@example.com',
'metadata' => ['user_id' => 'player_1042'],
'success_url' =>
'https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}',
'cancel_url' => 'https://yourstore.com/cart',
]),
]);
$session = json_decode(curl_exec($ch), true);{
"id": "pay_7AAiYH0Ykt11ED4hmfiN",
"object": "checkout_session",
"url": "https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN",
"status": "open",
"livemode": false,
"amount": "49.00",
"currency": "USD",
"description": "Pro Membership",
"reference": "order_1042",
"customer_email": "buyer@example.com",
"created": 1790380525,
"expires_at": 1790466925,
"started_at": null,
"metadata": { "user_id": "player_1042" },
"order_id": "ord_M4tQx8ZkP1vRn6WcLs2B"
}A retry with the same Idempotency-Key and body answers 200 OK with the same session.
Orders and customers
Every session belongs to an order. Send order and customer with the session, and the order has your items, totals, addresses and store order id, and the customer has the buyer's name and phone. They appear in the dashboard's Orders and Customers and in every webhook. Without them, the order has one line, your description, for the whole amount.
The buyer's checkout page still shows your description and the amount. The order's details are for your records, not for the buyer to confirm.
curl https://pay.railbed.io/v1/checkout_sessions \
-H "Authorization: Bearer $RAILBED_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_1042-attempt-1" \
-d '{
"amount": "135.31",
"currency": "USD",
"description": "Order #1042",
"reference": "order_1042",
"customer_email": "maya@example.com",
"customer": {
"name": "Maya Okafor",
"phone": "+1 503 555 0142",
"id": "88"
},
"order": {
"id": "1042",
"number": "1042",
"url": "https://yourstore.com/admin/orders/1042",
"store": {
"id": "yourstore",
"name": "yourstore.com",
"url": "https://yourstore.com"
},
"items": [
{
"name": "Magnesium glycinate",
"variant": "120 capsules",
"sku": "HN-MG-120",
"quantity": 2,
"unit_amount": "32.00",
"amount": "64.00"
},
{
"name": "Daily greens",
"sku": "HN-DG-30",
"quantity": 1,
"amount": "54.00"
},
{ "name": "Shaker bottle", "quantity": 1, "amount": "12.00" }
],
"discount": "13.00",
"discount_code": "WELCOME10",
"shipping": "8.95",
"shipping_method": "Standard",
"tax": "9.36",
"shipping_address": {
"name": "Maya Okafor",
"line1": "418 Linden Avenue",
"line2": "Apt 3B",
"city": "Portland",
"region": "OR",
"postal_code": "97214",
"country": "US"
}
},
"success_url": "https://yourstore.com/orders/1042/thanks"
}'const res = await fetch('https://pay.railbed.io/v1/checkout_sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order_1042-attempt-1',
},
body: JSON.stringify({
amount: '135.31',
currency: 'USD',
description: 'Order #1042',
reference: 'order_1042',
customer_email: 'maya@example.com',
customer: { name: 'Maya Okafor', phone: '+1 503 555 0142', id: '88' },
order: {
id: '1042',
number: '1042',
url: 'https://yourstore.com/admin/orders/1042',
store: {
id: 'yourstore',
name: 'yourstore.com',
url: 'https://yourstore.com',
},
items: [
{
name: 'Magnesium glycinate',
variant: '120 capsules',
sku: 'HN-MG-120',
quantity: 2,
unit_amount: '32.00',
amount: '64.00',
},
{ name: 'Daily greens', sku: 'HN-DG-30', quantity: 1, amount: '54.00' },
{ name: 'Shaker bottle', quantity: 1, amount: '12.00' },
],
discount: '13.00',
discount_code: 'WELCOME10',
shipping: '8.95',
shipping_method: 'Standard',
tax: '9.36',
shipping_address: {
name: 'Maya Okafor',
line1: '418 Linden Avenue',
line2: 'Apt 3B',
city: 'Portland',
region: 'OR',
postal_code: '97214',
country: 'US',
},
},
success_url: 'https://yourstore.com/orders/1042/thanks',
}),
});
if (!res.ok) throw new Error((await res.json()).error.code);
const session = await res.json();r = requests.post(
"https://pay.railbed.io/v1/checkout_sessions",
headers={
"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}",
"Idempotency-Key": "order_1042-attempt-1",
},
json={
"amount": "135.31",
"currency": "USD",
"description": "Order #1042",
"reference": "order_1042",
"customer_email": "maya@example.com",
"customer": {
"name": "Maya Okafor",
"phone": "+1 503 555 0142",
"id": "88",
},
"order": {
"id": "1042",
"number": "1042",
"url": "https://yourstore.com/admin/orders/1042",
"store": {
"id": "yourstore",
"name": "yourstore.com",
"url": "https://yourstore.com",
},
"items": [
{
"name": "Magnesium glycinate",
"variant": "120 capsules",
"sku": "HN-MG-120",
"quantity": 2,
"unit_amount": "32.00",
"amount": "64.00",
},
{
"name": "Daily greens",
"sku": "HN-DG-30",
"quantity": 1,
"amount": "54.00",
},
{"name": "Shaker bottle", "quantity": 1, "amount": "12.00"},
],
"discount": "13.00",
"discount_code": "WELCOME10",
"shipping": "8.95",
"shipping_method": "Standard",
"tax": "9.36",
"shipping_address": {
"name": "Maya Okafor",
"line1": "418 Linden Avenue",
"line2": "Apt 3B",
"city": "Portland",
"region": "OR",
"postal_code": "97214",
"country": "US",
},
},
"success_url": "https://yourstore.com/orders/1042/thanks",
},
timeout=15,
)
r.raise_for_status()
session = r.json()<?php
$ch = curl_init('https://pay.railbed.io/v1/checkout_sessions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order_1042-attempt-1',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => '135.31',
'currency' => 'USD',
'description' => 'Order #1042',
'reference' => 'order_1042',
'customer_email' => 'maya@example.com',
'customer' => [
'name' => 'Maya Okafor',
'phone' => '+1 503 555 0142',
'id' => '88',
],
'order' => [
'id' => '1042',
'number' => '1042',
'url' => 'https://yourstore.com/admin/orders/1042',
'store' => [
'id' => 'yourstore',
'name' => 'yourstore.com',
'url' => 'https://yourstore.com',
],
'items' => [
[
'name' => 'Magnesium glycinate',
'variant' => '120 capsules',
'sku' => 'HN-MG-120',
'quantity' => 2,
'unit_amount' => '32.00',
'amount' => '64.00',
],
[
'name' => 'Daily greens',
'sku' => 'HN-DG-30',
'quantity' => 1,
'amount' => '54.00',
],
['name' => 'Shaker bottle', 'quantity' => 1, 'amount' => '12.00'],
],
'discount' => '13.00',
'discount_code' => 'WELCOME10',
'shipping' => '8.95',
'shipping_method' => 'Standard',
'tax' => '9.36',
'shipping_address' => [
'name' => 'Maya Okafor',
'line1' => '418 Linden Avenue',
'line2' => 'Apt 3B',
'city' => 'Portland',
'region' => 'OR',
'postal_code' => '97214',
'country' => 'US',
],
],
'success_url' => 'https://yourstore.com/orders/1042/thanks',
]),
]);
$session = json_decode(curl_exec($ch), true);{
"id": "pay_Rt5Wm2KxQ8zLb3NvYc7P",
"object": "checkout_session",
"url": "https://pay.railbed.io/p/pay_Rt5Wm2KxQ8zLb3NvYc7P",
"status": "open",
"livemode": true,
"amount": "135.31",
"currency": "USD",
"description": "Order #1042",
"reference": "order_1042",
"customer_email": "maya@example.com",
"created": 1790380525,
"expires_at": 1790466925,
"started_at": null,
"metadata": null,
"order_id": "ord_Vd3kR8mQ1xTn6LpZs0Wa"
}Retrieve the order with order_id to see what was saved.
The customer field
| Field | Type | Description |
|---|---|---|
email | string or null | The buyer's email, with the same rules as customer_email. Send either one; when you send both, they must be the same address |
name | string or null | The buyer's name, up to 120 characters. Without it, the name on the shipping (or billing) address is used |
phone | string or null | Up to 40 characters, in any format |
id | string or null | The buyer's customer id in your store, up to 64 characters. It appears in the dashboard, on the order's store.customer_id and in the customer's store_accounts |
Name and phone reach the customer with the buyer's email: when the session is created with it, or once the buyer gives it and starts paying. See how customers are made.
The order field
| Field | Type | Description |
|---|---|---|
id | string or null | Your order id, up to 64 characters. A later session with the same id joins this order instead of making another. See Store orders and retries |
number | string or null | The order number your buyers see, up to 32 characters, such as 1042 (a leading # is dropped). Used only with id |
url | string or null | The order's page in your admin, up to 1,000 characters. Used only with id, and only when it's an https:// address |
store | object or null | Which of your stores the order is from. Needs id: store without it is 400 invalid_order. See below |
items | array or null | What's being bought, 1–100 items. Leave it out for a single line. See below |
discount | string or null | Taken off the items' total. Default "0.00". It can be more than the items when store credit covers shipping or tax too, as long as the order still adds up to amount |
discount_code | string or null | The code the buyer used, up to 64 characters |
shipping | string or null | Shipping charged. Default "0.00" |
shipping_method | string or null | Up to 120 characters, such as Standard |
tax | string or null | Tax charged. Default "0.00" |
shipping_address | object or null | Where to ship the order. See below |
billing_address | object or null | The buyer's billing address |
fulfillment | string or null | none, unfulfilled or fulfilled. Default unfulfilled when there's a shipping address, otherwise none. See fulfilment |
Money in order is written like amount: a decimal string with at most two decimal places, never a number. Zero is allowed. Text fields are trimmed, and a blank one counts as left out.
Each item in items:
| Field | Type | Description |
|---|---|---|
name | string Required | Up to 200 characters |
quantity | integer Required | A whole number from 1 to 100,000 |
amount | string Required | The line's total for all its units, such as "64.00". Zero is allowed |
unit_amount | string or null | One unit's price, for display. It isn't checked against amount |
sku | string or null | Up to 64 characters |
variant | string or null | Which version, up to 120 characters, such as 120 capsules |
store tells your stores apart, so two of them can use the same order ids. With one store, leave it out.
| Field | Type | Description |
|---|---|---|
id | string Required | A stable id for the store, up to 64 characters. Keep it the same: it's how later sessions and list filters find the order |
platform | string | woocommerce, shopify or other (the default) |
name | string or null | The store's name, up to 120 characters |
url | string or null | The store's address, up to 1,000 characters. Kept only when it's http:// or https:// |
shipping_address and billing_address take the same fields, each optional:
| Field | Description |
|---|---|
name | Up to 120 characters |
line1, line2 | Up to 200 characters each |
city | Up to 120 characters |
region | A state, province or county, up to 120 characters |
postal_code | Up to 32 characters |
country | A two-letter code such as US, in any letter case. Saved in capitals |
An address with nothing in it counts as none. Any field that breaks these rules is 400 invalid_order, with field naming it, such as order.items[2].quantity. A customer that isn't an object, or a customer.email that differs from customer_email, is 400 invalid_customer.
Totals
With items, the parts must add up to amount exactly, to the cent: the items' amounts, less discount, plus shipping and tax. For the order above:
| Part | Amount |
|---|---|
| Magnesium glycinate × 2 | 64.00 |
| Daily greens | 54.00 |
| Shaker bottle | 12.00 |
Discount, WELCOME10 | −13.00 |
| Shipping | +8.95 |
| Tax | +9.36 |
amount | 135.31 |
A session whose parts don't add up is 400 invalid_order_totals, and nothing is created; the error's message shows the sum. Line amounts come before the order's discount: when your store discounts single lines, send either the discounted line totals or the whole discount in discount, not both.
Without items, the order has one line named after your description, for whatever makes the parts add up: amount + discount − shipping − tax. If that comes to less than zero, the session is 400 invalid_order_totals.
Store orders and retries
A session with order.id pays for an order in your store. Railbed keeps one order for each order.id (within its store.id, in each mode):
- The first session creates the order. Later sessions with the same
order.idjoin it as more attempts at paying, for example after the buyer's card was declined or their session expired. - The latest session describes the order. Its items, totals and addresses replace the order's, and so does its customer when it has an email. The order's
numberand fulfilment stay, and its timeline in the dashboard notes when the total or items changed. - A paid order refuses a new session. When the order is
paid, orheldwith money you could accept as paid or that part-paid it, a new session gets409 order_paidand nothing is created. Check the order before asking the buyer to pay again. An order held only for money you can't accept (it went somewhere other than your wallet) takes new sessions, so the buyer can pay. - Earlier sessions stay open. Joining doesn't close them, and one that's still open can still be paid. Send the buyer only to the newest session's
url. A buyer who pays twice leaves two paid payments in the order'spayment_ids; refund one from your wallet. - Use a new
Idempotency-Keyfor each attempt. Repeating a request with the same key and body returns its original session, even after the order is paid: a replay is never refused withorder_paid. The key coversorderandcustomertoo, so the same key with a different order is409 idempotency_conflict.
Without order.id, every session creates a new order.
Retrieve a checkout session
GET/v1/checkout_sessions/:id
Returns a session of yours in the key's mode. Another account's session, or one in the other mode, is 404 not_found. Reading a session past its expires_at marks it expired.
curl https://pay.railbed.io/v1/checkout_sessions/pay_7AAiYH0Ykt11ED4hmfiN \
-H "Authorization: Bearer $RAILBED_SECRET_KEY"const session = await fetch(`https://pay.railbed.io/v1/checkout_sessions/${id}`, {
headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` },
}).then((r) => r.json());session = requests.get(
f"https://pay.railbed.io/v1/checkout_sessions/{id}",
headers={"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}"},
timeout=15,
).json()<?php
$ch = curl_init(
'https://pay.railbed.io/v1/checkout_sessions/' . rawurlencode($id)
);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),
],
]);
$session = json_decode(curl_exec($ch), true);The response is the checkout session object. To fulfil, read the payment instead: it adds the payment and settlement details.
Start a checkout session
POST/v1/checkout_sessions/:id/start
For your own checkout: starts the session as the buyer would on the hosted page, and returns the card providers that can take it, each with a handoff_url to open in the buyer's browser. Starting assigns the payment's deposit address and locks the fee and the order's value in USD.
| Field | Type | Description |
|---|---|---|
customer_email | string | The buyer's email. Optional when the session already has one; a new one replaces it |
country | string | The buyer's two-letter country code (US, DE), any letter case. Optional. Use the buyer's country, never your server's |
Send {} when the saved email is enough.
curl -X POST \
https://pay.railbed.io/v1/checkout_sessions/pay_7AAiYH0Ykt11ED4hmfiN/start \
-H "Authorization: Bearer $RAILBED_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "country": "US" }'const url = `https://pay.railbed.io/v1/checkout_sessions/${id}/start`;
const started = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ country: 'US' }),
}).then((r) => r.json());started = requests.post(
f"https://pay.railbed.io/v1/checkout_sessions/{id}/start",
headers={"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}"},
json={"country": "US"},
timeout=15,
).json()<?php
$ch = curl_init(
'https://pay.railbed.io/v1/checkout_sessions/' . rawurlencode($id) . '/start'
);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['country' => 'US']),
]);
$started = json_decode(curl_exec($ch), true);{
"id": "pay_7AAiYH0Ykt11ED4hmfiN",
"object": "checkout_session",
"url": "https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN",
"status": "open",
"livemode": false,
"amount": "49.00",
"currency": "USD",
"description": "Pro Membership",
"reference": "order_1042",
"customer_email": "buyer@example.com",
"created": 1790380525,
"expires_at": 1790466925,
"started_at": 1790380611,
"metadata": { "user_id": "player_1042" },
"order_id": "ord_M4tQx8ZkP1vRn6WcLs2B",
"country": "US",
"providers": [
{
"id": "stripe",
"name": "Stripe",
"note": "Card, Apple Pay or Google Pay",
"recommended": true,
"handoff_url": "https://pay.railbed.io/go/pay_7AAiYH0Ykt11ED4hmfiN?provider=stripe"
},
{
"id": "cashapp",
"name": "Cash App",
"note": "Cash App balance or card",
"recommended": false,
"handoff_url": "https://pay.railbed.io/go/pay_7AAiYH0Ykt11ED4hmfiN?provider=cashapp"
}
]
}The response is the session with two more fields:
| Field | Type | Description |
|---|---|---|
country | string or null | The country you sent, uppercased |
providers | array | The providers that can take this payment, best match first. Each has id, name, note (a short line on how the buyer pays), recommended (true for one) and handoff_url |
Starting again is safe: started_at and the deposit address stay the same. Providers depend on the amount, currency and country, so fetch them when the buyer reaches your checkout, and open a handoff_url only from the buyer's click, in a new tab.
| Status | Code | When |
|---|---|---|
| 400 | invalid_email | The session has no email and none was sent |
| 400 | invalid_country | country isn't two letters |
| 404 | not_found | Not a session of yours in this mode |
| 409 | no_providers | No provider can take this amount and currency now. Nothing was started |
| 409 | already_paid, held, failed | The payment has finished |
| 409 | unavailable | The account can't take payments now (Live: no payout wallet) |
| 410 | expired, canceled | The session can no longer be paid |
| 429 | rate_limited | More than about 12 starts a minute |
| 502 | network_unavailable | The card network didn't answer. Retry in a minute |