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.
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
- Your server creates the session, exactly as for the hosted checkout.
- 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 ahandoff_url. - Your screen shows the providers. The one marked
recommendedis Railbed's best match for this buyer. - The buyer picks one, and your page opens its
handoff_urlin a new tab, from the buyer's click. - Your server waits for the result from the
payment.paidwebhook or by checkingGET /v1/payments/:id, and your screen updates.
Start the session and get providers
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" }'// 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,
})),
);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
$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'],
));{
"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.
<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.
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 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 |