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

FieldTypeDescription
idstringThe session's id, pay_…. The same id identifies its payment
objectstring"checkout_session"
urlstringThe hosted checkout page for this session. Send the buyer here
statusstringopen, pending, paid, held, failed or expired. See statuses
livemodebooleanfalse for Test mode, true for Live mode
amountstringThe price, as set at creation: "49.00"
currencystringUSD, EUR, GBP, CAD or AUD
descriptionstringWhat the buyer is paying for, shown on the checkout
referencestring or nullYour own id for the order
customer_emailstring or nullThe buyer's email, lowercased
createdintegerWhen it was created, in Unix seconds
expires_atintegerWhen it stops accepting new payments: 24 hours after creation
started_atinteger or nullWhen the buyer (or your server) started it. Set once
metadataobject or nullYour key-value pairs, returned unchanged
order_idstring or nullThe 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.

FieldTypeDescription
amountstring RequiredThe 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
currencystring RequiredUSD, EUR, GBP, CAD or AUD, in any letter case
descriptionstring RequiredWhat the buyer is paying for, up to 120 characters
customer_emailstring or nullThe buyer's email. Optional here, but needed before the session can start
referencestring or nullYour order id, up to 120 characters. Returned on the payment and every webhook. Not unique: Railbed doesn't stop two sessions sharing one
metadataobject or nullUp 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_urlstring or nullWhere the checkout sends the buyer once the payment is confirmed. {PAYMENT_ID} and {REFERENCE} are filled in. Up to 1,000 characters
cancel_urlstring or nullWhere the checkout's "Cancel and return to …" and "Back to …" links lead (they name your business). Up to 1,000 characters
customerobject or nullWho is buying: name, phone and your customer id. See Orders and customers
orderobject or nullWhat 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"
  }'
Response
{
  "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"
}
201 Created

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"
  }'
Response
{
  "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"
}
201 Created

Retrieve the order with order_id to see what was saved.

The customer field

FieldTypeDescription
emailstring or nullThe buyer's email, with the same rules as customer_email. Send either one; when you send both, they must be the same address
namestring or nullThe buyer's name, up to 120 characters. Without it, the name on the shipping (or billing) address is used
phonestring or nullUp to 40 characters, in any format
idstring or nullThe 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

FieldTypeDescription
idstring or nullYour 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
numberstring or nullThe order number your buyers see, up to 32 characters, such as 1042 (a leading # is dropped). Used only with id
urlstring or nullThe order's page in your admin, up to 1,000 characters. Used only with id, and only when it's an https:// address
storeobject or nullWhich of your stores the order is from. Needs id: store without it is 400 invalid_order. See below
itemsarray or nullWhat's being bought, 1–100 items. Leave it out for a single line. See below
discountstring or nullTaken 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_codestring or nullThe code the buyer used, up to 64 characters
shippingstring or nullShipping charged. Default "0.00"
shipping_methodstring or nullUp to 120 characters, such as Standard
taxstring or nullTax charged. Default "0.00"
shipping_addressobject or nullWhere to ship the order. See below
billing_addressobject or nullThe buyer's billing address
fulfillmentstring or nullnone, 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:

FieldTypeDescription
namestring RequiredUp to 200 characters
quantityinteger RequiredA whole number from 1 to 100,000
amountstring RequiredThe line's total for all its units, such as "64.00". Zero is allowed
unit_amountstring or nullOne unit's price, for display. It isn't checked against amount
skustring or nullUp to 64 characters
variantstring or nullWhich 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.

FieldTypeDescription
idstring RequiredA stable id for the store, up to 64 characters. Keep it the same: it's how later sessions and list filters find the order
platformstringwoocommerce, shopify or other (the default)
namestring or nullThe store's name, up to 120 characters
urlstring or nullThe 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:

FieldDescription
nameUp to 120 characters
line1, line2Up to 200 characters each
cityUp to 120 characters
regionA state, province or county, up to 120 characters
postal_codeUp to 32 characters
countryA 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:

PartAmount
Magnesium glycinate × 264.00
Daily greens54.00
Shaker bottle12.00
Discount, WELCOME10−13.00
Shipping+8.95
Tax+9.36
amount135.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.id join 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 number and 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, or held with money you could accept as paid or that part-paid it, a new session gets 409 order_paid and 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's payment_ids; refund one from your wallet.
  • Use a new Idempotency-Key for 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 with order_paid. The key covers order and customer too, so the same key with a different order is 409 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"

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.

FieldTypeDescription
customer_emailstringThe buyer's email. Optional when the session already has one; a new one replaces it
countrystringThe 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" }'
Response
{
  "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"
    }
  ]
}
200 OK

The response is the session with two more fields:

FieldTypeDescription
countrystring or nullThe country you sent, uppercased
providersarrayThe 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.

StatusCodeWhen
400invalid_emailThe session has no email and none was sent
400invalid_countrycountry isn't two letters
404not_foundNot a session of yours in this mode
409no_providersNo provider can take this amount and currency now. Nothing was started
409already_paid, held, failedThe payment has finished
409unavailableThe account can't take payments now (Live: no payout wallet)
410expired, canceledThe session can no longer be paid
429rate_limitedMore than about 12 starts a minute
502network_unavailableThe card network didn't answer. Retry in a minute

Updated · This page as Markdown