# Your own checkout

> Keep buyers in your app or game. Your server starts the checkout session and gets the card providers that can take the payment; your screen shows them, and the buyer pays on the chosen provider's page.

Source: https://railbed.io/docs/guides/custom-checkout/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs

## When to build your own

Use this when the purchase belongs inside your product: an in-game store, an app's upgrade screen, a checkout with your own layout. Railbed still does the hard parts: it picks the providers that can take the payment, hands the buyer over and checks the settlement. You own everything the buyer sees before and after the provider's page.

Card details are never entered in your screen. The buyer always pays on the provider's own page, which keeps your product out of card-data scope.

## The flow

1. **Your server creates the session**, exactly as for the [hosted checkout](https://railbed.io/docs/guides/hosted-checkout.md).
2. **Your server starts it** with `POST /v1/checkout_sessions/:id/start`, passing the buyer's email and, if you know it, their two-letter country. The response lists the providers that can take this payment, each with a `handoff_url`.
3. **Your screen shows the providers.** The one marked `recommended` is Railbed's best match for this buyer.
4. **The buyer picks one**, and your page opens its `handoff_url` in a new tab, from the buyer's click.
5. **Your server waits for the result** from the `payment.paid` webhook or by checking `GET /v1/payments/:id`, and your screen updates.

## Start the session and get providers

cURL:

```bash
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 '{ "customer_email": "player1042@example.com", "country": "US" }'
```

Node.js:

```js
// Your server: POST /api/purchase/:orderId/providers
// (called by your checkout screen)
const res = await fetch(
  `https://pay.railbed.io/v1/checkout_sessions/${order.paymentId}/start`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      customer_email: buyer.email,
      country: buyer.country, // from the buyer's request, if known
    }),
  },
);
if (res.status === 409) {
  const { error } = await res.json(); // e.g. no_providers, already_paid
  return reply.status(409).send({ code: error.code, message: error.message });
}
const started = await res.json();
// Send only what the screen needs. Never send your API key to the browser.
reply.send(
  started.providers.map(({ id, name, note, recommended, handoff_url }) => ({
    id,
    name,
    note,
    recommended,
    handoff_url,
  })),
);
```

Python:

```python
r = requests.post(
    f"https://pay.railbed.io/v1/checkout_sessions/{order.payment_id}/start",
    headers={"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}"},
    json={
        "customer_email": buyer.email,
        "country": buyer.country,  # from the buyer's request, if known
    },
    timeout=15,
)
if r.status_code == 409:
    error = r.json()["error"]  # e.g. no_providers, already_paid
    return {"code": error["code"], "message": error["message"]}, 409
providers = [
    {k: p[k] for k in ("id", "name", "note", "recommended", "handoff_url")}
    for p in r.json()["providers"]
]
```

PHP:

```php
<?php
$paymentId = rawurlencode($order['payment_id']);
$ch = curl_init("https://pay.railbed.io/v1/checkout_sessions/$paymentId/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([
    'customer_email' => $buyer['email'],
    'country' => $buyer['country'],
  ]),
]);
$started = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) === 409) {
  http_response_code(409);
  exit(json_encode($started['error'])); // e.g. no_providers, already_paid
}
$fields = array_flip(['id', 'name', 'note', 'recommended', 'handoff_url']);
echo json_encode(array_map(
  fn ($p) => array_intersect_key($p, $fields),
  $started['providers'],
));
```

Response (trimmed) · response 200 OK:

```json
{
  "id": "pay_7AAiYH0Ykt11ED4hmfiN",
  "object": "checkout_session",
  "status": "open",
  "amount": "49.00",
  "currency": "USD",
  "started_at": 1790380611,
  "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": "paypal",
      "name": "PayPal",
      "note": "PayPal balance or card",
      "recommended": false,
      "handoff_url": "https://pay.railbed.io/go/pay_7AAiYH0Ykt11ED4hmfiN?provider=paypal"
    }
  ]
}
```

Starting is safe to repeat: the session keeps the same deposit address and `started_at`, and a new email replaces the saved one. Providers change with the amount, currency and country, so fetch them when the buyer reaches your checkout screen rather than caching them.

## Show providers and hand off

Render the providers however suits your screen: a list, cards or a menu. Show `name` and `note`, and highlight the one with `recommended: true`. When the buyer picks one, open its `handoff_url` **in a new tab, from the click itself**, so browsers don't block it, and keep your screen open to show the result.

In your checkout screen:

```html
<ul id="providers"></ul>
<p id="status" role="status">Choose how to pay.</p>
<script>
  // providers: what your server returned from the start call
  function showProviders(providers) {
    const list = document.getElementById('providers');
    for (const p of providers) {
      const a = document.createElement('a');
      a.href = p.handoff_url;
      a.target = '_blank';
      a.rel = 'noopener';
      a.textContent = p.name + (p.recommended ? ' (recommended)' : '');
      a.addEventListener('click', () => {
        document.getElementById('status').textContent =
          'Finish paying in the new tab. This page updates by itself.';
        waitForPayment();
      });
      const li = document.createElement('li');
      li.append(a, ' ', p.note);
      list.append(li);
    }
  }
</script>
```

Don't fetch a `handoff_url` from your server, frame it in an iframe or change its query. It must open in the buyer's own browser: providers check the buyer's real location and refuse to load inside frames. At the click, Railbed checks again using the buyer's own connection: if the chosen provider is no longer available or doesn't serve the country the buyer is in, the buyer lands on Railbed's page for this payment to choose another.

## Show the result

Providers don't send buyers back to your app, so your screen has to find out for itself. Ask **your server** every few seconds while the screen is open; your server answers from the webhook it received, or from `GET /v1/payments/:id`.

In your checkout screen:

```js
async function waitForPayment() {
  // Your server, never Railbed directly
  const res = await fetch(`/api/orders/${orderId}/status`);
  const { status } = await res.json();
  if (status === 'paid') return showPaid();
  if (status === 'held') {
    return showMessage(
      'Your payment arrived and is being reviewed. We’ll email you.',
    );
  }
  if (status === 'failed' || status === 'expired') {
    return showMessage('The payment didn’t go through. Try again.');
  }
  setTimeout(waitForPayment, 4000);
}
```

Your server must also keep checking unfinished orders when nobody has the screen open, because buyers close tabs. [Fulfil orders safely](https://railbed.io/docs/guides/fulfilment.md) covers the background check.

## Country

Pass `country` as the buyer's two-letter ISO code when you know it (for example from their account or their request's location). Railbed uses it to show only providers that serve that country. Never send your own server's location. When you leave it out, up to eight providers that fit the amount and currency are listed, including ones that serve only some countries, so pass it whenever you know it.

## Errors when starting

| Status | Code | Meaning |
|---|---|---|
| 400 | `invalid_email` | No email was saved on the session and none was sent. Send `customer_email` |
| 400 | `invalid_country` | `country` isn't a two-letter code |
| 409 | `no_providers` | No provider can take this amount in this currency right now. Nothing was started |
| 409 | `already_paid`, `held`, `failed` | The payment already finished. Show its result |
| 410 | `expired`, `canceled` | The session can no longer be paid. Create a new one |
| 409 | `unavailable` | The account can't take payments right now (for Live, check the payout wallet) |
| 429 | `rate_limited` | Too many starts. Wait for `Retry-After` seconds |
| 502 | `network_unavailable` | The card network didn't answer. Retry in a minute |
