{
 "product": "Railbed",
 "apiBase": "https://pay.railbed.io/v1",
 "llms": "https://railbed.io/docs/llms.txt",
 "sections": [
  {
   "title": "Get started",
   "pages": [
    "",
    "quickstart",
    "how-it-works",
    "testing"
   ]
  },
  {
   "title": "Guides",
   "pages": [
    "guides/hosted-checkout",
    "guides/custom-checkout",
    "guides/fulfilment",
    "guides/no-code",
    "guides/woocommerce"
   ]
  },
  {
   "title": "API reference",
   "pages": [
    "api",
    "api/checkout-sessions",
    "api/payments",
    "api/errors"
   ]
  },
  {
   "title": "Webhooks",
   "pages": [
    "webhooks",
    "webhooks/events",
    "webhooks/signatures"
   ]
  },
  {
   "title": "Resources",
   "pages": [
    "agents"
   ]
  }
 ],
 "pages": [
  {
   "id": "overview",
   "title": "Railbed developer docs",
   "section": "Get started",
   "description": "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.",
   "url": "https://railbed.io/docs/",
   "path": "/docs/",
   "markdown": "https://railbed.io/docs/index.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "What you can build",
     "anchor": "what-you-can-build",
     "text": "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."
    },
    {
     "heading": "Choose an integration",
     "anchor": "choose-an-integration",
     "text": "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."
    },
    {
     "heading": "The API at a glance",
     "anchor": "the-api-at-a-glance",
     "text": "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: Response · response 201 Created: 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\" ."
    },
    {
     "heading": "Test mode and live mode",
     "anchor": "test-mode-and-live-mode",
     "text": "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."
    },
    {
     "heading": "What Railbed doesn't do",
     "anchor": "what-railbed-doesn-t-do",
     "text": "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 ."
    }
   ]
  },
  {
   "id": "quickstart",
   "title": "Quickstart",
   "section": "Get started",
   "description": "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.",
   "url": "https://railbed.io/docs/quickstart/",
   "path": "/docs/quickstart/",
   "markdown": "https://railbed.io/docs/quickstart.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "Before you start",
     "anchor": "before-you-start",
     "text": "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."
    },
    {
     "heading": "1. Create a secret key",
     "anchor": "1-create-a-secret-key",
     "text": "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: !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."
    },
    {
     "heading": "2. Create a checkout session",
     "anchor": "2-create-a-checkout-session",
     "text": "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: Node.js: Python: PHP: The response is the new session. Save its id pay_… with your order. Response · response 201 Created:"
    },
    {
     "heading": "3. Pay as a buyer",
     "anchor": "3-pay-as-a-buyer",
     "text": "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."
    },
    {
     "heading": "4. Confirm the payment from your server",
     "anchor": "4-confirm-the-payment-from-your-server",
     "text": "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: Node.js: Python: PHP: Response trimmed · response 200 OK: Check that status is paid and that the id, amount, currency and reference match the order you saved, then fulfil it once."
    },
    {
     "heading": "5. Get told instead of asking",
     "anchor": "5-get-told-instead-of-asking",
     "text": "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 ."
    },
    {
     "heading": "Next",
     "anchor": "next",
     "text": "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."
    }
   ]
  },
  {
   "id": "how-it-works",
   "title": "How payments work",
   "section": "Get started",
   "description": "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.",
   "url": "https://railbed.io/docs/how-it-works/",
   "path": "/docs/how-it-works/",
   "markdown": "https://railbed.io/docs/how-it-works.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "The journey of one payment",
     "anchor": "the-journey-of-one-payment",
     "text": "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 ."
    },
    {
     "heading": "Statuses",
     "anchor": "statuses",
     "text": "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."
    },
    {
     "heading": "What arrives in your wallet",
     "anchor": "what-arrives-in-your-wallet",
     "text": "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."
    },
    {
     "heading": "Identity checks",
     "anchor": "identity-checks",
     "text": "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."
    },
    {
     "heading": "Refunds and disputes",
     "anchor": "refunds-and-disputes",
     "text": "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."
    },
    {
     "heading": "Timing",
     "anchor": "timing",
     "text": "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"
    }
   ]
  },
  {
   "id": "testing",
   "title": "Testing",
   "section": "Get started",
   "description": "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.",
   "url": "https://railbed.io/docs/testing/",
   "path": "/docs/testing/",
   "markdown": "https://railbed.io/docs/testing.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "Test mode",
     "anchor": "test-mode",
     "text": "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."
    },
    {
     "heading": "Simulate an outcome as a buyer",
     "anchor": "simulate-an-outcome-as-a-buyer",
     "text": "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 ."
    },
    {
     "heading": "Simulate an outcome from your server",
     "anchor": "simulate-an-outcome-from-your-server",
     "text": "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: Node.js: Python: PHP: 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."
    },
    {
     "heading": "Send sample webhook events",
     "anchor": "send-sample-webhook-events",
     "text": "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."
    },
    {
     "heading": "Receive webhooks on your own computer",
     "anchor": "receive-webhooks-on-your-own-computer",
     "text": "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."
    },
    {
     "heading": "A test plan worth running",
     "anchor": "a-test-plan-worth-running",
     "text": "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"
    },
    {
     "heading": "Going live",
     "anchor": "going-live",
     "text": "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."
    }
   ]
  },
  {
   "id": "guides/hosted-checkout",
   "title": "Hosted checkout",
   "section": "Guides",
   "description": "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.",
   "url": "https://railbed.io/docs/guides/hosted-checkout/",
   "path": "/docs/guides/hosted-checkout/",
   "markdown": "https://railbed.io/docs/guides/hosted-checkout.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "How it works",
     "anchor": "how-it-works",
     "text": "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."
    },
    {
     "heading": "Create the session",
     "anchor": "create-the-session",
     "text": "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: Python: PHP: !NOTE In Python f-strings, {{PAYMENT_ID}} writes the literal {PAYMENT_ID} placeholder. Railbed fills it in, not your code."
    },
    {
     "heading": "Return URLs",
     "anchor": "return-urls",
     "text": "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 ."
    },
    {
     "heading": "What the buyer sees",
     "anchor": "what-the-buyer-sees",
     "text": "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."
    },
    {
     "heading": "When there's no provider",
     "anchor": "when-there-s-no-provider",
     "text": "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."
    }
   ]
  },
  {
   "id": "guides/custom-checkout",
   "title": "Your own checkout",
   "section": "Guides",
   "description": "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.",
   "url": "https://railbed.io/docs/guides/custom-checkout/",
   "path": "/docs/guides/custom-checkout/",
   "markdown": "https://railbed.io/docs/guides/custom-checkout.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "When to build your own",
     "anchor": "when-to-build-your-own",
     "text": "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."
    },
    {
     "heading": "The flow",
     "anchor": "the-flow",
     "text": "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."
    },
    {
     "heading": "Start the session and get providers",
     "anchor": "start-the-session-and-get-providers",
     "text": "Start the session and get providers cURL: Node.js: Python: PHP: Response trimmed · response 200 OK: 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."
    },
    {
     "heading": "Show providers and hand off",
     "anchor": "show-providers-and-hand-off",
     "text": "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: 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."
    },
    {
     "heading": "Show the result",
     "anchor": "show-the-result",
     "text": "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: 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."
    },
    {
     "heading": "Country",
     "anchor": "country",
     "text": "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."
    },
    {
     "heading": "Errors when starting",
     "anchor": "errors-when-starting",
     "text": "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"
    }
   ]
  },
  {
   "id": "guides/fulfilment",
   "title": "Fulfil orders safely",
   "section": "Guides",
   "description": "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.",
   "url": "https://railbed.io/docs/guides/fulfilment/",
   "path": "/docs/guides/fulfilment/",
   "markdown": "https://railbed.io/docs/guides/fulfilment.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "The rule",
     "anchor": "the-rule",
     "text": "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."
    },
    {
     "heading": "Save before you create",
     "anchor": "save-before-you-create",
     "text": "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."
    },
    {
     "heading": "Check before you grant",
     "anchor": "check-before-you-grant",
     "text": "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: !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."
    },
    {
     "heading": "Webhooks, the API, or both",
     "anchor": "webhooks-the-api-or-both",
     "text": "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."
    },
    {
     "heading": "Check unfinished orders in the background",
     "anchor": "check-unfinished-orders-in-the-background",
     "text": "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."
    },
    {
     "heading": "Held payments",
     "anchor": "held-payments",
     "text": "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."
    },
    {
     "heading": "Late payments",
     "anchor": "late-payments",
     "text": "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."
    },
    {
     "heading": "Refunds",
     "anchor": "refunds",
     "text": "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."
    }
   ]
  },
  {
   "id": "guides/no-code",
   "title": "Payment links and buy buttons",
   "section": "Guides",
   "description": "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.",
   "url": "https://railbed.io/docs/guides/no-code/",
   "path": "/docs/guides/no-code/",
   "markdown": "https://railbed.io/docs/guides/no-code.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "What you can make in the dashboard",
     "anchor": "what-you-can-make-in-the-dashboard",
     "text": "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."
    },
    {
     "heading": "Add a buy button to any site",
     "anchor": "add-a-buy-button-to-any-site",
     "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: 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."
    },
    {
     "heading": "Put the checkout in your page",
     "anchor": "put-the-checkout-in-your-page",
     "text": "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: 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."
    },
    {
     "heading": "Good to know",
     "anchor": "good-to-know",
     "text": "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 ."
    }
   ]
  },
  {
   "id": "guides/woocommerce",
   "title": "WooCommerce",
   "section": "Guides",
   "description": "Add Railbed card checkout to a WordPress store with the Railbed for WooCommerce plugin. Orders complete from verified payments, with no code.",
   "url": "https://railbed.io/docs/guides/woocommerce/",
   "path": "/docs/guides/woocommerce/",
   "markdown": "https://railbed.io/docs/guides/woocommerce.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "What the plugin does",
     "anchor": "what-the-plugin-does",
     "text": "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."
    },
    {
     "heading": "Install and connect",
     "anchor": "install-and-connect",
     "text": "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."
    },
    {
     "heading": "Going live",
     "anchor": "going-live",
     "text": "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."
    },
    {
     "heading": "Store settings that matter",
     "anchor": "store-settings-that-matter",
     "text": "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."
    },
    {
     "heading": "Moving from another gateway",
     "anchor": "moving-from-another-gateway",
     "text": "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."
    }
   ]
  },
  {
   "id": "api",
   "title": "API reference",
   "section": "API reference",
   "description": "The Railbed REST API. JSON over HTTPS, one secret key per server, idempotent creates, cursor pagination and plain error codes.",
   "url": "https://railbed.io/docs/api/",
   "path": "/docs/api/",
   "markdown": "https://railbed.io/docs/api.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "Base URL",
     "anchor": "base-url",
     "text": "Base URL Base URL: 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"
    },
    {
     "heading": "Authentication",
     "anchor": "authentication",
     "text": "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: 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."
    },
    {
     "heading": "Requests and responses",
     "anchor": "requests-and-responses",
     "text": "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."
    },
    {
     "heading": "Idempotency",
     "anchor": "idempotency",
     "text": "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."
    },
    {
     "heading": "Pagination",
     "anchor": "pagination",
     "text": "Pagination GET /v1/payments returns the newest payments first, up to limit 1–100, default 20 at a time: A page · response 200 OK: 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."
    },
    {
     "heading": "Rate limits",
     "anchor": "rate-limits",
     "text": "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."
    },
    {
     "heading": "Errors",
     "anchor": "errors",
     "text": "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: Branch on code and the status, never on message , which may be reworded. Errors https://railbed.io/docs/api/errors.md lists every code."
    },
    {
     "heading": "Versioning",
     "anchor": "versioning",
     "text": "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."
    }
   ]
  },
  {
   "id": "api/checkout-sessions",
   "title": "Checkout sessions",
   "section": "API reference",
   "description": "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.",
   "url": "https://railbed.io/docs/api/checkout-sessions/",
   "path": "/docs/api/checkout-sessions/",
   "markdown": "https://railbed.io/docs/api/checkout-sessions.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "The checkout session object",
     "anchor": "the-checkout-session-object",
     "text": "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"
    },
    {
     "heading": "Create a checkout session",
     "anchor": "create-a-checkout-session",
     "text": "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_wall"
    },
    {
     "heading": "Retrieve a checkout session",
     "anchor": "retrieve-a-checkout-session",
     "text": "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: Node.js: Python: PHP: 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."
    },
    {
     "heading": "Start a checkout session",
     "anchor": "start-a-checkout-session",
     "text": "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: Node.js: Python: PHP: Response · response 200 OK: 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 sta"
    }
   ]
  },
  {
   "id": "api/payments",
   "title": "Payments",
   "section": "API reference",
   "description": "A payment is what a checkout session became. Read it to fulfil orders, list payments to reconcile, and simulate outcomes in Test mode.",
   "url": "https://railbed.io/docs/api/payments/",
   "path": "/docs/api/payments/",
   "markdown": "https://railbed.io/docs/api/payments.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "The payment object",
     "anchor": "the-payment-object",
     "text": "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."
    },
    {
     "heading": "Retrieve a payment",
     "anchor": "retrieve-a-payment",
     "text": "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: Node.js: Python: PHP: Response · response 200 OK: A held payment looks like this in part: A held payment trimmed · response 200 OK: Reading a payment past its expires_at marks an unpaid one expired ."
    },
    {
     "heading": "List payments",
     "anchor": "list-payments",
     "text": "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: Node.js: Python: PHP: Response · response 200 OK: 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 ."
    },
    {
     "heading": "Simulate a payment",
     "anchor": "simulate-a-payment",
     "text": "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: Node.js: Python: PHP: 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"
    }
   ]
  },
  {
   "id": "api/errors",
   "title": "Errors",
   "section": "API reference",
   "description": "Every error the Railbed API returns, with its HTTP status, what caused it and what to do next.",
   "url": "https://railbed.io/docs/api/errors/",
   "path": "/docs/api/errors/",
   "markdown": "https://railbed.io/docs/api/errors.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "The error object",
     "anchor": "the-error-object",
     "text": "The error object Every error has an HTTP status of 400 or above and the same JSON body: An error · response 409 Conflict: 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"
    },
    {
     "heading": "Handling errors",
     "anchor": "handling-errors",
     "text": "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:"
    },
    {
     "heading": "Every code",
     "anchor": "every-code",
     "text": "Every code"
    },
    {
     "heading": "400 Bad Request",
     "anchor": "status-400",
     "text": "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"
    },
    {
     "heading": "401 Unauthorized",
     "anchor": "status-401",
     "text": "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"
    },
    {
     "heading": "403 Forbidden",
     "anchor": "status-403",
     "text": "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"
    },
    {
     "heading": "404 Not Found",
     "anchor": "status-404",
     "text": "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"
    },
    {
     "heading": "409 Conflict",
     "anchor": "status-409",
     "text": "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"
    },
    {
     "heading": "410 Gone",
     "anchor": "status-410",
     "text": "410 Gone Code Cause --- --- expired The session passed its expires_at without being paid canceled The payment link was canceled in the dashboard"
    },
    {
     "heading": "Size, type and rate",
     "anchor": "other-4xx",
     "text": "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"
    },
    {
     "heading": "5xx",
     "anchor": "status-5xx",
     "text": "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."
    }
   ]
  },
  {
   "id": "webhooks",
   "title": "Webhooks",
   "section": "Webhooks",
   "description": "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.",
   "url": "https://railbed.io/docs/webhooks/",
   "path": "/docs/webhooks/",
   "markdown": "https://railbed.io/docs/webhooks.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "How webhooks work",
     "anchor": "how-webhooks-work",
     "text": "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: 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"
    },
    {
     "heading": "Add an endpoint",
     "anchor": "add-an-endpoint",
     "text": "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."
    },
    {
     "heading": "Respond to deliveries",
     "anchor": "respond-to-deliveries",
     "text": "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."
    },
    {
     "heading": "Retries",
     "anchor": "retries",
     "text": "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."
    },
    {
     "heading": "Duplicates and order",
     "anchor": "duplicates-and-order",
     "text": "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."
    },
    {
     "heading": "The delivery log",
     "anchor": "the-delivery-log",
     "text": "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."
    },
    {
     "heading": "Test events",
     "anchor": "test-events",
     "text": "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."
    },
    {
     "heading": "Manage endpoints",
     "anchor": "manage-endpoints",
     "text": "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"
    },
    {
     "heading": "A receiver, end to end",
     "anchor": "a-receiver-end-to-end",
     "text": "A receiver, end to end Node.js · Express: Python · Flask: PHP: The fulfilment job then reads the payment https://railbed.io/docs/api/payments.md retrieve-a-payment and grants the order once, as in Fulfil orders safely https://railbed.io/docs/guides/fulfilment.md . Verification in more languages, and a test vector to check yours against, are in Verify signatures https://railbed.io/docs/webhooks/signatures.md ."
    },
    {
     "heading": "Checklist",
     "anchor": "checklist",
     "text": "Checklist - The endpoint verifies Railbed-Signature against the raw body and rejects anything else with a 4xx - Timestamps older than five minutes are rejected - Event ids are stored with a unique constraint, and duplicates are acknowledged with 2xx and ignored - The endpoint answers within a second or two and fulfils in the background - Unknown event types are acknowledged with 2xx and ignored - Orders are granted once per payment, from paid only - A background job checks orders still waiting, in case a webhook never arrives - The Live endpoint uses the Live secret, and the Test endpoint the Test secret"
    }
   ]
  },
  {
   "id": "webhooks/events",
   "title": "Event types",
   "section": "Webhooks",
   "description": "Every webhook event Railbed sends, when it's sent, what the payment looks like at that moment, and the full payload reference.",
   "url": "https://railbed.io/docs/webhooks/events/",
   "path": "/docs/webhooks/events/",
   "markdown": "https://railbed.io/docs/webhooks/events.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "The event object",
     "anchor": "the-event-object",
     "text": "The event object Every delivery's body is one event: payment.paid · response Delivered · 200: 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 ."
    },
    {
     "heading": "Events",
     "anchor": "events",
     "text": "Events"
    },
    {
     "heading": "payment.started",
     "anchor": "payment-started",
     "text": "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 : In Test mode, depositAddress is null : no real address is created."
    },
    {
     "heading": "payment.paid",
     "anchor": "payment-paid",
     "text": "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."
    },
    {
     "heading": "payment.held",
     "anchor": "payment-held",
     "text": "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 :"
    },
    {
     "heading": "payment.updated",
     "anchor": "payment-updated",
     "text": "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."
    },
    {
     "heading": "payment.failed",
     "anchor": "payment-failed",
     "text": "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."
    },
    {
     "heading": "payment.expired",
     "anchor": "payment-expired",
     "text": "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."
    },
    {
     "heading": "payment.canceled",
     "anchor": "payment-canceled",
     "text": "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."
    },
    {
     "heading": "ping",
     "anchor": "ping",
     "text": "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:"
    },
    {
     "heading": "Typical sequences",
     "anchor": "typical-sequences",
     "text": "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."
    },
    {
     "heading": "The payment object",
     "anchor": "the-payment-object",
     "text": "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%"
    }
   ]
  },
  {
   "id": "webhooks/signatures",
   "title": "Verify signatures",
   "section": "Webhooks",
   "description": "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.",
   "url": "https://railbed.io/docs/webhooks/signatures/",
   "path": "/docs/webhooks/signatures/",
   "markdown": "https://railbed.io/docs/webhooks/signatures.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "How deliveries are signed",
     "anchor": "how-deliveries-are-signed",
     "text": "How deliveries are signed Every delivery carries a Railbed-Signature header: The header: - 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=<digits ,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."
    },
    {
     "heading": "Verify a delivery",
     "anchor": "verify-a-delivery",
     "text": "Verify a delivery Each function returns true only for a genuine, recent delivery. Check yours against the test vector test-vector below. Node.js: Web Crypto · Cloudflare Workers, Deno, Bun, Next.js route handlers: Python: PHP: Ruby: Go:"
    },
    {
     "heading": "Use the raw body",
     "anchor": "use-the-raw-body",
     "text": "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:"
    },
    {
     "heading": "Test vector",
     "anchor": "test-vector",
     "text": "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: 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."
    },
    {
     "heading": "Replays and clock skew",
     "anchor": "replays-and-clock-skew",
     "text": "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."
    },
    {
     "heading": "Rolling a secret",
     "anchor": "rolling-a-secret",
     "text": "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."
    },
    {
     "heading": "Troubleshooting",
     "anchor": "troubleshooting",
     "text": "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"
    }
   ]
  },
  {
   "id": "agents",
   "title": "Build with AI agents",
   "section": "Resources",
   "description": "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.",
   "url": "https://railbed.io/docs/agents/",
   "path": "/docs/agents/",
   "markdown": "https://railbed.io/docs/agents.md",
   "updated": "2026-09-26",
   "sections": [
    {
     "heading": "Give your assistant the docs",
     "anchor": "give-your-assistant-the-docs",
     "text": "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/<page .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: !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."
    },
    {
     "heading": "Rules worth giving an agent",
     "anchor": "rules-worth-giving-an-agent",
     "text": "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."
    },
    {
     "heading": "WebMCP tools",
     "anchor": "webmcp-tools",
     "text": "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."
    },
    {
     "heading": "On railbed.io and these docs",
     "anchor": "site-tools",
     "text": "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"
    },
    {
     "heading": "In the dashboard",
     "anchor": "dashboard-tools",
     "text": "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_c"
    }
   ]
  }
 ],
 "essentials": [
  "API base URL: `https://pay.railbed.io/v1`. JSON over HTTPS. Authenticate every call from your server with `Authorization: Bearer <secret key>`.",
  "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=<unix seconds>,v1=<hex HMAC-SHA256 of \"<t>.<raw body>\">` 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."
 ],
 "endpoints": [
  {
   "method": "POST",
   "path": "/v1/checkout_sessions",
   "page": "api/checkout-sessions",
   "url": "https://railbed.io/docs/api/checkout-sessions/#create-a-checkout-session",
   "markdown": "https://railbed.io/docs/api/checkout-sessions.md#create-a-checkout-session",
   "title": "Create a checkout session"
  },
  {
   "method": "GET",
   "path": "/v1/checkout_sessions/:id",
   "page": "api/checkout-sessions",
   "url": "https://railbed.io/docs/api/checkout-sessions/#retrieve-a-checkout-session",
   "markdown": "https://railbed.io/docs/api/checkout-sessions.md#retrieve-a-checkout-session",
   "title": "Retrieve a checkout session"
  },
  {
   "method": "POST",
   "path": "/v1/checkout_sessions/:id/start",
   "page": "api/checkout-sessions",
   "url": "https://railbed.io/docs/api/checkout-sessions/#start-a-checkout-session",
   "markdown": "https://railbed.io/docs/api/checkout-sessions.md#start-a-checkout-session",
   "title": "Start a checkout session"
  },
  {
   "method": "GET",
   "path": "/v1/payments/:id",
   "page": "api/payments",
   "url": "https://railbed.io/docs/api/payments/#retrieve-a-payment",
   "markdown": "https://railbed.io/docs/api/payments.md#retrieve-a-payment",
   "title": "Retrieve a payment"
  },
  {
   "method": "GET",
   "path": "/v1/payments",
   "page": "api/payments",
   "url": "https://railbed.io/docs/api/payments/#list-payments",
   "markdown": "https://railbed.io/docs/api/payments.md#list-payments",
   "title": "List payments"
  },
  {
   "method": "POST",
   "path": "/v1/payments/:id/simulate",
   "page": "api/payments",
   "url": "https://railbed.io/docs/api/payments/#simulate-a-payment",
   "markdown": "https://railbed.io/docs/api/payments.md#simulate-a-payment",
   "title": "Simulate a payment"
  }
 ],
 "webhooks": {
  "header": "Railbed-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of \"<t>.<raw body>\">",
  "toleranceSeconds": 300,
  "timeoutSeconds": 10,
  "retryAfterFailure": [
   "1m",
   "5m",
   "30m",
   "2h",
   "6h",
   "12h"
  ],
  "events": [
   {
    "type": "payment.started",
    "summary": "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.",
    "url": "https://railbed.io/docs/webhooks/events/#payment-started",
    "markdown": "https://railbed.io/docs/webhooks/events.md#payment-started"
   },
   {
    "type": "payment.paid",
    "summary": "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.",
    "url": "https://railbed.io/docs/webhooks/events/#payment-paid",
    "markdown": "https://railbed.io/docs/webhooks/events.md#payment-paid"
   },
   {
    "type": "payment.held",
    "summary": "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.",
    "url": "https://railbed.io/docs/webhooks/events/#payment-held",
    "markdown": "https://railbed.io/docs/webhooks/events.md#payment-held"
   },
   {
    "type": "payment.updated",
    "summary": "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.",
    "url": "https://railbed.io/docs/webhooks/events/#payment-updated",
    "markdown": "https://railbed.io/docs/webhooks/events.md#payment-updated"
   },
   {
    "type": "payment.failed",
    "summary": "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.",
    "url": "https://railbed.io/docs/webhooks/events/#payment-failed",
    "markdown": "https://railbed.io/docs/webhooks/events.md#payment-failed"
   },
   {
    "type": "payment.expired",
    "summary": "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.",
    "url": "https://railbed.io/docs/webhooks/events/#payment-expired",
    "markdown": "https://railbed.io/docs/webhooks/events.md#payment-expired"
   },
   {
    "type": "payment.canceled",
    "summary": "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.",
    "url": "https://railbed.io/docs/webhooks/events/#payment-canceled",
    "markdown": "https://railbed.io/docs/webhooks/events.md#payment-canceled"
   },
   {
    "type": "ping",
    "summary": "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.",
    "url": "https://railbed.io/docs/webhooks/events/#ping",
    "markdown": "https://railbed.io/docs/webhooks/events.md#ping"
   }
  ]
 },
 "samples": [
  {
   "page": "overview",
   "section": "The API at a glance",
   "url": "https://railbed.io/docs/#the-api-at-a-glance",
   "language": "cURL",
   "label": "Create a checkout session",
   "kind": "code",
   "code": "curl https://pay.railbed.io/v1/checkout_sessions \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: order_1042\" \\\n  -d '{\n    \"amount\": \"49.00\",\n    \"currency\": \"USD\",\n    \"description\": \"Pro Membership\",\n    \"reference\": \"order_1042\"\n  }'"
  },
  {
   "page": "overview",
   "section": "The API at a glance",
   "url": "https://railbed.io/docs/#the-api-at-a-glance",
   "language": "JSON",
   "label": "Response",
   "kind": "response",
   "code": "{\n  \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\",\n  \"object\": \"checkout_session\",\n  \"url\": \"https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN\",\n  \"status\": \"open\",\n  \"livemode\": false,\n  \"amount\": \"49.00\",\n  \"currency\": \"USD\",\n  \"description\": \"Pro Membership\",\n  \"reference\": \"order_1042\",\n  \"customer_email\": null,\n  \"created\": 1790380525,\n  \"expires_at\": 1790466925,\n  \"started_at\": null,\n  \"metadata\": null\n}"
  },
  {
   "page": "quickstart",
   "section": "1. Create a secret key",
   "url": "https://railbed.io/docs/quickstart/#1-create-a-secret-key",
   "language": "cURL",
   "label": "Your server's environment",
   "kind": "code",
   "code": "export RAILBED_SECRET_KEY=\"rb_test_…\""
  },
  {
   "page": "quickstart",
   "section": "2. Create a checkout session",
   "url": "https://railbed.io/docs/quickstart/#2-create-a-checkout-session",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl https://pay.railbed.io/v1/checkout_sessions \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: order_1042\" \\\n  -d '{\n    \"amount\": \"49.00\",\n    \"currency\": \"USD\",\n    \"description\": \"Pro Membership\",\n    \"reference\": \"order_1042\",\n    \"customer_email\": \"buyer@example.com\",\n    \"success_url\": \"https://yourstore.com/thanks?order={REFERENCE}\"\n  }'"
  },
  {
   "page": "quickstart",
   "section": "2. Create a checkout session",
   "url": "https://railbed.io/docs/quickstart/#2-create-a-checkout-session",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const res = await fetch('https://pay.railbed.io/v1/checkout_sessions', {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,\n    'Content-Type': 'application/json',\n    'Idempotency-Key': 'order_1042',\n  },\n  body: JSON.stringify({\n    amount: '49.00',\n    currency: 'USD',\n    description: 'Pro Membership',\n    reference: 'order_1042',\n    customer_email: 'buyer@example.com',\n    success_url: 'https://yourstore.com/thanks?order={REFERENCE}',\n  }),\n});\nconst session = await res.json();\nconsole.log(session.url);"
  },
  {
   "page": "quickstart",
   "section": "2. Create a checkout session",
   "url": "https://railbed.io/docs/quickstart/#2-create-a-checkout-session",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "import os, requests\n\nsession = requests.post(\n    \"https://pay.railbed.io/v1/checkout_sessions\",\n    headers={\n        \"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\",\n        \"Idempotency-Key\": \"order_1042\",\n    },\n    json={\n        \"amount\": \"49.00\",\n        \"currency\": \"USD\",\n        \"description\": \"Pro Membership\",\n        \"reference\": \"order_1042\",\n        \"customer_email\": \"buyer@example.com\",\n        \"success_url\": \"https://yourstore.com/thanks?order={REFERENCE}\",\n    },\n    timeout=15,\n).json()\nprint(session[\"url\"])"
  },
  {
   "page": "quickstart",
   "section": "2. Create a checkout session",
   "url": "https://railbed.io/docs/quickstart/#2-create-a-checkout-session",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$ch = curl_init('https://pay.railbed.io/v1/checkout_sessions');\ncurl_setopt_array($ch, [\n  CURLOPT_POST => true,\n  CURLOPT_RETURNTRANSFER => true,\n  CURLOPT_HTTPHEADER => [\n    'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),\n    'Content-Type: application/json',\n    'Idempotency-Key: order_1042',\n  ],\n  CURLOPT_POSTFIELDS => json_encode([\n    'amount' => '49.00',\n    'currency' => 'USD',\n    'description' => 'Pro Membership',\n    'reference' => 'order_1042',\n    'customer_email' => 'buyer@example.com',\n    'success_url' => 'https://yourstore.com/thanks?order={REFERENCE}',\n  ]),\n]);\n$session = json_decode(curl_exec($ch), true);\necho $session['url'];"
  },
  {
   "page": "quickstart",
   "section": "2. Create a checkout session",
   "url": "https://railbed.io/docs/quickstart/#2-create-a-checkout-session",
   "language": "JSON",
   "label": "Response",
   "kind": "response",
   "code": "{\n  \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\",\n  \"object\": \"checkout_session\",\n  \"url\": \"https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN\",\n  \"status\": \"open\",\n  \"livemode\": false,\n  \"amount\": \"49.00\",\n  \"currency\": \"USD\",\n  \"description\": \"Pro Membership\",\n  \"reference\": \"order_1042\",\n  \"customer_email\": \"buyer@example.com\",\n  \"created\": 1790380525,\n  \"expires_at\": 1790466925,\n  \"started_at\": null,\n  \"metadata\": null\n}"
  },
  {
   "page": "quickstart",
   "section": "4. Confirm the payment from your server",
   "url": "https://railbed.io/docs/quickstart/#4-confirm-the-payment-from-your-server",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\""
  },
  {
   "page": "quickstart",
   "section": "4. Confirm the payment from your server",
   "url": "https://railbed.io/docs/quickstart/#4-confirm-the-payment-from-your-server",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const res = await fetch(`https://pay.railbed.io/v1/payments/${paymentId}`, {\n  headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` },\n});\nconst payment = await res.json();\nif (payment.status === 'paid') fulfil(payment.reference);"
  },
  {
   "page": "quickstart",
   "section": "4. Confirm the payment from your server",
   "url": "https://railbed.io/docs/quickstart/#4-confirm-the-payment-from-your-server",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "payment = requests.get(\n    f\"https://pay.railbed.io/v1/payments/{payment_id}\",\n    headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n    timeout=15,\n).json()\nif payment[\"status\"] == \"paid\":\n    fulfil(payment[\"reference\"])"
  },
  {
   "page": "quickstart",
   "section": "4. Confirm the payment from your server",
   "url": "https://railbed.io/docs/quickstart/#4-confirm-the-payment-from-your-server",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$ch = curl_init('https://pay.railbed.io/v1/payments/' . rawurlencode($paymentId));\ncurl_setopt_array($ch, [\n  CURLOPT_RETURNTRANSFER => true,\n  CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY')],\n]);\n$payment = json_decode(curl_exec($ch), true);\nif ($payment['status'] === 'paid') fulfil($payment['reference']);"
  },
  {
   "page": "quickstart",
   "section": "4. Confirm the payment from your server",
   "url": "https://railbed.io/docs/quickstart/#4-confirm-the-payment-from-your-server",
   "language": "JSON",
   "label": "Response (trimmed)",
   "kind": "response",
   "code": "{\n  \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\",\n  \"object\": \"payment\",\n  \"status\": \"paid\",\n  \"livemode\": false,\n  \"amount\": \"49.00\",\n  \"currency\": \"USD\",\n  \"reference\": \"order_1042\",\n  \"paid_at\": 1790381342,\n  \"provider\": \"stripe\",\n  \"settlement\": {\n    \"coin\": \"polygon_usdc\",\n    \"value_coin\": \"47.53\",\n    \"merchant_received\": \"44.915850\",\n    \"txid_in\": \"0x0c65…582e\",\n    \"txid_out\": \"0x8202…356f\",\n    \"payout_wallet\": \"0xF977814e90dA44bFA03b6295A0616a897441aceC\"\n  }\n}"
  },
  {
   "page": "testing",
   "section": "Simulate an outcome from your server",
   "url": "https://railbed.io/docs/testing/#simulate-an-outcome-from-your-server",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "# Start it (the buyer would normally do this by choosing a provider)\ncurl -X POST \\\n  https://pay.railbed.io/v1/checkout_sessions/pay_7AAiYH0Ykt11ED4hmfiN/start \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"customer_email\": \"buyer@example.com\" }'\n\n# Then decide how it ends: \"paid\", \"underpaid\" or \"failed\"\ncurl -X POST \\\n  https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN/simulate \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"outcome\": \"paid\" }'"
  },
  {
   "page": "testing",
   "section": "Simulate an outcome from your server",
   "url": "https://railbed.io/docs/testing/#simulate-an-outcome-from-your-server",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const api = (path, body) =>\n  fetch(`https://pay.railbed.io/v1${path}`, {\n    method: 'POST',\n    headers: {\n      Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify(body),\n  }).then((r) => r.json());\n\nawait api(`/checkout_sessions/${id}/start`, {\n  customer_email: 'buyer@example.com',\n});\nconst payment = await api(`/payments/${id}/simulate`, {\n  outcome: 'paid', // or 'underpaid', 'failed'\n});"
  },
  {
   "page": "testing",
   "section": "Simulate an outcome from your server",
   "url": "https://railbed.io/docs/testing/#simulate-an-outcome-from-your-server",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "def api(path, body):\n    return requests.post(\n        f\"https://pay.railbed.io/v1{path}\",\n        headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n        json=body,\n        timeout=15,\n    ).json()\n\napi(f\"/checkout_sessions/{id}/start\", {\"customer_email\": \"buyer@example.com\"})\npayment = api(\n    f\"/payments/{id}/simulate\",\n    {\"outcome\": \"paid\"},  # or \"underpaid\", \"failed\"\n)"
  },
  {
   "page": "testing",
   "section": "Simulate an outcome from your server",
   "url": "https://railbed.io/docs/testing/#simulate-an-outcome-from-your-server",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\nfunction railbed_post(string $path, array $body): array {\n  $ch = curl_init('https://pay.railbed.io/v1' . $path);\n  curl_setopt_array($ch, [\n    CURLOPT_POST => true,\n    CURLOPT_RETURNTRANSFER => true,\n    CURLOPT_HTTPHEADER => [\n      'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),\n      'Content-Type: application/json',\n    ],\n    CURLOPT_POSTFIELDS => json_encode($body),\n  ]);\n  return json_decode(curl_exec($ch), true);\n}\n\nrailbed_post(\"/checkout_sessions/$id/start\", [\n  'customer_email' => 'buyer@example.com',\n]);\n$payment = railbed_post(\"/payments/$id/simulate\", [\n  'outcome' => 'paid', // or 'underpaid', 'failed'\n]);"
  },
  {
   "page": "guides/hosted-checkout",
   "section": "Create the session",
   "url": "https://railbed.io/docs/guides/hosted-checkout/#create-the-session",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "// POST /checkout on your server, after saving the order\napp.post('/checkout', async (req, res) => {\n  const order = await orders.create({\n    userId: req.user.id,\n    sku: 'pro-monthly',\n    price: '49.00',\n    currency: 'USD',\n  });\n\n  const response = await fetch('https://pay.railbed.io/v1/checkout_sessions', {\n    method: 'POST',\n    headers: {\n      Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,\n      'Content-Type': 'application/json',\n      'Idempotency-Key': `order_${order.id}`,\n    },\n    body: JSON.stringify({\n      amount: order.price,\n      currency: order.currency,\n      description: 'Pro Membership · monthly',\n      reference: `order_${order.id}`,\n      customer_email: req.user.email,\n      metadata: { user_id: String(req.user.id) },\n      success_url:\n        `https://yourstore.com/orders/${order.id}/thanks?payment={PAYMENT_ID}`,\n      cancel_url: `https://yourstore.com/cart`,\n    }),\n  });\n  if (!response.ok) {\n    return res.status(502).send(\n      'Checkout is unavailable. Try again in a moment.',\n    );\n  }\n  const session = await response.json();\n\n  await orders.update(order.id, { paymentId: session.id });\n  res.redirect(303, session.url);\n});"
  },
  {
   "page": "guides/hosted-checkout",
   "section": "Create the session",
   "url": "https://railbed.io/docs/guides/hosted-checkout/#create-the-session",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "# Flask: after saving the order\n@app.post(\"/checkout\")\ndef checkout():\n    order = orders.create(\n        user_id=current_user.id,\n        sku=\"pro-monthly\",\n        price=\"49.00\",\n        currency=\"USD\",\n    )\n    r = requests.post(\n        \"https://pay.railbed.io/v1/checkout_sessions\",\n        headers={\n            \"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\",\n            \"Idempotency-Key\": f\"order_{order.id}\",\n        },\n        json={\n            \"amount\": order.price,\n            \"currency\": order.currency,\n            \"description\": \"Pro Membership · monthly\",\n            \"reference\": f\"order_{order.id}\",\n            \"customer_email\": current_user.email,\n            \"metadata\": {\"user_id\": str(current_user.id)},\n            \"success_url\": (\n                f\"https://yourstore.com/orders/{order.id}\"\n                f\"/thanks?payment={{PAYMENT_ID}}\"\n            ),\n            \"cancel_url\": \"https://yourstore.com/cart\",\n        },\n        timeout=15,\n    )\n    if not r.ok:\n        return \"Checkout is unavailable. Try again in a moment.\", 502\n    session = r.json()\n    orders.update(order.id, payment_id=session[\"id\"])\n    return redirect(session[\"url\"], code=303)"
  },
  {
   "page": "guides/hosted-checkout",
   "section": "Create the session",
   "url": "https://railbed.io/docs/guides/hosted-checkout/#create-the-session",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n// After saving the order\n$ch = curl_init('https://pay.railbed.io/v1/checkout_sessions');\ncurl_setopt_array($ch, [\n  CURLOPT_POST => true,\n  CURLOPT_RETURNTRANSFER => true,\n  CURLOPT_HTTPHEADER => [\n    'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),\n    'Content-Type: application/json',\n    'Idempotency-Key: order_' . $order['id'],\n  ],\n  CURLOPT_POSTFIELDS => json_encode([\n    'amount' => $order['price'],\n    'currency' => $order['currency'],\n    'description' => 'Pro Membership · monthly',\n    'reference' => 'order_' . $order['id'],\n    'customer_email' => $user['email'],\n    'metadata' => ['user_id' => (string) $user['id']],\n    'success_url' => 'https://yourstore.com/orders/' . $order['id']\n      . '/thanks?payment={PAYMENT_ID}',\n    'cancel_url' => 'https://yourstore.com/cart',\n  ]),\n]);\n$session = json_decode(curl_exec($ch), true);\nif (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 300) {\n  http_response_code(502);\n  exit('Checkout is unavailable.');\n}\nsave_payment_id($order['id'], $session['id']);\nheader('Location: ' . $session['url'], true, 303);"
  },
  {
   "page": "guides/custom-checkout",
   "section": "Start the session and get providers",
   "url": "https://railbed.io/docs/guides/custom-checkout/#start-the-session-and-get-providers",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl -X POST \\\n  https://pay.railbed.io/v1/checkout_sessions/pay_7AAiYH0Ykt11ED4hmfiN/start \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"customer_email\": \"player1042@example.com\", \"country\": \"US\" }'"
  },
  {
   "page": "guides/custom-checkout",
   "section": "Start the session and get providers",
   "url": "https://railbed.io/docs/guides/custom-checkout/#start-the-session-and-get-providers",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "// Your server: POST /api/purchase/:orderId/providers\n// (called by your checkout screen)\nconst res = await fetch(\n  `https://pay.railbed.io/v1/checkout_sessions/${order.paymentId}/start`,\n  {\n    method: 'POST',\n    headers: {\n      Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({\n      customer_email: buyer.email,\n      country: buyer.country, // from the buyer's request, if known\n    }),\n  },\n);\nif (res.status === 409) {\n  const { error } = await res.json(); // e.g. no_providers, already_paid\n  return reply.status(409).send({ code: error.code, message: error.message });\n}\nconst started = await res.json();\n// Send only what the screen needs. Never send your API key to the browser.\nreply.send(\n  started.providers.map(({ id, name, note, recommended, handoff_url }) => ({\n    id,\n    name,\n    note,\n    recommended,\n    handoff_url,\n  })),\n);"
  },
  {
   "page": "guides/custom-checkout",
   "section": "Start the session and get providers",
   "url": "https://railbed.io/docs/guides/custom-checkout/#start-the-session-and-get-providers",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "r = requests.post(\n    f\"https://pay.railbed.io/v1/checkout_sessions/{order.payment_id}/start\",\n    headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n    json={\n        \"customer_email\": buyer.email,\n        \"country\": buyer.country,  # from the buyer's request, if known\n    },\n    timeout=15,\n)\nif r.status_code == 409:\n    error = r.json()[\"error\"]  # e.g. no_providers, already_paid\n    return {\"code\": error[\"code\"], \"message\": error[\"message\"]}, 409\nproviders = [\n    {k: p[k] for k in (\"id\", \"name\", \"note\", \"recommended\", \"handoff_url\")}\n    for p in r.json()[\"providers\"]\n]"
  },
  {
   "page": "guides/custom-checkout",
   "section": "Start the session and get providers",
   "url": "https://railbed.io/docs/guides/custom-checkout/#start-the-session-and-get-providers",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$paymentId = rawurlencode($order['payment_id']);\n$ch = curl_init(\"https://pay.railbed.io/v1/checkout_sessions/$paymentId/start\");\ncurl_setopt_array($ch, [\n  CURLOPT_POST => true,\n  CURLOPT_RETURNTRANSFER => true,\n  CURLOPT_HTTPHEADER => [\n    'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),\n    'Content-Type: application/json',\n  ],\n  CURLOPT_POSTFIELDS => json_encode([\n    'customer_email' => $buyer['email'],\n    'country' => $buyer['country'],\n  ]),\n]);\n$started = json_decode(curl_exec($ch), true);\nif (curl_getinfo($ch, CURLINFO_HTTP_CODE) === 409) {\n  http_response_code(409);\n  exit(json_encode($started['error'])); // e.g. no_providers, already_paid\n}\n$fields = array_flip(['id', 'name', 'note', 'recommended', 'handoff_url']);\necho json_encode(array_map(\n  fn ($p) => array_intersect_key($p, $fields),\n  $started['providers'],\n));"
  },
  {
   "page": "guides/custom-checkout",
   "section": "Start the session and get providers",
   "url": "https://railbed.io/docs/guides/custom-checkout/#start-the-session-and-get-providers",
   "language": "JSON",
   "label": "Response (trimmed)",
   "kind": "response",
   "code": "{\n  \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\",\n  \"object\": \"checkout_session\",\n  \"status\": \"open\",\n  \"amount\": \"49.00\",\n  \"currency\": \"USD\",\n  \"started_at\": 1790380611,\n  \"country\": \"US\",\n  \"providers\": [\n    {\n      \"id\": \"stripe\",\n      \"name\": \"Stripe\",\n      \"note\": \"Card, Apple Pay or Google Pay\",\n      \"recommended\": true,\n      \"handoff_url\": \"https://pay.railbed.io/go/pay_7AAiYH0Ykt11ED4hmfiN?provider=stripe\"\n    },\n    {\n      \"id\": \"paypal\",\n      \"name\": \"PayPal\",\n      \"note\": \"PayPal balance or card\",\n      \"recommended\": false,\n      \"handoff_url\": \"https://pay.railbed.io/go/pay_7AAiYH0Ykt11ED4hmfiN?provider=paypal\"\n    }\n  ]\n}"
  },
  {
   "page": "guides/custom-checkout",
   "section": "Show providers and hand off",
   "url": "https://railbed.io/docs/guides/custom-checkout/#show-providers-and-hand-off",
   "language": "HTML",
   "label": "In your checkout screen",
   "kind": "code",
   "code": "<ul id=\"providers\"></ul>\n<p id=\"status\" role=\"status\">Choose how to pay.</p>\n<script>\n  // providers: what your server returned from the start call\n  function showProviders(providers) {\n    const list = document.getElementById('providers');\n    for (const p of providers) {\n      const a = document.createElement('a');\n      a.href = p.handoff_url;\n      a.target = '_blank';\n      a.rel = 'noopener';\n      a.textContent = p.name + (p.recommended ? ' (recommended)' : '');\n      a.addEventListener('click', () => {\n        document.getElementById('status').textContent =\n          'Finish paying in the new tab. This page updates by itself.';\n        waitForPayment();\n      });\n      const li = document.createElement('li');\n      li.append(a, ' ', p.note);\n      list.append(li);\n    }\n  }\n</script>"
  },
  {
   "page": "guides/custom-checkout",
   "section": "Show the result",
   "url": "https://railbed.io/docs/guides/custom-checkout/#show-the-result",
   "language": "Node.js",
   "label": "In your checkout screen",
   "kind": "code",
   "code": "async function waitForPayment() {\n  // Your server, never Railbed directly\n  const res = await fetch(`/api/orders/${orderId}/status`);\n  const { status } = await res.json();\n  if (status === 'paid') return showPaid();\n  if (status === 'held') {\n    return showMessage(\n      'Your payment arrived and is being reviewed. We’ll email you.',\n    );\n  }\n  if (status === 'failed' || status === 'expired') {\n    return showMessage('The payment didn’t go through. Try again.');\n  }\n  setTimeout(waitForPayment, 4000);\n}"
  },
  {
   "page": "guides/fulfilment",
   "section": "Check before you grant",
   "url": "https://railbed.io/docs/guides/fulfilment/#check-before-you-grant",
   "language": "Node.js",
   "label": "Node.js: one grant per payment, in one transaction",
   "kind": "code",
   "code": "async function fulfilFromPayment(payment) {\n  // Held, pending, expired: nothing to deliver yet\n  if (payment.status !== 'paid') return;\n  const order = await db.orders.findByPaymentId(payment.id);\n  // Not ours (another store sharing the endpoint), or not saved yet\n  if (!order) return;\n  if (payment.livemode !== order.livemode) {\n    throw new Error('mode mismatch');\n  }\n  if (payment.reference !== order.reference) {\n    throw new Error('reference mismatch');\n  }\n  if (payment.amount !== order.price || payment.currency !== order.currency) {\n    throw new Error('amount mismatch');\n  }\n\n  await db.transaction(async (tx) => {\n    // UNIQUE(payment_id): a duplicate event or a second worker fails here\n    // and delivers nothing\n    const inserted = await tx.grants.insertIfAbsent({\n      paymentId: payment.id,\n      orderId: order.id,\n    });\n    if (!inserted) return;\n    await tx.inventory.deliver(order);\n    await tx.orders.markPaid(order.id, payment.paid_at);\n  });\n}"
  },
  {
   "page": "guides/no-code",
   "section": "Add a buy button to any site",
   "url": "https://railbed.io/docs/guides/no-code/#add-a-buy-button-to-any-site",
   "language": "HTML",
   "label": "A buy button that opens the checkout over your page",
   "kind": "code",
   "code": "<script src=\"https://pay.railbed.io/embed.js\" async></script>\n\n<railbed-button checkout=\"your-slug\"></railbed-button>"
  },
  {
   "page": "guides/no-code",
   "section": "Put the checkout in your page",
   "url": "https://railbed.io/docs/guides/no-code/#put-the-checkout-in-your-page",
   "language": "HTML",
   "label": "The checkout, in your page",
   "kind": "code",
   "code": "<script src=\"https://pay.railbed.io/embed.js\" async></script>\n\n<railbed-checkout checkout=\"your-slug\"></railbed-checkout>"
  },
  {
   "page": "api",
   "section": "Authentication",
   "url": "https://railbed.io/docs/api/#authentication",
   "language": "cURL",
   "label": "An authenticated request",
   "kind": "code",
   "code": "curl \"https://pay.railbed.io/v1/payments?limit=1\" \\\n  -H \"Authorization: Bearer rb_test_…\""
  },
  {
   "page": "api",
   "section": "Pagination",
   "url": "https://railbed.io/docs/api/#pagination",
   "language": "JSON",
   "label": "A page",
   "kind": "response",
   "code": "{\n  \"data\": [{ \"id\": \"pay_…\", \"object\": \"payment\", \"status\": \"paid\" }],\n  \"has_more\": true,\n  \"next_cursor\": \"pay_Q3cNtwPT0bRqGmS5eZkB\"\n}"
  },
  {
   "page": "api",
   "section": "Errors",
   "url": "https://railbed.io/docs/api/#errors",
   "language": "JSON",
   "label": "An error",
   "kind": "response",
   "code": "{\n  \"error\": {\n    \"code\": \"invalid_amount\",\n    \"message\": \"amount must be a decimal string between \\\"1.00\\\" and \\\"100000.00\\\".\",\n    \"field\": \"amount\"\n  }\n}"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Create a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#create-a-checkout-session",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl https://pay.railbed.io/v1/checkout_sessions \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: order_1042\" \\\n  -d '{\n    \"amount\": \"49.00\",\n    \"currency\": \"USD\",\n    \"description\": \"Pro Membership\",\n    \"reference\": \"order_1042\",\n    \"customer_email\": \"buyer@example.com\",\n    \"metadata\": { \"user_id\": \"player_1042\" },\n    \"success_url\":\n      \"https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}\",\n    \"cancel_url\": \"https://yourstore.com/cart\"\n  }'"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Create a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#create-a-checkout-session",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const res = await fetch('https://pay.railbed.io/v1/checkout_sessions', {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,\n    'Content-Type': 'application/json',\n    'Idempotency-Key': 'order_1042',\n  },\n  body: JSON.stringify({\n    amount: '49.00',\n    currency: 'USD',\n    description: 'Pro Membership',\n    reference: 'order_1042',\n    customer_email: 'buyer@example.com',\n    metadata: { user_id: 'player_1042' },\n    success_url:\n      'https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}',\n    cancel_url: 'https://yourstore.com/cart',\n  }),\n});\nif (!res.ok) throw new Error((await res.json()).error.code);\nconst session = await res.json();"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Create a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#create-a-checkout-session",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "r = requests.post(\n    \"https://pay.railbed.io/v1/checkout_sessions\",\n    headers={\n        \"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\",\n        \"Idempotency-Key\": \"order_1042\",\n    },\n    json={\n        \"amount\": \"49.00\",\n        \"currency\": \"USD\",\n        \"description\": \"Pro Membership\",\n        \"reference\": \"order_1042\",\n        \"customer_email\": \"buyer@example.com\",\n        \"metadata\": {\"user_id\": \"player_1042\"},\n        \"success_url\": (\n            \"https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}\"\n        ),\n        \"cancel_url\": \"https://yourstore.com/cart\",\n    },\n    timeout=15,\n)\nr.raise_for_status()\nsession = r.json()"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Create a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#create-a-checkout-session",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$ch = curl_init('https://pay.railbed.io/v1/checkout_sessions');\ncurl_setopt_array($ch, [\n  CURLOPT_POST => true,\n  CURLOPT_RETURNTRANSFER => true,\n  CURLOPT_HTTPHEADER => [\n    'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),\n    'Content-Type: application/json',\n    'Idempotency-Key: order_1042',\n  ],\n  CURLOPT_POSTFIELDS => json_encode([\n    'amount' => '49.00',\n    'currency' => 'USD',\n    'description' => 'Pro Membership',\n    'reference' => 'order_1042',\n    'customer_email' => 'buyer@example.com',\n    'metadata' => ['user_id' => 'player_1042'],\n    'success_url' =>\n      'https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}',\n    'cancel_url' => 'https://yourstore.com/cart',\n  ]),\n]);\n$session = json_decode(curl_exec($ch), true);"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Create a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#create-a-checkout-session",
   "language": "JSON",
   "label": "Response",
   "kind": "response",
   "code": "{\n  \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\",\n  \"object\": \"checkout_session\",\n  \"url\": \"https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN\",\n  \"status\": \"open\",\n  \"livemode\": false,\n  \"amount\": \"49.00\",\n  \"currency\": \"USD\",\n  \"description\": \"Pro Membership\",\n  \"reference\": \"order_1042\",\n  \"customer_email\": \"buyer@example.com\",\n  \"created\": 1790380525,\n  \"expires_at\": 1790466925,\n  \"started_at\": null,\n  \"metadata\": { \"user_id\": \"player_1042\" }\n}"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Retrieve a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#retrieve-a-checkout-session",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl https://pay.railbed.io/v1/checkout_sessions/pay_7AAiYH0Ykt11ED4hmfiN \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\""
  },
  {
   "page": "api/checkout-sessions",
   "section": "Retrieve a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#retrieve-a-checkout-session",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const session = await fetch(`https://pay.railbed.io/v1/checkout_sessions/${id}`, {\n  headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` },\n}).then((r) => r.json());"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Retrieve a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#retrieve-a-checkout-session",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "session = requests.get(\n    f\"https://pay.railbed.io/v1/checkout_sessions/{id}\",\n    headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n    timeout=15,\n).json()"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Retrieve a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#retrieve-a-checkout-session",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$ch = curl_init(\n  'https://pay.railbed.io/v1/checkout_sessions/' . rawurlencode($id)\n);\ncurl_setopt_array($ch, [\n  CURLOPT_RETURNTRANSFER => true,\n  CURLOPT_HTTPHEADER => [\n    'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),\n  ],\n]);\n$session = json_decode(curl_exec($ch), true);"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Start a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#start-a-checkout-session",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl -X POST \\\n  https://pay.railbed.io/v1/checkout_sessions/pay_7AAiYH0Ykt11ED4hmfiN/start \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"country\": \"US\" }'"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Start a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#start-a-checkout-session",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const url = `https://pay.railbed.io/v1/checkout_sessions/${id}/start`;\nconst started = await fetch(url, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({ country: 'US' }),\n}).then((r) => r.json());"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Start a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#start-a-checkout-session",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "started = requests.post(\n    f\"https://pay.railbed.io/v1/checkout_sessions/{id}/start\",\n    headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n    json={\"country\": \"US\"},\n    timeout=15,\n).json()"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Start a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#start-a-checkout-session",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$ch = curl_init(\n  'https://pay.railbed.io/v1/checkout_sessions/' . rawurlencode($id) . '/start'\n);\ncurl_setopt_array($ch, [\n  CURLOPT_POST => true,\n  CURLOPT_RETURNTRANSFER => true,\n  CURLOPT_HTTPHEADER => [\n    'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),\n    'Content-Type: application/json',\n  ],\n  CURLOPT_POSTFIELDS => json_encode(['country' => 'US']),\n]);\n$started = json_decode(curl_exec($ch), true);"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Start a checkout session",
   "url": "https://railbed.io/docs/api/checkout-sessions/#start-a-checkout-session",
   "language": "JSON",
   "label": "Response",
   "kind": "response",
   "code": "{\n  \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\",\n  \"object\": \"checkout_session\",\n  \"url\": \"https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN\",\n  \"status\": \"open\",\n  \"livemode\": false,\n  \"amount\": \"49.00\",\n  \"currency\": \"USD\",\n  \"description\": \"Pro Membership\",\n  \"reference\": \"order_1042\",\n  \"customer_email\": \"buyer@example.com\",\n  \"created\": 1790380525,\n  \"expires_at\": 1790466925,\n  \"started_at\": 1790380611,\n  \"metadata\": { \"user_id\": \"player_1042\" },\n  \"country\": \"US\",\n  \"providers\": [\n    {\n      \"id\": \"stripe\",\n      \"name\": \"Stripe\",\n      \"note\": \"Card, Apple Pay or Google Pay\",\n      \"recommended\": true,\n      \"handoff_url\": \"https://pay.railbed.io/go/pay_7AAiYH0Ykt11ED4hmfiN?provider=stripe\"\n    },\n    {\n      \"id\": \"cashapp\",\n      \"name\": \"Cash App\",\n      \"note\": \"Cash App balance or card\",\n      \"recommended\": false,\n      \"handoff_url\": \"https://pay.railbed.io/go/pay_7AAiYH0Ykt11ED4hmfiN?provider=cashapp\"\n    }\n  ]\n}"
  },
  {
   "page": "api/payments",
   "section": "Retrieve a payment",
   "url": "https://railbed.io/docs/api/payments/#retrieve-a-payment",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\""
  },
  {
   "page": "api/payments",
   "section": "Retrieve a payment",
   "url": "https://railbed.io/docs/api/payments/#retrieve-a-payment",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const payment = await fetch(`https://pay.railbed.io/v1/payments/${id}`, {\n  headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` },\n}).then((r) => r.json());"
  },
  {
   "page": "api/payments",
   "section": "Retrieve a payment",
   "url": "https://railbed.io/docs/api/payments/#retrieve-a-payment",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "payment = requests.get(\n    f\"https://pay.railbed.io/v1/payments/{id}\",\n    headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n    timeout=15,\n).json()"
  },
  {
   "page": "api/payments",
   "section": "Retrieve a payment",
   "url": "https://railbed.io/docs/api/payments/#retrieve-a-payment",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$ch = curl_init('https://pay.railbed.io/v1/payments/' . rawurlencode($id));\ncurl_setopt_array($ch, [\n  CURLOPT_RETURNTRANSFER => true,\n  CURLOPT_HTTPHEADER => [\n    'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),\n  ],\n]);\n$payment = json_decode(curl_exec($ch), true);"
  },
  {
   "page": "api/payments",
   "section": "Retrieve a payment",
   "url": "https://railbed.io/docs/api/payments/#retrieve-a-payment",
   "language": "JSON",
   "label": "Response",
   "kind": "response",
   "code": "{\n  \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\",\n  \"object\": \"payment\",\n  \"url\": \"https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN\",\n  \"status\": \"paid\",\n  \"livemode\": true,\n  \"amount\": \"49.00\",\n  \"currency\": \"USD\",\n  \"description\": \"Pro Membership\",\n  \"reference\": \"order_1042\",\n  \"customer_email\": \"buyer@example.com\",\n  \"created\": 1790380525,\n  \"expires_at\": 1790466925,\n  \"started_at\": 1790380611,\n  \"metadata\": { \"user_id\": \"player_1042\" },\n  \"paid_at\": 1790381342,\n  \"provider\": \"stripe\",\n  \"hold_reason\": null,\n  \"hold_acceptable\": false,\n  \"canceled_at\": null,\n  \"settlement\": {\n    \"coin\": \"polygon_usdc\",\n    \"value_coin\": \"47.53\",\n    \"merchant_received\": \"46.341750\",\n    \"txid_in\": \"0x0c651ba1d59c7a32e8b1f4bd2c7e0e4f96a55d13a6b0f2d1c8e7a9b4f3d29b58\",\n    \"txid_out\": \"0x8202d1373e0a9c4f1b6d5e2c7a8f9b0e1d2c3b4a5f6e7d8c9b0a1f2e3d7356ff\",\n    \"payout_wallet\": \"0xF977814e90dA44bFA03b6295A0616a897441aceC\"\n  }\n}"
  },
  {
   "page": "api/payments",
   "section": "Retrieve a payment",
   "url": "https://railbed.io/docs/api/payments/#retrieve-a-payment",
   "language": "JSON",
   "label": "A held payment (trimmed)",
   "kind": "response",
   "code": "{\n  \"id\": \"pay_Kx81mQv2PzR0dT7eWcYa\",\n  \"object\": \"payment\",\n  \"status\": \"held\",\n  \"amount\": \"49.00\",\n  \"currency\": \"USD\",\n  \"paid_at\": null,\n  \"hold_reason\": \"The provider sent 24.50 USDC, below 90% of the order’s 49.00 USD value.\",\n  \"hold_acceptable\": true,\n  \"settlement\": { \"coin\": \"polygon_usdc\", \"value_coin\": \"24.50\", \"merchant_received\": null }\n}"
  },
  {
   "page": "api/payments",
   "section": "List payments",
   "url": "https://railbed.io/docs/api/payments/#list-payments",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl \"https://pay.railbed.io/v1/payments?limit=50\" \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\""
  },
  {
   "page": "api/payments",
   "section": "List payments",
   "url": "https://railbed.io/docs/api/payments/#list-payments",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "// Every payment, page by page\nlet cursor = null;\ndo {\n  const url = new URL('https://pay.railbed.io/v1/payments');\n  url.searchParams.set('limit', '100');\n  if (cursor) url.searchParams.set('starting_after', cursor);\n  const page = await fetch(url, {\n    headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` },\n  }).then((r) => r.json());\n  for (const payment of page.data) reconcile(payment);\n  cursor = page.next_cursor;\n} while (cursor);"
  },
  {
   "page": "api/payments",
   "section": "List payments",
   "url": "https://railbed.io/docs/api/payments/#list-payments",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "cursor = None\nwhile True:\n    params = {\"limit\": 100, **({\"starting_after\": cursor} if cursor else {})}\n    page = requests.get(\n        \"https://pay.railbed.io/v1/payments\",\n        headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n        params=params,\n        timeout=15,\n    ).json()\n    for payment in page[\"data\"]:\n        reconcile(payment)\n    cursor = page[\"next_cursor\"]\n    if not cursor:\n        break"
  },
  {
   "page": "api/payments",
   "section": "List payments",
   "url": "https://railbed.io/docs/api/payments/#list-payments",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$cursor = null;\ndo {\n  $query = http_build_query(array_filter([\n    'limit' => 100,\n    'starting_after' => $cursor,\n  ]));\n  $ch = curl_init('https://pay.railbed.io/v1/payments?' . $query);\n  curl_setopt_array($ch, [\n    CURLOPT_RETURNTRANSFER => true,\n    CURLOPT_HTTPHEADER => [\n      'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),\n    ],\n  ]);\n  $page = json_decode(curl_exec($ch), true);\n  foreach ($page['data'] as $payment) reconcile($payment);\n  $cursor = $page['next_cursor'];\n} while ($cursor);"
  },
  {
   "page": "api/payments",
   "section": "List payments",
   "url": "https://railbed.io/docs/api/payments/#list-payments",
   "language": "JSON",
   "label": "Response",
   "kind": "response",
   "code": "{\n  \"data\": [\n    {\n      \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\",\n      \"object\": \"payment\",\n      \"status\": \"paid\",\n      \"amount\": \"49.00\",\n      \"currency\": \"USD\"\n    },\n    {\n      \"id\": \"pay_Kx81mQv2PzR0dT7eWcYa\",\n      \"object\": \"payment\",\n      \"status\": \"held\",\n      \"amount\": \"49.00\",\n      \"currency\": \"USD\"\n    }\n  ],\n  \"has_more\": true,\n  \"next_cursor\": \"pay_Kx81mQv2PzR0dT7eWcYa\"\n}"
  },
  {
   "page": "api/payments",
   "section": "Simulate a payment",
   "url": "https://railbed.io/docs/api/payments/#simulate-a-payment",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl -X POST \\\n  https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN/simulate \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"outcome\": \"paid\" }'"
  },
  {
   "page": "api/payments",
   "section": "Simulate a payment",
   "url": "https://railbed.io/docs/api/payments/#simulate-a-payment",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const payment = await fetch(`https://pay.railbed.io/v1/payments/${id}/simulate`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({ outcome: 'paid' }),\n}).then((r) => r.json());"
  },
  {
   "page": "api/payments",
   "section": "Simulate a payment",
   "url": "https://railbed.io/docs/api/payments/#simulate-a-payment",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "payment = requests.post(\n    f\"https://pay.railbed.io/v1/payments/{id}/simulate\",\n    headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n    json={\"outcome\": \"paid\"},\n    timeout=15,\n).json()"
  },
  {
   "page": "api/payments",
   "section": "Simulate a payment",
   "url": "https://railbed.io/docs/api/payments/#simulate-a-payment",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$ch = curl_init(\n  'https://pay.railbed.io/v1/payments/' . rawurlencode($id) . '/simulate'\n);\ncurl_setopt_array($ch, [\n  CURLOPT_POST => true,\n  CURLOPT_RETURNTRANSFER => true,\n  CURLOPT_HTTPHEADER => [\n    'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),\n    'Content-Type: application/json',\n  ],\n  CURLOPT_POSTFIELDS => json_encode(['outcome' => 'paid']),\n]);\n$payment = json_decode(curl_exec($ch), true);"
  },
  {
   "page": "api/errors",
   "section": "The error object",
   "url": "https://railbed.io/docs/api/errors/#the-error-object",
   "language": "JSON",
   "label": "An error",
   "kind": "response",
   "code": "{\n  \"error\": {\n    \"code\": \"no_payout_wallet\",\n    \"message\": \"Add a payout wallet in the dashboard before taking live payments.\"\n  }\n}"
  },
  {
   "page": "api/errors",
   "section": "Handling errors",
   "url": "https://railbed.io/docs/api/errors/#handling-errors",
   "language": "Node.js",
   "label": "A small error handler",
   "kind": "code",
   "code": "async function railbed(path, init = {}) {\n  const res = await fetch(`https://pay.railbed.io/v1${path}`, {\n    ...init,\n    headers: {\n      Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,\n      'Content-Type': 'application/json',\n      ...init.headers,\n    },\n  });\n  const body = await res.json();\n  if (res.ok) return body;\n  const err = Object.assign(new Error(body.error.message), {\n    status: res.status,\n    code: body.error.code,\n    field: body.error.field,\n  });\n  err.retryable = res.status === 429 || res.status >= 500;\n  err.retryAfter = Number(res.headers.get('Retry-After')) || null;\n  throw err;\n}"
  },
  {
   "page": "webhooks",
   "section": "A receiver, end to end",
   "url": "https://railbed.io/docs/webhooks/#a-receiver-end-to-end",
   "language": "Node.js",
   "label": "Node.js · Express",
   "kind": "code",
   "code": "import crypto from 'node:crypto';\nimport express from 'express';\n\nconst app = express();\n\n// The raw body: verify exactly the bytes that were signed.\nconst rawJson = express.raw({ type: 'application/json' });\n\napp.post('/webhooks/railbed', rawJson, async (req, res) => {\n  const raw = req.body.toString('utf8');\n  const secret = process.env.RAILBED_WEBHOOK_SECRET;\n  const header = req.get('Railbed-Signature');\n  if (!verify(raw, header, secret)) return res.sendStatus(400);\n\n  const event = JSON.parse(raw);\n  const isNew = await db.events.insertIfAbsent(event.id); // UNIQUE(id)\n  if (isNew && event.type === 'payment.paid') {\n    await queue.add('fulfil', { paymentId: event.data.payment.id });\n  }\n  res.sendStatus(200);\n});\n\nfunction verify(raw, header, secret) {\n  const m = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');\n  if (!m) return false;\n  if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;\n  const expected = crypto\n    .createHmac('sha256', secret)\n    .update(`${m[1]}.${raw}`)\n    .digest('hex');\n  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));\n}"
  },
  {
   "page": "webhooks",
   "section": "A receiver, end to end",
   "url": "https://railbed.io/docs/webhooks/#a-receiver-end-to-end",
   "language": "Python",
   "label": "Python · Flask",
   "kind": "code",
   "code": "import hashlib, hmac, os, re, time\nfrom flask import Flask, request\n\napp = Flask(__name__)\n\n@app.post(\"/webhooks/railbed\")\ndef railbed_webhook():\n    raw = request.get_data()  # the raw bytes, before any JSON parsing\n    header = request.headers.get(\"Railbed-Signature\", \"\")\n    if not verify(raw, header, os.environ[\"RAILBED_WEBHOOK_SECRET\"]):\n        return \"\", 400\n    event = request.get_json()\n    is_new = db.events.insert_if_absent(event[\"id\"])\n    if is_new and event[\"type\"] == \"payment.paid\":\n        queue.enqueue(\"fulfil\", event[\"data\"][\"payment\"][\"id\"])\n    return \"\", 200\n\ndef verify(raw: bytes, header: str, secret: str) -> bool:\n    m = re.fullmatch(r\"t=(\\d+),v1=([0-9a-f]{64})\", header or \"\")\n    if not m:\n        return False\n    expected = hmac.new(\n        secret.encode(), m[1].encode() + b\".\" + raw, hashlib.sha256\n    ).hexdigest()\n    return (\n        abs(time.time() - int(m[1])) < 300\n        and hmac.compare_digest(expected, m[2])\n    )"
  },
  {
   "page": "webhooks",
   "section": "A receiver, end to end",
   "url": "https://railbed.io/docs/webhooks/#a-receiver-end-to-end",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$raw = file_get_contents('php://input');\n$header = $_SERVER['HTTP_RAILBED_SIGNATURE'] ?? '';\n\nif (!railbed_verify($raw, $header, getenv('RAILBED_WEBHOOK_SECRET'))) {\n  http_response_code(400);\n  exit;\n}\n\n$event = json_decode($raw, true);\nif (events_insert_if_absent($event['id']) && $event['type'] === 'payment.paid') {\n  queue_fulfilment($event['data']['payment']['id']);\n}\nhttp_response_code(200);\n\nfunction railbed_verify(string $raw, string $header, string $secret): bool {\n  if (!preg_match('/^t=(\\d+),v1=([0-9a-f]{64})$/', $header, $m)) return false;\n  $expected = hash_hmac('sha256', $m[1] . '.' . $raw, $secret);\n  return abs(time() - (int) $m[1]) < 300 && hash_equals($expected, $m[2]);\n}"
  },
  {
   "page": "webhooks/events",
   "section": "The event object",
   "url": "https://railbed.io/docs/webhooks/events/#the-event-object",
   "language": "JSON",
   "label": "payment.paid",
   "kind": "response",
   "code": "{\n  \"id\": \"evt_4Qm8ZsUe2VhNc7RwTb1Y\",\n  \"type\": \"payment.paid\",\n  \"created\": 1790381342,\n  \"livemode\": true,\n  \"data\": {\n    \"payment\": {\n      \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\",\n      \"mode\": \"live\",\n      \"source\": \"api\",\n      \"checkoutId\": null,\n      \"planLabel\": null,\n      \"description\": \"Pro Membership\",\n      \"reference\": \"order_1042\",\n      \"customerEmail\": \"buyer@example.com\",\n      \"amount\": \"49.00\",\n      \"currency\": \"USD\",\n      \"status\": \"paid\",\n      \"provider\": \"stripe\",\n      \"providerName\": \"Stripe\",\n      \"depositAddress\": \"0x5b0e8a3f2d1c4b7a9e6f0d3c2b1a4e7f8d9c0b1a\",\n      \"payoutWallet\": \"0xF977814e90dA44bFA03b6295A0616a897441aceC\",\n      \"feeBps\": 150,\n      \"valueCoin\": \"47.53\",\n      \"merchantReceived\": \"46.341750\",\n      \"coin\": \"polygon_usdc\",\n      \"holdReason\": null,\n      \"holdAcceptable\": false,\n      \"txidIn\": \"0x0c651ba1d59c7a32e8b1f4bd2c7e0e4f96a55d13a6b0f2d1c8e7a9b4f3d29b58\",\n      \"txidOut\": \"0x8202d1373e0a9c4f1b6d5e2c7a8f9b0e1d2c3b4a5f6e7d8c9b0a1f2e3d7356ff\",\n      \"metadata\": { \"user_id\": \"player_1042\" },\n      \"customerName\": null,\n      \"memo\": null,\n      \"trackingUrl\": null,\n      \"canceledAt\": null,\n      \"successUrl\": \"https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}\",\n      \"createdAt\": 1790380525000,\n      \"paidAt\": 1790381342000,\n      \"expiresAt\": 1790466925000,\n      \"url\": \"https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN\"\n    }\n  }\n}"
  },
  {
   "page": "webhooks/events",
   "section": "payment.started",
   "url": "https://railbed.io/docs/webhooks/events/#payment-started",
   "language": "JSON",
   "label": "payment.started (trimmed)",
   "kind": "response",
   "code": "{\n  \"id\": \"evt_9sPq2XbLr5TtVn0KcWmE\",\n  \"type\": \"payment.started\",\n  \"created\": 1790380611,\n  \"livemode\": true,\n  \"data\": {\n    \"payment\": {\n      \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\",\n      \"status\": \"open\",\n      \"customerEmail\": \"buyer@example.com\",\n      \"amount\": \"49.00\",\n      \"currency\": \"USD\",\n      \"provider\": null,\n      \"depositAddress\": \"0x5b0e8a3f2d1c4b7a9e6f0d3c2b1a4e7f8d9c0b1a\",\n      \"feeBps\": 150\n    }\n  }\n}"
  },
  {
   "page": "webhooks/events",
   "section": "payment.held",
   "url": "https://railbed.io/docs/webhooks/events/#payment-held",
   "language": "JSON",
   "label": "payment.held (trimmed)",
   "kind": "response",
   "code": "{\n  \"id\": \"evt_Hq3nW8ZkT1cVbR6sYp0M\",\n  \"type\": \"payment.held\",\n  \"created\": 1790381342,\n  \"livemode\": true,\n  \"data\": {\n    \"payment\": {\n      \"id\": \"pay_Kx81mQv2PzR0dT7eWcYa\",\n      \"status\": \"held\",\n      \"amount\": \"49.00\",\n      \"currency\": \"USD\",\n      \"provider\": \"stripe\",\n      \"valueCoin\": \"24.50\",\n      \"coin\": \"polygon_usdc\",\n      \"holdReason\": \"The provider sent 24.50 USDC, below 90% of the order’s 49.00 USD value.\",\n      \"holdAcceptable\": true,\n      \"paidAt\": null\n    }\n  }\n}"
  },
  {
   "page": "webhooks/events",
   "section": "ping",
   "url": "https://railbed.io/docs/webhooks/events/#ping",
   "language": "JSON",
   "label": "ping",
   "kind": "response",
   "code": "{\n  \"id\": \"evt_T2rVx9KcQm4NwLb7Ez0P\",\n  \"type\": \"ping\",\n  \"created\": 1790380800,\n  \"livemode\": false,\n  \"data\": { \"payment\": null }\n}"
  },
  {
   "page": "webhooks/signatures",
   "section": "Verify a delivery",
   "url": "https://railbed.io/docs/webhooks/signatures/#verify-a-delivery",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "import crypto from 'node:crypto';\n\nexport function verifyRailbedSignature(\n  rawBody,\n  header,\n  secret,\n  toleranceSeconds = 300,\n) {\n  const match = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');\n  if (!match) return false;\n  const [, timestamp, signature] = match;\n  const age = Math.abs(Date.now() / 1000 - Number(timestamp));\n  if (age > toleranceSeconds) return false;\n  const expected = crypto\n    .createHmac('sha256', secret)\n    .update(`${timestamp}.${rawBody}`)\n    .digest('hex');\n  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));\n}"
  },
  {
   "page": "webhooks/signatures",
   "section": "Verify a delivery",
   "url": "https://railbed.io/docs/webhooks/signatures/#verify-a-delivery",
   "language": "TypeScript",
   "label": "Web Crypto · Cloudflare Workers, Deno, Bun, Next.js route handlers",
   "kind": "code",
   "code": "export async function verifyRailbedSignature(\n  rawBody: string,\n  header: string | null,\n  secret: string,\n  toleranceSeconds = 300,\n) {\n  const match = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');\n  if (!match) return false;\n  const [, timestamp, signature] = match;\n  const age = Math.abs(Date.now() / 1000 - Number(timestamp));\n  if (age > toleranceSeconds) return false;\n  const enc = new TextEncoder();\n  const key = await crypto.subtle.importKey(\n    'raw',\n    enc.encode(secret),\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['verify'],\n  );\n  const bytes = new Uint8Array(\n    signature.match(/../g)!.map((h) => parseInt(h, 16)),\n  );\n  const signedPayload = enc.encode(`${timestamp}.${rawBody}`);\n  // subtle.verify compares in constant time\n  return crypto.subtle.verify('HMAC', key, bytes, signedPayload);\n}"
  },
  {
   "page": "webhooks/signatures",
   "section": "Verify a delivery",
   "url": "https://railbed.io/docs/webhooks/signatures/#verify-a-delivery",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "import hashlib, hmac, re, time\n\ndef verify_railbed_signature(\n    raw_body: bytes,\n    header: str,\n    secret: str,\n    tolerance_seconds: int = 300,\n) -> bool:\n    match = re.fullmatch(r\"t=(\\d+),v1=([0-9a-f]{64})\", header or \"\")\n    if not match:\n        return False\n    timestamp, signature = match.groups()\n    if abs(time.time() - int(timestamp)) > tolerance_seconds:\n        return False\n    expected = hmac.new(\n        secret.encode(), timestamp.encode() + b\".\" + raw_body, hashlib.sha256\n    ).hexdigest()\n    return hmac.compare_digest(expected, signature)"
  },
  {
   "page": "webhooks/signatures",
   "section": "Verify a delivery",
   "url": "https://railbed.io/docs/webhooks/signatures/#verify-a-delivery",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\nfunction verify_railbed_signature(\n  string $rawBody,\n  string $header,\n  string $secret,\n  int $toleranceSeconds = 300\n): bool {\n  if (!preg_match('/^t=(\\d+),v1=([0-9a-f]{64})$/', $header, $match)) return false;\n  [, $timestamp, $signature] = $match;\n  if (abs(time() - (int) $timestamp) > $toleranceSeconds) return false;\n  $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);\n  return hash_equals($expected, $signature);\n}"
  },
  {
   "page": "webhooks/signatures",
   "section": "Verify a delivery",
   "url": "https://railbed.io/docs/webhooks/signatures/#verify-a-delivery",
   "language": "Ruby",
   "label": "Ruby",
   "kind": "code",
   "code": "require 'openssl'\nrequire 'rack/utils' # secure_compare; Rails and Sinatra already load it\n\ndef verify_railbed_signature(raw_body, header, secret, tolerance_seconds = 300)\n  match = /\\At=(\\d+),v1=([0-9a-f]{64})\\z/.match(header.to_s)\n  return false unless match\n  timestamp, signature = match.captures\n  return false if (Time.now.to_i - timestamp.to_i).abs > tolerance_seconds\n  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, \"#{timestamp}.#{raw_body}\")\n  Rack::Utils.secure_compare(expected, signature)\nend"
  },
  {
   "page": "webhooks/signatures",
   "section": "Verify a delivery",
   "url": "https://railbed.io/docs/webhooks/signatures/#verify-a-delivery",
   "language": "Go",
   "label": "Go",
   "kind": "code",
   "code": "package railbed\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"time\"\n)\n\nvar signatureHeader = regexp.MustCompile(`^t=(\\d+),v1=([0-9a-f]{64})$`)\n\nfunc VerifySignature(\n\trawBody []byte,\n\theader, secret string,\n\ttolerance time.Duration,\n) bool {\n\tm := signatureHeader.FindStringSubmatch(header)\n\tif m == nil {\n\t\treturn false\n\t}\n\tts, err := strconv.ParseInt(m[1], 10, 64)\n\tif err != nil {\n\t\treturn false\n\t}\n\tif age := time.Since(time.Unix(ts, 0)); age > tolerance || age < -tolerance {\n\t\treturn false\n\t}\n\tmac := hmac.New(sha256.New, []byte(secret))\n\tmac.Write([]byte(m[1] + \".\"))\n\tmac.Write(rawBody)\n\texpected := hex.EncodeToString(mac.Sum(nil))\n\treturn hmac.Equal([]byte(expected), []byte(m[2]))\n}"
  },
  {
   "page": "webhooks/signatures",
   "section": "Use the raw body",
   "url": "https://railbed.io/docs/webhooks/signatures/#use-the-raw-body",
   "language": "TypeScript",
   "label": "A complete Cloudflare Worker or Next.js route handler",
   "kind": "code",
   "code": "export async function POST(request: Request) {\n  const raw = await request.text();\n  const header = request.headers.get('Railbed-Signature');\n  const secret = process.env.RAILBED_WEBHOOK_SECRET!;\n  if (!(await verifyRailbedSignature(raw, header, secret))) {\n    return new Response('Invalid signature', { status: 400 });\n  }\n  const event = JSON.parse(raw);\n  // Record event.id with a unique constraint, queue the work, then answer.\n  return new Response(null, { status: 200 });\n}"
  },
  {
   "page": "webhooks/signatures",
   "section": "Test vector",
   "url": "https://railbed.io/docs/webhooks/signatures/#test-vector",
   "language": "cURL",
   "label": "Reproduce it with OpenSSL",
   "kind": "code",
   "code": "printf '%s' '1790380800.' \\\n  '{\"id\":\"evt_ExampleEventId0001\",\"type\":\"ping\",\"created\":1790380800,' \\\n  '\"livemode\":false,\"data\":{\"payment\":null}}' \\\n  | openssl dgst -sha256 -hmac 'whsec_4mJ9pQx2VtR7cY1nKs8LwZ3bHd6fGa0e'"
  }
 ]
}
