# Railbed developer docs > Railbed is a card checkout for online merchants that settles every sale as USDC on Polygon to a wallet the merchant controls. These docs cover the REST API (checkout sessions and payments), signed webhooks, test mode, hosted and merchant-built checkouts, embeds and the WooCommerce plugin. Every page below is also available as Markdown. Essentials for building against Railbed: - API base URL: `https://pay.railbed.io/v1`. JSON over HTTPS. Authenticate every call from your server with `Authorization: Bearer `. - Keys decide the mode: `rb_test_…` keys work only with simulated Test payments (no money moves), `rb_live_…` keys take real card payments. Create and revoke keys in the dashboard under Developers (https://app.railbed.io/developers). Never put a secret key in browser or mobile code; there is no publishable key. - The flow: create a checkout session (`POST /v1/checkout_sessions`), send the buyer to its `url` (or start it with `POST /v1/checkout_sessions/:id/start` and render the returned providers in your own checkout), then fulfil only when the payment is `paid`, confirmed by a signed `payment.paid` webhook or by `GET /v1/payments/:id`. Never fulfil from the buyer's return to `success_url`. - Send an `Idempotency-Key` header (1–120 printable ASCII characters, remembered for 24 hours) on every create so a retried request never makes a second payment. - Money is a decimal string (`"49.00"`, from 1.00 to 100000.00); currencies are USD, EUR, GBP, CAD and AUD. `/v1` timestamps are Unix seconds; in webhooks the event's `created` is Unix seconds and the payment object uses camelCase with millisecond timestamps. Payment ids look like `pay_…` and are both the session id and the payment id. - Payment statuses: `open` and `pending` (wait), `paid` (fulfil, once), `held` (money arrived but failed a check; never fulfil automatically, the merchant reviews it), `failed` (Test mode decline), `expired` (can still become `paid` or `held` if money arrives late). - Webhooks are signed: `Railbed-Signature: t=,v1=.">` keyed with the endpoint's whole `whsec_…` secret. Verify against the raw body, reject timestamps more than 5 minutes off, dedupe on the event `id`, answer 2xx within 10 seconds. Failed deliveries are retried 1m, 5m, 30m, 2h, 6h and 12h after each failure. - Webhook events: `payment.started`, `payment.paid`, `payment.held`, `payment.updated`, `payment.failed`, `payment.expired`, `payment.canceled`, and `ping` for tests. Ignore types you don't handle; events can arrive more than once and out of order. - Errors are `{"error": {"code", "message", "field"?}}`; branch on `code` and the HTTP status, never the message. 429 responses carry `Retry-After: 60`; limits are about 120 requests and 12 session starts a minute per account. - Test mode: `POST /v1/payments/:id/simulate` with `outcome` `paid`, `underpaid` or `failed` settles a started Test payment through the same checks and webhooks as a live one. - Card details are always entered on a licensed card provider's page, never on Railbed's pages or yours. There are no refund, subscription or event-list endpoints; refunds are sent from the merchant's wallet. ## Get started - [Railbed developer docs](https://railbed.io/docs/index.md): Take card payments that settle as USDC to a wallet you control. Create checkouts from your server, send buyers to a hosted page or build your own, and fulfil orders from signed webhooks. - [Quickstart](https://railbed.io/docs/quickstart.md): Take your first payment in Test mode in about ten minutes. Create a secret key, create a checkout session, pay it as a buyer, and confirm it from your server. - [How payments work](https://railbed.io/docs/how-it-works.md): The life of a Railbed payment, from the checkout to the USDC in your wallet, and what each status means for the order behind it. - [Testing](https://railbed.io/docs/testing.md): Test mode simulates payments end to end, with no card charged and no money moved. Make payments succeed, fall short or be declined on demand, send sample webhook events, and go live with confidence. ## Guides - [Hosted checkout](https://railbed.io/docs/guides/hosted-checkout.md): Create a checkout session for each order on your server, send the buyer to Railbed's hosted page, and bring them back to your store once they've paid. - [Your own checkout](https://railbed.io/docs/guides/custom-checkout.md): 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. - [Fulfil orders safely](https://railbed.io/docs/guides/fulfilment.md): Deliver each order exactly once, only for money that really arrived. The checks that keep fulfilment correct through retries, duplicate events, late payments and reviews. - [Payment links and buy buttons](https://railbed.io/docs/guides/no-code.md): Take payments without writing server code. Create payment links, checkout pages and pricing tables in the dashboard, and add a buy button or an embedded checkout to any website with two lines of HTML. - [WooCommerce](https://railbed.io/docs/guides/woocommerce.md): Add Railbed card checkout to a WordPress store with the Railbed for WooCommerce plugin. Orders complete from verified payments, with no code. ## API reference - [API reference](https://railbed.io/docs/api.md): The Railbed REST API. JSON over HTTPS, one secret key per server, idempotent creates, cursor pagination and plain error codes. - [Checkout sessions](https://railbed.io/docs/api/checkout-sessions.md): 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. - [Payments](https://railbed.io/docs/api/payments.md): A payment is what a checkout session became. Read it to fulfil orders, list payments to reconcile, and simulate outcomes in Test mode. - [Errors](https://railbed.io/docs/api/errors.md): Every error the Railbed API returns, with its HTTP status, what caused it and what to do next. ## Webhooks - [Webhooks](https://railbed.io/docs/webhooks.md): Railbed sends a signed HTTPS request to your server when a payment starts, settles, is held, expires or is canceled. How endpoints, deliveries, retries and the delivery log work. - [Event types](https://railbed.io/docs/webhooks/events.md): Every webhook event Railbed sends, when it's sent, what the payment looks like at that moment, and the full payload reference. - [Verify signatures](https://railbed.io/docs/webhooks/signatures.md): Prove each webhook came from Railbed and wasn't changed. The signing scheme, verification code in six languages, how to get the raw body in common frameworks, and a test vector. ## Resources - [Build with AI agents](https://railbed.io/docs/agents.md): Everything in these docs is available to AI assistants and coding agents: llms.txt, a Markdown copy of every page, a structured index, and WebMCP tools on railbed.io and in the dashboard. ## API endpoints - [`POST /v1/checkout_sessions`](https://railbed.io/docs/api/checkout-sessions.md#create-a-checkout-session): Create a checkout session - [`GET /v1/checkout_sessions/:id`](https://railbed.io/docs/api/checkout-sessions.md#retrieve-a-checkout-session): Retrieve a checkout session - [`POST /v1/checkout_sessions/:id/start`](https://railbed.io/docs/api/checkout-sessions.md#start-a-checkout-session): Start a checkout session - [`GET /v1/payments/:id`](https://railbed.io/docs/api/payments.md#retrieve-a-payment): Retrieve a payment - [`GET /v1/payments`](https://railbed.io/docs/api/payments.md#list-payments): List payments - [`POST /v1/payments/:id/simulate`](https://railbed.io/docs/api/payments.md#simulate-a-payment): Simulate a payment ## Webhook events - [`payment.started`](https://railbed.io/docs/webhooks/events.md#payment-started): The buyer entered their email and was given a way to pay: Railbed assigned the payment's deposit address and locked the fee and the order's USD value. The payment is still open (it becomes pending when the buyer reaches a provider), and provider is usually still null. - [`payment.paid`](https://railbed.io/docs/webhooks/events.md#payment-paid): The money arrived and passed Railbed's checks: at least 90% of the order's value in a dollar coin and, from the processor's signed notice, the right deposit address, your wallet in the payout and a transaction never used before. Or you accepted a held payment as paid. This is the event to fulfil from. - [`payment.held`](https://railbed.io/docs/webhooks/events.md#payment-held): Money arrived but failed a check, most often because less arrived than 90% of the order's value. Don't fulfil. The payment waits for you in the dashboard. - [`payment.updated`](https://railbed.io/docs/webhooks/events.md#payment-updated): A paid payment's settlement details arrived after it was confirmed: txidIn, valueCoin, coin and merchantReceived are now filled in. The status stays paid. Update your records; there's nothing to fulfil again. - [`payment.failed`](https://railbed.io/docs/webhooks/events.md#payment-failed): The payment was declined. Today this is sent in Test mode only, when you simulate a decline: live card providers don't report declines, so a live payment the buyer never completes ends as payment.expired. Let the buyer try again with a new session. - [`payment.expired`](https://railbed.io/docs/webhooks/events.md#payment-expired): Nobody paid before the payment's expiresAt: 24 hours for API sessions, or the lifetime chosen for a payment link. It's sent within five minutes of expiry. - [`payment.canceled`](https://railbed.io/docs/webhooks/events.md#payment-canceled): You canceled a payment link in the dashboard before anyone had gone to a provider. The payment's status is expired and canceledAt is set. - [`ping`](https://railbed.io/docs/webhooks/events.md#ping): Sent only when you choose Send test event and pick ping (Test endpoints) or Send ping (Live endpoints). data.payment is null. Use it to check the address, the secret and your signature code. ## Optional - [All of these docs in one file](https://railbed.io/docs/llms-full.txt) - [Railbed company overview (llms.txt)](https://railbed.io/llms.txt) - [Dashboard: create API keys and webhook endpoints](https://app.railbed.io/developers) - [Sign up](https://app.railbed.io/signup) --- # Railbed developer docs > Take card payments that settle as USDC to a wallet you control. Create checkouts from your server, send buyers to a hosted page or build your own, and fulfil orders from signed webhooks. Source: https://railbed.io/docs/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## What you can build Railbed gives your store a card checkout that never holds your money. A buyer pays by card through a licensed on-ramp provider, and the sale settles as USDC on Polygon straight to your own wallet. Your integration decides where the buyer pays and when an order counts as paid; Railbed handles the checkout, the provider hand-off and the settlement checks. - [Quickstart](https://railbed.io/docs/quickstart.md): Create a key, make a checkout session and take a simulated payment in about ten minutes. - [How payments work](https://railbed.io/docs/how-it-works.md): The life of a payment, from checkout to settlement, and what each status means for your order. - [API reference](https://railbed.io/docs/api.md): Authentication, idempotency, pagination, errors and every endpoint, with examples. - [Webhooks](https://railbed.io/docs/webhooks.md): Signed events for every step of a payment, retries, and the delivery log. ## Choose an integration Every integration ends the same way: the buyer pays on a provider's page and the money settles to your wallet. They differ in how much you build. | Integration | You build | Best for | |---|---|---| | [Payment links and buy buttons](https://railbed.io/docs/guides/no-code.md) | Nothing. Create links, checkout pages and pricing tables in the dashboard; paste a buy button into any site | Invoices, one product, getting started this afternoon | | [WooCommerce](https://railbed.io/docs/guides/woocommerce.md) | Nothing. Install the plugin and connect it with a key and a webhook secret | WordPress stores | | [Hosted checkout](https://railbed.io/docs/guides/hosted-checkout.md) | One API call per order, then a redirect | Custom stores and apps that want Railbed's checkout page | | [Your own checkout](https://railbed.io/docs/guides/custom-checkout.md) | The whole buyer screen: your server starts the session and shows the card providers | Apps and games with their own purchase flow | Whichever you choose, fulfil orders the same way: from a verified `payment.paid` webhook or an authenticated status check. [Fulfil orders safely](https://railbed.io/docs/guides/fulfilment.md) shows how. ## The API at a glance The API is JSON over HTTPS at `https://pay.railbed.io/v1`. Your server authenticates with a secret key; buyers never see it. Create a checkout session: ```bash 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" }' ``` Response · response 201 Created: ```json { "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": null, "created": 1790380525, "expires_at": 1790466925, "started_at": null, "metadata": null } ``` Send the buyer to `url`. When they pay, Railbed sends a signed `payment.paid` event to your server, and `GET /v1/payments/pay_…` reports `"status": "paid"`. ## Test mode and live mode Every account has two separate modes. **Test mode** simulates payments: no card is charged, no money moves, and you can make a payment succeed, fall short or be declined on demand. **Live mode** takes real card payments. Keys, webhook endpoints, checkouts and payments belong to one mode, and a test key can never touch a live payment. Build and test everything in Test mode first; [Testing](https://railbed.io/docs/testing.md) covers the tools. ## What Railbed doesn't do Card details are always entered on the provider's own page, never on Railbed's pages or yours, so your integration stays out of card-data scope. Railbed can't reverse a settled payment: refunds are sent from your wallet, and card disputes are handled by the provider that charged the card. There are no subscription, refund or event-list endpoints today. > [!TIP] > Building with an AI assistant? Every page here has a Markdown copy, and [/docs/llms.txt](https://railbed.io/docs/llms.txt) summarises the whole API. See [For AI agents](https://railbed.io/docs/agents.md). --- # Quickstart > Take your first payment in Test mode in about ten minutes. Create a secret key, create a checkout session, pay it as a buyer, and confirm it from your server. Source: https://railbed.io/docs/quickstart/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## Before you start You need a Railbed account ([sign up](https://app.railbed.io/signup) takes a minute) and a terminal. Everything below runs in **Test mode**: no card is charged and no money moves. The same code works in Live mode with a live key. ## 1. Create a secret key In the dashboard, switch to **Test** mode, open [Developers](https://app.railbed.io/developers) and choose **Create key**. Name it after the server that will use it, for example "Store backend". The key starts with `rb_test_` and is shown once: copy it into your server's environment. Your server's environment: ```bash export RAILBED_SECRET_KEY="rb_test_…" ``` > [!IMPORTANT] > A secret key can create checkouts and read your payments. Keep it on your server. Never put it in a web page, a mobile app or a repository. If one leaks, revoke it in Developers and create another. ## 2. Create a checkout session A checkout session is one payment for one order: a fixed amount, a currency, and your order's reference. Send an `Idempotency-Key` so a retried request can never create a second payment. cURL: ```bash 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", "success_url": "https://yourstore.com/thanks?order={REFERENCE}" }' ``` Node.js: ```js 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', success_url: 'https://yourstore.com/thanks?order={REFERENCE}', }), }); const session = await res.json(); console.log(session.url); ``` Python: ```python import os, requests session = 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", "success_url": "https://yourstore.com/thanks?order={REFERENCE}", }, timeout=15, ).json() print(session["url"]) ``` PHP: ```php 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', 'success_url' => 'https://yourstore.com/thanks?order={REFERENCE}', ]), ]); $session = json_decode(curl_exec($ch), true); echo $session['url']; ``` The response is the new session. Save its `id` (`pay_…`) with your order. Response · response 201 Created: ```json { "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": null } ``` ## 3. Pay as a buyer Open the session's `url` in a browser. Continue with the email, pick a card provider and choose **Pay**. In Test mode the provider's page is replaced by Railbed's test provider, where **Simulate successful payment** stands in for entering a card. The checkout tab then shows the payment as paid and takes the buyer to your `success_url`, with `{REFERENCE}` filled in. ## 4. Confirm the payment from your server The redirect is for the buyer's comfort, not proof of payment. Confirm it from your server, with the same key: cURL: ```bash curl https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN \ -H "Authorization: Bearer $RAILBED_SECRET_KEY" ``` Node.js: ```js const res = await fetch(`https://pay.railbed.io/v1/payments/${paymentId}`, { headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` }, }); const payment = await res.json(); if (payment.status === 'paid') fulfil(payment.reference); ``` Python: ```python payment = requests.get( f"https://pay.railbed.io/v1/payments/{payment_id}", headers={"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}"}, timeout=15, ).json() if payment["status"] == "paid": fulfil(payment["reference"]) ``` PHP: ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY')], ]); $payment = json_decode(curl_exec($ch), true); if ($payment['status'] === 'paid') fulfil($payment['reference']); ``` Response (trimmed) · response 200 OK: ```json { "id": "pay_7AAiYH0Ykt11ED4hmfiN", "object": "payment", "status": "paid", "livemode": false, "amount": "49.00", "currency": "USD", "reference": "order_1042", "paid_at": 1790381342, "provider": "stripe", "settlement": { "coin": "polygon_usdc", "value_coin": "47.53", "merchant_received": "44.915850", "txid_in": "0x0c65…582e", "txid_out": "0x8202…356f", "payout_wallet": "0xF977814e90dA44bFA03b6295A0616a897441aceC" } } ``` Check that `status` is `paid` and that the id, amount, currency and reference match the order you saved, then fulfil it once. ## 5. Get told instead of asking Polling works, but webhooks tell you the moment something happens. In [Developers](https://app.railbed.io/developers), choose **Add endpoint** and enter your server's `https://` address (while you build on your own computer, [use a tunnel](https://railbed.io/docs/testing.md#receive-webhooks-on-your-own-computer)). Copy the signing secret, then choose **Send test event** to see exactly what your server receives. [Webhooks](https://railbed.io/docs/webhooks.md) covers the events and [verifying signatures](https://railbed.io/docs/webhooks/signatures.md). ## Next - [Fulfil orders safely](https://railbed.io/docs/guides/fulfilment.md): The checks that make fulfilment correct even with retries, late payments and reviews. - [Your own checkout](https://railbed.io/docs/guides/custom-checkout.md): Show card providers in your own screen instead of redirecting. - [Testing](https://railbed.io/docs/testing.md): Simulate underpayments and declines, and send sample events. - [Going live](https://railbed.io/docs/testing.md#going-live): The short checklist before real payments. --- # How payments work > The life of a Railbed payment, from the checkout to the USDC in your wallet, and what each status means for the order behind it. Source: https://railbed.io/docs/how-it-works/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## The journey of one payment 1. **Your server creates a checkout session** for an order: amount, currency, description and your reference. Nothing is charged yet, and nothing is reserved. 2. **The buyer starts paying.** On Railbed's hosted page (or your own screen) they enter an email and see the card providers that can take this payment in their country, ranked by Smart Routing. Starting assigns the payment a one-time deposit address and locks the fee and the order's value in USD. 3. **The buyer pays on the provider's page.** The provider (Stripe, Coinbase, PayPal and others) runs its own card checkout and any identity check it requires, then buys USDC with the card payment and sends it to the payment's deposit address. 4. **The USDC is forwarded to your wallet.** Your share goes straight to the payout wallet saved in your dashboard, on the Polygon network. Railbed never holds it. 5. **Railbed checks the settlement** before calling the payment paid: the right amount reached the right wallet, and the transaction hasn't been counted before. Then the payment becomes `paid`, and your webhooks and API report it. The buyer's checkout tab waits while they pay and sends them to your `success_url` once the payment is confirmed. Providers don't send buyers back on their own, so your order should never depend on the redirect: fulfil from the [webhook or the API](https://railbed.io/docs/guides/fulfilment.md). ## Statuses | Status | Meaning | What to do | |---|---|---| | `open` | Created, or started but not yet handed to a provider | Wait. The buyer hasn't paid | | `pending` | The buyer went to a provider to pay | Wait. How long depends on the provider; see [timing](#timing) | | `paid` | The money arrived and passed Railbed's checks | Fulfil the order, once | | `held` | Money arrived but failed a check, usually because less arrived than expected | Don't fulfil yet. Review it in the dashboard, where you can accept it as paid | | `failed` | Declined. Test mode only for now: live providers don't report declines | Let the buyer try again with a new session | | `expired` | Nobody paid within the session's time (24 hours for API sessions) | Treat as abandoned, but keep listening: money that arrives late still settles and the payment becomes `paid` or `held` | A payment link the merchant cancels before the payer has gone to a card provider also reads `expired`, with `canceled_at` set. ## What arrives in your wallet Providers charge the buyer the order's price in their currency and deliver USDC for it. The amount that arrives (`value_coin`) is lower than the price because the provider keeps its own fee and spread. From that, a 1% network fee and the Railbed fee shown in your dashboard come off, and the rest (`merchant_received`) is forwarded to your wallet. The fee that applies is locked when the buyer starts paying, so a later fee change never affects a payment already in progress. A payment counts as paid when what arrived is at least 90% of the order's value in USD. Anything less is `held` for your review rather than paid, so an order is never fulfilled on a large shortfall by accident. Orders in EUR, GBP, CAD or AUD are converted to USD at the rate when the buyer starts. > [!NOTE] > Settlement is usually USDC on Polygon (`polygon_usdc`). If a payment ever arrives in another coin that can't be valued in dollars, it's held for review instead of counted. ## Identity checks Each provider decides whether to ask the buyer for identification, usually based on amount, country and history. Smart Routing ranks the providers least likely to ask first, but it can't promise that a provider won't. The buyer's identity details stay with the provider. ## Refunds and disputes A settled payment can't be reversed by Railbed: the money is already in your wallet. To refund a buyer, send the funds back yourself. Card disputes and chargebacks are handled by the provider that charged the card, under its own terms. ## Timing | Step | Typical time | |---|---| | Checkout session lifetime (API) | 24 hours from creation | | Payment link lifetime | 1–30 days, chosen when it's created | | Card payment on the provider's page | A few minutes | | Settlement after the provider sends USDC | As soon as the forwarding is reported; if that report is late, Railbed checks unsettled payments on a schedule for up to two days | | Webhook retries | For about a day: 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, then 12 hours | --- # Testing > Test mode simulates payments end to end, with no card charged and no money moved. Make payments succeed, fall short or be declined on demand, send sample webhook events, and go live with confidence. Source: https://railbed.io/docs/testing/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## Test mode Test mode is a complete, separate copy of your account. Test keys start with `rb_test_`, and everything they create is simulated: checkouts, payments, settlement and webhooks all behave as in Live mode, but no card is charged and no money moves. Switch the dashboard between Test and Live with the toggle at the top of each page. | | Test mode | Live mode | |---|---|---| | Secret keys | `rb_test_…` | `rb_live_…` | | Card payments | Simulated on Railbed's test provider page | Real, on the provider's page | | Settlement | Simulated, with the same checks as live | USDC to your payout wallet | | Webhook endpoints | Any `https://` address (for your own computer, a tunnel) | Public `https://` addresses only | | Webhooks | Real, signed HTTP requests to your endpoints | The same | | `POST /v1/payments/:id/simulate` | Available | Refused | Test and live data never mix: a test key can't read or change a live payment, and each mode has its own endpoints and keys. ## Simulate an outcome as a buyer Open a test session's `url` (or any test checkout or payment link), enter an email and choose **Pay**. The test provider page offers three outcomes: - **Simulate successful payment**: about 97% of the order's value arrives, as a real provider's fee would leave it, and the payment becomes `paid`. - **Simulate an underpayment**: half arrives, so the payment is `held` for review, exactly as a live shortfall would be. - **Simulate declined card**: the payment becomes `failed`. ## Simulate an outcome from your server To test without a browser, start the session and simulate the outcome through the API. This is how automated tests should drive payments. cURL: ```bash # Start it (the buyer would normally do this by choosing a provider) 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": "buyer@example.com" }' # Then decide how it ends: "paid", "underpaid" or "failed" curl -X POST \ https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN/simulate \ -H "Authorization: Bearer $RAILBED_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "outcome": "paid" }' ``` Node.js: ```js const api = (path, body) => fetch(`https://pay.railbed.io/v1${path}`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }).then((r) => r.json()); await api(`/checkout_sessions/${id}/start`, { customer_email: 'buyer@example.com', }); const payment = await api(`/payments/${id}/simulate`, { outcome: 'paid', // or 'underpaid', 'failed' }); ``` Python: ```python def api(path, body): return requests.post( f"https://pay.railbed.io/v1{path}", headers={"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}"}, json=body, timeout=15, ).json() api(f"/checkout_sessions/{id}/start", {"customer_email": "buyer@example.com"}) payment = api( f"/payments/{id}/simulate", {"outcome": "paid"}, # or "underpaid", "failed" ) ``` PHP: ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($body), ]); return json_decode(curl_exec($ch), true); } railbed_post("/checkout_sessions/$id/start", [ 'customer_email' => 'buyer@example.com', ]); $payment = railbed_post("/payments/$id/simulate", [ 'outcome' => 'paid', // or 'underpaid', 'failed' ]); ``` The simulated outcome runs the same settlement checks and sends the same webhooks as a live payment. Each session can end only once: simulating again on a finished payment returns it unchanged, so use a new session for each scenario. See [Simulate a payment](https://railbed.io/docs/api/payments.md#simulate-a-payment) for the details. ## Send sample webhook events In [Developers](https://app.railbed.io/developers), choose **Send test event** on a Test endpoint and pick any event. Railbed sends a signed, realistic event with a made-up payment (its `metadata.sample` is `"true"`, and it isn't in your payments or the API), then shows the exact body your server received and how it answered. Live endpoints can receive a `ping` only, so a live system never sees a payment that didn't happen. Every delivery, test or real, appears in the endpoint's delivery log with its body, and can be sent again. ## Receive webhooks on your own computer Railbed sends webhooks from the internet, so it can't reach a server that only listens on your computer. While you build, expose your local server through a tunnel that gives it a public `https://` address (for example Cloudflare Tunnel or ngrok), and add that address as a Test endpoint. When the tunnel's address changes, edit the endpoint: pending retries go to the new address. ## A test plan worth running Before going live, check that your integration handles each of these without a human: | Scenario | How to cause it | Expected result | |---|---|---| | A normal payment | Simulate `paid` | Order fulfilled once | | The same event twice | Resend a delivery from the delivery log | Nothing changes the second time | | An underpayment | Simulate `underpaid` | Order not fulfilled; `payment.held` received | | A decline | Simulate `failed` | Order not fulfilled; buyer can start again | | A buyer who never pays | Create a session and leave it | Order not fulfilled; `payment.expired` after 24 hours | | A lost create response | Send the same create request twice with one `Idempotency-Key` | One payment, the same `id` both times | | Your server is down | Point the endpoint at a failing address, then fix it | Deliveries retry; **Retry now** in the log delivers it | | A forged webhook | Send a request with a wrong signature | Your endpoint rejects it with a 4xx | ## Going live 1. Add your payout wallet in [Settings](https://app.railbed.io/settings). It must be a self-custody Polygon wallet you control, not an exchange deposit address. 2. Switch the dashboard to **Live** and create a live key and live webhook endpoints. Live endpoints need public `https://` addresses. 3. Put the live key and the live endpoint's signing secret on your production server. Keep the test ones for your test environment. 4. Take one small real payment and follow it through: `payment.paid` received, the order fulfilled once, USDC in your wallet. --- # Hosted checkout > Create a checkout session for each order on your server, send the buyer to Railbed's hosted page, and bring them back to your store once they've paid. Source: https://railbed.io/docs/guides/hosted-checkout/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## How it works Your server creates a [checkout session](https://railbed.io/docs/api/checkout-sessions.md) for the order and redirects the buyer to its `url`. Railbed's page asks for the buyer's email, shows the card providers that can take this payment in their country (the recommended one first), and opens the chosen provider in a new tab. The checkout tab waits there while the buyer pays, then sends them to your `success_url`. 1. **Order placed.** Your server saves the order, then calls `POST /v1/checkout_sessions`. 2. **Redirect.** Your server sends the buyer to the session's `url`. 3. **Payment.** The buyer pays on the provider's page, in its own tab. 4. **Return.** The Railbed tab sees the payment confirmed and sends the buyer to your `success_url`. 5. **Fulfilment.** Your server fulfils the order from the `payment.paid` webhook or `GET /v1/payments/:id`, never from the return alone. ## Create the session Create one session per order attempt, with an `Idempotency-Key` saved alongside the order before you call. A retry after a timeout then returns the same session instead of creating a second one. Node.js: ```js // POST /checkout on your server, after saving the order app.post('/checkout', async (req, res) => { const order = await orders.create({ userId: req.user.id, sku: 'pro-monthly', price: '49.00', currency: 'USD', }); const response = 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_${order.id}`, }, body: JSON.stringify({ amount: order.price, currency: order.currency, description: 'Pro Membership · monthly', reference: `order_${order.id}`, customer_email: req.user.email, metadata: { user_id: String(req.user.id) }, success_url: `https://yourstore.com/orders/${order.id}/thanks?payment={PAYMENT_ID}`, cancel_url: `https://yourstore.com/cart`, }), }); if (!response.ok) { return res.status(502).send( 'Checkout is unavailable. Try again in a moment.', ); } const session = await response.json(); await orders.update(order.id, { paymentId: session.id }); res.redirect(303, session.url); }); ``` Python: ```python # Flask: after saving the order @app.post("/checkout") def checkout(): order = orders.create( user_id=current_user.id, sku="pro-monthly", price="49.00", currency="USD", ) r = requests.post( "https://pay.railbed.io/v1/checkout_sessions", headers={ "Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}", "Idempotency-Key": f"order_{order.id}", }, json={ "amount": order.price, "currency": order.currency, "description": "Pro Membership · monthly", "reference": f"order_{order.id}", "customer_email": current_user.email, "metadata": {"user_id": str(current_user.id)}, "success_url": ( f"https://yourstore.com/orders/{order.id}" f"/thanks?payment={{PAYMENT_ID}}" ), "cancel_url": "https://yourstore.com/cart", }, timeout=15, ) if not r.ok: return "Checkout is unavailable. Try again in a moment.", 502 session = r.json() orders.update(order.id, payment_id=session["id"]) return redirect(session["url"], code=303) ``` PHP: ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', 'Idempotency-Key: order_' . $order['id'], ], CURLOPT_POSTFIELDS => json_encode([ 'amount' => $order['price'], 'currency' => $order['currency'], 'description' => 'Pro Membership · monthly', 'reference' => 'order_' . $order['id'], 'customer_email' => $user['email'], 'metadata' => ['user_id' => (string) $user['id']], 'success_url' => 'https://yourstore.com/orders/' . $order['id'] . '/thanks?payment={PAYMENT_ID}', 'cancel_url' => 'https://yourstore.com/cart', ]), ]); $session = json_decode(curl_exec($ch), true); if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 300) { http_response_code(502); exit('Checkout is unavailable.'); } save_payment_id($order['id'], $session['id']); header('Location: ' . $session['url'], true, 303); ``` > [!NOTE] > In Python f-strings, `{{PAYMENT_ID}}` writes the literal `{PAYMENT_ID}` placeholder. Railbed fills it in, not your code. ## Return URLs | Field | When the buyer sees it | |---|---| | `success_url` | Once the payment is confirmed, the Railbed checkout tab sends the buyer here. `{PAYMENT_ID}` and `{REFERENCE}` in the address are replaced with the payment's id and your reference | | `cancel_url` | A link back to your store, named after your business, while the buyer is paying and after a decline or an expiry | Both are optional, must be full `https://` addresses in Live mode (Test also accepts `http://`), up to 1,000 characters, and can't contain a username or password. Without a `success_url`, the buyer sees Railbed's own confirmation. > [!IMPORTANT] > A buyer can open a `success_url` without paying, and a buyer who pays can close the tab before it loads. Treat the return page as a status screen: show "Payment confirmed" only after your server has seen the payment as `paid`, and fulfil from your server. See [Fulfil orders safely](https://railbed.io/docs/guides/fulfilment.md). ## What the buyer sees - **The order.** Your business name and logo, the description and the price, in the currency you set. - **Their email.** Prefilled when you send `customer_email`. Providers use it for their receipt. - **Card providers.** Only those that serve the buyer's country, take the currency and accept the amount, the recommended one first. The buyer pays on the provider's page. - **Waiting.** The checkout tab says to finish paying in the provider's tab and updates by itself. If the browser blocks the new tab, the provider opens in the same tab instead. - **The result.** Paid, being reviewed (held), declined or expired, each with a clear next step. A session can be paid for 24 hours. After that it's `expired`; create a new one if the buyer comes back. ## When there's no provider Providers have minimum amounts and regional limits. When none can take a payment, the checkout says so and asks the buyer to try later. You can check in advance by starting the session through the API: `POST /v1/checkout_sessions/:id/start` answers `409 no_providers`. Orders of a few dollars are the most likely to hit provider minimums. --- # 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 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

    Choose how to pay.

    ``` 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 | --- # Fulfil orders safely > Deliver each order exactly once, only for money that really arrived. The checks that keep fulfilment correct through retries, duplicate events, late payments and reviews. Source: https://railbed.io/docs/guides/fulfilment/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## The rule Fulfil an order only when your server has seen its payment as `paid` from Railbed itself: a webhook whose signature you verified, or an authenticated `GET /v1/payments/:id`. Never fulfil from the buyer's return to your `success_url`, a query parameter, or anything the buyer's browser reports. ## Save before you create Before calling Railbed, save the order with everything that decides what the buyer gets: the user, the items, the price, the currency and the `Idempotency-Key` you'll send. After the create call, save the returned `pay_…` id on the order. If the call times out, retry it with the same key: you get the same session back, never a second payment. ## Check before you grant When a payment reports `paid`, look up your order by the payment's `reference` (or its id) and check, in this order: 1. **It's the right payment.** The payment id matches the one saved on the order, and the mode matches (`livemode` in the API, `livemode` on the event). 2. **It's for this order.** `reference`, and any `metadata` you set, match the order. 3. **It's the price you asked.** `amount` and `currency` match the order. They can't change after creation, so a mismatch means you're looking at the wrong payment. 4. **It's paid.** `status` is `paid`. Not `held`, not `pending`. 5. **It isn't done already.** Record the grant with a unique constraint on the payment id (or the order), in the same database transaction as the delivery itself. A second event for the same payment then changes nothing. Resolve the customer from your own order, never from the payment's `customer_email`: buyers can type any email at checkout. Node.js: one grant per payment, in one transaction: ```js async function fulfilFromPayment(payment) { // Held, pending, expired: nothing to deliver yet if (payment.status !== 'paid') return; const order = await db.orders.findByPaymentId(payment.id); // Not ours (another store sharing the endpoint), or not saved yet if (!order) return; if (payment.livemode !== order.livemode) { throw new Error('mode mismatch'); } if (payment.reference !== order.reference) { throw new Error('reference mismatch'); } if (payment.amount !== order.price || payment.currency !== order.currency) { throw new Error('amount mismatch'); } await db.transaction(async (tx) => { // UNIQUE(payment_id): a duplicate event or a second worker fails here // and delivers nothing const inserted = await tx.grants.insertIfAbsent({ paymentId: payment.id, orderId: order.id, }); if (!inserted) return; await tx.inventory.deliver(order); await tx.orders.markPaid(order.id, payment.paid_at); }); } ``` > [!NOTE] > The webhook payload uses camelCase names (`reference`, `amount`, `currency`, `mode`, `paidAt` in milliseconds), while the API uses snake_case (`livemode`, `paid_at` in seconds). Many integrations read the webhook only as a signal and then fetch `GET /v1/payments/:id`, so all their checks run on one shape. ## Webhooks, the API, or both | Signal | Strengths | Watch out for | |---|---|---| | `payment.paid` webhook | Arrives the moment the payment settles; retried for about a day | Your endpoint must be reachable, answer 2xx quickly and verify signatures | | `GET /v1/payments/:id` | Always current; no public endpoint needed | You decide when to ask: poll gently, with backoff | The most robust integrations use both: fulfil on the webhook, and run a background job that checks orders still waiting. The job catches anything a webhook missed (your server was down for a day, a deploy dropped a request) without anyone watching. ## Check unfinished orders in the background Run a job every few minutes that checks each order still waiting on a payment, gently: - Check recent orders often and older ones less often (for example after 1, 5, 15 and 60 minutes, then hourly). - Include orders whose payment `expired` in the last two days. Money can arrive after the 24-hour window, and the payment then becomes `paid` or `held`. - Stop checking a payment once it's `paid`, `failed`, or has been `expired` for two days. - Respect the shared rate limit: 120 requests a minute per account. On a `429`, wait for `Retry-After` seconds. - Survive restarts: keep the queue in your database, not in memory. ## Held payments `held` means money arrived but didn't pass a check, most often because the provider delivered less than 90% of the order's value. Don't fulfil. You can review the payment in the dashboard and **Accept as paid** when the shortfall is fine with you; the payment then becomes `paid` and `payment.paid` is sent, so your normal fulfilment path handles it. Payments held because the money may have gone somewhere else can't be accepted; contact support. Listen for `payment.held` if you want to tell the buyer their payment is being reviewed. ## Late payments A buyer can open a provider's page, leave, and come back to finish after the session's 24 hours. The payment was `expired`, and then becomes `paid` (or `held`). If your system cancelled the order on `payment.expired`, decide what a late payment means for you: fulfil it, or refund it from your wallet. Either way, don't ignore a `payment.paid` because the order was once marked abandoned. ## Refunds Railbed can't reverse a settled payment; the money is already in your wallet. To refund, send USDC from your wallet to the buyer (ask them for an address), or refund in another way you both agree. Card disputes are handled by the provider that charged the card. --- # Payment links and buy buttons > Take payments without writing server code. Create payment links, checkout pages and pricing tables in the dashboard, and add a buy button or an embedded checkout to any website with two lines of HTML. Source: https://railbed.io/docs/guides/no-code/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## What you can make in the dashboard Everything here is created under **Create** in the dashboard, in Test or Live mode. Each one produces a Railbed-hosted page, and every payment appears in Payments and sends your webhooks, exactly like an API payment. | Kind | What it is | Address | |---|---|---| | Payment link | One payment for one payer: an invoice or a deposit, with an optional note, reference and expiry (1–30 days). Comes with a read-only tracking link you can share | `https://pay.railbed.io/p/pay_…` | | Checkout page | One product at one price that any number of buyers can pay | `https://pay.railbed.io/c/your-slug` | | Pricing table | Plans side by side, with an optional monthly/yearly switch. The buyer picks a plan, then pays | `https://pay.railbed.io/c/your-slug` | | Payment widget | The checkout itself in a compact card, made to sit inside your page | `https://pay.railbed.io/c/your-slug` | Every checkout has a success URL (where buyers go once paid, with `{PAYMENT_ID}` filled in; checkout payments carry no reference, so `{REFERENCE}` is empty) and a light or dark theme. Checkout pages and widgets also take the button's text. ## Add a buy button to any site Paste the script once, anywhere in the page, then place a button wherever you want one. `checkout` is the slug at the end of the checkout's address. A buy button that opens the checkout over your page: ```html ``` The button takes your brand colour, and screen readers hear the product and its price. Two attributes change it: | Attribute | Values | Default | |---|---|---| | `checkout` | The checkout's slug | required | | `label` | The button text, for example `Get the notes` | "Buy now" ("See plans" for a pricing table) | | `shape` | `pill` or `rounded` | `pill` | If the checkout is deleted or can't take payments, the button reads "Checkout unavailable" and is disabled. Clicking opens the checkout in a dialog over your page. The buyer pays on the provider's page in a new tab; once the payment is confirmed, your page goes to the checkout's success URL. If the buyer tries to close the dialog while paying, it asks first. ## Put the checkout in your page For a payment widget or a pricing table, place the checkout itself in your layout: The checkout, in your page: ```html ``` It sizes itself to its content and keeps its theme. Card providers never open inside the frame: they open in a new tab or, if the browser blocks that, in place of your page. ## Good to know - The script has no dependencies, sets no cookies and stores nothing in the buyer's browser. It's plain JavaScript that works in current browsers; older ones show nothing rather than a broken button. - Your page needs no Railbed key: the button only knows the public checkout slug. - Only `/c/` and `/p/` pages can be embedded; the dashboard and tracking pages can't be framed. - Delete a checkout in the dashboard to stop new payments. Past payments stay. - Fulfil from webhooks, as with every integration: payments from buttons carry the checkout's id (`checkoutId`) and, for pricing tables, the chosen plan (`planLabel`). --- # WooCommerce > Add Railbed card checkout to a WordPress store with the Railbed for WooCommerce plugin. Orders complete from verified payments, with no code. Source: https://railbed.io/docs/guides/woocommerce/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## What the plugin does Railbed for WooCommerce adds Railbed as a payment method at your store's checkout. When a buyer places an order, the plugin creates a checkout session and sends the buyer to Railbed's hosted page. The order completes only after the plugin has verified the payment with Railbed: a signed webhook starts the check, and the plugin confirms it with an authenticated status request before marking the order paid. Card details never touch WordPress. **Requirements:** WordPress 6.9 or later, WooCommerce 10.9 or later, PHP 8.3 or later. Works with classic checkout and Checkout Blocks, and with both order storage modes. Order currencies USD, EUR, GBP, CAD and AUD; amounts from 1.00 to 100,000.00. ## Install and connect 1. Download the plugin from **Integrations** in the [dashboard](https://app.railbed.io/integrations). 2. In WordPress, open **Plugins → Add new → Upload plugin**, upload the ZIP and activate it alongside WooCommerce. 3. Open **WooCommerce → Settings → Payments → Railbed**. Start in **Test** mode. 4. In Railbed, switch to **Test**, open [Developers](https://app.railbed.io/developers) and **Create key**. Then **Add endpoint** with the webhook address shown in the plugin's settings (it ends in `?wc-api=railbed_webhook`). 5. Paste the secret key and the endpoint's signing secret into the plugin's Test fields and save. Saving checks the key. 6. In Railbed, choose **Send test event** on the endpoint, then reload the plugin settings and tick **Enable** (Offer Railbed at checkout). When the settings show **Connection verified** and Enable is ticked, the payment method appears at checkout. 7. Place a test order and pay it with **Simulate successful payment**. The order becomes Processing (or Completed when every item is virtual and downloadable), as with any paid order. ## Going live Add your payout wallet in Railbed, switch the plugin's **Payment mode** to Live, and repeat steps 4 to 6 with a live key and a live endpoint (in Railbed, a Live endpoint takes **Send ping** instead of test events). The payment method is offered only on an HTTPS store, in Test mode too (unless WordPress's environment type is `local`). Orders keep the mode they were placed in, so switching the plugin to Live doesn't affect test orders in progress. ## Store settings that matter - Your store must accept a public POST to its webhook address without a login, cache or bot challenge, and keep the `Railbed-Signature` header and the request body unchanged. Security plugins and CDN rules sometimes block it; allow the address. - Run WordPress cron regularly (a real cron job is best). The plugin uses it to re-check orders whose webhook was missed. - If you change the webhook address, send another test event before new checkouts are offered. - When you roll the signing secret in Railbed, paste the new one into the plugin straight away. From the roll on, deliveries are signed with the new secret and the plugin rejects them until it has it; they're retried for about a day, and the plugin's status checks keep orders moving meanwhile. After saving the new secret, send another test event: until a delivery signed with it arrives, the payment method isn't offered at checkout. ## Moving from another gateway Install Railbed as a separate payment method. Keep your old gateway active until its outstanding orders finish, then disable it for new purchases. Orders aren't moved between gateways. --- # API reference > The Railbed REST API. JSON over HTTPS, one secret key per server, idempotent creates, cursor pagination and plain error codes. Source: https://railbed.io/docs/api/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## Base URL Base URL: ```text https://pay.railbed.io/v1 ``` Every request uses HTTPS. The API has two resources: [checkout sessions](https://railbed.io/docs/api/checkout-sessions.md), which you create for each order, and [payments](https://railbed.io/docs/api/payments.md), which you read to fulfil them. A session and its payment share one id (`pay_…`). | Endpoint | What it does | |---|---| | `POST /v1/checkout_sessions` | [Create a checkout session](https://railbed.io/docs/api/checkout-sessions.md#create-a-checkout-session) | | `GET /v1/checkout_sessions/:id` | [Retrieve a checkout session](https://railbed.io/docs/api/checkout-sessions.md#retrieve-a-checkout-session) | | `POST /v1/checkout_sessions/:id/start` | [Start a checkout session](https://railbed.io/docs/api/checkout-sessions.md#start-a-checkout-session) and get its card providers | | `GET /v1/payments/:id` | [Retrieve a payment](https://railbed.io/docs/api/payments.md#retrieve-a-payment) | | `GET /v1/payments` | [List payments](https://railbed.io/docs/api/payments.md#list-payments) | | `POST /v1/payments/:id/simulate` | [Simulate a payment](https://railbed.io/docs/api/payments.md#simulate-a-payment) (Test mode) | ## Authentication Send your secret key as a bearer token on every request. Keys are created and revoked in [Developers](https://app.railbed.io/developers), and each is shown once, when it's created. An authenticated request: ```bash curl "https://pay.railbed.io/v1/payments?limit=1" \ -H "Authorization: Bearer rb_test_…" ``` The key decides everything about the request: which account it belongs to and whether it works on Test or Live payments. `rb_test_…` keys see only Test data and `rb_live_…` keys only Live data; nothing else can switch the mode. A missing, malformed or revoked key gets `401 invalid_api_key`; a suspended account gets `403 suspended`. > [!IMPORTANT] > Secret keys belong on your server. The API doesn't accept requests from browsers (it sends no CORS headers), and there's no publishable key: your web pages and apps call your server, and your server calls Railbed. ## Requests and responses - Send JSON bodies with `Content-Type: application/json`, up to 64 KiB. Other types get `415`, larger bodies `413`, and bodies that aren't a JSON object `400 invalid_json`. - Responses are JSON and are never cached (`Cache-Control: no-store`). - Unknown fields in a request are ignored. Build against the fields documented here. - **Money** is a decimal string, never a number, so no amount is ever rounded in transit. Prices have two places (`"49.00"`); settlement amounts in the coin can have more (`merchant_received` has six). - **Currencies** are `USD`, `EUR`, `GBP`, `CAD` and `AUD`. - **Timestamps** in the API are Unix seconds. (A webhook event's `created` is Unix seconds too, but the payment inside it uses milliseconds; see [event payloads](https://railbed.io/docs/webhooks/events.md#the-payment-object).) - **Ids** are prefixed: `pay_` for sessions and payments, `evt_` for webhook events. ## Idempotency Networks fail. To retry a create safely, send an `Idempotency-Key` header: a value unique to the order attempt, 1–120 printable ASCII characters. Save it with your order before the first call. | Situation | Response | |---|---| | First request with a key | `201` and the new session | | Same key, same body (a retry) | `200` and the same session, with its current status | | Same key, different body | `409 idempotency_conflict`. Use a new key for a different order | | Same key and body while the first request is still running | The same session: one request gets `201`, the others `200`. Rarely, `409 idempotency_conflict` asks you to retry in a moment | | Key longer than 120 characters, blank, or with other characters | `400 invalid_idempotency_key` (never truncated) | Keys are scoped to your account and the key's mode, and remembered for 24 hours; after that the same key creates a new session. Metadata key order doesn't matter when comparing bodies. Without the header, every create makes a new session, and your `reference` alone doesn't prevent duplicates. ## Pagination `GET /v1/payments` returns the newest payments first, up to `limit` (1–100, default 20) at a time: A page · response 200 OK: ```json { "data": [{ "id": "pay_…", "object": "payment", "status": "paid" }], "has_more": true, "next_cursor": "pay_Q3cNtwPT0bRqGmS5eZkB" } ``` Pass `next_cursor` as `starting_after` to get the next page, until `next_cursor` is `null`. The order is stable, even for payments created in the same second. A `limit` outside 1–100, or a cursor that isn't one of your payments in this mode, gets `400` rather than a silent first page. ## Rate limits Each account can make about **120 requests a minute** across all its keys and modes, and **12 session starts a minute**. Over the limit, requests get `429 rate_limited` with a `Retry-After: 60` header. Queue requests on your server and back off with a little randomness; never make a request per animation frame or per page view. The limits protect the service rather than set a quota: they're enforced per region and are approximate, so plan well below them. ## Errors Errors use HTTP status codes and a JSON body with a stable `code`, a sentence for people, and sometimes the `field` at fault: An error · response 400 Bad Request: ```json { "error": { "code": "invalid_amount", "message": "amount must be a decimal string between \"1.00\" and \"100000.00\".", "field": "amount" } } ``` Branch on `code` and the status, never on `message`, which may be reworded. [Errors](https://railbed.io/docs/api/errors.md) lists every code. ## Versioning The API is `v1`. Changes within `v1` only add things: new fields in responses, new optional request fields, new error codes, new webhook event types. Write your integration to ignore fields and event types it doesn't know. A change that could break an integration would come as a new version. --- # 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. Source: https://railbed.io/docs/api/checkout-sessions/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## The checkout session object | Field | Type | Description | |---|---|---| | `id` | string | The session's id, `pay_…`. The same id identifies its [payment](https://railbed.io/docs/api/payments.md) | | `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](https://railbed.io/docs/how-it-works.md#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 | ## 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`](https://railbed.io/docs/api.md#idempotency) 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 | 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: ```bash 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" }' ``` Node.js: ```js 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(); ``` Python: ```python 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: ```php 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); ``` Response · response 201 Created: ```json { "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" } } ``` A retry with the same `Idempotency-Key` and body answers `200 OK` with the same session. ## 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: ```bash curl https://pay.railbed.io/v1/checkout_sessions/pay_7AAiYH0Ykt11ED4hmfiN \ -H "Authorization: Bearer $RAILBED_SECRET_KEY" ``` Node.js: ```js const session = await fetch(`https://pay.railbed.io/v1/checkout_sessions/${id}`, { headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` }, }).then((r) => r.json()); ``` Python: ```python 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: ```php true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), ], ]); $session = json_decode(curl_exec($ch), true); ``` The response is the [checkout session object](#the-checkout-session-object). To fulfil, read the [payment](https://railbed.io/docs/api/payments.md#retrieve-a-payment) instead: it adds the payment and settlement details. ## Start a checkout session `POST /v1/checkout_sessions/:id/start` For [your own checkout](https://railbed.io/docs/guides/custom-checkout.md): 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: ```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 '{ "country": "US" }' ``` Node.js: ```js 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()); ``` Python: ```python 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: ```php 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); ``` Response · response 200 OK: ```json { "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" }, "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 | --- # Payments > A payment is what a checkout session became. Read it to fulfil orders, list payments to reconcile, and simulate outcomes in Test mode. Source: https://railbed.io/docs/api/payments/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## The payment object A payment has every field of its [checkout session](https://railbed.io/docs/api/checkout-sessions.md#the-checkout-session-object), with `object` set to `"payment"`, plus: | Field | Type | Description | |---|---|---| | `paid_at` | integer or null | When it became `paid`, in Unix seconds | | `provider` | string or null | The provider the buyer chose, such as `stripe` or `paypal` | | `hold_reason` | string or null | Why it's `held`, in plain words. Null otherwise | | `hold_acceptable` | boolean | For a `held` payment: whether you can accept it as paid in the dashboard | | `canceled_at` | integer or null | When you canceled it (payment links only). A canceled payment's status is `expired` | | `settlement` | object | What arrived and where it went. See below | The `settlement` object: | Field | Type | Description | |---|---|---| | `coin` | string or null | What was delivered, usually `polygon_usdc` (`polygon_usdt` is also possible) | | `value_coin` | string or null | How much arrived at the payment's deposit address, before fees | | `merchant_received` | string or null | How much was forwarded to your wallet, in the coin (six decimal places). Can be null for a while after `paid` and fill in later. Stays null for a held payment you accepted as paid | | `txid_in` | string or null | The Polygon transaction that delivered the money | | `txid_out` | string or null | The Polygon transaction that forwarded it to your wallet | | `payout_wallet` | string or null | The wallet it went to, fixed when the buyer started | `amount` and `currency` are always the price you set; `value_coin` and `merchant_received` are what actually moved. Look up both transactions on a Polygon block explorer to see them for yourself. ## Retrieve a payment `GET /v1/payments/:id` Returns the current state of a payment of yours in the key's mode. This is the authority for fulfilment: when it says `paid`, the money arrived and passed Railbed's [settlement checks](https://railbed.io/docs/webhooks/events.md#payment-paid). cURL: ```bash curl https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN \ -H "Authorization: Bearer $RAILBED_SECRET_KEY" ``` Node.js: ```js const payment = await fetch(`https://pay.railbed.io/v1/payments/${id}`, { headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` }, }).then((r) => r.json()); ``` Python: ```python payment = requests.get( f"https://pay.railbed.io/v1/payments/{id}", headers={"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}"}, timeout=15, ).json() ``` PHP: ```php true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), ], ]); $payment = json_decode(curl_exec($ch), true); ``` Response · response 200 OK: ```json { "id": "pay_7AAiYH0Ykt11ED4hmfiN", "object": "payment", "url": "https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN", "status": "paid", "livemode": true, "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" }, "paid_at": 1790381342, "provider": "stripe", "hold_reason": null, "hold_acceptable": false, "canceled_at": null, "settlement": { "coin": "polygon_usdc", "value_coin": "47.53", "merchant_received": "46.341750", "txid_in": "0x0c651ba1d59c7a32e8b1f4bd2c7e0e4f96a55d13a6b0f2d1c8e7a9b4f3d29b58", "txid_out": "0x8202d1373e0a9c4f1b6d5e2c7a8f9b0e1d2c3b4a5f6e7d8c9b0a1f2e3d7356ff", "payout_wallet": "0xF977814e90dA44bFA03b6295A0616a897441aceC" } } ``` A held payment looks like this in part: A held payment (trimmed) · response 200 OK: ```json { "id": "pay_Kx81mQv2PzR0dT7eWcYa", "object": "payment", "status": "held", "amount": "49.00", "currency": "USD", "paid_at": null, "hold_reason": "The provider sent 24.50 USDC, below 90% of the order’s 49.00 USD value.", "hold_acceptable": true, "settlement": { "coin": "polygon_usdc", "value_coin": "24.50", "merchant_received": null } } ``` Reading a payment past its `expires_at` marks an unpaid one `expired`. ## List payments `GET /v1/payments` Returns your payments in the key's mode, newest first. Use it to reconcile, not to find new payments quickly: it lists by creation time, so recheck unfinished payments you saved by id. | Parameter | Type | Description | |---|---|---| | `limit` | integer | 1–100. Default 20 | | `starting_after` | string | A payment id from the previous page's `next_cursor` | cURL: ```bash curl "https://pay.railbed.io/v1/payments?limit=50" \ -H "Authorization: Bearer $RAILBED_SECRET_KEY" ``` Node.js: ```js // Every payment, page by page let cursor = null; do { const url = new URL('https://pay.railbed.io/v1/payments'); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('starting_after', cursor); const page = await fetch(url, { headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` }, }).then((r) => r.json()); for (const payment of page.data) reconcile(payment); cursor = page.next_cursor; } while (cursor); ``` Python: ```python cursor = None while True: params = {"limit": 100, **({"starting_after": cursor} if cursor else {})} page = requests.get( "https://pay.railbed.io/v1/payments", headers={"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}"}, params=params, timeout=15, ).json() for payment in page["data"]: reconcile(payment) cursor = page["next_cursor"] if not cursor: break ``` PHP: ```php 100, 'starting_after' => $cursor, ])); $ch = curl_init('https://pay.railbed.io/v1/payments?' . $query); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), ], ]); $page = json_decode(curl_exec($ch), true); foreach ($page['data'] as $payment) reconcile($payment); $cursor = $page['next_cursor']; } while ($cursor); ``` Response · response 200 OK: ```json { "data": [ { "id": "pay_7AAiYH0Ykt11ED4hmfiN", "object": "payment", "status": "paid", "amount": "49.00", "currency": "USD" }, { "id": "pay_Kx81mQv2PzR0dT7eWcYa", "object": "payment", "status": "held", "amount": "49.00", "currency": "USD" } ], "has_more": true, "next_cursor": "pay_Kx81mQv2PzR0dT7eWcYa" } ``` Each item is a full [payment object](#the-payment-object) (trimmed here). An invalid `limit` is `400 invalid_limit`; a cursor that isn't one of your payments in this mode is `400 invalid_cursor`. ## Simulate a payment `POST /v1/payments/:id/simulate` **Test mode only.** Decides how a started test payment ends, as a buyer on the test provider page would. The outcome goes through the same settlement checks and sends the same webhooks as a live payment. | Field | Type | Description | |---|---|---| | `outcome` | string · required | `paid` (about 97% of the value arrives), `underpaid` (half arrives, so it's `held`) or `failed` (declined) | cURL: ```bash curl -X POST \ https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN/simulate \ -H "Authorization: Bearer $RAILBED_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "outcome": "paid" }' ``` Node.js: ```js const payment = await fetch(`https://pay.railbed.io/v1/payments/${id}/simulate`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ outcome: 'paid' }), }).then((r) => r.json()); ``` Python: ```python payment = requests.post( f"https://pay.railbed.io/v1/payments/{id}/simulate", headers={"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}"}, json={"outcome": "paid"}, timeout=15, ).json() ``` PHP: ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['outcome' => 'paid']), ]); $payment = json_decode(curl_exec($ch), true); ``` The response is the [payment](#the-payment-object) after the outcome. A payment can end only once: simulating a finished payment returns it as it is. Use a new session for each scenario. | Status | Code | When | |---|---|---| | 400 | `invalid_outcome` | `outcome` is missing or not one of the three | | 403 | `live_payment` | The payment is a Live payment | | 409 | `not_started` | Start the session first, with [Start a checkout session](https://railbed.io/docs/api/checkout-sessions.md#start-a-checkout-session) | --- # Errors > Every error the Railbed API returns, with its HTTP status, what caused it and what to do next. Source: https://railbed.io/docs/api/errors/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## The error object Every error has an HTTP status of 400 or above and the same JSON body: An error · response 409 Conflict: ```json { "error": { "code": "no_payout_wallet", "message": "Add a payout wallet in the dashboard before taking live payments." } } ``` | Field | Type | Description | |---|---|---| | `code` | string | Stable and machine-readable. Branch on this | | `message` | string | A sentence for people. It can be reworded at any time, so show it or log it, but never parse it | | `field` | string | The request field at fault, when there is one: `amount`, `metadata`, `starting_after` | ## Handling errors Decide by status first, then by `code` where it matters: | Status | Meaning | What your code should do | |---|---|---| | `400` | The request was invalid | Fix the request. Retrying it unchanged fails the same way | | `401` | The key is missing, malformed or revoked | Check the key and its mode. Don't retry | | `403` | Not allowed for this account or key | Don't retry | | `404` | Not found in this account and mode | Check the id and whether the key's mode matches the object's | | `409` | The object's state doesn't allow this | Read the object and act on its current status | | `410` | The session can't be paid any more | Create a new session | | `413`, `415` | The body is too large or isn't JSON | Fix the request | | `429` | Rate limited | Wait for `Retry-After` seconds, then retry | | `500`, `502`, `503` | Something failed on our side or upstream | Retry with backoff. Creates are safe to retry with the same `Idempotency-Key` | A small error handler: ```js async function railbed(path, init = {}) { const res = await fetch(`https://pay.railbed.io/v1${path}`, { ...init, headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`, 'Content-Type': 'application/json', ...init.headers, }, }); const body = await res.json(); if (res.ok) return body; const err = Object.assign(new Error(body.error.message), { status: res.status, code: body.error.code, field: body.error.field, }); err.retryable = res.status === 429 || res.status >= 500; err.retryAfter = Number(res.headers.get('Retry-After')) || null; throw err; } ``` ## Every code ### 400 Bad Request | Code | Endpoint | Cause | |---|---|---| | `invalid_json` | Any with a body | The body isn't a JSON object | | `invalid_amount` | Create | `amount` isn't a string, or isn't from `"1.00"` to `"100000.00"` with at most two decimal places | | `invalid_currency` | Create | `currency` isn't `USD`, `EUR`, `GBP`, `CAD` or `AUD` | | `missing_description` | Create | `description` is missing, blank or not a string | | `too_long` | Create | `description` or `reference` is over 120 characters. See `field` | | `invalid_reference` | Create | `reference` isn't a string | | `invalid_email` | Create, Start | `customer_email` isn't a valid address, or Start needs one and none is on the session | | `invalid_url` | Create | `success_url` or `cancel_url` isn't a full `http(s)://` address, has a username or password, is over 1,000 characters, or isn't `https://` in Live mode | | `invalid_metadata` | Create | Over 20 keys, a blank key, a key over 40 characters or starting with `__`, or a value that isn't a string of at most 500 characters | | `invalid_idempotency_key` | Create | The `Idempotency-Key` header is blank, over 120 characters or has characters other than printable ASCII | | `invalid_country` | Start | `country` isn't a two-letter code such as `US` | | `invalid_limit` | List payments | `limit` isn't a whole number from 1 to 100 | | `invalid_cursor` | List payments | `starting_after` isn't one of your payments in this mode | | `invalid_outcome` | Simulate | `outcome` isn't `paid`, `underpaid` or `failed` | ### 401 Unauthorized | Code | Cause | |---|---| | `invalid_api_key` | No `Authorization: Bearer` header, a malformed key, or a revoked one. Create a key in [Developers](https://app.railbed.io/developers) | ### 403 Forbidden | Code | Cause | |---|---| | `suspended` | The account is suspended. Contact support | | `live_payment` | Simulate was called on a Live payment. Only Test payments can be simulated | ### 404 Not Found | Code | Cause | |---|---| | `not_found` | No such session or payment in your account in this key's mode, or no such endpoint. A Test key can't see Live objects and the other way round | ### 409 Conflict | Code | Endpoint | Cause | |---|---|---| | `idempotency_conflict` | Create | The key was used with a different body. Rarely, the first request with the key hadn't finished saving: retry in a moment | | `no_payout_wallet` | Create | Live mode needs a payout wallet. Add one in [Settings](https://app.railbed.io/settings) | | `unavailable` | Start | The account can't take payments now | | `no_providers` | Start | No card provider can take this amount and currency right now. Nothing was started; try later or a different amount | | `already_paid` | Start | The payment is complete | | `held` | Start | The money arrived and the payment is held for review | | `failed` | Start | The payment was declined. Create a new session | | `not_started` | Simulate | Start the session before simulating | ### 410 Gone | Code | Cause | |---|---| | `expired` | The session passed its `expires_at` without being paid | | `canceled` | The payment link was canceled in the dashboard | ### Size, type and rate | Status | Code | Cause | |---|---|---| | 413 | `request_too_large` | The body is over 64 KiB | | 415 | `unsupported_media_type` | The body isn't sent as `Content-Type: application/json` | | 429 | `rate_limited` | Over about 120 requests a minute, or 12 starts a minute. Wait for `Retry-After` | ### 5xx | Status | Code | Cause | |---|---|---| | 500 | `internal` | Something failed on our side. Retry with backoff; contact support if it continues | | 502 | `network_unavailable` | The card network didn't answer while starting. Retry in a minute | | 503 | `misconfigured` | Card payments are briefly unavailable. Retry later | > [!NOTE] > New codes can be added within `v1`. Treat an unknown `code` by its status: an unknown `4xx` is a request to fix, an unknown `5xx` a reason to retry. --- # Webhooks > Railbed sends a signed HTTPS request to your server when a payment starts, settles, is held, expires or is canceled. How endpoints, deliveries, retries and the delivery log work. Source: https://railbed.io/docs/webhooks/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## How webhooks work When something happens to a payment, Railbed sends a `POST` with a JSON [event](https://railbed.io/docs/webhooks/events.md) to each of your endpoints that subscribes to it. The request is signed with the endpoint's secret, so your server can prove it came from Railbed and wasn't changed. One delivery: ```text POST https://yourstore.com/webhooks/railbed Content-Type: application/json User-Agent: Railbed-Webhooks/1.0 Railbed-Signature: t=1790381342,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd {"id":"evt_4Qm8ZsUe2VhNc7RwTb1Y","type":"payment.paid","created":1790381342,"livemode":true,"data":{"payment":{…}}} ``` Your server verifies the signature, records the event, answers `2xx` and does the slow work afterwards. Events are queued in the same step as the payment change itself, so a payment can't become paid without its webhook being queued. Most are sent as soon as the change is saved; expiries go out within five minutes. | Event | Sent when | |---|---| | [`payment.started`](https://railbed.io/docs/webhooks/events.md#payment-started) | The buyer entered their email and was given a way to pay | | [`payment.paid`](https://railbed.io/docs/webhooks/events.md#payment-paid) | The money arrived and passed Railbed's checks. **Fulfil on this** | | [`payment.held`](https://railbed.io/docs/webhooks/events.md#payment-held) | Money arrived but failed a check, so it waits for your review | | [`payment.updated`](https://railbed.io/docs/webhooks/events.md#payment-updated) | A paid payment's settlement details were filled in | | [`payment.failed`](https://railbed.io/docs/webhooks/events.md#payment-failed) | The payment was declined (Test mode) | | [`payment.expired`](https://railbed.io/docs/webhooks/events.md#payment-expired) | Nobody paid in time | | [`payment.canceled`](https://railbed.io/docs/webhooks/events.md#payment-canceled) | You canceled a payment link | | [`ping`](https://railbed.io/docs/webhooks/events.md#ping) | You chose **Send test event** (or **Send ping** on a Live endpoint) | ## Add an endpoint 1. In the dashboard, choose **Test** or **Live**, then open [Developers](https://app.railbed.io/developers). 2. Choose **Add endpoint** and enter your server's address. Add a description if you like ("Fulfilment server"), and pick the events it should receive. It gets every event unless you choose. 3. Copy the **signing secret** (`whsec_…`) into your server's configuration, for example `RAILBED_WEBHOOK_SECRET`. You can reveal it again on the endpoint later. 4. Choose **Send test event** (on a Live endpoint, **Send ping**) and check how your server answered. Each mode has its own endpoints and secrets: Test endpoints receive only Test events, and Live endpoints only Live events. You can have up to 10 endpoints in each mode, each with a different address. **Address rules.** Endpoints use `https://` with no username or password in the address. (Test endpoints also accept `http://localhost`, but Railbed's servers can't reach your computer that way.) Live endpoints must be on the public internet: private networks, `localhost` and internal hostnames are refused, and addresses are checked again at each send. While you build on your own computer, [use a tunnel](https://railbed.io/docs/testing.md#receive-webhooks-on-your-own-computer). > [!NOTE] > Endpoints added before September 26, 2026 receive only `payment.paid` and `payment.failed` until you edit them and choose their events, so existing integrations see no new traffic unannounced. ## Respond to deliveries A delivery succeeds when your endpoint answers with any `2xx` status within **10 seconds**. The body of your answer is ignored. - **Answer fast.** Verify, record the event, answer `200`, then fulfil in a background job. A slow fulfilment that runs past 10 seconds counts as a failure and is retried, even if it later finishes. - **Redirects aren't followed.** A `3xx` is a failure. Save the final address as the endpoint. - **Reject what you can't verify** with a `4xx`, such as `400`. Deliveries that fail are retried, so a bad secret shows up in the log instead of losing events. - **Don't answer `2xx` before the event is saved.** A `2xx` tells Railbed to stop sending it. ## Retries A delivery that fails is retried **1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours** after each failed attempt: seven attempts over about 21 hours. After the last one it's marked failed and stays in the delivery log, where you can send it again. Every attempt carries the same event `id` and body, with a fresh signature and timestamp. When you edit the endpoint's address, pending retries go to the new one. When you roll its secret, later attempts are signed with the new secret. ## Duplicates and order Webhooks are delivered at least once. The same event can arrive more than once (a retry after a timeout, a resend from the log), and events for one payment can arrive in a different order than they happened, even in the same second. - **Deduplicate on the event `id`.** Store each id you process, with a unique constraint, in the same transaction as its effects. - **Make fulfilment idempotent too.** Grant each order once per payment id, whatever event or job arrives first. See [Fulfil orders safely](https://railbed.io/docs/guides/fulfilment.md). - **Don't undo a paid order on a later event.** A `payment.expired` that arrives after `payment.paid` doesn't make the payment unpaid. When in doubt, [read the payment](https://railbed.io/docs/api/payments.md#retrieve-a-payment): it's always current. ## The delivery log **Deliveries** on each endpoint lists its latest 50 deliveries: the event, when it was sent, how many attempts it took, the status code or error your server answered with, when the next retry is, and the exact body that was sent. Filter it to **Failed** or **Retrying** to see what needs attention. - **Retry now** sends a delivery that's still retrying straight away. If it fails again, its automatic retries carry on as before, so pressing it while your server is down never uses them up. - **Resend** sends a finished delivery again, once, with the same event id. - When the latest delivery to an endpoint failed or is retrying, its card says so and links to the log. Finished deliveries are kept for 30 days. A payment's timeline in the dashboard also shows when its webhooks were delivered or failed. ## Test events **Send test event** on a Test endpoint sends one signed event, straight away, and shows the body sent and how your server answered. On a Live endpoint the button is **Send ping**: it sends a ping and reports the answer. Test sends are never retried. - **Test endpoints** can receive a `ping` or a sample of any payment event. The sample carries a made-up payment (`metadata.sample` is `"true"`) that doesn't exist in your account or the API. - **Live endpoints** can receive a `ping` only, so a live system never receives a payment that didn't happen. To test the whole flow, create a Test session through the API and [simulate its outcome](https://railbed.io/docs/testing.md). That sends the real sequence of events for a real Test payment. ## Manage endpoints | Action | What happens | |---|---| | **Edit** | Change the address, the description or the events. Pending retries follow the new address | | **Roll secret** | A new secret takes effect at once; the old one signs nothing further, retries included. Update your server straight away, or deliveries fail verification until you do | | **Remove** | The endpoint stops receiving events, its pending retries stop, and its history is no longer listed | ## A receiver, end to end Node.js · Express: ```js import crypto from 'node:crypto'; import express from 'express'; const app = express(); // The raw body: verify exactly the bytes that were signed. const rawJson = express.raw({ type: 'application/json' }); app.post('/webhooks/railbed', rawJson, async (req, res) => { const raw = req.body.toString('utf8'); const secret = process.env.RAILBED_WEBHOOK_SECRET; const header = req.get('Railbed-Signature'); if (!verify(raw, header, secret)) return res.sendStatus(400); const event = JSON.parse(raw); const isNew = await db.events.insertIfAbsent(event.id); // UNIQUE(id) if (isNew && event.type === 'payment.paid') { await queue.add('fulfil', { paymentId: event.data.payment.id }); } res.sendStatus(200); }); function verify(raw, header, secret) { const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? ''); if (!m) return false; if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false; const expected = crypto .createHmac('sha256', secret) .update(`${m[1]}.${raw}`) .digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2])); } ``` Python · Flask: ```python import hashlib, hmac, os, re, time from flask import Flask, request app = Flask(__name__) @app.post("/webhooks/railbed") def railbed_webhook(): raw = request.get_data() # the raw bytes, before any JSON parsing header = request.headers.get("Railbed-Signature", "") if not verify(raw, header, os.environ["RAILBED_WEBHOOK_SECRET"]): return "", 400 event = request.get_json() is_new = db.events.insert_if_absent(event["id"]) if is_new and event["type"] == "payment.paid": queue.enqueue("fulfil", event["data"]["payment"]["id"]) return "", 200 def verify(raw: bytes, header: str, secret: str) -> bool: m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "") if not m: return False expected = hmac.new( secret.encode(), m[1].encode() + b"." + raw, hashlib.sha256 ).hexdigest() return ( abs(time.time() - int(m[1])) < 300 and hmac.compare_digest(expected, m[2]) ) ``` PHP: ```php Every webhook event Railbed sends, when it's sent, what the payment looks like at that moment, and the full payload reference. Source: https://railbed.io/docs/webhooks/events/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## The event object Every delivery's body is one event: payment.paid · response Delivered · 200: ```json { "id": "evt_4Qm8ZsUe2VhNc7RwTb1Y", "type": "payment.paid", "created": 1790381342, "livemode": true, "data": { "payment": { "id": "pay_7AAiYH0Ykt11ED4hmfiN", "mode": "live", "source": "api", "checkoutId": null, "planLabel": null, "description": "Pro Membership", "reference": "order_1042", "customerEmail": "buyer@example.com", "amount": "49.00", "currency": "USD", "status": "paid", "provider": "stripe", "providerName": "Stripe", "depositAddress": "0x5b0e8a3f2d1c4b7a9e6f0d3c2b1a4e7f8d9c0b1a", "payoutWallet": "0xF977814e90dA44bFA03b6295A0616a897441aceC", "feeBps": 150, "valueCoin": "47.53", "merchantReceived": "46.341750", "coin": "polygon_usdc", "holdReason": null, "holdAcceptable": false, "txidIn": "0x0c651ba1d59c7a32e8b1f4bd2c7e0e4f96a55d13a6b0f2d1c8e7a9b4f3d29b58", "txidOut": "0x8202d1373e0a9c4f1b6d5e2c7a8f9b0e1d2c3b4a5f6e7d8c9b0a1f2e3d7356ff", "metadata": { "user_id": "player_1042" }, "customerName": null, "memo": null, "trackingUrl": null, "canceledAt": null, "successUrl": "https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}", "createdAt": 1790380525000, "paidAt": 1790381342000, "expiresAt": 1790466925000, "url": "https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN" } } } ``` | Field | Type | Description | |---|---|---| | `id` | string | The event's id, `evt_…`. The same on every attempt and resend. **Deduplicate on this** | | `type` | string | One of the types below | | `created` | integer | When the event happened, in Unix **seconds** | | `livemode` | boolean | `true` for Live events, `false` for Test | | `data.payment` | object or null | The [payment](#the-payment-object) as it was when the event happened. `null` for `ping` | The payment in an event is a snapshot. Events can arrive late or out of order, so when you need the current state, [read the payment](https://railbed.io/docs/api/payments.md#retrieve-a-payment). ## Events ### payment.started The buyer entered their email and was given a way to pay: Railbed assigned the payment's deposit address and locked the fee and the order's USD value. The payment is still `open` (it becomes `pending` when the buyer reaches a provider), and `provider` is usually still `null`. Use it for abandoned-checkout follow-ups, or to show "awaiting payment" in your system. Never fulfil from it. payment.started (trimmed): ```json { "id": "evt_9sPq2XbLr5TtVn0KcWmE", "type": "payment.started", "created": 1790380611, "livemode": true, "data": { "payment": { "id": "pay_7AAiYH0Ykt11ED4hmfiN", "status": "open", "customerEmail": "buyer@example.com", "amount": "49.00", "currency": "USD", "provider": null, "depositAddress": "0x5b0e8a3f2d1c4b7a9e6f0d3c2b1a4e7f8d9c0b1a", "feeBps": 150 } } } ``` In Test mode, `depositAddress` is `null`: no real address is created. ### payment.paid The money arrived and passed Railbed's checks: at least 90% of the order's value in a dollar coin and, from the processor's signed notice, the right deposit address, your wallet in the payout and a transaction never used before. Or you [accepted a held payment](#payment-held) as paid. **This is the event to fulfil from.** - The full payload is the example in [The event object](#the-event-object). - `status` is `paid` and `paidAt` is set. - `valueCoin`, `coin` and `txidIn` say what arrived; `txidOut` and `merchantReceived` say what was forwarded to you. - If the processor's notice is late, Railbed's scheduled status check can confirm the payment on the coin and amount first. The notice's details are checked when it arrives and fill in `txidIn` and `merchantReceived` in a [`payment.updated`](#payment-updated); a notice that doesn't match is flagged on the payment's timeline in the dashboard. - For a held payment you accepted, `merchantReceived` stays `null` and no `payment.updated` follows. ### payment.held Money arrived but failed a check, most often because less arrived than 90% of the order's value. **Don't fulfil.** The payment waits for you in the dashboard. - `holdReason` says what failed, in plain words. - `holdAcceptable` says whether you can **Accept as paid** in the dashboard. It's `false` when the evidence shows the money went somewhere else, for example to a wallet that isn't yours. - If you accept it, a `payment.paid` follows for the same payment. payment.held (trimmed): ```json { "id": "evt_Hq3nW8ZkT1cVbR6sYp0M", "type": "payment.held", "created": 1790381342, "livemode": true, "data": { "payment": { "id": "pay_Kx81mQv2PzR0dT7eWcYa", "status": "held", "amount": "49.00", "currency": "USD", "provider": "stripe", "valueCoin": "24.50", "coin": "polygon_usdc", "holdReason": "The provider sent 24.50 USDC, below 90% of the order’s 49.00 USD value.", "holdAcceptable": true, "paidAt": null } } } ``` ### payment.updated A paid payment's settlement details arrived after it was confirmed: `txidIn`, `valueCoin`, `coin` and `merchantReceived` are now filled in. The status stays `paid`. Update your records; there's nothing to fulfil again. ### payment.failed The payment was declined. Today this is sent in **Test mode only**, when you simulate a decline: live card providers don't report declines, so a live payment the buyer never completes ends as [`payment.expired`](#payment-expired). Let the buyer try again with a new session. ### payment.expired Nobody paid before the payment's `expiresAt`: 24 hours for API sessions, or the lifetime chosen for a payment link. It's sent within five minutes of expiry. It's not always the end. A provider that delivers late still settles the payment, and a `payment.paid` or `payment.held` follows. Release reserved stock if you like, but keep the order able to complete. ### payment.canceled You canceled a payment link in the dashboard before anyone had gone to a provider. The payment's `status` is `expired` and `canceledAt` is set. ### ping Sent only when you choose **Send test event** and pick `ping` (Test endpoints) or **Send ping** (Live endpoints). `data.payment` is `null`. Use it to check the address, the secret and your signature code. ping: ```json { "id": "evt_T2rVx9KcQm4NwLb7Ez0P", "type": "ping", "created": 1790380800, "livemode": false, "data": { "payment": null } } ``` ## Typical sequences | What happened | Events, in order | |---|---| | A normal payment | `payment.started` → `payment.paid` (→ `payment.updated`) | | An underpayment you accept | `payment.started` → `payment.held` → `payment.paid` | | A buyer who never pays | `payment.started` → `payment.expired` | | A buyer who never started | `payment.expired` | | A late payment | `payment.started` → `payment.expired` → `payment.paid` or `payment.held` | | A canceled payment link | `payment.canceled` | | A declined test payment | `payment.started` → `payment.failed` | Events can arrive out of order, so handle each one on its own merits, and never undo a `paid` order because of a later event. ## The payment object Webhook payloads carry the payment in the dashboard's shape: camelCase names and timestamps in **milliseconds** (the event's own `created` is Unix seconds). The [API's payment](https://railbed.io/docs/api/payments.md#the-payment-object) has the same facts in snake_case with Unix seconds. | Field | Type | Description | |---|---|---| | `id` | string | The payment's id, `pay_…`. The same id the API uses | | `mode` | string | `live` or `test` | | `source` | string | `api` (the API), `checkout` (a checkout page, pricing table or widget) or `payment_link` | | `checkoutId` | string or null | The checkout it came from, `chk_…`, for `checkout` payments | | `planLabel` | string or null | For pricing tables: the plan and price the buyer chose, such as `Pro · Yearly` | | `description` | string | What the buyer is paying for | | `reference` | string or null | Your reference, as sent when creating the session | | `customerEmail` | string or null | The email the buyer entered. Buyers can type any address, so don't use it to identify an account | | `amount` | string | The price you set, as a decimal string | | `currency` | string | The price's currency | | `status` | string | `open`, `pending`, `paid`, `held`, `failed` or `expired`. See [statuses](https://railbed.io/docs/how-it-works.md#statuses) | | `provider` | string or null | The provider the buyer chose, such as `stripe` | | `providerName` | string or null | Its display name, such as `Stripe` | | `depositAddress` | string or null | The one-time Polygon address this payment is paid into. Set when a Live payment starts | | `payoutWallet` | string or null | Your wallet the payment is forwarded to, fixed when it starts | | `feeBps` | integer | The Railbed fee locked into this payment, in basis points of what arrives (150 = 1.5%) | | `valueCoin` | string or null | How much arrived at the deposit address, in `coin` | | `merchantReceived` | string or null | How much was forwarded to your wallet, in `coin`. `null` until it's reported; never estimated | | `coin` | string or null | What arrived, usually `polygon_usdc` | | `holdReason` | string or null | Why the payment is held, in plain words | | `holdAcceptable` | boolean | For a held payment: whether you can accept it as paid | | `txidIn` | string or null | The Polygon transaction that delivered the money | | `txidOut` | string or null | The Polygon transaction that forwarded it to you | | `metadata` | object or null | Your metadata, as sent when creating the session | | `customerName` | string or null | Payment links: who it's for | | `memo` | string or null | Payment links: the note to the payer | | `trackingUrl` | string or null | Payment links: the read-only tracking page | | `canceledAt` | integer or null | When you canceled the payment link, in ms | | `successUrl` | string or null | The success URL as you set it, with its `{PAYMENT_ID}` and `{REFERENCE}` placeholders left in (percent-encoded if they're in the path). The buyer's checkout fills them in | | `createdAt` | integer | When the payment was created, in ms | | `paidAt` | integer or null | When it became `paid`, in ms | | `expiresAt` | integer | When an unpaid payment expires, in ms | | `url` | string | The payment's hosted checkout page | > [!TIP] > Want one shape everywhere? Treat the webhook as a signal: verify it, then fetch `GET /v1/payments/:id` and run all your checks on the API's answer. --- # Verify signatures > Prove each webhook came from Railbed and wasn't changed. The signing scheme, verification code in six languages, how to get the raw body in common frameworks, and a test vector. Source: https://railbed.io/docs/webhooks/signatures/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## How deliveries are signed Every delivery carries a `Railbed-Signature` header: The header: ```text Railbed-Signature: t=1790381342,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` - `t` is when this attempt was signed, in Unix seconds. - `v1` is the hex HMAC-SHA256 of the string `{t}.{raw body}`, keyed with the endpoint's signing secret: the whole `whsec_…` value, as text. To verify a delivery: 1. Read the header and split out `t` and `v1`. Reject the request if the header is missing or doesn't match `t=,v1=<64 hex characters>`. 2. Reject it if `t` is more than five minutes from your clock. This stops someone replaying an old delivery they captured. 3. Compute HMAC-SHA256 over `t`, a full stop and the **raw request body**, with your secret as the key, and hex-encode it. 4. Compare your result with `v1` in constant time. Reject the request if they differ. Each attempt is signed afresh, so a retry has a new `t` and `v1` for the same body. ## Verify a delivery Each function returns `true` only for a genuine, recent delivery. Check yours against the [test vector](#test-vector) below. Node.js: ```js import crypto from 'node:crypto'; export function verifyRailbedSignature( rawBody, header, secret, toleranceSeconds = 300, ) { const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? ''); if (!match) return false; const [, timestamp, signature] = match; const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (age > toleranceSeconds) return false; const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); } ``` Web Crypto · Cloudflare Workers, Deno, Bun, Next.js route handlers: ```ts export async function verifyRailbedSignature( rawBody: string, header: string | null, secret: string, toleranceSeconds = 300, ) { const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? ''); if (!match) return false; const [, timestamp, signature] = match; const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (age > toleranceSeconds) return false; const enc = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'], ); const bytes = new Uint8Array( signature.match(/../g)!.map((h) => parseInt(h, 16)), ); const signedPayload = enc.encode(`${timestamp}.${rawBody}`); // subtle.verify compares in constant time return crypto.subtle.verify('HMAC', key, bytes, signedPayload); } ``` Python: ```python import hashlib, hmac, re, time def verify_railbed_signature( raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300, ) -> bool: match = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "") if not match: return False timestamp, signature = match.groups() if abs(time.time() - int(timestamp)) > tolerance_seconds: return False expected = hmac.new( secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature) ``` PHP: ```php $toleranceSeconds) return false; $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, $signature); } ``` Ruby: ```ruby require 'openssl' require 'rack/utils' # secure_compare; Rails and Sinatra already load it def verify_railbed_signature(raw_body, header, secret, tolerance_seconds = 300) match = /\At=(\d+),v1=([0-9a-f]{64})\z/.match(header.to_s) return false unless match timestamp, signature = match.captures return false if (Time.now.to_i - timestamp.to_i).abs > tolerance_seconds expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}") Rack::Utils.secure_compare(expected, signature) end ``` Go: ```go package railbed import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "regexp" "strconv" "time" ) var signatureHeader = regexp.MustCompile(`^t=(\d+),v1=([0-9a-f]{64})$`) func VerifySignature( rawBody []byte, header, secret string, tolerance time.Duration, ) bool { m := signatureHeader.FindStringSubmatch(header) if m == nil { return false } ts, err := strconv.ParseInt(m[1], 10, 64) if err != nil { return false } if age := time.Since(time.Unix(ts, 0)); age > tolerance || age < -tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(m[1] + ".")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(m[2])) } ``` ## Use the raw body The signature covers the exact bytes Railbed sent. If your framework parses the JSON and you serialize it again, spacing, key order or escaping can change and the signature won't match. Read the body as text or bytes, verify it, then parse it. | Framework | The raw body | |---|---| | Express | `express.raw({ type: 'application/json' })` on the webhook route, then `req.body.toString('utf8')` | | Fastify | Add a content-type parser with `parseAs: 'string'` for the route, or use `fastify-raw-body` | | Next.js (App Router) | `const raw = await request.text()` in the route handler | | Cloudflare Workers, Deno, Bun | `const raw = await request.text()` | | Flask | `request.get_data()` | | Django | `request.body` | | FastAPI | `raw = await request.body()` | | Laravel | `$request->getContent()` | | Plain PHP, WordPress | `file_get_contents('php://input')` | | Rails | `request.raw_post` | | Go `net/http` | `io.ReadAll(r.Body)` before anything else reads it | A complete Cloudflare Worker or Next.js route handler: ```ts export async function POST(request: Request) { const raw = await request.text(); const header = request.headers.get('Railbed-Signature'); const secret = process.env.RAILBED_WEBHOOK_SECRET!; if (!(await verifyRailbedSignature(raw, header, secret))) { return new Response('Invalid signature', { status: 400 }); } const event = JSON.parse(raw); // Record event.id with a unique constraint, queue the work, then answer. return new Response(null, { status: 200 }); } ``` ## Test vector Check your code against these known values. The secret is an example only; it isn't a real endpoint's. | Input | Value | |---|---| | Secret | `whsec_4mJ9pQx2VtR7cY1nKs8LwZ3bHd6fGa0e` | | Timestamp `t` | `1790380800` | | Raw body | `{"id":"evt_ExampleEventId0001","type":"ping","created":1790380800,"livemode":false,"data":{"payment":null}}` | | String to sign | `1790380800.{"id":"evt_ExampleEventId0001",…}`: the timestamp, a full stop, then the body | | Expected `v1` | `0f48db16650bc9a4d07b56d6db5b8f7cd53e778317e31492ed32c8fb0749fd0f` | Reproduce it with OpenSSL: ```bash printf '%s' '1790380800.' \ '{"id":"evt_ExampleEventId0001","type":"ping","created":1790380800,' \ '"livemode":false,"data":{"payment":null}}' \ | openssl dgst -sha256 -hmac 'whsec_4mJ9pQx2VtR7cY1nKs8LwZ3bHd6fGa0e' ``` The timestamp is in the past, so a verifier with a five-minute tolerance rejects this header. To test with it, pass a very large tolerance (every function above takes one), or test the HMAC step on its own. Change one byte of the body and the result must be `false`. For an end-to-end check, choose **Send test event** (Test) or **Send ping** (Live) on your endpoint in [Developers](https://app.railbed.io/developers): the result shows whether your server accepted it. ## Replays and clock skew The five-minute window assumes your server's clock is right; keep it synced with NTP. A captured delivery replayed within the window still has a valid signature, which is why you also [deduplicate on the event `id`](https://railbed.io/docs/webhooks.md#duplicates-and-order): a replay of an event you've processed then changes nothing. ## Rolling a secret **Roll secret** on the endpoint replaces its secret at once, and every later delivery, retries included, is signed with the new one. Update your server's secret straight away. Deliveries that fail verification in between are retried, and any that run out of retries can be sent again from the [delivery log](https://railbed.io/docs/webhooks.md#the-delivery-log). A roll can't be seamless: from the moment you roll, every delivery is signed with the new secret, and you see it only then. Deliveries that reach your server before it has the new secret fail and are retried a few minutes later, so update promptly and nothing is lost. ## Troubleshooting | Symptom | Likely cause | |---|---| | Every delivery fails verification | The wrong secret (Test and Live endpoints have different ones, and each endpoint has its own), or a secret with extra spaces or quotes around it | | Only some deliveries fail | The body was parsed and re-serialized before verifying. Verify the raw body | | Test events pass, real ones fail | A proxy, CDN or security plugin changes the body or strips the `Railbed-Signature` header on real traffic. Allow the webhook path through untouched | | Fails after a while | Your server's clock has drifted beyond five minutes | | Your server answered `2xx` but verification failed | Check the order: verify first, then answer. A `2xx` stops retries | --- # Build with AI agents > Everything in these docs is available to AI assistants and coding agents: llms.txt, a Markdown copy of every page, a structured index, and WebMCP tools on railbed.io and in the dashboard. Source: https://railbed.io/docs/agents/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs ## Give your assistant the docs Point your coding assistant at one of these, depending on how much context it can take: | Resource | What it is | |---|---| | [/docs/llms.txt](https://railbed.io/docs/llms.txt) | A summary of the API and its rules, with a link to every page. Start here | | [/docs/llms-full.txt](https://railbed.io/docs/llms-full.txt) | Every docs page in one Markdown file | | `/docs/.md` | Any page as Markdown: add `.md` to its path, as in [/docs/quickstart.md](https://railbed.io/docs/quickstart.md). The overview is [/docs/index.md](https://railbed.io/docs/index.md) | | [/agents/docs.json](https://railbed.io/agents/docs.json) | A structured index: every page, section and endpoint, with links | Each page also has **Copy page for AI** in its sidebar, which copies the page's Markdown for pasting into a chat. A prompt that works well: ```text Read https://railbed.io/docs/llms.txt and the pages it links to that you need. Then add Railbed card checkout to this app: create a checkout session on the server when the user clicks Buy, redirect to its url, and fulfil the order from a verified payment.paid webhook. Use my test key from RAILBED_SECRET_KEY and my webhook secret from RAILBED_WEBHOOK_SECRET. Deduplicate on the event id. ``` > [!IMPORTANT] > Keep secret keys and webhook secrets out of prompts, chats and code. Put them in your server's environment and let the assistant refer to them by name. ## Rules worth giving an agent These are the mistakes that matter most in a payments integration. They're in `llms.txt` too. - Call the API only from a server, with the key in an environment variable. There is no publishable key. - Fulfil only when a payment is `paid`, confirmed by a verified webhook or `GET /v1/payments/:id`. Never from the buyer's return to `success_url`. - Verify webhook signatures against the raw body, and deduplicate on the event `id`. - Send an `Idempotency-Key` with every create. - Treat `held` as not paid, and `expired` as possibly paid later. - Start in Test mode (`rb_test_…` keys) and [simulate outcomes](https://railbed.io/docs/testing.md); no money moves. ## WebMCP tools [WebMCP](https://webmachinelearning.github.io/webmcp/) lets a web page offer tools to an AI agent working in the visitor's browser, so the agent can act through the page's own logic instead of reading the screen. Railbed's pages offer tools in browsers that support it; elsewhere nothing changes. ### On railbed.io and these docs Every page on railbed.io, including these docs, offers tools for learning about Railbed and building with it. All are read-only except the two that open signup or login. | Tool | What it does | |---|---| | `railbed_docs_search` | Search the developer docs; returns the best matching sections with links | | `railbed_docs_read` | A docs page, or one section of it, as Markdown | | `railbed_api_reference` | Every API endpoint with its method, path and reference link, plus the base URL and the rules for authentication, idempotency, money and errors | | `railbed_webhook_reference` | The webhook event types, when each is sent, the signature scheme and the retry schedule | | `railbed_code_samples` | The docs' code examples, filtered by topic and language | | `railbed_overview`, `railbed_faq`, `railbed_search`, `railbed_read_page`, `railbed_contact` | What Railbed is, the FAQ, and the company and legal pages | | `railbed_start_signup`, `railbed_open_login` | Take the visitor to signup (email optionally filled in) or login. Nothing is submitted for them | ### In the dashboard When a merchant is signed in, the **Developers** and **Integrations** pages offer tools that work on their account, in the mode the dashboard is in (Test or Live). They run with the merchant's own session, in their browser, so an agent can do only what the merchant could. | Tool | Page | What it does | |---|---|---| | `railbed_developer_status` | Developers | The mode, the setup steps done so far, and a summary of keys and endpoints | | `railbed_list_api_keys` | Developers | The keys, masked (`rb_test_••••••••3f9a`), with when each was created and last used | | `railbed_list_webhook_endpoints` | Developers | Endpoints with their addresses, labels, events and latest delivery. Signing secrets are never included | | `railbed_list_webhook_deliveries` | Developers | An endpoint's recent deliveries: status, attempts, your server's answer and, if asked, the body sent | | `railbed_send_test_webhook` | Developers | Send a signed test event to an endpoint and report how it answered. Test endpoints take any event type; Live endpoints take `ping` only | | `railbed_prepare_api_key` | Developers | Open **Create key** with a name filled in. The merchant creates it; the secret is shown only to them | | `railbed_prepare_webhook_endpoint` | Developers | Open **Add endpoint** with the address, description and events filled in, for the merchant to review and save | | `railbed_open_webhook_deliveries` | Developers | Open an endpoint's delivery log, optionally filtered to failed or retrying deliveries, so the merchant can resend | | `railbed_list_integrations` | Integrations | The ways to take payments (links, checkouts, the API, store plugins), their status and where to set each up | | `railbed_list_card_providers` | Integrations | The card providers Railbed routes to, with their status and minimum amounts (in Test mode, the fixed test list) | Some things are deliberately left to the merchant. Tools never return a secret key or a signing secret, and they never create or revoke keys, roll secrets, remove endpoints or resend real events: those open the dashboard's own dialog for the merchant to confirm, or aren't offered at all. > [!NOTE] > Content returned by the delivery log includes data your buyers typed, such as their email, and your server's answers. Agents receive it marked as untrusted content.