{
 "product": "Railbed",
 "apiBase": "https://pay.railbed.io/v1",
 "llms": "https://railbed.com/docs/llms.txt",
 "authMd": "https://railbed.com/auth.md",
 "sections": [
  {
   "title": "Start here",
   "pages": [
    "user-guide",
    "user-guide/getting-started",
    "user-guide/test-and-live"
   ],
   "audience": "merchants"
  },
  {
   "title": "Take payments",
   "pages": [
    "user-guide/payment-links",
    "user-guide/checkout-pages",
    "user-guide/pricing-tables",
    "user-guide/widgets-and-buttons",
    "user-guide/buyer-experience",
    "user-guide/crypto-payments"
   ],
   "audience": "merchants"
  },
  {
   "title": "Orders and customers",
   "pages": [
    "user-guide/orders",
    "user-guide/customers",
    "user-guide/search"
   ],
   "audience": "merchants"
  },
  {
   "title": "Manage your money",
   "pages": [
    "user-guide/payments",
    "user-guide/held-payments",
    "user-guide/wallet-and-settlement"
   ],
   "audience": "merchants"
  },
  {
   "title": "Your business",
   "pages": [
    "user-guide/branding-and-settings",
    "user-guide/integrations",
    "user-guide/mobile"
   ],
   "audience": "merchants"
  },
  {
   "title": "Help and reference",
   "pages": [
    "user-guide/troubleshooting",
    "user-guide/glossary"
   ],
   "audience": "merchants"
  },
  {
   "title": "Get started",
   "pages": [
    "",
    "quickstart",
    "how-it-works",
    "testing"
   ],
   "audience": "developers"
  },
  {
   "title": "Guides",
   "pages": [
    "guides/hosted-checkout",
    "guides/custom-checkout",
    "guides/fulfilment",
    "guides/no-code",
    "guides/woocommerce"
   ],
   "audience": "developers"
  },
  {
   "title": "API reference",
   "pages": [
    "api",
    "api/checkout-sessions",
    "api/payments",
    "api/orders",
    "api/customers",
    "api/errors"
   ],
   "audience": "developers"
  },
  {
   "title": "Webhooks",
   "pages": [
    "webhooks",
    "webhooks/events",
    "webhooks/signatures"
   ],
   "audience": "developers"
  },
  {
   "title": "Resources",
   "pages": [
    "agents"
   ],
   "audience": "developers"
  }
 ],
 "pages": [
  {
   "id": "user-guide",
   "title": "Your guide to getting paid",
   "nav": "Welcome to Railbed",
   "section": "Start here",
   "description": "Set up your business, take your first card payment, and follow every sale to your wallet. Start here, with or without a website.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/",
   "path": "/docs/user-guide/",
   "markdown": "https://railbed.com/docs/user-guide.md",
   "updated": "2026-09-26",
   "bodyLineOffset": 8,
   "markdownHash": "b3cf67a4e244c78adae37ddcf2e3e3b24df6632e2bf73ac5d6ed5e6e8783cd98",
   "sections": [
    {
     "heading": "Your guide to getting paid",
     "anchor": "",
     "level": 1,
     "startLine": 0,
     "text": "New to Railbed? Set up your account and try a Test payment. Visual example: How a payment works. 1. Share your checkout: Send a payment link or publish a reusable checkout. You set the price. 2. Your buyer pays by card: They choose an eligible provider and pay on its page. Provider checks may apply. 3. Confirm the payment: Read the payment status and reported USDC settlement in Railbed. Your wallet · Polygon. Deliver only when the payment is Paid. A browser return does not confirm payment. The Live payment journey. In Test mode, the provider and settlement are simulated.",
     "endLine": 49
    },
    {
     "heading": "Start with your first payment",
     "anchor": "start-with-your-first-payment",
     "level": 2,
     "startLine": 6,
     "text": "Start with your first payment A customer pays by card. You receive USDC on Polygon in a wallet you control. Railbed gives you the checkout, a record of each payment, and a place to manage it all. Start in Test mode to try the full experience without charging a card. When you are ready to take real payments, create your customer-facing links in Live mode . - Set up your account: Add your business, choose a payout wallet, and find your way around the dashboard. - Try a test payment: See a successful payment, an underpayment, and a decline before going live.",
     "endLine": 15
    },
    {
     "heading": "Choose how to get paid",
     "anchor": "choose-how-to-get-paid",
     "level": 2,
     "startLine": 15,
     "text": "Choose how to get paid The right starting point depends on whether you are requesting one payment or selling the same thing repeatedly. You want to… Use What you share --- --- --- Ask one customer for a specific amount Payment link A one-time link or QR code, with an optional invoice reference Sell one product to many buyers Checkout page A reusable page; each buyer gets a separate payment Let buyers choose between packages Pricing table A reusable page with up to four plans Take payments inside your website Widget or buy button A short HTML snippet copied from the dashboard Connect a WordPress store WooCommerce The Railbed plugin, connected to your account",
     "endLine": 27
    },
    {
     "heading": "Make Railbed part of your day",
     "anchor": "make-railbed-part-of-your-day",
     "level": 2,
     "startLine": 27,
     "text": "Make Railbed part of your day - Manage your orders: See items, payment attempts and what still needs to be fulfilled. - Know your customers: Read purchase histories, save private notes and manage customer data. - Understand your payments: Find an order, read its status, and open its timeline. - Read your wallet: Understand the difference between a balance, a sale, and the amount received. - Make it yours: Set your business name, logo, support email, and checkout appearance. - Solve a problem: Find the next step for a missing payment, an unavailable checkout, or a buyer who needs help. Use account search to jump between records. Prefer reading in the dark? Choose Dark or Match system from the docs theme selector. Appearance settings explains the separate dashboard and checkout preferences.",
     "endLine": 38
    },
    {
     "heading": "A few things to know",
     "anchor": "a-few-things-to-know",
     "level": 2,
     "startLine": 38,
     "text": "A few things to know You control the wallet. Railbed does not hold a balance for you to withdraw. Settlement goes to your own wallet. Your buyer pays with a card provider. They enter card details on that provider's page. Available providers and identity checks vary by buyer, country, currency and amount. Payment status is what matters. Check that a payment is Paid before delivering the order. A buyer returning to a thank-you page is not proof of payment. Read the statuses. Pricing tables are one-time purchases. A monthly or yearly label does not create automatic recurring billing. Building your own store, app or game? Switch to the developer docs for API examples and signed webhooks. These user guides focus on the dashboard and everyday merchant tasks.",
     "endLine": 49
    }
   ]
  },
  {
   "id": "user-guide/getting-started",
   "title": "Set up your account",
   "nav": "Set up your account",
   "section": "Start here",
   "description": "Go from a new account to a checkout you can share, with the right business details and payout wallet in place.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/getting-started/",
   "path": "/docs/user-guide/getting-started/",
   "markdown": "https://railbed.com/docs/user-guide/getting-started.md",
   "updated": "2026-10-04",
   "bodyLineOffset": 8,
   "markdownHash": "d32b8a3d5bea7c0196c784e36331364c1a41d91007daf3816afe987a2d9d91d8",
   "sections": [
    {
     "heading": "Before you begin",
     "anchor": "before-you-begin",
     "level": 2,
     "startLine": 0,
     "text": "Before you begin Have a business name, a product or service to sell, and the public address of a self-custody wallet on Polygon . Use a wallet you control, rather than an exchange deposit address. The address tells Railbed where to send settlement; you never need to give Railbed a seed phrase or private key. Your customers see prices in USD, EUR, GBP, CAD or AUD. Settlement reaches your wallet as USDC on Polygon, regardless of the checkout currency.",
     "endLine": 6
    },
    {
     "heading": "Create your account",
     "anchor": "create-your-account",
     "level": 2,
     "startLine": 6,
     "text": "Create your account 1. Open Railbed signup. Continue with email or a sign-in option shown on the page, then complete any verification prompts. 2. In Set up your business , enter the name buyers should see, add your logo if you have one, and choose Connect a wallet to pick your payout wallet from MetaMask, Trust Wallet, Rabby, Rainbow or any wallet that scans a WalletConnect QR code. Railbed reads the wallet's public address and disconnects; it never asks you to sign anything or approve a transaction. No wallet yet? Create one with Base Account , Coinbase's free wallet that you set up with a passkey. You can also choose Paste an address instead : Railbed checks a pasted address for typos and shows it in groups of four so you can compare it with your wallet app. 3. In How will you take payments? , choose your main way to sell. You can add the others later from Integrations . Choose When What happens next --- --- --- Send payment links or invoices You bill customers one at a time, or sell without a website Make your first payment link and share it by link or QR code. You can try a test payment first. Add it to your own app or website You have a site or app of your own Without a developer: make a checkout and paste two lines of HTML. With one: connect your server with a Test key, then a Live key, through the API. Sell on Shopify Your store runs on Shopify Enter your store address, then follow the Shopify setup page's four steps. Sell on WooCommerce Your store runs on WordPress Download the plugin, then follow the WooCommerce setup. 4. Back on Wallet , the Your first payment card lists what's left for your way to sell and ticks each step as it happens. Hide it when you don't need it; Settings brings it back. 5. Check the dashboard's Live switch before sharing anything. Turn Live off to use Test mode. A link or checkout belongs to the mode in which you created it. [!IMPORTANT] Test mode and Live mode have separate checkouts and payment records. Switching the dashboard to Live does not convert a Test checkout into a real one. Create a checkout in the intended mode and share its new link.",
     "endLine": 25
    },
    {
     "heading": "Find your way around",
     "anchor": "find-your-way-around",
     "level": 2,
     "startLine": 25,
     "text": "Find your way around Dashboard area What you do there --- --- Wallet Follow the steps to your first payment, and see your latest checkout, payout wallet, recent receipts and payments needing review Orders Review items, customers, payment attempts and fulfilment for each purchase Customers See purchase histories, contact details and your private customer notes Payments Search all payment sources, filter by status and inspect a payment's details Payment links Request a one-time payment, share its QR code and follow its progress Checkouts Manage reusable checkout pages, pricing tables and payment widgets Create Choose which payment experience to make next Integrations Download the WooCommerce plugin and review ways to connect Railbed Developers Create API keys (full access or read only), connect webhooks, inspect event deliveries and manage connected AI agents Settings Change your business details, branding, profile photo or payout wallet Use Search in the sidebar or phone navigation to find a record or jump to another page. Account search supports keyboard shortcuts and queries such as an order number, email or payment ID. Choose the dashboard's Light , Dark or Match system appearance in Settings. You may see additional controls if your account is a platform operator. They are not needed for ordinary merchant setup.",
     "endLine": 44
    },
    {
     "heading": "Make your first practice checkout",
     "anchor": "make-your-first-practice-checkout",
     "level": 2,
     "startLine": 44,
     "text": "Make your first practice checkout 1. Turn Live off. Confirm the Test mode label is visible. 2. Choose Create → Checkout page . Enter an example product such as Design consultation, a price such as 49.00 , and USD. 3. Create the page, then open its Share tab and choose Open checkout . 4. Follow Try a test payment to experience the buyer's steps and confirm the result in Payments. Visual example: A reusable checkout page. A checkout shows Northstar Studio, Design consultation, $49.00 USD and an email field. Continue to pay leads to provider selection; card details are entered on the provider's page. Illustrative example · your product on the left, buyer details on the right.",
     "endLine": 55
    },
    {
     "heading": "Finish your business settings",
     "anchor": "finish-your-business-settings",
     "level": 2,
     "startLine": 55,
     "text": "Finish your business settings In Settings, add your logo and support email. Buyers use those details to recognize your business and get help with their order. Choose a checkout brand color and Save settings . Logo and profile photo uploads save as soon as you finish cropping them. Branding and settings explains where each detail appears and which changes are shared across modes.",
     "endLine": 61
    },
    {
     "heading": "When you are ready for customers",
     "anchor": "when-you-are-ready-for-customers",
     "level": 2,
     "startLine": 61,
     "text": "When you are ready for customers Open Test and Live mode and work through the launch checklist. Confirm the payout address, the product price and currency, the buyer experience, and how you will deliver orders after payment. For a one-off invoice or deposit, start with a payment link. For a product you sell repeatedly, keep using a checkout page.",
     "endLine": 66
    }
   ]
  },
  {
   "id": "user-guide/test-and-live",
   "title": "Test and Live mode",
   "nav": "Test and Live mode",
   "section": "Start here",
   "description": "Practice the buyer experience without moving money, then prepare a separate checkout for real customers.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/test-and-live/",
   "path": "/docs/user-guide/test-and-live/",
   "markdown": "https://railbed.com/docs/user-guide/test-and-live.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "d60863c328582516d960d75c8f0eef255f7b6e0bdc62cc4dd1f863879521ef97",
   "sections": [
    {
     "heading": "Check the mode before you create",
     "anchor": "check-the-mode-before-you-create",
     "level": 2,
     "startLine": 0,
     "text": "Check the mode before you create Use the Live switch in the dashboard. Live off means Test mode. The app shows a Test mode banner and labels so you can tell which records you are viewing. What changes Test mode Live mode --- --- --- Card payments Simulated; no card is charged Real payments through available card providers Wallet display Simulated USDC received in the last 7 days USDC balance read from your wallet on Polygon Links, checkouts, orders, customers and payments Separate Test records Separate Live records Provider list Fixed for practice Depends on current availability and buyer eligibility Webhooks Real signed HTTP deliveries for simulated events Real signed HTTP deliveries for actual events Business details, branding and the payout wallet in Settings are shared across both modes. Changing those settings while viewing Test mode still changes your account settings.",
     "endLine": 14
    },
    {
     "heading": "Run a practice payment",
     "anchor": "run-a-practice-payment",
     "level": 2,
     "startLine": 14,
     "text": "Run a practice payment 1. Turn Live off and create a payment link or checkout with a sample price, such as $49. 2. Open the buyer link and continue with an email address you use for testing. Choose one of the available Test providers. 3. Railbed opens a simulated provider page. Choose Simulate successful payment . You do not enter card details. 4. Return to the original checkout tab. It updates to the paid result, or follows your success URL if you set one. 5. Open Payments in Test mode. Find the payment, confirm Paid , and inspect the timeline and simulated receipt. You can also open Orders to inspect the purchase and its attempts, then open its customer. These are Test records. A simulated Paid status does not represent a real charge or settlement.",
     "endLine": 24
    },
    {
     "heading": "Practice the exceptions too",
     "anchor": "practice-the-exceptions-too",
     "level": 2,
     "startLine": 24,
     "text": "Practice the exceptions too Create a new Test payment for each completed-payment scenario. A declined card can be retried on the same payment. - Simulate an underpayment: the payment becomes Underpaid . Open its details to understand the hold and any available review action. - Simulate declined card: the payment stays open. Return to the checkout tab, choose Choose another way to pay , and try another provider. You can then simulate a successful payment on the same checkout. - Leave a payment unfinished: see how an open or in-progress payment differs from a paid one. A Live card decline happens on the provider's page and is not directly reported to Railbed. The payment may stay open or in progress until it is paid or expires. Test mode teaches the UI and integration behavior; it does not prove which providers will approve a real buyer. [!NOTE] A configured Test webhook still sends real requests to its endpoint. Use an endpoint intended for testing, especially if your connected system sends emails or fulfils orders automatically.",
     "endLine": 37
    },
    {
     "heading": "Before sharing a Live link",
     "anchor": "before-sharing-a-live-link",
     "level": 2,
     "startLine": 37,
     "text": "Before sharing a Live link - [ ] Confirm the full Polygon payout address in Settings against the wallet you control. - [ ] Review your business name, logo, support email and displayed Railbed fee. - [ ] Switch to Live and create the payment link or checkout there. - [ ] Open that exact buyer link and confirm its price, currency, description and appearance. - [ ] Copy the Live embed code again if you are using a button or widget. - [ ] Confirm how you will deliver the order after Paid , and what you will do with a payment that needs review. - [ ] If you use a store or API integration, connect the matching Live key and Live webhook endpoint. Opening a Live checkout is different from completing a card payment. A successful Test payment does not move funds or demonstrate real settlement.",
     "endLine": 49
    },
    {
     "heading": "Where did my checkout go?",
     "anchor": "where-did-my-checkout-go",
     "level": 2,
     "startLine": 49,
     "text": "Where did my checkout go? Check the mode first. A Test link, payment or key is not listed in Live mode, and vice versa. Switching modes changes the records you see; it does not delete them. The buyer URL already identifies its checkout or payment. Your current dashboard mode does not change what a customer sees when they open a previously shared URL.",
     "endLine": 54
    }
   ]
  },
  {
   "id": "user-guide/payment-links",
   "title": "Create and share a payment link",
   "nav": "Payment links",
   "section": "Take payments",
   "description": "Request one payment for one customer. Add an invoice reference, share a link or QR code, and track it until it is paid.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/payment-links/",
   "path": "/docs/user-guide/payment-links/",
   "markdown": "https://railbed.com/docs/user-guide/payment-links.md",
   "updated": "2026-09-26",
   "bodyLineOffset": 8,
   "markdownHash": "c6caf31bb5b38b86aedd10880caa7147f34e256aa8de4e07e05587b6a0d7f4bb",
   "sections": [
    {
     "heading": "When to use a payment link",
     "anchor": "when-to-use-a-payment-link",
     "level": 2,
     "startLine": 0,
     "text": "When to use a payment link Use a payment link for an invoice, a project deposit, a consultation or a custom order. It has a fixed amount and can be paid once. To sell the same product repeatedly, use a checkout page instead. Visual example: Create a payment link. New payment link: Amount 49.00, Currency USD, What it is for Design consultation, Reference INV-0042, Can be paid for 7 days. The buyer sees a payment request with the amount, description and reference. Illustrative example · a $49 consultation with a 7-day payment link.",
     "endLine": 8
    },
    {
     "heading": "Create the request",
     "anchor": "create-the-request",
     "level": 2,
     "startLine": 8,
     "text": "Create the request 1. Check Test or Live mode, then open Create → Payment link , or choose New payment link in Payment links. 2. Enter the Amount , Currency , and What it’s for . The description is shown to the customer, so use a recognizable product or service name. 3. Add the optional customer and invoice details below. 4. Choose how long the link Can be paid for : 1 day , 7 days , or 30 days . The default is 7 days. 5. Choose Create payment link . Review the saved details before sharing the link. Field What to enter Example --- --- --- Amount A price from 1.00 to 100,000.00; provider minimums may be higher 49.00 Currency USD, EUR, GBP, CAD or AUD USD What it’s for A customer-facing description, up to 120 characters Design consultation Reference An optional order or invoice number, up to 64 characters INV-0042 Name An optional customer name, up to 80 characters Alex Morgan Email Prefills the buyer's email at checkout alex@example.com Note to the customer An optional note shown on the payment page, up to 500 characters Consultation on Friday at 2 pm Railbed does not email the payment link. Adding a customer email prefills checkout; you still send the link yourself. A customer's name or email is not an access restriction. Share the payment link only with people who should see the request.",
     "endLine": 28
    },
    {
     "heading": "Share the payment link or QR code",
     "anchor": "share-the-payment-link-or-qr-code",
     "level": 2,
     "startLine": 28,
     "text": "Share the payment link or QR code The saved request has a Payment link section. Copy the link into your invoice, message or email. Use its QR code for a printed invoice or for a customer to scan from another screen. The QR code opens the same payment page; it is not a crypto transfer address. Here is a message you can adapt: Example: Example message to a customer text Hi Alex, Your design consultation is ready to book. The total is $49.00 USD (reference INV-0042). Pay using the Railbed link included in this message. The link is available for 7 days. Thank you, Northstar Studio Paste your actual saved link into the message before sending it. Recheck the mode: a Test link cannot collect real money.",
     "endLine": 51
    },
    {
     "heading": "Share progress with someone else",
     "anchor": "share-progress-with-someone-else",
     "level": 2,
     "startLine": 51,
     "text": "Share progress with someone else Use the separate Tracking link for an accountant or collaborator who needs to see whether the request has been paid. Visual example: A status-only tracking page. The tracking page shows a $49 Design consultation for Alex Morgan, reference INV-0042, In progress. It omits the email, note, wallet, transactions and a way to pay. Illustrative example · share progress without sharing payment details. The tracking page shows the amount, status, progress, reference and billed-to name. It omits the buyer's email, your note, deposit and wallet details, transaction links and a way to pay. Anyone with the tracking link can see that limited information; treat it as a shareable link, not a login-protected report.",
     "endLine": 61
    },
    {
     "heading": "Read the result",
     "anchor": "read-the-result",
     "level": 2,
     "startLine": 61,
     "text": "Read the result Open the request in Payment links to see its status, details and timeline. Once paid, the buyer's payment link shows their receipt. A held payment shows that it is being reviewed. The list's Paid filter also includes payments being reviewed, and Expired includes declined payments. Always read the individual row status before delivering an order. Payment statuses explains the next action for each one.",
     "endLine": 67
    },
    {
     "heading": "Cancel, duplicate or replace a request",
     "anchor": "cancel-duplicate-or-replace-a-request",
     "level": 2,
     "startLine": 67,
     "text": "Cancel, duplicate or replace a request Cancel link is available only while the link is still payable and the buyer has not gone to a card provider. After that hand-off, the link stays open for the payment to arrive or expire. Canceling a request does not reverse a card payment or refund money. Saved payment links are not edited in place. To correct an amount or request payment again, choose Duplicate , review the copied fields, and create a new link. Duplicate clears the reference. Once the original payment has started, it also clears the saved email, so review and re-enter the details needed for the new request. Cancel the old link if that action is still available.",
     "endLine": 73
    },
    {
     "heading": "Send the buyer to your thank-you page",
     "anchor": "send-the-buyer-to-your-thank-you-page",
     "level": 2,
     "startLine": 73,
     "text": "Send the buyer to your thank-you page Under More options , fill After payment, send them to . You can include {PAYMENT_ID} and {REFERENCE} : Example: Example success URL text https://example.com/thanks?payment={PAYMENT_ID}&order={REFERENCE} The original Railbed tab follows this URL after payment is confirmed. If the buyer closes the tab, the redirect cannot happen. Leaving the field empty shows Railbed's on-screen receipt; it does not send a receipt email. The buyer experience explains the two-tab flow.",
     "endLine": 84
    }
   ]
  },
  {
   "id": "user-guide/checkout-pages",
   "title": "Create a reusable checkout",
   "nav": "Checkout pages",
   "section": "Take payments",
   "description": "Sell one product at one price with a page you can share again and again. Every buyer gets a separate payment.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/checkout-pages/",
   "path": "/docs/user-guide/checkout-pages/",
   "markdown": "https://railbed.com/docs/user-guide/checkout-pages.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "5ea77778c67f43befee5d56b44e544336fb6cecab42a96fce6dea5ac761c1733",
   "sections": [
    {
     "heading": "One page, many customers",
     "anchor": "one-page-many-customers",
     "level": 2,
     "startLine": 0,
     "text": "One page, many customers A checkout page is a reusable product page hosted by Railbed. Unlike a one-time payment link, it keeps accepting new buyers until you delete it. Share it in your website, social profile or messages, or use it behind a buy button. Visual example: A reusable checkout page. A checkout shows Northstar Studio, Design consultation, $49.00 USD and an email field. Continue to pay leads to provider selection; card details are entered on the provider's page. Illustrative example · your product on the left, buyer details on the right.",
     "endLine": 8
    },
    {
     "heading": "Build your checkout",
     "anchor": "build-your-checkout",
     "level": 2,
     "startLine": 8,
     "text": "Build your checkout 1. Check the dashboard mode, then choose Create → Checkout page . 2. Enter the product Name , optional Description , Price and Currency . The preview shows how those details appear to buyers. 3. Choose the Checkout theme , Light or Dark. The editor also lets you choose a brand color; that brand setting applies across your business's checkouts. 4. Optionally change the Button text . Keep the next action clear, such as Continue to pay. This changes the button inside the checkout; a buy button on your website has its own label. 5. Choose the Provider choice : Smart Routing (the default), Buyers choose, or One provider. How buyers reach a provider explains each. 6. Under More options , add a success URL if you have a thank-you page. 7. Choose Create checkout page , then open the saved page's Share tab. Names can be up to 80 characters; descriptions up to 280. Prices range from 1.00 to 100,000.00 in USD, EUR, GBP, CAD or AUD. A price that Railbed accepts may still be below a provider's minimum. Smart Routing checks the providers available to that buyer.",
     "endLine": 20
    },
    {
     "heading": "Choose how buyers reach a provider",
     "anchor": "choose-how-buyers-reach-a-provider",
     "level": 2,
     "startLine": 20,
     "text": "Choose how buyers reach a provider Provider choice in the Checkout card decides what happens after the buyer enters their email. It applies to this checkout, including any widget or buy button that uses it. - Smart Routing sends each buyer straight to the best card provider for their country, amount and currency. Their one click on Continue to pay opens the provider. New checkouts start here. - Buyers choose shows Smart Routing's ranked list, with the best match selected, and the buyer picks. Checkouts made before this setting existed use it until you change them. - One provider sends every buyer to the provider you pick. The editor shows the provider's minimum and warns when your price is below it (or asks you to check, when the minimum is in another currency), when the provider isn't taking payments, or when it serves only some countries. With One provider, If it can't take a buyer, use Smart Routing instead is on by default: a buyer the provider can't serve (another country, below its minimum, or while it's down) goes to the best provider for them, and the payment's timeline says so. Turn it off only if every buyer must use that provider. Buyers it can't serve then see a message and can't pay at this checkout, and no payment is created. In every mode, a buyer can choose Choose another way to pay to see the full list, including after a declined card. The payment's timeline shows who chose each provider: Smart Routing, your checkout's setting, or the buyer. Shopify stores and WooCommerce have the same setting on their setup pages (Integrations). Payment links and custom API checkouts always show the list.",
     "endLine": 32
    },
    {
     "heading": "Share the saved page",
     "anchor": "share-the-saved-page",
     "level": 2,
     "startLine": 32,
     "text": "Share the saved page The Share tab contains the hosted URL and QR code. Open the actual checkout to check the buyer view, then copy the URL wherever customers will find it. A checkout address looks like this: Example: Example checkout address — use your saved URL text https://pay.railbed.io/c/your-checkout-slug The slug is the final part of the saved address. It is also what the embed code uses. Use the URL Railbed gives you instead of typing a slug from the product name. To take payments on your own website, use Add it to your site in the Share tab. Widgets and buy buttons covers the available formats and copyable HTML.",
     "endLine": 48
    },
    {
     "heading": "Edit an existing checkout",
     "anchor": "edit-an-existing-checkout",
     "level": 2,
     "startLine": 48,
     "text": "Edit an existing checkout Open Checkouts , choose the checkout, then select Edit . Change the product details and choose Save changes in the page header. The Share and Edit tabs keep sharing controls separate from your product settings. The hosted URL stays the same when you edit the product. Future payment creation uses the updated checkout details. Payments already created retain their saved amount and description, so do not expect an edit to rewrite an existing customer's order. Currency becomes fixed once a payment reaches a card provider, or a payment is paid or being reviewed. If the field is locked, create a new checkout to sell in a different currency. For a different checkout kind, create that kind separately.",
     "endLine": 56
    },
    {
     "heading": "Use a success URL",
     "anchor": "use-a-success-url",
     "level": 2,
     "startLine": 56,
     "text": "Use a success URL Set After payment, send buyers to under More options: Example: Send a confirmed buyer to your thank\\-you page text https://example.com/thanks?payment={PAYMENT_ID} Railbed fills in the payment ID after confirmation. Reusable checkout payments do not have your own order reference, so {REFERENCE} would be empty. Use an API integration if every order needs a reference or customer-account metadata supplied by your server. A redirect is a convenience for the buyer. For manual delivery, check the payment in the dashboard. For automatic delivery, use verified webhooks or authenticated payment retrieval.",
     "endLine": 70
    },
    {
     "heading": "Stop accepting new buyers",
     "anchor": "stop-accepting-new-buyers",
     "level": 2,
     "startLine": 70,
     "text": "Stop accepting new buyers Open Edit → Delete checkout page and confirm. The hosted checkout and any embedded button or widget using it stop accepting new payments. Past payments remain in Payments. Deleting the checkout is not a refund or a cancellation of payments that already exist. If you want to keep the page available, remove links to it from your own website instead; anyone who still has its URL can continue to open it until it is deleted.",
     "endLine": 75
    }
   ]
  },
  {
   "id": "user-guide/pricing-tables",
   "title": "Offer plans with a pricing table",
   "nav": "Pricing tables",
   "section": "Take payments",
   "description": "Present up to four packages side by side, highlight one recommendation, and let buyers choose the right one.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/pricing-tables/",
   "path": "/docs/user-guide/pricing-tables/",
   "markdown": "https://railbed.com/docs/user-guide/pricing-tables.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "c0a60c9d5bf5fb5d11b01504b868be029fc609e084aec79c1247a36d670ce496",
   "sections": [
    {
     "heading": "What a pricing table does",
     "anchor": "what-a-pricing-table-does",
     "level": 2,
     "startLine": 0,
     "text": "What a pricing table does A pricing table is a reusable checkout with multiple plans. It works for service packages, access passes, resource bundles or other choices with a fixed price. A buyer chooses a plan, enters their details, and continues to a card provider. Visual example: Compare three one-time plans. Starter costs $19, Studio $49 and Team $99. Studio is highlighted as recommended. Each purchase is a one-time payment; nothing renews automatically. Illustrative example · Studio is the recommended plan. [!IMPORTANT] Every purchase is one payment. Pricing tables do not create subscriptions or automatic renewals. Labels such as / mo and / yr describe what you sell; they do not schedule another card charge.",
     "endLine": 11
    },
    {
     "heading": "Create the table",
     "anchor": "create-the-table",
     "level": 2,
     "startLine": 11,
     "text": "Create the table 1. Choose the correct mode, then Create → Pricing table . 2. Set the table Title , optional Description and Currency . All plans use that currency. 3. Add between one and four plans . The editor starts with Starter, Pro and Team as names; enter your own prices and details. 4. For each plan, fill Name , optional Description , Price , optional Label , and Features . Enter one feature per line, up to eight. 5. Optionally turn on Recommended plan for one plan. It appears as the dark highlighted card. Only one plan can be recommended. 6. Arrange plans with their move controls. Review the preview, choose the theme, and create the pricing table. 7. Optionally change the Provider choice in the Checkout card. It works as it does on checkout pages; with One provider, the editor compares every plan price with the provider's minimum when both are in the same currency, and asks you to check them when they aren't. A plan name can contain up to 40 characters, its description up to 140, and the price label up to 16. Keep feature descriptions short enough for buyers to compare at a glance.",
     "endLine": 23
    },
    {
     "heading": "Offer two price options",
     "anchor": "offer-two-price-options",
     "level": 2,
     "startLine": 23,
     "text": "Offer two price options Turn on Price switch to provide two prices for every plan. Name First option and Second option , then fill in both prices for each plan. For example, a studio could sell a Single session or a Three-session pack . Switching the option changes the displayed prices across the table. Monthly and Yearly are also possible labels when the purchase buys that period of access, but Railbed will not charge again when it ends. Make both the price and what it includes explicit: Plan Single session Three-session pack --- --- --- Consultation $49 $129 Extended session $99 $269 These are illustrative prices. You decide what you deliver and any access duration outside Railbed.",
     "endLine": 38
    },
    {
     "heading": "Share or embed it",
     "anchor": "share-or-embed-it",
     "level": 2,
     "startLine": 38,
     "text": "Share or embed it Open the saved table's Share tab. Share its hosted URL or QR code, add a Buy button that opens the plans, or place the Pricing table directly in your website from Add it to your site . Example: Place your pricing table on a website html <script src=\"https://pay.railbed.io/embed.js\" async </script <railbed-checkout checkout=\"your-pricing-table-slug\" </railbed-checkout Replace the example slug with the one copied from your saved table. No API key goes in this snippet. Embed instructions cover testing and site-builder behavior.",
     "endLine": 54
    },
    {
     "heading": "See which plan was purchased",
     "anchor": "see-which-plan-was-purchased",
     "level": 2,
     "startLine": 54,
     "text": "See which plan was purchased Open Payments and choose the order. Its plan label identifies the selected plan and, when used, the selected price option. This detail is also available to connected integrations, so fulfil the package actually purchased.",
     "endLine": 58
    },
    {
     "heading": "Change your plans later",
     "anchor": "change-your-plans-later",
     "level": 2,
     "startLine": 58,
     "text": "Change your plans later Use Edit → Save changes to update descriptions, features, prices, order or the recommended plan. The currency locks once a payment reaches a card provider, or is paid or being reviewed. Create a new table for another currency if the field is locked. Edits affect future purchases. Existing payment records keep the details captured when they were created. Deleting the table disables its link and embeds while preserving past payments.",
     "endLine": 63
    }
   ]
  },
  {
   "id": "user-guide/widgets-and-buttons",
   "title": "Add Railbed to your website",
   "nav": "Widgets and buy buttons",
   "section": "Take payments",
   "description": "Keep customers on your site while they choose what to buy. Copy a small HTML snippet; Railbed handles the checkout.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/widgets-and-buttons/",
   "path": "/docs/user-guide/widgets-and-buttons/",
   "markdown": "https://railbed.com/docs/user-guide/widgets-and-buttons.md",
   "updated": "2026-09-26",
   "bodyLineOffset": 8,
   "markdownHash": "c1d9b5808de76431888ce0572fedce8a3a6ecb7ea07f77be692c5cd70cc4ae33",
   "sections": [
    {
     "heading": "Choose the right format",
     "anchor": "choose-the-right-format",
     "level": 2,
     "startLine": 0,
     "text": "Choose the right format A buy button opens a checkout over your website when clicked. A payment widget places a compact checkout directly inside your page. A pricing table can also sit inside your page with its plans visible. These use checkouts created in the dashboard. Your website does not need an API key, a server integration or card fields. Format Start with What buyers see on your site --- --- --- Buy button A checkout page or pricing table A button; clicking opens the checkout in an overlay Payment widget A payment widget checkout The compact checkout itself, inline Pricing table A pricing table checkout Plans and the price switch, if configured",
     "endLine": 12
    },
    {
     "heading": "Copy the code from Railbed",
     "anchor": "copy-the-code-from-railbed",
     "level": 2,
     "startLine": 12,
     "text": "Copy the code from Railbed 1. Open Checkouts and select your saved checkout. 2. Choose Share , then find Add it to your site . 3. Where offered, choose what to add. For a button, set Button text and Shape . 4. Copy the generated code and paste it into your site's HTML or custom-code block. 5. Preview the published page. Some site builders do not run custom scripts inside their editing canvas. Start with a Test checkout. When ready, copy code from a separate Live checkout and replace the Test snippet.",
     "endLine": 22
    },
    {
     "heading": "Add a buy button",
     "anchor": "add-a-buy-button",
     "level": 2,
     "startLine": 22,
     "text": "Add a buy button Include the script once per page, then put the button where you want it: Example: A button that opens your checkout html <script src=\"https://pay.railbed.io/embed.js\" async </script <railbed-button checkout=\"your-checkout-slug\" label=\"Book a consultation\" shape=\"rounded\" </railbed-button Attribute What it does --- --- checkout Required. The slug from your saved checkout URL label Optional. Your button text; defaults to Buy now, or See plans for a pricing table shape Optional. Use pill or rounded; the default is pill The button takes your business's brand color. Set that in Settings. The button label is separate from the payment button text inside the checkout.",
     "endLine": 46
    },
    {
     "heading": "Add an inline checkout",
     "anchor": "add-an-inline-checkout",
     "level": 2,
     "startLine": 46,
     "text": "Add an inline checkout For a payment widget or pricing table, use this element: Example: A checkout embedded in your page html <script src=\"https://pay.railbed.io/embed.js\" async </script <railbed-checkout checkout=\"your-widget-slug\" </railbed-checkout The embedded page adjusts its height to its content and keeps the configured Light or Dark theme. Give it enough width for the checkout to be readable; test the surrounding layout on a phone too.",
     "endLine": 62
    },
    {
     "heading": "What happens when a buyer pays",
     "anchor": "what-happens-when-a-buyer-pays",
     "level": 2,
     "startLine": 62,
     "text": "What happens when a buyer pays The buyer starts in your embedded checkout. The card provider opens in a new tab because card entry happens on the provider's own page. If the browser blocks the new tab, the provider can open in the same tab instead. After confirmation, the checkout can send the parent page to the success URL you configured. If a buyer tries to close an overlay while paying, it asks before closing. Returning to your website alone does not confirm payment: check Payments or use your connected store's verified order status.",
     "endLine": 68
    },
    {
     "heading": "Troubleshoot an embed",
     "anchor": "troubleshoot-an-embed",
     "level": 2,
     "startLine": 68,
     "text": "Troubleshoot an embed - Nothing appears: confirm your site builder allows custom HTML and JavaScript, includes the script, and keeps the custom element tags. Check the published page as well as the editor. - Checkout unavailable: check that the slug matches an existing checkout and that it has not been deleted or become unavailable. - Still in Test mode: copy the code from the Live checkout. Flipping your dashboard mode does not change the slug in your website. - Provider opens elsewhere: that is expected. Provider card entry is not embedded inside your website. Only Railbed checkout and payment pages can be framed. Do not try to embed the dashboard or a tracking page. If a custom integration needs more control, use the developer guides.",
     "endLine": 76
    }
   ]
  },
  {
   "id": "user-guide/buyer-experience",
   "title": "What your customer sees",
   "nav": "The buyer experience",
   "section": "Take payments",
   "description": "Understand the card checkout, provider choice and return journey so you can confidently guide a customer through payment.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/buyer-experience/",
   "path": "/docs/user-guide/buyer-experience/",
   "markdown": "https://railbed.com/docs/user-guide/buyer-experience.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "e5651b30a19dfee43386f50e9abaed565e96c1a5bdb277339b3f69fdbaf1f99d",
   "sections": [
    {
     "heading": "From your link to payment",
     "anchor": "from-your-link-to-payment",
     "level": 2,
     "startLine": 0,
     "text": "From your link to payment Visual example: Explore a payment from start to finish. Create a link: set an amount and share it. Buyer pays: the buyer opens a licensed provider in a new tab and pays by card. Payment confirmed: check the payment status and the reported settlement in your dashboard. Illustrative Test payment. Select a step to see what happens. 1. Your customer opens the payment link, checkout page or embed. They see your business, product and price. A pricing table first asks them to choose a plan. 2. They provide an email address and continue. A payment link can prefill the email you entered when creating it. 3. Depending on the checkout's or store's Provider choice , Continue to pay opens the best provider for them straight away, opens the one provider you picked, or shows eligible card providers with a recommended Best match for the buyer to choose. Payment links always show the list. 4. The provider opens in a new tab. The buyer enters card details and completes any checks there. 5. The original Railbed checkout tab waits for confirmation. When the payment is marked paid, it shows the result or follows your configured success URL. Visual example: From a card payment to your wallet. Smart Routing checks country, currency, amount and availability to show eligible card providers. The buyer pays with a provider, and settlement goes to the merchant's wallet as USDC on Polygon. Eligibility depends on the buyer. Identity checks are decided by the provider.",
     "endLine": 16
    },
    {
     "heading": "How Smart Routing chooses providers",
     "anchor": "how-smart-routing-chooses-providers",
     "level": 2,
     "startLine": 16,
     "text": "How Smart Routing chooses providers Smart Routing considers the buyer's country, checkout currency, amount, provider minimums and availability. It filters for eligible card methods and ranks the available choices. You do not need to configure a routing rule for each country. A checkout on Smart Routing skips the list and sends the buyer to the top choice; Provider choice sets this for each checkout. The provider list can differ between buyers and change over time. Test mode uses a fixed example list, so it is not a live availability check. A low checkout amount may have no eligible provider even though Railbed accepted the product price. Best match is a recommendation, not an approval guarantee. The provider decides whether to accept the card, what fees or conversion terms to show, and which identity checks to require. Railbed cannot promise that a buyer will never be asked for ID.",
     "endLine": 24
    },
    {
     "heading": "Why there are two tabs",
     "anchor": "why-there-are-two-tabs",
     "level": 2,
     "startLine": 24,
     "text": "Why there are two tabs Keeping the Railbed tab open gives the customer a page that can report the verified payment result and return them to your store. The provider's own final page may have a different destination that your Railbed success URL does not control. Tell customers to keep the original checkout tab open. After finishing on the provider page, they can return to it to see progress. If their browser blocks new tabs, payment can continue in the original tab, and they may need to navigate back to Railbed to see the result.",
     "endLine": 30
    },
    {
     "heading": "If a card is declined",
     "anchor": "if-a-card-is-declined",
     "level": 2,
     "startLine": 30,
     "text": "If a card is declined A Live decline is shown by the card provider. Railbed does not receive a direct decline signal, so your dashboard may still show Open or In progress . The buyer can use Choose another way to pay to return to the full provider list before the payment expires, including on checkouts that sent them straight to a provider. Avoid assuming that an in-progress payment means the card was charged. If the buyer believes they were charged, inspect the payment and the provider's confirmation before asking them to make a separate payment.",
     "endLine": 36
    },
    {
     "heading": "If the page says Being reviewed or Underpaid",
     "anchor": "if-the-page-says-being-reviewed-or-underpaid",
     "level": 2,
     "startLine": 36,
     "text": "If the page says Being reviewed or Underpaid Payment evidence arrived but did not pass all the checks needed for normal confirmation. Underpaid tells the buyer that less than the full amount arrived and that you will contact them. Ask the buyer to wait while you review the payment. A held payment is not an instruction to top up the original payment.",
     "endLine": 40
    },
    {
     "heading": "Thank-you pages and receipts",
     "anchor": "thank-you-pages-and-receipts",
     "level": 2,
     "startLine": 40,
     "text": "Thank-you pages and receipts A success URL runs from the original checkout after payment confirmation. If that tab is closed, Railbed cannot redirect it. Closing the page does not cancel money already sent. Without a success URL, Railbed shows an on-screen receipt. Automatic buyer receipt emails are not available. If your store sends order emails, those are handled by the store or your own integration. For manual order delivery, verify Paid in your dashboard. For automatic delivery, connect signed webhooks or authenticated payment checks. Never use the buyer's screenshot, browser redirect or thank-you page as the only payment proof.",
     "endLine": 47
    }
   ]
  },
  {
   "id": "user-guide/crypto-payments",
   "title": "Accept crypto payments",
   "nav": "Crypto payments",
   "section": "Take payments",
   "description": "Let buyers pay in USDC or USDT from their own wallet, beside card. It's off until you turn it on, and the money goes to your payout wallet.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/crypto-payments/",
   "path": "/docs/user-guide/crypto-payments/",
   "markdown": "https://railbed.com/docs/user-guide/crypto-payments.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "15ca842daa74d16e873cf5a69e170735b9f168cf2ae4b9e0614ae846dcf9fb1e",
   "sections": [
    {
     "heading": "How it works",
     "anchor": "how-it-works",
     "level": 2,
     "startLine": 0,
     "text": "How it works Card stays the main way to pay. When you turn crypto payments on, your checkout pages, payment links and store checkouts show a Card Crypto wallet switch, with Card selected. A buyer who picks Crypto wallet enters their email, chooses a coin and network, and sends the exact amount to a one-time address shown with a QR code. The page updates when the money arrives, and the payment settles to your payout wallet like a card payment. Buyers can send: Coin Networks --- --- USDC Polygon, Base, Arbitrum, Optimism, BNB Chain, Ethereum USDT Polygon, Base, Arbitrum, Optimism, BNB Chain, Ethereum One coin counts as one US dollar. An order only offers the networks it can use: each network has a minimum, and networks that cost more to forward (Ethereum and Base) only appear on larger orders, so the network cost never takes much of your payment.",
     "endLine": 13
    },
    {
     "heading": "Before you turn it on",
     "anchor": "before-you-turn-it-on",
     "level": 2,
     "startLine": 13,
     "text": "Before you turn it on Crypto payments arrive at your payout wallet on whichever network the buyer used. Your wallet must be a self-custody wallet (one where you hold the recovery phrase, such as MetaMask, Rabby, Trust Wallet or a hardware wallet). The same address then works on every network above. [!WARNING] Don't turn crypto payments on if your payout wallet is an exchange deposit address. An exchange may not credit money sent on a network it doesn't support for that address, and it can be lost.",
     "endLine": 20
    },
    {
     "heading": "Turn it on",
     "anchor": "turn-it-on",
     "level": 2,
     "startLine": 20,
     "text": "Turn it on 1. Open Settings and find Crypto payments . 2. Turn it on, and confirm your payout wallet is a self-custody wallet that works on every network. 3. Your checkout pages, payment links and store checkouts now offer Crypto wallet . If you change your payout wallet later, you confirm the new one the same way. To keep one checkout card only, open it, and turn off Accept crypto .",
     "endLine": 30
    },
    {
     "heading": "Fees",
     "anchor": "fees",
     "level": 2,
     "startLine": 30,
     "text": "Fees Crypto payments have their own Railbed fee, shown in Settings , usually lower than the card fee. It's taken from what arrives, the same way as for card payments, together with the network cost of forwarding the money to your wallet.",
     "endLine": 34
    },
    {
     "heading": "Short and late payments",
     "anchor": "short-and-late-payments",
     "level": 2,
     "startLine": 34,
     "text": "Short and late payments The buyer must send the full amount. A payment that arrives at least 99% complete counts as paid. Less is marked Underpaid , and you decide what to do from the payment, as for any held payment. A second, separate transfer isn't added to the first. Money that arrives after the checkout closed still settles, as with card.",
     "endLine": 40
    },
    {
     "heading": "Test mode",
     "anchor": "test-mode",
     "level": 2,
     "startLine": 40,
     "text": "Test mode In Test mode the address is an example: never send real funds to it. Use Simulate payment or Simulate underpayment on the page to see what your buyers and your dashboard show.",
     "endLine": 44
    },
    {
     "heading": "In the dashboard and integrations",
     "anchor": "in-the-dashboard-and-integrations",
     "level": 2,
     "startLine": 44,
     "text": "In the dashboard and integrations A crypto payment reads like Crypto · USDC on Base in Payments, Orders and alerts, with links to that network's block explorer. Webhooks and the API say method: crypto and name the network (payment fields, webhook fields). Crypto addresses can't be created through the API yet: an API checkout session offers crypto on its hosted payment page.",
     "endLine": 47
    }
   ]
  },
  {
   "id": "user-guide/orders",
   "title": "Manage orders and fulfilment",
   "nav": "Orders and fulfilment",
   "section": "Orders and customers",
   "description": "See what a customer bought, review their payment attempts, and keep track of what still needs to be delivered.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/orders/",
   "path": "/docs/user-guide/orders/",
   "markdown": "https://railbed.com/docs/user-guide/orders.md",
   "updated": "2026-09-26",
   "bodyLineOffset": 8,
   "markdownHash": "9e453d2aaf9dfaf3893a3596b051b40b38186c2383aa50c36c7f734db3704f9b",
   "sections": [
    {
     "heading": "An order brings the purchase together",
     "anchor": "an-order-brings-the-purchase-together",
     "level": 2,
     "startLine": 0,
     "text": "An order brings the purchase together Open Orders for the purchase itself: the items, customer, total, addresses, payment attempts and fulfilment. Use Payments when you need the evidence for a particular attempt to pay. One order can have several payment attempts. A retry does not always mean a second purchase, and one customer can have many orders. Always match the order and payment before delivering. Visual example: How customers, orders and payments relate. Customer: Alex Morgan. Their history can include many orders. alex@example.com. Order 1001: Design consultation. One purchase, with its own total and fulfilment record. $49.00 USD. Payment attempts: One retry, one purchase. An order can be paid after an earlier attempt expires. Attempt 1: Expired · Attempt 2: Paid. Fulfil the order once; inspect individual attempts when reviewing payment evidence. Illustrative records. A customer can have many orders, and an order can have several payment attempts. Orders appear from payment links, reusable checkouts and sessions created by your store or the API. Store integrations can send item details, shipping, tax, discounts and addresses. A simple checkout creates an order for its product or plan. Missing details mean the source did not provide them; Railbed does not invent an address or product catalog entry.",
     "endLine": 12
    },
    {
     "heading": "Find the orders that need you",
     "anchor": "find-the-orders-that-need-you",
     "level": 2,
     "startLine": 12,
     "text": "Find the orders that need you 1. Check Test or Live mode. Orders, customers and payments are separate in each mode. 2. Choose a view in Orders , then narrow by Source if you want one store or checkout type. 3. Search for an order number, customer name, email, product, reference, or order/payment ID. Clear filters returns to the broader list when nothing matches. 4. Open a row to review the order. Use Show more to load more results. View What it includes --- --- All Orders matching your current source, customer and search filters Not fulfilled Paid orders recorded as waiting to be fulfilled Being reviewed Orders whose payment is held for review Awaiting payment Open or In progress orders Ended unpaid Declined, Expired or Canceled orders The counts on Not fulfilled and Being reviewed stop at 100+ . That is a capped count, not a claim that there are exactly 100 orders. Filtered page totals can also show 10,000+ . Filters stay in the page URL, so refreshing or using Back keeps your view. An order link still requires sign-in and the matching account and mode.",
     "endLine": 31
    },
    {
     "heading": "Read an order",
     "anchor": "read-an-order",
     "level": 2,
     "startLine": 31,
     "text": "Read an order The drawer keeps the list behind it so you can move through your work. Use Previous order and Next order , or K and J while focused in the drawer and outside a text field. - Items and total: products, quantities, variants, discounts, shipping and tax when sent by the source. The order total is what the customer was charged in the order's currency. - Customer and address: open the linked customer for their wider history. Copy the shipping address when one is present. Addresses are stored as provided; they have not been checked against a postal service. - Attempts: inspect each attempt to pay. One failed or expired attempt does not undo another attempt that is Paid. - Settlement: reported USDC receipts describe what reached your wallet. They are different from the order total. See wallet and settlement. - Timeline: follow the order, payment and fulfilment events in context. If the drawer warns that the buyer paid more than once, inspect each payment under Attempts . Deliver the intended order once, then agree any duplicate-payment refund with the buyer. Marking fulfilment does not refund a payment.",
     "endLine": 43
    },
    {
     "heading": "Payment and fulfilment are separate",
     "anchor": "payment-and-fulfilment-are-separate",
     "level": 2,
     "startLine": 43,
     "text": "Payment and fulfilment are separate A payment status answers whether the order was paid. Fulfilment records whether you have delivered it. Mark as fulfilled cannot turn an unpaid order into a paid one and does not send money. Fulfilment label Meaning --- --- No shipping The order was created without a shipping requirement, such as a service or digital product Not fulfilled The order is waiting to be shipped or delivered Fulfilled You or the connected store recorded delivery or dispatch Do not treat No shipping as proof that a digital item was delivered. Your business or connected system still handles delivery. Likewise, a fulfilment label alone is not proof of payment.",
     "endLine": 55
    },
    {
     "heading": "Mark an order fulfilled",
     "anchor": "mark-an-order-fulfilled",
     "level": 2,
     "startLine": 55,
     "text": "Mark an order fulfilled For an order managed in Railbed: 1. Open the order and confirm Paid . Check the items, customer and delivery details. 2. Deliver the goods or service through your normal process. 3. Choose Mark as fulfilled . To correct the record later, use Mark as not fulfilled . For several orders, select the rows and choose Mark as fulfilled in the selection bar. You can select up to 100 orders. Railbed checks each order; unpaid orders and orders whose fulfilment is controlled by a store are skipped. Read the result message and review anything that was skipped. [!IMPORTANT] A store-connected order says Fulfilment comes from the store . Update it in that store. Use Open in WooCommerce , or the source's available order link, to find it. Railbed shows the state sent by the integration. If an order is Being reviewed , choose Review payment and follow the held-payment guide. Do not mark it fulfilled to bypass a payment review.",
     "endLine": 70
    },
    {
     "heading": "Export orders",
     "anchor": "export-orders",
     "level": 2,
     "startLine": 70,
     "text": "Export orders Choose Export CSV to download orders matching the current filters. The export includes the order number, customer, items, amounts, currency, payment/fulfilment labels and available source details. A selection export downloads the selected orders instead. Each full export contains up to 10,000 orders. Narrow the filters if you need a smaller set. Keep the currency column with every amount; do not add USD, EUR and other currencies as if they were one unit. Received (USDC) is a separate settlement measure. Exports contain customer details when available. Store and share them only where you intend to keep those details. Deleting customer data in Railbed does not recall a CSV you already downloaded.",
     "endLine": 78
    },
    {
     "heading": "When something looks wrong",
     "anchor": "when-something-looks-wrong",
     "level": 2,
     "startLine": 78,
     "text": "When something looks wrong What you see What to check --- --- An order is missing Confirm the mode, clear filters and search by its order or payment ID A customer tried again Open Attempts; several attempts can belong to the same order An expired order later becomes Paid Late settlement can update the order; read its current status before sending a new request Fulfilment cannot be changed here Check whether the order is controlled by its source store, or has not been paid The store shows a different fulfilment state Check the integration and latest sync; make changes in the source store An address or item is missing Check what the store or API sent when the order was created - Understand your customers: See each customer's orders, contact details and private note. - Find records across Railbed: Search by order number, email, payment ID or transaction.",
     "endLine": 91
    }
   ]
  },
  {
   "id": "user-guide/customers",
   "title": "Understand your customers",
   "nav": "Customers",
   "section": "Orders and customers",
   "description": "See a customer's order history, contact details and spending. Keep a private note and manage the personal data saved in Railbed.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/customers/",
   "path": "/docs/user-guide/customers/",
   "markdown": "https://railbed.com/docs/user-guide/customers.md",
   "updated": "2026-09-26",
   "bodyLineOffset": 8,
   "markdownHash": "1d760d84df235fc2baf0a602489a0c07fc4d7798c5b3e7262c41d81d171675d5",
   "sections": [
    {
     "heading": "How customers appear",
     "anchor": "how-customers-appear",
     "level": 2,
     "startLine": 0,
     "text": "How customers appear Customers brings together purchases associated with the same email address in the current mode. Railbed creates a customer when a checkout or store order provides an email. You do not need to enter each customer by hand. Email matching ignores letter case. Test and Live records are separate. Names, phone numbers and addresses appear when the checkout or connected source supplies them. Later orders can update those saved details; omitted details keep their earlier value. A customer first created when a buyer starts payment may contain only their email. Open the order to inspect any additional addresses or details supplied with the purchase. [!IMPORTANT] A checkout email is supplied by the buyer or your store. A matching customer record is purchase history, not proof that the buyer owns an account in your own system.",
     "endLine": 11
    },
    {
     "heading": "Find a customer",
     "anchor": "find-a-customer",
     "level": 2,
     "startLine": 11,
     "text": "Find a customer 1. Open Customers and check the mode. 2. Search by name, email or phone. You can also use the customer's full ID. 3. Choose a view to narrow the list, then open a customer to see their details. View What it means --- --- All Customers matching the current search Returning At least two paid orders New First seen in the last 30 days Never paid No paid orders yet Sort by recent activity or Spent using the list's column controls. The Spent sort compares spending on a USD basis; displayed amounts retain their original currencies. They are not a converted wallet balance. Choose Show more when more records are available. If nothing matches, use Clear filters and check whether the customer belongs to the other mode.",
     "endLine": 28
    },
    {
     "heading": "Read the customer page",
     "anchor": "read-the-customer-page",
     "level": 2,
     "startLine": 28,
     "text": "Read the customer page The page links the person to their orders and the information supplied with them: - Spent: the amounts of their paid payment attempts, grouped by currency. Unpaid attempts are not included. If the buyer paid twice for one order, both paid amounts count here even though there is still one order. - Reached your wallet: reported net receipts in USDC from their payment activity. This can differ from Spent because of fees, conversion, unreported receipts or multiple attempts. - Orders: the number of paid orders alongside all their orders, with a compact history of payment outcomes. - Needs you: a shortcut to an order needing payment review or fulfilment among the orders shown. - Contact and address: available email, phone, source-store customer IDs and the latest saved shipping or billing address. Open an order to inspect its items, payment attempts and timeline without leaving the customer page. For a long history, All their orders opens the Orders list filtered to that customer. The detail page shows the latest 50 orders first. Email opens your device's email app with the customer's address. It does not send a message by itself. Copy address copies the displayed address for use in your delivery workflow.",
     "endLine": 42
    },
    {
     "heading": "Keep a private note",
     "anchor": "keep-a-private-note",
     "level": 2,
     "startLine": 42,
     "text": "Keep a private note Use the Note card for context that helps you serve the customer, such as a preferred delivery instruction. The customer never sees this note, and it is not included in API customer responses or merchant webhooks. Choose Add note or Edit note , write up to 2,000 characters, then Save note . Cancel keeps the saved version. Clearing the text and saving removes the note. The note belongs to this Railbed record. It does not update your store's customer profile, and changes to a store profile do not edit this private note.",
     "endLine": 50
    },
    {
     "heading": "Export your customer list",
     "anchor": "export-your-customer-list",
     "level": 2,
     "startLine": 50,
     "text": "Export your customer list Choose Export CSV on Customers. It follows the current search, view and sort, and exports up to 10,000 records. The file includes available contact details, order counts, spending by currency, reported USDC receipts and customer IDs. Deleted customers are left out of the customer list and its export. Previously downloaded files remain wherever you saved them.",
     "endLine": 56
    },
    {
     "heading": "Delete customer data",
     "anchor": "delete-customer-data",
     "level": 2,
     "startLine": 56,
     "text": "Delete customer data Open the customer, choose Delete customer data , then read the confirmation carefully. This action cannot be undone. It removes the saved name, email, phone, addresses, store customer identifiers and private note from the customer record, and removes the associated personal-detail fields from orders, payments and stored webhook delivery bodies. Orders, payment records and amounts remain for your records, with the person shown as Deleted customer . After deletion: - The customer leaves the normal customer list. Existing order links can still show the anonymized record. - The same email in a future purchase creates a new customer record. - Webhooks already delivered, emails sent and files exported cannot be recalled. Handle those copies in the systems where they were saved. - Custom order descriptions, references and integration metadata are separate fields. Do not assume they have been scrubbed if you or an integration placed personal information in them. Deleting customer data does not refund, cancel or fulfil an order. Review the relevant order or payment separately if the buyer also needs help with a purchase. - Manage orders and fulfilment: Review what was bought and what still needs to be delivered. - Understand payment statuses: Distinguish payment confirmation from delivery.",
     "endLine": 73
    }
   ]
  },
  {
   "id": "user-guide/search",
   "title": "Find records across Railbed",
   "nav": "Search your account",
   "section": "Orders and customers",
   "description": "Jump to an order, customer, payment or setting from anywhere in your dashboard. Search by a name, amount, email or record ID.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/search/",
   "path": "/docs/user-guide/search/",
   "markdown": "https://railbed.com/docs/user-guide/search.md",
   "updated": "2026-09-26",
   "bodyLineOffset": 8,
   "markdownHash": "882621a372b36a8efc2d843f5cb7034a081bd9c4607b9c71fedcbd83d6a3fefb",
   "sections": [
    {
     "heading": "Open search",
     "anchor": "open-search",
     "level": 2,
     "startLine": 0,
     "text": "Open search Choose Search in the dashboard sidebar or phone navigation. On a keyboard, press ⌘K on Mac, Ctrl+K on Windows/Linux, or / when you are not typing in a field. Before you type, search shows shortcuts for the current page, recent searches, other pages and common actions. Type at least two characters to search your records. Results are grouped by type, with the current page's group prioritized. Use ↑ and ↓ to choose a result, Enter to open it, and Escape to close search. Selecting a page or action takes you to that workflow; it does not complete a payment, export or record change by itself. A mode-switch action changes the dashboard's current mode.",
     "endLine": 8
    },
    {
     "heading": "What you can look for",
     "anchor": "what-you-can-look-for",
     "level": 2,
     "startLine": 8,
     "text": "What you can look for Search for Example What Railbed looks for --- --- --- Order number 1042 A Railbed or store order number Customer alex@example.com The exact email, or matching names/phone text when you type those instead Product or reference consultation Matching records and their descriptions Record ID pay_example , ord_example , cus_example The matching payment, order or customer ID; replace the example with a real ID Amount 49.00 or $49.00 The amount across currencies; check each result's currency Transaction or address A full Polygon transaction hash or deposit address Matching payment evidence Shortened chain reference 0x1234…abcd A matching beginning and ending, when enough characters are supplied Page or action customers , settings , create payment link Dashboard pages and available shortcuts The search field explains recognized queries, such as an exact email, order number or transaction. Read that hint when results are different from what you expected. A currency symbol in an amount search does not limit the results to that currency. Search can find orders, customers, payments, payment links, checkouts and developer records such as named API keys, endpoints and delivery events. Search API keys by their names or IDs; do not paste a secret key or webhook signing secret.",
     "endLine": 25
    },
    {
     "heading": "Test and Live stay separate",
     "anchor": "test-and-live-stay-separate",
     "level": 2,
     "startLine": 25,
     "text": "Test and Live stay separate Record results belong to the current dashboard mode. If nothing matches, check the mode before assuming the record is missing. Search can offer Search Test mode instead or Search Live mode instead when appropriate. Changing the mode clears the old mode's results. Your records have not been deleted or moved. Test and Live mode explains the separation.",
     "endLine": 31
    },
    {
     "heading": "See more results",
     "anchor": "see-more-results",
     "level": 2,
     "startLine": 31,
     "text": "See more results For supported record groups, Show all matching … opens the relevant page with the search applied. Some developer groups show only the newest matching results; use the Developers page to inspect the full context. If a result opens the wrong view, clear that page's filters or search directly using the full record ID. A payment can belong to an order, so open the related order when you need the purchase and fulfilment details.",
     "endLine": 37
    },
    {
     "heading": "Recent searches and privacy",
     "anchor": "recent-searches-and-privacy",
     "level": 2,
     "startLine": 37,
     "text": "Recent searches and privacy Railbed remembers up to five searches that you used to open a result. They are saved in this browser. Choose Clear recent searches from the search dialog to remove the saved list, especially on a shared device. Queries that look like a Railbed secret key or webhook signing secret are refused and are not saved as recent searches. Do not rely on search as a place to test or store credentials.",
     "endLine": 43
    },
    {
     "heading": "When search cannot load",
     "anchor": "when-search-cannot-load",
     "level": 2,
     "startLine": 43,
     "text": "When search cannot load Check your connection, then use the retry action. You can still navigate through the sidebar and search within a specific list. Recent results are not shown as if they answered a different query or belonged to another mode. The search box on this documentation site searches public articles and code examples. It does not read your account or customer data.",
     "endLine": 48
    }
   ]
  },
  {
   "id": "user-guide/payments",
   "title": "Find and understand a payment",
   "nav": "Payments and statuses",
   "section": "Manage your money",
   "description": "Search across your payment links, checkouts and API orders. See what happened, what arrived and what to do next.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/payments/",
   "path": "/docs/user-guide/payments/",
   "markdown": "https://railbed.com/docs/user-guide/payments.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "65a1db9fb67733d758f6d167f753fca6b625cbd1805cd891d168c23afc872ba4",
   "sections": [
    {
     "heading": "Find an order",
     "anchor": "find-an-order",
     "level": 2,
     "startLine": 0,
     "text": "Find an order Open Payments and check the mode. The list includes payments from payment links, reusable checkouts and the API in that mode. For the full purchase, including items, customer and fulfilment, open Orders. A payment is one attempt to pay; several attempts can belong to the same order. Use the search field for a buyer email, product name or payment ID. Choose a status filter to narrow the list, and Show more when there are additional results. Open a payment to view its details drawer. Visual example: Read the status of each payment. The sample payment list shows a $49 Paid consultation, a $250 In progress deposit and $19 Being reviewed resources. Only the Paid order is ready for normal fulfilment. Illustrative records · read each status before delivering an order.",
     "endLine": 12
    },
    {
     "heading": "Understand each status",
     "anchor": "understand-each-status",
     "level": 2,
     "startLine": 12,
     "text": "Understand each status Choose the label you see in the dashboard. The explanation below shows what it means and the next step to take. Visual example: Choose a status to find your next step. Open: The payment is ready for your customer. A payment exists, but the buyer has not reached the provider hand-off yet. Your next step: Share the link or wait for the buyer. Keep the order unfulfilled. Check that the payment is in the right Test or Live mode. In progress: Your buyer has reached a card provider. Railbed is still waiting for payment confirmation. Opening the provider or returning to your site does not confirm a payment. Your next step: Wait and follow the payment timeline. Do not deliver yet. A Live card decline may stay In progress until the payment expires. Paid: This order is ready for fulfilment. The payment passed the applicable confirmation checks, or you explicitly accepted an eligible hold. Your next step: Match the payment to the order, then deliver once. Review the reported settlement when needed. A Paid Test payment is a simulation. It does not represent money received. Being reviewed: Payment evidence needs your attention. Evidence arrived, but the amount or another confirmation check needs review. Your next step: Open the payment and read the hold reason. Do not fulfil automatically. Accept as paid is available only for eligible holds. Read the held-payment guide before accepting. Review a held payment Underpaid: Less than the full amount arrived. Money arrived, but for less than the order’s value allows. It is a held payment with a named reason. Your next step: Open the payment and compare what arrived with the order. Do not fulfil automatically. You can accept what arrived as paid, or ask for a new payment. Railbed has no top-up for the difference. Review a held payment Declined: This simulated payment was declined. Declined is the Test-mode outcome. Live card declines are handled by the provider and are not directly reported to Railbed. Your next step: Start a new Test payment to try another outcome. An unsuccessful Live attempt can remain Open or In progress, then expire. Expired: The payment’s time limit has passed. The checkout can no longer start a new payment attempt. Late settlement evidence can still change its status. Your next step: Recheck the current status and timeline before sending a replacement request. An expired payment can still become Paid or Being reviewed if money arrives late. Canceled: You canceled this payment link. The link can no longer be used to start a new payment. Canceling a link does not reverse a transfer already in progress. Your next step: Check the payment timeline for late settlement before requesting payment again. Use a new payment link if another request is needed. Explanations only. This example does not read or change a real payment. Live card declines are handled by the provider and are not directly reported to Railbed. An unsuccessful Live attempt may remain open or in progress and later expire. A hold caused only by a short amount reads Underpaid ; every other hold reads Being reviewed . The Being reviewed filter lists all held payments, and Underpaid narrows it to short amounts. A payment that expires can still receive late settlement evidence. Recheck the current status before acting on an old screenshot or list view.",
     "endLine": 38
    },
    {
     "heading": "Read the details drawer",
     "anchor": "read-the-details-drawer",
     "level": 2,
     "startLine": 38,
     "text": "Read the details drawer The payment drawer brings together the information saved for the order and the evidence received afterwards. Depending on the payment, it includes: - The description, original price and currency, customer email, source and payment ID. - Your reference, selected pricing plan or integration metadata when present. - Provider information, the amount sent by the provider and the amount reported as received by your wallet. - The payout address locked for that payment and available settlement transaction links. - A timeline of creation, payment start, provider hand-off, confirmation, review and webhook delivery events. Use Copy next to the payment ID when matching an order or asking for help. A shortened wallet or transaction address can be opened in the linked explorer for its full details on Live payments.",
     "endLine": 50
    },
    {
     "heading": "Follow the timeline",
     "anchor": "follow-the-timeline",
     "level": 2,
     "startLine": 50,
     "text": "Follow the timeline The timeline explains the sequence. A checkout being created, a buyer entering their email and a buyer going to a provider are all earlier steps than a confirmed payment. A Webhook delivered entry concerns the notification to your connected system. It does not mean the connected system has completed delivery to the customer. If the payment is Paid but the store has not updated, inspect the endpoint's delivery log in Developers, then check the receiving system.",
     "endLine": 56
    },
    {
     "heading": "Read the money correctly",
     "anchor": "read-the-money-correctly",
     "level": 2,
     "startLine": 56,
     "text": "Read the money correctly The order amount is the customer's checkout price. You received is the reported net amount that reached your payout wallet. Fees and conversion can make those amounts different. Not reported yet means the net receipt is not yet available in that record. Do not treat it as zero, or substitute the order price as the amount received. Some Paid payments are not included in received totals until the receipt amount is reported. Wallet and settlement explains how those totals work.",
     "endLine": 62
    },
    {
     "heading": "Decide whether to deliver",
     "anchor": "decide-whether-to-deliver",
     "level": 2,
     "startLine": 62,
     "text": "Decide whether to deliver For manual fulfilment, check that the payment is Paid and that it matches the intended order. For connected systems, use the fulfilment guide so repeated notifications cannot fulfil the same order twice. For Being reviewed or Underpaid , read Review a held payment before making a decision. Do not deliver automatically from Open, In progress, Being reviewed, Underpaid or an unconfirmed browser return.",
     "endLine": 67
    }
   ]
  },
  {
   "id": "user-guide/held-payments",
   "title": "Review a held payment",
   "nav": "Payments needing review",
   "section": "Manage your money",
   "description": "Understand why a payment is being reviewed or underpaid, and when you can choose to accept the amount that arrived.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/held-payments/",
   "path": "/docs/user-guide/held-payments/",
   "markdown": "https://railbed.com/docs/user-guide/held-payments.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "c7b4a793629a3bedb5260dffe180da96f923c5d0373c18a66932ef49752e43a3",
   "sections": [
    {
     "heading": "What Being reviewed and Underpaid mean",
     "anchor": "what-being-reviewed-and-underpaid-mean",
     "level": 2,
     "startLine": 0,
     "text": "What Being reviewed and Underpaid mean Railbed received payment evidence that did not pass all the checks needed to confirm the payment normally. Both labels mark a held payment: - Underpaid: the only problem is that less than the expected amount arrived. Your buyer's page says so too. - Being reviewed: any other hold, involving the currency, address or transaction evidence. A hold is a review state in Railbed's record. It does not mean Railbed is holding a balance that you can release from the dashboard. Whether money reached your wallet depends on the evidence for that payment.",
     "endLine": 9
    },
    {
     "heading": "Open the review",
     "anchor": "open-the-review",
     "level": 2,
     "startLine": 9,
     "text": "Open the review 1. On Wallet , use the review notice, or open Payments → Being reviewed . It lists every held payment; Underpaid shows only the short amounts. 2. Select the payment and read the reason at the top of the drawer. 3. Match the payment ID, amount, currency and reference to the order you expected. 4. Check the settlement details and your payout wallet. Use the available transaction link for a Live payment. 5. Decide your next step based on the reason and the controls Railbed makes available. A Needs review tag identifies holds that the dashboard allows you to accept. The Payment links → Paid list filter also includes held payments, so that filter alone is not a confirmation that an order should be delivered.",
     "endLine": 19
    },
    {
     "heading": "When Accept as paid is available",
     "anchor": "when-accept-as-paid-is-available",
     "level": 2,
     "startLine": 19,
     "text": "When Accept as paid is available For an eligible hold, the drawer offers Accept as paid . Use it only after checking your wallet and deciding that the amount received is sufficient for this order. The confirmation marks the payment as Paid with what arrived and sends the normal paid notification to connected integrations. That can trigger order fulfilment. It does not charge the customer for a difference, create another transfer or increase the amount in your wallet. [!IMPORTANT] Accept as paid is an order decision. Check the wallet and intended order first. Do not use it just to make a warning disappear.",
     "endLine": 28
    },
    {
     "heading": "When the action is unavailable",
     "anchor": "when-the-action-is-unavailable",
     "level": 2,
     "startLine": 28,
     "text": "When the action is unavailable Some holds cannot be accepted in the dashboard because the evidence does not establish a payment that is safe to accept there. The drawer explains that the money may not have reached your wallet. Keep the order undelivered while you investigate. When asking for help, include the payment ID, the exact reason shown, the order amount and whether it is Test or Live. Do not send an API key, signing secret, seed phrase or private key.",
     "endLine": 34
    },
    {
     "heading": "Do not top up the original payment",
     "anchor": "do-not-top-up-the-original-payment",
     "level": 2,
     "startLine": 34,
     "text": "Do not top up the original payment Railbed does not offer a partial-payment top-up flow. Do not ask a buyer to send a difference to the original payment address. If a new payment is needed, make a new request after establishing what already arrived and what the new request is for. A later valid confirmation can resolve a held payment automatically. Refresh its current status before taking another action, especially if you are returning to an older review.",
     "endLine": 40
    },
    {
     "heading": "Practice before you need it",
     "anchor": "practice-before-you-need-it",
     "level": 2,
     "startLine": 40,
     "text": "Practice before you need it Create a new Test payment and choose Simulate an underpayment on the simulated provider page. Find the resulting hold, read its details and try the review flow with simulated money. Test and Live mode has the full walkthrough.",
     "endLine": 43
    }
   ]
  },
  {
   "id": "user-guide/wallet-and-settlement",
   "title": "Understand your wallet and settlement",
   "nav": "Wallet and settlement",
   "section": "Manage your money",
   "description": "Know where the money goes, how received totals are calculated, and why your checkout price and wallet balance can differ.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/wallet-and-settlement/",
   "path": "/docs/user-guide/wallet-and-settlement/",
   "markdown": "https://railbed.com/docs/user-guide/wallet-and-settlement.md",
   "updated": "2026-09-26",
   "bodyLineOffset": 8,
   "markdownHash": "0b48d27e0a3f3bce56165e0c4a2a883e4a57acf85da917f9cc997940e913dfb6",
   "sections": [
    {
     "heading": "Your wallet is yours",
     "anchor": "your-wallet-is-yours",
     "level": 2,
     "startLine": 0,
     "text": "Your wallet is yours Railbed sends settlement to the Polygon wallet saved for the payment. It does not keep a custodial balance or require you to request a payout from the dashboard. The Wallet page is a view of your checkout activity and payout wallet, not a separate Railbed bank account. Use a self-custody Polygon wallet whose keys you control. Your public address belongs in Settings; your seed phrase and private key never do. Visual example: Read your wallet balance and receipts. The sample wallet balance is 182.40 USDC. The sample last-24-hours Railbed receipts are 46.31 USDC. The wallet can include other transfers; Railbed receipts include reported net settlements. Illustrative amounts, not a fee quote. Wallet balance and Railbed receipts measure different things.",
     "endLine": 10
    },
    {
     "heading": "Balance and received totals are different",
     "anchor": "balance-and-received-totals-are-different",
     "level": 2,
     "startLine": 10,
     "text": "Balance and received totals are different Number What it represents --- --- USDC balance in Live mode A balance read from your payout wallet on Polygon; it can include funds from elsewhere and reflect transfers out Received in 24 hours or 7 days Reported net receipts from Railbed payments in that period Simulated USDC in Test mode Simulated receipts in the last 7 days; no money in a real wallet Original order amount The price the buyer was asked to pay, in the checkout currency You received on a payment The reported net settlement for that specific payment A wallet balance can go down if you send funds from that wallet. The Railbed received total is a record of receipts, so those two numbers do not need to match.",
     "endLine": 22
    },
    {
     "heading": "Why the amount received differs",
     "anchor": "why-the-amount-received-differs",
     "level": 2,
     "startLine": 22,
     "text": "Why the amount received differs Your checkout might be priced in USD, EUR, GBP, CAD or AUD, but settlement is in USDC on Polygon. Provider pricing, conversion and fees affect what arrives. Railbed's fee is shown in Settings → Payout wallet ; use the rate shown for your account instead of assuming every merchant has the same rate. The fee and payout wallet are locked when a payment starts. Later changes apply to payments started afterwards. Use the payment's own details when investigating an older transaction. An illustrative dollar amount in these docs is not a promise of the net amount you will receive. Review the buyer's provider quote and the payment's reported settlement for the actual figures.",
     "endLine": 30
    },
    {
     "heading": "What Not reported yet means",
     "anchor": "what-not-reported-yet-means",
     "level": 2,
     "startLine": 30,
     "text": "What Not reported yet means A payment can be marked Paid before all settlement detail has been reported. The dashboard keeps the net receipt as Not reported yet rather than guessing from the order price or the amount the provider sent. Those payments may be omitted from received totals until the amount reaching the wallet is reported. The Wallet page shows a note when paid payments are missing from those totals. Open the payment for its latest details and any available transaction information.",
     "endLine": 36
    },
    {
     "heading": "Check a particular receipt",
     "anchor": "check-a-particular-receipt",
     "level": 2,
     "startLine": 36,
     "text": "Check a particular receipt 1. Open Payments and choose the payment. 2. Confirm its mode, payment ID, order amount and current status. 3. Read You received and the payout address for that payment. 4. For a Live payment with a settlement transaction, use View on Polygonscan to inspect it. 5. If the payment is Being reviewed, follow the review guide before delivering the order. A real-time balance read can fail independently of your payment list. If Wallet shows a balance error, choose Try again . The error does not itself mean that a payment failed or that the wallet is empty.",
     "endLine": 46
    },
    {
     "heading": "Change the payout wallet",
     "anchor": "change-the-payout-wallet",
     "level": 2,
     "startLine": 46,
     "text": "Change the payout wallet Go to Settings → Payout wallet . Choose Connect a different wallet and pick the wallet, or paste the new public address into Polygon address . Then choose Save settings and review the full address in the confirmation before choosing Change wallet . Settings shows which wallet app a connected address came from. Payments already in progress continue using their saved address. Keep access to the old wallet for those payments. Changing the setting does not move its existing balance to the new wallet.",
     "endLine": 52
    },
    {
     "heading": "Refunds and card disputes",
     "anchor": "refunds-and-card-disputes",
     "level": 2,
     "startLine": 52,
     "text": "Refunds and card disputes Railbed has no automatic refund control that reverses a settled payment. A refund must be arranged separately by the merchant. Recording a refund in WooCommerce does not send funds. Card disputes are handled by the card provider; a payment status in Railbed is not a dispute-management service. Establish what was paid and what arrived before deciding how to handle a customer request. Never interpret deleting a checkout or canceling a payment link as a refund.",
     "endLine": 57
    }
   ]
  },
  {
   "id": "user-guide/branding-and-settings",
   "title": "Make Railbed your own",
   "nav": "Branding and settings",
   "section": "Your business",
   "description": "Put your business name, logo and support details on checkout, and keep your account settings accurate.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/branding-and-settings/",
   "path": "/docs/user-guide/branding-and-settings/",
   "markdown": "https://railbed.com/docs/user-guide/branding-and-settings.md",
   "updated": "2026-09-26",
   "bodyLineOffset": 8,
   "markdownHash": "c47b5de6e4282b51e41e0429bdd0b03b74bbd3e422a243816846f2bb289bb0d4",
   "sections": [
    {
     "heading": "Business details buyers recognize",
     "anchor": "business-details-buyers-recognize",
     "level": 2,
     "startLine": 0,
     "text": "Business details buyers recognize Open Settings. The Business section controls the identity customers see on Railbed payment pages. Setting Where it is used --- --- Business name Identifies your business on buyer-facing checkout Logo Appears on checkouts, payment links and tracking pages Support email Gives buyers a way to contact your business about their order Checkout brand color Applies your selected accent across your business's checkouts and buy buttons Profile photo Identifies your signed-in user in the dashboard Your name Shows your teammates who you are on the Team page Use a support email you monitor. This is your business's support address, not a setting that makes Railbed send receipts or payment-link emails.",
     "endLine": 15
    },
    {
     "heading": "Save text and color changes",
     "anchor": "save-text-and-color-changes",
     "level": 2,
     "startLine": 15,
     "text": "Save text and color changes 1. Change your business name, support email or brand color. 2. Review the You have unsaved changes bar. 3. Choose Save settings , or Discard to return to the saved values. 4. Open a buyer checkout to confirm the saved appearance. Settings are shared across Test and Live mode. A brand or wallet change made while viewing Test mode is still a real change to the business settings.",
     "endLine": 24
    },
    {
     "heading": "Upload a logo or profile photo",
     "anchor": "upload-a-logo-or-profile-photo",
     "level": 2,
     "startLine": 24,
     "text": "Upload a logo or profile photo Use the image control, choose your image, and adjust the crop. The app re-encodes the cropped image before upload. The image saves as soon as you finish cropping it ; you do not need to press Save settings afterwards. Choose a simple, legible logo that still works at small sizes. Use a supported raster image such as PNG, JPEG or WebP. SVG uploads are not accepted. If an image is rejected, follow the uploader's message and try an ordinary raster export. The business logo and profile photo are separate. Changing your profile photo does not replace the logo customers see.",
     "endLine": 32
    },
    {
     "heading": "Your name and photo",
     "anchor": "your-name-and-photo",
     "level": 2,
     "startLine": 32,
     "text": "Your name and photo If you sign in with Google or GitHub, Railbed fills in your name and profile picture from that account when you haven’t set them yet. Change either one in Settings → Account : type your name and choose Save name , or upload a different photo. A photo you remove stays removed; signing in again doesn't bring it back.",
     "endLine": 36
    },
    {
     "heading": "Set an individual checkout's appearance",
     "anchor": "set-an-individual-checkout-s-appearance",
     "level": 2,
     "startLine": 36,
     "text": "Set an individual checkout's appearance Open Checkouts → your checkout → Edit . The Checkout theme chooses Light or Dark for that checkout. The editor's brand color control changes the merchant brand color, rather than creating a separate color for that one page. For checkout pages and widgets, Button text changes the payment button inside the checkout. For a buy button embedded on your website, set its label under Share → Add it to your site or in the label attribute of its snippet. Save the checkout, then inspect its actual buyer page and any embed using it. The editor preview is useful for composition, but the buyer page shows the full flow.",
     "endLine": 44
    },
    {
     "heading": "Choose Light, Dark or Match system",
     "anchor": "choose-light-dark-or-match-system",
     "level": 2,
     "startLine": 44,
     "text": "Choose Light, Dark or Match system Open Settings → Appearance and choose a theme: Choice What happens --- --- Light Uses Railbed's paper background and dark text; the default Dark Uses charcoal surfaces and light text throughout the dashboard Match system Follows this device's appearance preference, including changes made while Railbed is open The choice applies immediately, without Save settings . It is saved in this browser. Other dashboard tabs in the same browser follow the change; another browser or device can have its own preference. If your browser blocks storage, the choice lasts for the current page and the app explains that it cannot save it. Dashboard appearance does not change your customer's checkout, payment link or tracking page. A checkout's own Checkout theme remains the buyer-facing setting. Logos and checkout previews keep the backgrounds they need to remain readable. These docs also offer Light , Dark and Match system in the theme selector beside the collection links. The docs site remembers its own choice separately from the dashboard. Code windows remain dark in both reading themes, and the visual examples follow the docs palette while keeping every status readable.",
     "endLine": 60
    },
    {
     "heading": "Keep the payout address accurate",
     "anchor": "keep-the-payout-address-accurate",
     "level": 2,
     "startLine": 60,
     "text": "Keep the payout address accurate The Payout wallet section shows your Polygon address and your Railbed fee. Changing the wallet requires confirmation of the full new address. Payments already in progress still use their saved wallet. Read Wallet and settlement before making a wallet change. Never paste a seed phrase or private key into the address field.",
     "endLine": 66
    },
    {
     "heading": "Account access",
     "anchor": "account-access",
     "level": 2,
     "startLine": 66,
     "text": "Account access Railbed uses its sign-in flow to verify your identity. Follow the email or social sign-in prompts shown when you log in. If access fails, see Troubleshooting. Owners can invite other people on the Team page and give each one a role: Role What they can do --- --- Owner Everything, including the payout wallet, business settings and the team Manager Checkouts, payment links, orders and customers, and accepting held payments Developer API keys, webhooks, integrations and AI agents, and reading records Viewer Reading records only Only owners can invite people or change roles. Only owners and developers can create API keys or connect AI agents. Removing someone, or moving them to a role that can't manage keys, disconnects the AI agents they approved for good; connect them again if they're still needed. Use a tracking link when someone only needs to see the status of a particular payment request.",
     "endLine": 80
    }
   ]
  },
  {
   "id": "user-guide/integrations",
   "title": "Connect Railbed to your store",
   "nav": "Stores and integrations",
   "section": "Your business",
   "description": "Choose the connection that fits your business, from Shopify or a WordPress plugin to a checkout built by your developer.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/integrations/",
   "path": "/docs/user-guide/integrations/",
   "markdown": "https://railbed.com/docs/user-guide/integrations.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "6cad8d91e7ec02466a4e2549befad08237019ae08e1446c9b7a6ba60074ab15b",
   "sections": [
    {
     "heading": "Choose a supported connection",
     "anchor": "choose-a-supported-connection",
     "level": 2,
     "startLine": 0,
     "text": "Choose a supported connection Open Integrations to see the current options. The ready-made store connections are Railbed Checkout for Shopify and Railbed for WooCommerce . For other websites, use a hosted checkout, a buy button, a widget or a custom API integration. Your setup Start here --- --- No website, or occasional invoices Payment links A website with an HTML or custom-code block Widgets and buy buttons A Shopify store Set up Shopify A WordPress store using WooCommerce Install WooCommerce A custom store, app or game Give your developer the API quickstart For a different store platform, choose a website embed or ask your developer to use the API.",
     "endLine": 14
    },
    {
     "heading": "Set up Shopify",
     "anchor": "set-up-shopify",
     "level": 2,
     "startLine": 14,
     "text": "Set up Shopify Railbed Checkout for Shopify keeps buyers in your store's cart, then takes them to Railbed's checkout to pay by card. Shopify prices the order, including shipping, tax and discounts, and each paid checkout becomes a real Shopify order with the customer attached. Before you start: - You're the store owner, or staff with the App development permission. - Your store sells in USD, EUR, GBP, CAD or AUD and has at least one product. - Your store uses a theme. Headless storefronts (Hydrogen) aren't supported. - You can stop and come back at any step; Railbed keeps your progress. Open Integrations → Shopify and follow its four steps. Railbed checks each one on your store. 1. Connect your store. Type your store's .myshopify.com address, your own domain or any link to the store. 2. Create the Railbed app in Shopify. In Shopify's Dev Dashboard, create an app manually using the name, App URL and permissions the setup page shows (each has a copy button), and release it. Under the app's API access requests , request Protected customer data access and select Name, Email, Phone and Address, each for App functionality . Install the app on your store, then paste its Client ID and secret into Railbed. 3. Add Railbed to your theme. In the theme editor, add a Custom Liquid section to the footer, paste the code from the setup page, set its top and bottom padding to 0 and save. 4. Try it, then go live. Place a test order through the preview link, then switch the store to Live. Live needs every app check passed and a payout wallet. Until you switch it, the store stays in Test mode: only browsers that opened the preview link see Railbed's checkout, and test orders arrive in Shopify tagged railbed-test . Your buyers keep Shopify's checkout. Checkout settings on the setup page also sets the store's Provider choice , as on checkout pages: new stores send buyers straight to the best provider with Smart Routing, and Pay opens it in a new tab. Choose Save provider choice after changing it. Some carts go to Shopify's own checkout instead, such as subscriptions, gift cards, bundles or a currency other than your store's. Checkout apps such as upsells don't run on Railbed's checkout. The setup page's Good to know lists the rest.",
     "endLine": 38
    },
    {
     "heading": "Install WooCommerce",
     "anchor": "install-woocommerce",
     "level": 2,
     "startLine": 38,
     "text": "Install WooCommerce The plugin requires WordPress 6.9 or later, WooCommerce 10.9 or later and PHP 8.3 or later. It supports classic checkout and Checkout Blocks. Use HTTPS for the store, including Test mode outside a local WordPress environment. Open Integrations → WooCommerce in Railbed and follow its four steps. Railbed checks each one as you go. The page follows the dashboard's mode: set up in Live to take real payments straight away, or turn Live off to try it in Test first. 1. Download the plugin ZIP from that page. In WordPress, open Plugins → Add new plugin → Upload plugin , choose the ZIP, then Install now and Activate . 2. Create an API key on the setup page and copy it (it's shown once), and add your store's webhook by typing your store's address. In WooCommerce → Settings → Payments → Railbed , choose the same mode, paste the key and the webhook's secret into that mode's fields and save. Keep both values private. 3. Send the test webhook from the setup page. Reload the plugin settings and confirm Connection verified . 4. Tick Enable to offer Railbed at checkout. In Live mode, your first order completes this step. In Test mode, open the plugin's preview link (Test mode is shown only to store managers and to browsers with that link), place a Test order and complete Simulate successful payment . Confirm the order changes to Processing, or Completed for an order whose items are all virtual and downloadable. The setup page's Checkout settings card holds the Provider choice for all your WooCommerce orders, in Test and Live mode. Your first WooCommerce key starts it on Smart Routing, so buyers go straight to the best provider after Continue to pay. The plugin needs no change. The WooCommerce guide contains the connection details, requirements and recovery notes. Keep that guide open alongside the plugin settings.",
     "endLine": 53
    },
    {
     "heading": "Move the store to Live",
     "anchor": "move-the-store-to-live",
     "level": 2,
     "startLine": 53,
     "text": "Move the store to Live If you set up in Test first, confirm your payout wallet, switch Railbed to Live mode and repeat the setup page's steps using a Live key , a Live endpoint and the plugin's Live fields. A Live endpoint uses Send ping rather than a simulated payment event. Keep the Enable setting on only when the connection has been verified and you are ready to accept customers. Test orders retain their Test mode when you change the plugin mode. A mode switch does not turn an old Test order into a real payment.",
     "endLine": 59
    },
    {
     "heading": "What connects the payment to the order",
     "anchor": "what-connects-the-payment-to-the-order",
     "level": 2,
     "startLine": 59,
     "text": "What connects the payment to the order The plugin creates a payment for the order, directs the customer to checkout, and verifies the result before marking the order paid. A signed webhook starts the check; the plugin then retrieves the payment through the authenticated API. The customer's return to the store is not the proof that releases an order. If the store and Railbed disagree, compare the payment ID, mode and status, then check webhook delivery and WordPress cron. See Troubleshooting.",
     "endLine": 65
    },
    {
     "heading": "Follow store orders and customers",
     "anchor": "follow-store-orders-and-customers",
     "level": 2,
     "startLine": 65,
     "text": "Follow store orders and customers With Railbed for WooCommerce 1.1 or later, new checkouts also send the purchase details to Orders and the buyer details to Customers. The order can include item quantities and variants, coupon discounts, shipping, tax, addresses and a link back to WooCommerce. Manage fulfilment in WooCommerce. Completed marks the linked Railbed order fulfilled; moving it back to Processing or On hold marks it not fulfilled. Updates run in the background and retry when Railbed cannot be reached. If an update still fails, inspect the order's private note and use Order actions → Send fulfilment status to Railbed . Cancelling an order or recording a refund in WooCommerce shows on the linked Railbed order's timeline; it never moves money. In Test mode, the plugin offers Railbed only to signed-in store managers and to browsers that opened the preview link in its settings, and from version 1.1.1 WordPress offers plugin updates like any other plugin. Details: WooCommerce. Orders placed before the 1.1 upgrade are not linked for this fulfilment sync. If a store extension makes the line totals inconsistent, the plugin can send the order as a single total instead; check its private order note and the WooCommerce order for the original breakdown. The payment confirmation process still applies. The WooCommerce developer guide covers upgrade and sync details. A custom API integration can provide the same order and customer information when creating a session.",
     "endLine": 77
    },
    {
     "heading": "Give your developer the right resources",
     "anchor": "give-your-developer-the-right-resources",
     "level": 2,
     "startLine": 77,
     "text": "Give your developer the right resources You do not need to learn the API to use payment links. When you need automatic order delivery, a custom purchase screen, or customer-account metadata, share these guides with the person connecting your system: - Developer quickstart: Create and check a payment from a server. - Fulfil orders safely: Confirm the right order and prevent duplicate delivery. - Webhooks: Receive signed payment events and recover missed deliveries. - Your own checkout: Build a custom purchase screen while card entry stays with a provider. Create keys and endpoints in the matching mode. API keys and signing secrets belong in your server or store configuration, never in a public web page, widget snippet or customer message. A key that only needs to look things up, such as one for a reporting tool, can be Read only .",
     "endLine": 91
    },
    {
     "heading": "Let an AI agent connect",
     "anchor": "let-an-ai-agent-connect",
     "level": 3,
     "startLine": 88,
     "text": "Let an AI agent connect If you or your developer use an AI coding assistant, it can ask for its own access instead of being given a key: it reads railbed.com/auth.md , gives you a link, and you approve it on a Railbed page, choosing the business, Test or Live and full access or read only. Owners and developers can approve agents. Connected agents are listed under Developers → AI agents , where you can disconnect them. How agent sign-in works.",
     "endLine": 91
    }
   ]
  },
  {
   "id": "user-guide/mobile",
   "title": "Use Railbed on your phone",
   "nav": "Railbed on your device",
   "section": "Your business",
   "description": "Manage payments in your browser, or add the dashboard to your Home Screen for quicker access.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/mobile/",
   "path": "/docs/user-guide/mobile/",
   "markdown": "https://railbed.com/docs/user-guide/mobile.md",
   "updated": "2026-09-26",
   "bodyLineOffset": 8,
   "markdownHash": "d98247fc1489f0837d8e24068f7845b39bc3a71eb2cd507f640fec4c4030aebc",
   "sections": [
    {
     "heading": "Use the dashboard anywhere",
     "anchor": "use-the-dashboard-anywhere",
     "level": 2,
     "startLine": 0,
     "text": "Use the dashboard anywhere Open the dashboard in your browser and sign in. On smaller screens, use the navigation menu to move between Wallet, Payments, Payment links, Checkouts and Settings. Payment lists adapt to the screen so the amount and status remain readable. You can share a payment link from your phone, open a request to check its progress, or review payment details without installing a separate store app.",
     "endLine": 6
    },
    {
     "heading": "Add the dashboard to your device",
     "anchor": "add-the-dashboard-to-your-device",
     "level": 2,
     "startLine": 6,
     "text": "Add the dashboard to your device In Settings → Railbed on your device , follow the instructions for your browser: - iPhone or iPad: open Railbed in Safari, use the Share menu and choose Add to Home Screen. If offered, leave Open as Web App on. - Android or desktop: use your browser's Install app or Add to Home screen command when available. - Safari on Mac: use File → Add to Dock when available. Browser wording can vary. The installed shortcut opens the same Railbed dashboard and uses the same account. Buyer checkout links remain ordinary browser pages rather than becoming the installed merchant dashboard.",
     "endLine": 16
    },
    {
     "heading": "Stay connected when managing payments",
     "anchor": "stay-connected-when-managing-payments",
     "level": 2,
     "startLine": 16,
     "text": "Stay connected when managing payments An internet connection is required to load current payment and wallet information or to save changes. The offline fallback explains that you are offline; it is not an offline copy of your payment history and does not queue payment actions for later. If the connection drops, reconnect and refresh the relevant payment before deciding whether to deliver an order. Do not use an old screen as confirmation of a payment's current status.",
     "endLine": 22
    },
    {
     "heading": "Help a buyer returning from a provider",
     "anchor": "help-a-buyer-returning-from-a-provider",
     "level": 2,
     "startLine": 22,
     "text": "Help a buyer returning from a provider A provider can open a new browser tab when the buyer pays. They should keep the original Railbed checkout tab available and return to it after finishing. If the browser blocks the new tab, payment may continue in the original tab. The exact tab and app-switching experience depends on the device and browser. See The buyer experience for what the buyer should expect and why a return page alone does not prove payment.",
     "endLine": 27
    }
   ]
  },
  {
   "id": "user-guide/troubleshooting",
   "title": "Find your next step",
   "nav": "Troubleshooting",
   "section": "Help and reference",
   "description": "Start with the symptom, check the current payment state, and take the next action without losing track of the order.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/troubleshooting/",
   "path": "/docs/user-guide/troubleshooting/",
   "markdown": "https://railbed.com/docs/user-guide/troubleshooting.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "bd8cfd8ed7916bfc304116f635cadb31197705fe428109c0be75ae6e39a31723",
   "sections": [
    {
     "heading": "My link or payment is missing",
     "anchor": "my-link-or-payment-is-missing",
     "level": 2,
     "startLine": 0,
     "text": "My link or payment is missing Check the dashboard's Live switch. Links, checkouts, payments, API keys and webhook endpoints belong to either Test or Live mode. Switch to the mode in which the item was created, then search Payments using the payment ID or customer email. If you still cannot find it, confirm that you are signed in to the intended Railbed account. An existing buyer URL is not changed by your current dashboard mode.",
     "endLine": 6
    },
    {
     "heading": "The customer did not receive an email",
     "anchor": "the-customer-did-not-receive-an-email",
     "level": 2,
     "startLine": 6,
     "text": "The customer did not receive an email Railbed does not send payment links by email. The Email field on a payment request prefills the buyer's checkout. Copy and send the saved link yourself. Railbed also does not send automatic buyer receipt emails. The confirmed checkout shows an on-screen receipt, or follows your success URL. Store order emails are handled by your store or integration.",
     "endLine": 12
    },
    {
     "heading": "The payment is stuck In progress",
     "anchor": "the-payment-is-stuck-in-progress",
     "level": 2,
     "startLine": 12,
     "text": "The payment is stuck In progress Open its details and timeline. In progress means the buyer went to a provider; it does not mean Railbed has confirmed payment. Ask the buyer whether the provider completed the payment, requested another step or showed a decline. Keep the original checkout tab available. Live provider declines are not sent directly to Railbed, so an unsuccessful attempt can stay in progress until it expires. Avoid creating another charge request solely because the buyer's browser has not updated. For an API or reusable-checkout payment, the normal payment window is 24 hours. Payment links use their configured expiry. Late payment evidence can still change the result; check the latest status before following up.",
     "endLine": 20
    },
    {
     "heading": "The payment says Being reviewed or Underpaid",
     "anchor": "the-payment-says-being-reviewed-or-underpaid",
     "level": 2,
     "startLine": 20,
     "text": "The payment says Being reviewed or Underpaid Open Payments → Being reviewed and read the reason on that payment. Underpaid means less than the full amount arrived; Being reviewed covers every other cause. Follow Review a held payment. Only use Accept as paid when it is offered and you have checked what arrived in your wallet. That action can trigger fulfilment. Do not ask a customer to top up the original payment address.",
     "endLine": 26
    },
    {
     "heading": "Railbed says Paid but my store does not",
     "anchor": "railbed-says-paid-but-my-store-does-not",
     "level": 2,
     "startLine": 26,
     "text": "Railbed says Paid but my store does not Match the order to the correct Railbed payment ID and mode. In Developers , inspect the endpoint's delivery log. A failing endpoint may need a corrected URL, public access to that URL, or the correct signing secret in your store. After fixing the cause, use the available retry or resend action for the event. Your receiver must handle duplicate events safely. In WooCommerce, also check the plugin's connection status and that WordPress cron is running. A delivered webhook means the endpoint accepted the request. Your own store still needs to process it correctly. Webhook delivery and retries and WooCommerce explain the integration checks.",
     "endLine": 34
    },
    {
     "heading": "My wallet balance does not match my sales",
     "anchor": "my-wallet-balance-does-not-match-my-sales",
     "level": 2,
     "startLine": 34,
     "text": "My wallet balance does not match my sales The Live wallet balance is read from Polygon and can include non-Railbed deposits or transfers out. Railbed's received totals include reported net receipts, not the full original order prices. If a Paid payment shows Not reported yet , it is missing a reported net settlement amount and may not be included in the received totals. If the wallet balance itself failed to load, use Try again . Read the balance correctly.",
     "endLine": 40
    },
    {
     "heading": "There are no available card providers",
     "anchor": "there-are-no-available-card-providers",
     "level": 2,
     "startLine": 40,
     "text": "There are no available card providers Provider eligibility depends on the buyer's country, currency, amount, minimums and current availability. Test mode's fixed providers do not prove that the same choices are available for a Live buyer. A checkout set to One provider with Smart Routing turned off stops buyers that provider can't serve, with a message naming it. Turn the Smart Routing option back on, or choose Smart Routing, to let those buyers pay. Check the amount and currency you intended to charge, and that the checkout is in the intended mode. Do not promise a specific provider or an ID-free checkout to every customer. If asking for help, include the country, price, currency, mode and checkout URL; do not include card details.",
     "endLine": 48
    },
    {
     "heading": "A button or widget does not appear",
     "anchor": "a-button-or-widget-does-not-appear",
     "level": 2,
     "startLine": 48,
     "text": "A button or widget does not appear Check that your site contains the embed script and the matching railbed-button or railbed-checkout element. Use the slug copied from the saved checkout, and verify that the checkout still exists. Some website editors strip scripts or do not execute them in preview. Test the published page and use the site's supported custom-code block. Widgets and buy buttons has copyable examples.",
     "endLine": 54
    },
    {
     "heading": "The buyer did not reach my thank-you page",
     "anchor": "the-buyer-did-not-reach-my-thank-you-page",
     "level": 2,
     "startLine": 54,
     "text": "The buyer did not reach my thank-you page Check that the success URL was saved on the right link or checkout. Railbed redirects from the original checkout tab only after payment confirmation. A buyer who closes it or remains on the provider's page may not reach your URL. Check payment status in Railbed instead of treating a missing redirect as a failed payment. Do not alter a provider's own return links. The two-tab flow explains the behavior.",
     "endLine": 60
    },
    {
     "heading": "My AI agent can't connect or stopped working",
     "anchor": "my-ai-agent-can-t-connect-or-stopped-working",
     "level": 2,
     "startLine": 60,
     "text": "My AI agent can't connect or stopped working An AI agent connects through a link it gives you, which you approve on a Railbed page (how it works). What the page or the agent says usually tells you which of these it is: What you see What to do --- --- The request expired, or the code didn't work The link and the code each last only a few minutes. Ask your agent to start connecting again, then use the new link straight away The agent asked with a different email address Sign in with the email address you gave the agent, or ask the agent to start again with yours Only owners and developers can connect agents Ask an owner to make you a developer, or to connect the agent themselves Agents use the Checkout API, which is off The API is turned off for this business. Contact support to ask about turning it on The agent reports agent_disconnected Someone disconnected it under Developers → AI agents , or the person who approved it left the business or can no longer manage keys. Connect it again The agent reports insufficient_scope It was approved with Read only . Connect it again with Full access if it needs to create checkouts or update orders The agent can't find a payment you can see It works in the mode it was approved for. An agent connected in Test sees only Test data Railbed never asks you for the code. If someone you didn't ask contacts you about one, don't share it.",
     "endLine": 76
    },
    {
     "heading": "I cannot sign in",
     "anchor": "i-cannot-sign-in",
     "level": 2,
     "startLine": 76,
     "text": "I cannot sign in Return to Railbed sign-in and follow the currently offered email or social sign-in flow. Use the email/account associated with your merchant account and complete the verification prompts shown. If your session expired, sign in again before retrying the action. An offline page or failed connection cannot confirm current account access. Do not share a one-time code, password or session details in a support message.",
     "endLine": 82
    },
    {
     "heading": "Prepare a useful support request",
     "anchor": "prepare-a-useful-support-request",
     "level": 2,
     "startLine": 82,
     "text": "Prepare a useful support request Include the payment ID, Test or Live mode, the time of the issue, what you expected, and the exact message shown. For a website issue, include the checkout URL and browser/device. For a store issue, include the store order reference and its matching payment ID. Leave out passwords, seed phrases, private keys, card numbers, API keys and webhook signing secrets. Screenshots should be cropped to the issue and any unrelated customer details removed.",
     "endLine": 87
    }
   ]
  },
  {
   "id": "user-guide/glossary",
   "title": "Railbed, in plain language",
   "nav": "Glossary",
   "section": "Help and reference",
   "description": "A short reference for the words you will see in checkout, the dashboard and your payment records.",
   "audience": "merchants",
   "url": "https://railbed.com/docs/user-guide/glossary/",
   "path": "/docs/user-guide/glossary/",
   "markdown": "https://railbed.com/docs/user-guide/glossary.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "6aa4353ae269033693623e9fac3a44d1b71404461c1ab9e756091dfb6327cd5e",
   "sections": [
    {
     "heading": "Payment and checkout terms",
     "anchor": "payment-and-checkout-terms",
     "level": 2,
     "startLine": 0,
     "text": "Payment and checkout terms Term Meaning --- --- Payment link A request for one payment with a set amount; useful for an invoice or deposit Checkout page A reusable product page; every buyer creates a separate payment Pricing table A reusable checkout with several plans and optional two-way price choices Payment widget A compact checkout placed inside your own website Buy button A website button that opens a Railbed checkout in an overlay Tracking link A shareable, status-only view of a payment request Reference Your own invoice or order label, attached to a payment Payment ID Railbed's identifier for one payment, beginning with pay_ Order The purchase record containing items, totals, customer and one or more payment attempts; see Orders Customer The purchase history associated with one email address in one mode; see Customers Payment attempt One try at paying an order; a retry can belong to the same order Success URL The thank-you destination opened from checkout after payment confirmation Smart Routing The checks and preferences that choose eligible card providers for a buyer, and the checkout setting that sends buyers straight to the top one Provider The licensed service whose page takes the buyer's card details and payment",
     "endLine": 19
    },
    {
     "heading": "Wallet and money terms",
     "anchor": "wallet-and-money-terms",
     "level": 2,
     "startLine": 19,
     "text": "Wallet and money terms Term Meaning --- --- USDC The dollar-denominated stablecoin used for merchant settlement Polygon The blockchain network used for that settlement Self-custody wallet A wallet whose private keys you control Payout wallet Your saved public Polygon address for receiving settlement Deposit address A payment-specific intermediate address; it is not the wallet setting you should change Settlement The funds transfer associated with a confirmed payment Net receipt The reported amount that reached your wallet after applicable fees Held payment A payment marked Being reviewed or Underpaid because confirmation checks need attention Underpaid A held payment whose only problem is that less than the full amount arrived Accept as paid Your explicit decision to accept an eligible held payment with the amount that arrived",
     "endLine": 34
    },
    {
     "heading": "Integration terms",
     "anchor": "integration-terms",
     "level": 2,
     "startLine": 34,
     "text": "Integration terms Term Meaning --- --- Test mode A separate environment of simulated payments; no card is charged Live mode The mode for real card payments API key A private credential that lets your server or plugin access Railbed in a specific mode, with full access or read only AI agent connection Access you approved for an AI agent (such as a coding assistant) to use your account's API in one mode, with full access or read only. You can disconnect it under Developers → AI agents ; see Connect an agent Webhook A signed notification from Railbed to a server when a payment event happens Signing secret A private value your receiver uses to verify a webhook Fulfilment Your delivery record, separate from payment status; see Orders and fulfilment Idempotency Retrying the same operation without accidentally creating the same order's payment twice For the meaning of a specific status and what to do next, see Payments and statuses. For API fields and event payloads, use the developer reference.",
     "endLine": 48
    }
   ]
  },
  {
   "id": "overview",
   "title": "Railbed developer docs",
   "nav": "Overview",
   "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.",
   "audience": "developers",
   "url": "https://railbed.com/docs/",
   "path": "/docs/",
   "markdown": "https://railbed.com/docs/index.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "e4d5216b26b07169b8ef48763add170b80b0259dc273df97188bdca66b348f1f",
   "sections": [
    {
     "heading": "What you can build",
     "anchor": "what-you-can-build",
     "level": 2,
     "startLine": 0,
     "text": "What you can build Using the dashboard without an integration? Start with the Railbed user guide for account setup, payment links, checkouts and everyday payment management. 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: Create a key, make a checkout session and take a simulated payment. - How payments work: The life of a payment, from checkout to settlement, and what each status means for your order. - API reference: Authentication, idempotency, pagination, errors and every endpoint, with examples. - Webhooks: Signed events for every step of a payment, retries, and the delivery log.",
     "endLine": 11
    },
    {
     "heading": "Choose an integration",
     "anchor": "choose-an-integration",
     "level": 2,
     "startLine": 11,
     "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 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 Nothing. Install the plugin and connect it with a key and a webhook secret WordPress stores Hosted checkout One API call per order, then a redirect Custom stores and apps that want Railbed's checkout page Your own checkout 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 shows how.",
     "endLine": 24
    },
    {
     "heading": "The API at a glance",
     "anchor": "the-api-at-a-glance",
     "level": 2,
     "startLine": 24,
     "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. Example: Create a checkout session bash curl https://pay.railbed.io/v1/checkout_sessions \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\ -H \"Content-Type: application/json\" \\ -H \"Idempotency-Key: order_1042\" \\ -d '{ \"amount\": \"49.00\", \"currency\": \"USD\", \"description\": \"Pro Membership\", \"reference\": \"order_1042\" }' Example: Response · response 201 Created json { \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\", \"object\": \"checkout_session\", \"url\": \"https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN\", \"status\": \"open\", \"livemode\": false, \"amount\": \"49.00\", \"currency\": \"USD\", \"description\": \"Pro Membership\", \"reference\": \"order_1042\", \"customer_email\": null, \"created\": 1790380525, \"expires_at\": 1790466925, \"started_at\": null, \"metadata\": null, \"order_id\": \"ord_M4tQx8ZkP1vRn6WcLs2B\" } 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\" . Every session belongs to an order: send its items, totals and addresses as order , and your orders and customers appear in the dashboard and in every webhook.",
     "endLine": 67
    },
    {
     "heading": "Test mode and live mode",
     "anchor": "test-mode-and-live-mode",
     "level": 2,
     "startLine": 67,
     "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 covers the tools.",
     "endLine": 71
    },
    {
     "heading": "What Railbed doesn't do",
     "anchor": "what-railbed-doesn-t-do",
     "level": 2,
     "startLine": 71,
     "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 summarises the whole API. See For AI agents.",
     "endLine": 77
    }
   ]
  },
  {
   "id": "quickstart",
   "title": "Quickstart",
   "nav": "Quickstart",
   "section": "Get started",
   "description": "Take your first payment in Test mode. Create a secret key, create a checkout session, pay it as a buyer, and confirm it from your server.",
   "audience": "developers",
   "url": "https://railbed.com/docs/quickstart/",
   "path": "/docs/quickstart/",
   "markdown": "https://railbed.com/docs/quickstart.md",
   "updated": "2026-10-06",
   "bodyLineOffset": 8,
   "markdownHash": "e9d3e6f78b6d317c48dd58dd0b0a6667d2c74df7273e5160392beb1551252f22",
   "sections": [
    {
     "heading": "Before you start",
     "anchor": "before-you-start",
     "level": 2,
     "startLine": 0,
     "text": "Before you start You need a Railbed account (sign up) 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.",
     "endLine": 4
    },
    {
     "heading": "1. Create a secret key",
     "anchor": "1-create-a-secret-key",
     "level": 2,
     "startLine": 4,
     "text": "1. Create a secret key In the dashboard, switch to Test mode, open Developers and choose Create key . Name it after the server that will use it, for example \"Store backend\", and keep Full access (a Read only key can't create checkouts). The key starts with rb_test_ and is shown once: copy it into your server's environment. Using an AI coding agent? It can connect to your account itself through railbed.com/auth.md , so you never paste a key into a chat. Example: Your server's environment bash export RAILBED_SECRET_KEY=\"rb_test_…\" [!IMPORTANT] A secret key can create checkouts and read your payments. Keep it on your server. Never put it in a web page, a mobile app or a repository. If one leaks, revoke it in Developers and create another.",
     "endLine": 19
    },
    {
     "heading": "2. Create a checkout session",
     "anchor": "2-create-a-checkout-session",
     "level": 2,
     "startLine": 19,
     "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. Example: cURL bash curl https://pay.railbed.io/v1/checkout_sessions \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\ -H \"Content-Type: application/json\" \\ -H \"Idempotency-Key: order_1042\" \\ -d '{ \"amount\": \"49.00\", \"currency\": \"USD\", \"description\": \"Pro Membership\", \"reference\": \"order_1042\", \"customer_email\": \"buyer@example.com\", \"success_url\": \"https://yourstore.com/thanks?order={REFERENCE}\" }' Example: Node\\.js js const res = await fetch('https://pay.railbed.io/v1/checkout_sessions', { method: 'POST', headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} , 'Content-Type': 'application/json', 'Idempotency-Key': 'order_1042', }, body: JSON.stringify({ amount: '49.00', currency: 'USD', description: 'Pro Membership', reference: 'order_1042', customer_email: 'buyer@example.com', success_url: 'https://yourstore.com/thanks?order={REFERENCE}', }), }); const session = await res.json(); console.log(session.url); Example: Python python import os, requests session = requests.post( \"https://pay.railbed.io/v1/checkout_sessions\", headers={ \"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\", \"Idempotency-Key\": \"order_1042\", }, json={ \"amount\": \"49.00\", \"currency\": \"USD\", \"description\": \"Pro Membership\", \"reference\": \"order_1042\", \"customer_email\": \"buyer@example.com\", \"success_url\": \"https://yourstore.com/thanks?order={REFERENCE}\", }, timeout=15, ).json() print(session[\"url\"]) Example: PHP php <?php $ch = curl_init('https://pay.railbed.io/v1/checkout_sessions'); curl_setopt_array($ch, [ CURLOPT_POST = true, CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', 'Idempotency-Key: order_1042', ], CURLOPT_POSTFIELDS = json_encode([ 'amount' = '49.00', 'currency' = 'USD', 'description' = 'Pro Membership', 'reference' = 'order_1042', 'customer_email' = 'buyer@example.com', 'success_url' = 'https://yourstore.com/thanks?order={REFERENCE}', ]), ]); $session = json_decode(curl_exec($ch), true); echo $session['url']; The response is the new session. Save its id ( pay_… ) with your order. Example: Response · response 201 Created json { \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\", \"object\": \"checkout_session\", \"url\": \"https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN\", \"status\": \"open\", \"livemode\": false, \"amount\": \"49.00\", \"currency\": \"USD\", \"description\": \"Pro Membership\", \"reference\": \"order_1042\", \"customer_email\": \"buyer@example.com\", \"created\": 1790380525, \"expires_at\": 1790466925, \"started_at\": null, \"metadata\": null, \"order_id\": \"ord_M4tQx8ZkP1vRn6WcLs2B\" }",
     "endLine": 137
    },
    {
     "heading": "3. Pay as a buyer",
     "anchor": "3-pay-as-a-buyer",
     "level": 2,
     "startLine": 137,
     "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.",
     "endLine": 141
    },
    {
     "heading": "4. Confirm the payment from your server",
     "anchor": "4-confirm-the-payment-from-your-server",
     "level": 2,
     "startLine": 141,
     "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: Example: cURL bash curl https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" Example: Node\\.js js const res = await fetch( https://pay.railbed.io/v1/payments/${paymentId} , { headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} }, }); const payment = await res.json(); if (payment.status === 'paid') fulfil(payment.reference); Example: Python python payment = requests.get( f\"https://pay.railbed.io/v1/payments/{payment_id}\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, timeout=15, ).json() if payment[\"status\"] == \"paid\": fulfil(payment[\"reference\"]) Example: PHP php <?php $ch = curl_init('https://pay.railbed.io/v1/payments/' . rawurlencode($paymentId)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = ['Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY')], ]); $payment = json_decode(curl_exec($ch), true); if ($payment['status'] === 'paid') fulfil($payment['reference']); Example: Response (trimmed) · response 200 OK json { \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\", \"object\": \"payment\", \"status\": \"paid\", \"livemode\": false, \"amount\": \"49.00\", \"currency\": \"USD\", \"reference\": \"order_1042\", \"paid_at\": 1790381342, \"provider\": \"stripe\", \"settlement\": { \"coin\": \"polygon_usdc\", \"value_coin\": \"47.53\", \"merchant_received\": \"44.915850\", \"txid_in\": \"0x0c65…582e\", \"txid_out\": \"0x8202…356f\", \"payout_wallet\": \"0xF977814e90dA44bFA03b6295A0616a897441aceC\" } } Check that status is paid and that the id, amount, currency and reference match the order you saved, then fulfil it once.",
     "endLine": 213
    },
    {
     "heading": "5. Get told instead of asking",
     "anchor": "5-get-told-instead-of-asking",
     "level": 2,
     "startLine": 213,
     "text": "5. Get told instead of asking Polling works, but webhooks tell you the moment something happens. In Developers, choose Add endpoint and enter your server's https:// address (while you build on your own computer, use a tunnel). Copy the signing secret, then choose Send test event to see exactly what your server receives. Webhooks covers the events and verifying signatures.",
     "endLine": 217
    },
    {
     "heading": "Next",
     "anchor": "next",
     "level": 2,
     "startLine": 217,
     "text": "Next - Fulfil orders safely: The checks that make fulfilment correct even with retries, late payments and reviews. - Your own checkout: Show card providers in your own screen instead of redirecting. - Testing: Simulate underpayments and declines, and send sample events. - Going live: The short checklist before real payments.",
     "endLine": 223
    }
   ]
  },
  {
   "id": "how-it-works",
   "title": "How payments work",
   "nav": "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.",
   "audience": "developers",
   "url": "https://railbed.com/docs/how-it-works/",
   "path": "/docs/how-it-works/",
   "markdown": "https://railbed.com/docs/how-it-works.md",
   "updated": "2026-10-04",
   "bodyLineOffset": 8,
   "markdownHash": "2858d30817e00dd4468ff200f1b87ea4018475a0b88e31858cadc207c3d10194",
   "sections": [
    {
     "heading": "The journey of one payment",
     "anchor": "the-journey-of-one-payment",
     "level": 2,
     "startLine": 0,
     "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.",
     "endLine": 10
    },
    {
     "heading": "Statuses",
     "anchor": "statuses",
     "level": 2,
     "startLine": 10,
     "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 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 Terminal Test failure. Live providers do not 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.",
     "endLine": 23
    },
    {
     "heading": "What arrives in your wallet",
     "anchor": "what-arrives-in-your-wallet",
     "level": 2,
     "startLine": 23,
     "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.",
     "endLine": 32
    },
    {
     "heading": "Identity checks",
     "anchor": "identity-checks",
     "level": 2,
     "startLine": 32,
     "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.",
     "endLine": 36
    },
    {
     "heading": "Refunds and disputes",
     "anchor": "refunds-and-disputes",
     "level": 2,
     "startLine": 36,
     "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.",
     "endLine": 40
    },
    {
     "heading": "Timing",
     "anchor": "timing",
     "level": 2,
     "startLine": 40,
     "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",
     "endLine": 49
    }
   ]
  },
  {
   "id": "testing",
   "title": "Testing",
   "nav": "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.",
   "audience": "developers",
   "url": "https://railbed.com/docs/testing/",
   "path": "/docs/testing/",
   "markdown": "https://railbed.com/docs/testing.md",
   "updated": "2026-10-04",
   "bodyLineOffset": 8,
   "markdownHash": "8ad230075ddc63ae547ad9fa1872b2ddfab666b4094c4ec6b994f44976b8452a",
   "sections": [
    {
     "heading": "Test mode",
     "anchor": "test-mode",
     "level": 2,
     "startLine": 0,
     "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 AI agents The default when you approve one Only if you choose Live when you approve it 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.",
     "endLine": 16
    },
    {
     "heading": "Simulate an outcome as a buyer",
     "anchor": "simulate-an-outcome-as-a-buyer",
     "level": 2,
     "startLine": 16,
     "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 stays open. Return to checkout and choose another way to pay on the same payment.",
     "endLine": 24
    },
    {
     "heading": "Simulate an outcome from your server",
     "anchor": "simulate-an-outcome-from-your-server",
     "level": 2,
     "startLine": 24,
     "text": "Simulate an outcome from your server To test without a browser, start the session and simulate the outcome through the API. Use declined to test trying another provider on the same payment. Use failed to test a terminal failure and its payment.failed webhook. Example: cURL bash Start it (the buyer would normally do this by choosing a provider) curl -X POST \\ https://pay.railbed.io/v1/checkout_sessions/pay_7AAiYH0Ykt11ED4hmfiN/start \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\ -H \"Content-Type: application/json\" \\ -d '{ \"customer_email\": \"buyer@example.com\" }' Choose an outcome: \"paid\", \"underpaid\", \"declined\" or \"failed\" curl -X POST \\ https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN/simulate \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\ -H \"Content-Type: application/json\" \\ -d '{ \"outcome\": \"paid\" }' Example: Node\\.js js const api = (path, body) = fetch( https://pay.railbed.io/v1${path} , { method: 'POST', headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} , 'Content-Type': 'application/json', }, body: JSON.stringify(body), }).then((r) = r.json()); await api( /checkout_sessions/${id}/start , { customer_email: 'buyer@example.com', }); const payment = await api( /payments/${id}/simulate , { outcome: 'paid', // or 'underpaid', 'declined', 'failed' }); Example: Python python def api(path, body): return requests.post( f\"https://pay.railbed.io/v1{path}\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, json=body, timeout=15, ).json() api(f\"/checkout_sessions/{id}/start\", {\"customer_email\": \"buyer@example.com\"}) payment = api( f\"/payments/{id}/simulate\", {\"outcome\": \"paid\"}, or \"underpaid\", \"declined\", \"failed\" ) Example: PHP php <?php function railbed_post(string $path, array $body): array { $ch = curl_init('https://pay.railbed.io/v1' . $path); curl_setopt_array($ch, [ CURLOPT_POST = true, CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS = json_encode($body), ]); return json_decode(curl_exec($ch), true); } railbed_post(\"/checkout_sessions/$id/start\", [ 'customer_email' = 'buyer@example.com', ]); $payment = railbed_post(\"/payments/$id/simulate\", [ 'outcome' = 'paid', // or 'underpaid', 'declined', 'failed' ]); Simulated money runs through the same settlement checks and webhooks as a live payment. A declined outcome records the card decline without a failed webhook or a final payment state, so the same session can be retried. Simulating again on a finished payment returns it unchanged. See Simulate a payment for the details.",
     "endLine": 113
    },
    {
     "heading": "Send sample webhook events",
     "anchor": "send-sample-webhook-events",
     "level": 2,
     "startLine": 113,
     "text": "Send sample webhook events In Developers, choose Send test event on a Test endpoint and pick any event. Railbed sends a signed, realistic event with a made-up payment, order and customer (the payment's metadata.sample is \"true\" , the order is ord_sample and the customer cus_sample , and none of them is in your account 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.",
     "endLine": 119
    },
    {
     "heading": "Receive webhooks on your own computer",
     "anchor": "receive-webhooks-on-your-own-computer",
     "level": 2,
     "startLine": 119,
     "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.",
     "endLine": 123
    },
    {
     "heading": "A test plan worth running",
     "anchor": "a-test-plan-worth-running",
     "level": 2,
     "startLine": 123,
     "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 card decline Simulate declined Order not fulfilled; buyer can retry another provider on the same session A terminal failure Simulate failed Order not fulfilled; payment.failed received; a new session is needed 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 A second try at one order Create two sessions with the same order.id and new keys, and pay the second One order, both payments in its payment_ids , fulfilled once A paid order sent again Create another session for an order.id that's paid 409 order_paid ; the buyer isn't asked to pay twice 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",
     "endLine": 141
    },
    {
     "heading": "Going live",
     "anchor": "going-live",
     "level": 2,
     "startLine": 141,
     "text": "Going live 1. Add your payout wallet in 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. An AI agent you connected in Test stays in Test: if it should work on live data, connect it again and choose Live . 4. Take one small real payment and follow it through: payment.paid received, the order fulfilled once, USDC in your wallet.",
     "endLine": 147
    }
   ]
  },
  {
   "id": "guides/hosted-checkout",
   "title": "Hosted checkout",
   "nav": "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.",
   "audience": "developers",
   "url": "https://railbed.com/docs/guides/hosted-checkout/",
   "path": "/docs/guides/hosted-checkout/",
   "markdown": "https://railbed.com/docs/guides/hosted-checkout.md",
   "updated": "2026-09-27",
   "bodyLineOffset": 8,
   "markdownHash": "ca8d801276cfe380c018a63d22363d635ff89dac245facac1d283c7299507e0c",
   "sections": [
    {
     "heading": "How it works",
     "anchor": "how-it-works",
     "level": 2,
     "startLine": 0,
     "text": "How it works Your server creates a checkout session 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.",
     "endLine": 10
    },
    {
     "heading": "Create the session",
     "anchor": "create-the-session",
     "level": 2,
     "startLine": 10,
     "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. Example: Node\\.js js // POST /checkout on your server, after saving the order app.post('/checkout', async (req, res) = { const order = await orders.create({ userId: req.user.id, sku: 'pro-monthly', price: '49.00', currency: 'USD', }); const response = await fetch('https://pay.railbed.io/v1/checkout_sessions', { method: 'POST', headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} , 'Content-Type': 'application/json', 'Idempotency-Key': order_${order.id} , }, body: JSON.stringify({ amount: order.price, currency: order.currency, description: 'Pro Membership · monthly', reference: order_${order.id} , customer_email: req.user.email, metadata: { user_id: String(req.user.id) }, success_url: https://yourstore.com/orders/${order.id}/thanks?payment={PAYMENT_ID} , cancel_url: https://yourstore.com/cart , }), }); if (!response.ok) { return res.status(502).send( 'Checkout is unavailable. Try again in a moment.', ); } const session = await response.json(); await orders.update(order.id, { paymentId: session.id }); res.redirect(303, session.url); }); Example: Python python Flask: after saving the order @app.post(\"/checkout\") def checkout(): order = orders.create( user_id=current_user.id, sku=\"pro-monthly\", price=\"49.00\", currency=\"USD\", ) r = requests.post( \"https://pay.railbed.io/v1/checkout_sessions\", headers={ \"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\", \"Idempotency-Key\": f\"order_{order.id}\", }, json={ \"amount\": order.price, \"currency\": order.currency, \"description\": \"Pro Membership · monthly\", \"reference\": f\"order_{order.id}\", \"customer_email\": current_user.email, \"metadata\": {\"user_id\": str(current_user.id)}, \"success_url\": ( f\"https://yourstore.com/orders/{order.id}\" f\"/thanks?payment={{PAYMENT_ID}}\" ), \"cancel_url\": \"https://yourstore.com/cart\", }, timeout=15, ) if not r.ok: return \"Checkout is unavailable. Try again in a moment.\", 502 session = r.json() orders.update(order.id, payment_id=session[\"id\"]) return redirect(session[\"url\"], code=303) Example: PHP php <?php // After saving the order $ch = curl_init('https://pay.railbed.io/v1/checkout_sessions'); curl_setopt_array($ch, [ CURLOPT_POST = true, CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', 'Idempotency-Key: order_' . $order['id'], ], CURLOPT_POSTFIELDS = json_encode([ 'amount' = $order['price'], 'currency' = $order['currency'], 'description' = 'Pro Membership · monthly', 'reference' = 'order_' . $order['id'], 'customer_email' = $user['email'], 'metadata' = ['user_id' = (string) $user['id']], 'success_url' = 'https://yourstore.com/orders/' . $order['id'] . '/thanks?payment={PAYMENT_ID}', 'cancel_url' = 'https://yourstore.com/cart', ]), ]); $session = json_decode(curl_exec($ch), true); if (curl_getinfo($ch, CURLINFO_HTTP_CODE) = 300) { http_response_code(502); exit('Checkout is unavailable.'); } save_payment_id($order['id'], $session['id']); header('Location: ' . $session['url'], true, 303); [!NOTE] In Python f-strings, {{PAYMENT_ID}} writes the literal {PAYMENT_ID} placeholder. Railbed fills it in, not your code. [!TIP] Send the order's items, totals and shipping address as order , with your order's id in order.id . The order then shows in the dashboard and in every webhook, a buyer's second try joins the same order, and an order that's already paid can't be paid again. See Orders and customers.",
     "endLine": 138
    },
    {
     "heading": "Return URLs",
     "anchor": "return-urls",
     "level": 2,
     "startLine": 138,
     "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.",
     "endLine": 150
    },
    {
     "heading": "What the buyer sees",
     "anchor": "what-the-buyer-sees",
     "level": 2,
     "startLine": 150,
     "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.",
     "endLine": 160
    },
    {
     "heading": "When there's no provider",
     "anchor": "when-there-s-no-provider",
     "level": 2,
     "startLine": 160,
     "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.",
     "endLine": 163
    }
   ]
  },
  {
   "id": "guides/custom-checkout",
   "title": "Your own checkout",
   "nav": "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.",
   "audience": "developers",
   "url": "https://railbed.com/docs/guides/custom-checkout/",
   "path": "/docs/guides/custom-checkout/",
   "markdown": "https://railbed.com/docs/guides/custom-checkout.md",
   "updated": "2026-09-26",
   "bodyLineOffset": 8,
   "markdownHash": "2f3387f0500890c31da727069de0297b850165ff6639c852921377d963610890",
   "sections": [
    {
     "heading": "When to build your own",
     "anchor": "when-to-build-your-own",
     "level": 2,
     "startLine": 0,
     "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.",
     "endLine": 6
    },
    {
     "heading": "The flow",
     "anchor": "the-flow",
     "level": 2,
     "startLine": 6,
     "text": "The flow 1. Your server creates the session , exactly as for the hosted checkout. 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.",
     "endLine": 14
    },
    {
     "heading": "Start the session and get providers",
     "anchor": "start-the-session-and-get-providers",
     "level": 2,
     "startLine": 14,
     "text": "Start the session and get providers Example: cURL bash curl -X POST \\ https://pay.railbed.io/v1/checkout_sessions/pay_7AAiYH0Ykt11ED4hmfiN/start \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\ -H \"Content-Type: application/json\" \\ -d '{ \"customer_email\": \"player1042@example.com\", \"country\": \"US\" }' Example: Node\\.js js // Your server: POST /api/purchase/:orderId/providers // (called by your checkout screen) const res = await fetch( https://pay.railbed.io/v1/checkout_sessions/${order.paymentId}/start , { method: 'POST', headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} , 'Content-Type': 'application/json', }, body: JSON.stringify({ customer_email: buyer.email, country: buyer.country, // from the buyer's request, if known }), }, ); if (res.status === 409) { const { error } = await res.json(); // e.g. no_providers, already_paid return reply.status(409).send({ code: error.code, message: error.message }); } const started = await res.json(); // Send only what the screen needs. Never send your API key to the browser. reply.send( started.providers.map(({ id, name, note, recommended, handoff_url }) = ({ id, name, note, recommended, handoff_url, })), ); Example: Python python r = requests.post( f\"https://pay.railbed.io/v1/checkout_sessions/{order.payment_id}/start\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, json={ \"customer_email\": buyer.email, \"country\": buyer.country, from the buyer's request, if known }, timeout=15, ) if r.status_code == 409: error = r.json()[\"error\"] e.g. no_providers, already_paid return {\"code\": error[\"code\"], \"message\": error[\"message\"]}, 409 providers = [ {k: p[k] for k in (\"id\", \"name\", \"note\", \"recommended\", \"handoff_url\")} for p in r.json()[\"providers\"] ] Example: PHP php <?php $paymentId = rawurlencode($order['payment_id']); $ch = curl_init(\"https://pay.railbed.io/v1/checkout_sessions/$paymentId/start\"); curl_setopt_array($ch, [ CURLOPT_POST = true, CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS = json_encode([ 'customer_email' = $buyer['email'], 'country' = $buyer['country'], ]), ]); $started = json_decode(curl_exec($ch), true); if (curl_getinfo($ch, CURLINFO_HTTP_CODE) === 409) { http_response_code(409); exit(json_encode($started['error'])); // e.g. no_providers, already_paid } $fields = array_flip(['id', 'name', 'note', 'recommended', 'handoff_url']); echo json_encode(array_map( fn ($p) = array_intersect_key($p, $fields), $started['providers'], )); Example: Response (trimmed) · response 200 OK json { \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\", \"object\": \"checkout_session\", \"status\": \"open\", \"amount\": \"49.00\", \"currency\": \"USD\", \"started_at\": 1790380611, \"country\": \"US\", \"providers\": [ { \"id\": \"stripe\", \"name\": \"Stripe\", \"note\": \"Card, Apple Pay or Google Pay\", \"recommended\": true, \"handoff_url\": \"https://pay.railbed.io/go/pay_7AAiYH0Ykt11ED4hmfiN?provider=stripe\" }, { \"id\": \"paypal\", \"name\": \"PayPal\", \"note\": \"PayPal balance or card\", \"recommended\": false, \"handoff_url\": \"https://pay.railbed.io/go/pay_7AAiYH0Ykt11ED4hmfiN?provider=paypal\" } ] } Starting is safe to repeat: the session keeps the same deposit address and started_at , and a new email replaces the saved one. Providers change with the amount, currency and country, so fetch them when the buyer reaches your checkout screen rather than caching them.",
     "endLine": 145
    },
    {
     "heading": "Show providers and hand off",
     "anchor": "show-providers-and-hand-off",
     "level": 2,
     "startLine": 145,
     "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. Example: In your checkout screen html <ul id=\"providers\" </ul <p id=\"status\" role=\"status\" Choose how to pay.</p <script // providers: what your server returned from the start call function showProviders(providers) { const list = document.getElementById('providers'); for (const p of providers) { const a = document.createElement('a'); a.href = p.handoff_url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = p.name + (p.recommended ? ' (recommended)' : ''); a.addEventListener('click', () = { document.getElementById('status').textContent = 'Finish paying in the new tab. This page updates by itself.'; waitForPayment(); }); const li = document.createElement('li'); li.append(a, ' ', p.note); list.append(li); } } </script Don't fetch a handoff_url from your server, frame it in an iframe or change its query. It must open in the buyer's own browser: providers check the buyer's real location and refuse to load inside frames. At the click, Railbed checks again using the buyer's own connection: if the chosen provider is no longer available or doesn't serve the country the buyer is in, the buyer lands on Railbed's page for this payment to choose another.",
     "endLine": 179
    },
    {
     "heading": "Show the result",
     "anchor": "show-the-result",
     "level": 2,
     "startLine": 179,
     "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 . Example: In your checkout screen js async function waitForPayment() { // Your server, never Railbed directly const res = await fetch( /api/orders/${orderId}/status ); const { status } = await res.json(); if (status === 'paid') return showPaid(); if (status === 'held') { return showMessage( 'Your payment arrived and is being reviewed. We’ll email you.', ); } if (status === 'failed' status === 'expired') { return showMessage('The payment didn’t go through. Try again.'); } setTimeout(waitForPayment, 4000); } Your server must also keep checking unfinished orders when nobody has the screen open, because buyers close tabs. Fulfil orders safely covers the background check.",
     "endLine": 205
    },
    {
     "heading": "Country",
     "anchor": "country",
     "level": 2,
     "startLine": 205,
     "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.",
     "endLine": 209
    },
    {
     "heading": "Errors when starting",
     "anchor": "errors-when-starting",
     "level": 2,
     "startLine": 209,
     "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",
     "endLine": 221
    }
   ]
  },
  {
   "id": "guides/fulfilment",
   "title": "Fulfil orders safely",
   "nav": "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.",
   "audience": "developers",
   "url": "https://railbed.com/docs/guides/fulfilment/",
   "path": "/docs/guides/fulfilment/",
   "markdown": "https://railbed.com/docs/guides/fulfilment.md",
   "updated": "2026-09-27",
   "bodyLineOffset": 8,
   "markdownHash": "94acb9a8ec86241d15468dd9bf6377cda39775252a2716c4225c22b6aba7a2ca",
   "sections": [
    {
     "heading": "The rule",
     "anchor": "the-rule",
     "level": 2,
     "startLine": 0,
     "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.",
     "endLine": 4
    },
    {
     "heading": "Save before you create",
     "anchor": "save-before-you-create",
     "level": 2,
     "startLine": 4,
     "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. Send the order itself as order , with your order's id in order.id : a later session for the same order then joins it, and one for an order that's already paid is refused with 409 order_paid .",
     "endLine": 8
    },
    {
     "heading": "Check before you grant",
     "anchor": "check-before-you-grant",
     "level": 2,
     "startLine": 8,
     "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. When you ship goods, key it on the order: a buyer who paid two sessions for one order has two paid payments, and should get one parcel and a refund. Resolve the customer from your own order, never from the payment's customer_email : buyers can type any email at checkout. Example: Node\\.js: one grant per payment, in one transaction js async function fulfilFromPayment(payment) { // Held, pending, expired: nothing to deliver yet if (payment.status !== 'paid') return; const order = await db.orders.findByPaymentId(payment.id); // Not ours (another store sharing the endpoint), or not saved yet if (!order) return; if (payment.livemode !== order.livemode) { throw new Error('mode mismatch'); } if (payment.reference !== order.reference) { throw new Error('reference mismatch'); } if (payment.amount !== order.price payment.currency !== order.currency) { throw new Error('amount mismatch'); } await db.transaction(async (tx) = { // UNIQUE(payment_id): a duplicate event or a second worker fails here // and delivers nothing const inserted = await tx.grants.insertIfAbsent({ paymentId: payment.id, orderId: order.id, }); if (!inserted) return; await tx.inventory.deliver(order); await tx.orders.markPaid(order.id, payment.paid_at); }); } [!NOTE] The webhook payload uses camelCase names ( reference , amount , currency , mode , paidAt in milliseconds), while the API uses snake_case ( livemode , paid_at in seconds). Many integrations read the webhook only as a signal and then fetch GET /v1/payments/:id , so all their checks run on one shape.",
     "endLine": 56
    },
    {
     "heading": "Ship what the order says",
     "anchor": "ship-what-the-order-says",
     "level": 2,
     "startLine": 56,
     "text": "Ship what the order says Every payment belongs to an order with the items, the addresses and the customer, as your store sent them in order and customer . If your store keeps its own orders, ship from those, checked as above. If your fulfilment works from Railbed instead (a warehouse connected to your webhooks, for example), take what to ship from the order: - In a webhook: data.order , with items and shipTo (camelCase). See the order object. - From the API: GET /v1/orders/:id with the payment's order_id , with items and shipping_address . The checks above still apply, plus one: the paid payment's amount and currency equal the order's total and currency . An order's items and totals follow its latest session, so a late payment for an older session can be for a different basket. Hold those for a person to look at. Ship each order once, keyed on the order's id, then report it with Update an order so the dashboard shows it as fulfilled: Example: Node\\.js: ship a paid order once, then report it js // railbed() is the small helper from /docs/api/errors/ async function shipOrder(paymentId) { const payment = await railbed( /payments/${paymentId} ); if (payment.status !== 'paid') return; const order = await railbed( /orders/${payment.order_id} ); // The order follows its latest session: check this payment covers it if ( payment.amount !== order.total payment.currency !== order.currency ) { return flagForReview(order, payment); } await db.transaction(async (tx) = { // UNIQUE(order_id): a second paid payment for this order ships nothing const isNew = await tx.shipments.insertIfAbsent({ orderId: order.id }); if (!isNew) return; await tx.shipments.queue({ to: order.shipping_address, lines: order.items.map((i) = ({ sku: i.sku, quantity: i.quantity })), }); }); } // Later, when the parcel leaves the warehouse async function markShipped(orderId) { await railbed( /orders/${orderId} , { method: 'PATCH', body: JSON.stringify({ fulfillment: 'fulfilled' }), }); } fulfillment is a record for you and your team; it doesn't touch the payment. Orders you sent with order.id get their fulfilment only from your store, so report every change; send unfulfilled if a shipment is recalled. The WooCommerce plugin does this for you.",
     "endLine": 104
    },
    {
     "heading": "Webhooks, the API, or both",
     "anchor": "webhooks-the-api-or-both",
     "level": 2,
     "startLine": 104,
     "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.",
     "endLine": 113
    },
    {
     "heading": "Check unfinished orders in the background",
     "anchor": "check-unfinished-orders-in-the-background",
     "level": 2,
     "startLine": 113,
     "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.",
     "endLine": 123
    },
    {
     "heading": "Held payments",
     "anchor": "held-payments",
     "level": 2,
     "startLine": 123,
     "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.",
     "endLine": 129
    },
    {
     "heading": "Late payments",
     "anchor": "late-payments",
     "level": 2,
     "startLine": 129,
     "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.",
     "endLine": 133
    },
    {
     "heading": "Refunds",
     "anchor": "refunds",
     "level": 2,
     "startLine": 133,
     "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.",
     "endLine": 136
    }
   ]
  },
  {
   "id": "guides/no-code",
   "title": "Payment links and buy buttons",
   "nav": "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.",
   "audience": "developers",
   "url": "https://railbed.com/docs/guides/no-code/",
   "path": "/docs/guides/no-code/",
   "markdown": "https://railbed.com/docs/guides/no-code.md",
   "updated": "2026-09-27",
   "bodyLineOffset": 8,
   "markdownHash": "784098c110c2478db1000927a6491fca12046150b5cd4057497e205e6bc29b48",
   "sections": [
    {
     "heading": "What you can make in the dashboard",
     "anchor": "what-you-can-make-in-the-dashboard",
     "level": 2,
     "startLine": 0,
     "text": "What you can make in the dashboard For a complete walkthrough of the dashboard, use the user guide. It includes visual examples, payment-link setup, branding, payment review and troubleshooting. 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. Each buyer's attempt is also an order in Orders , with one item (the product, the plan or what the link is for), and each buyer's email a customer in Customers . 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.",
     "endLine": 15
    },
    {
     "heading": "Add a buy button to any site",
     "anchor": "add-a-buy-button-to-any-site",
     "level": 2,
     "startLine": 15,
     "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. Example: A buy button that opens the checkout over your page html <script src=\"https://pay.railbed.io/embed.js\" async </script <railbed-button checkout=\"your-slug\" </railbed-button 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.",
     "endLine": 39
    },
    {
     "heading": "Put the checkout in your page",
     "anchor": "put-the-checkout-in-your-page",
     "level": 2,
     "startLine": 39,
     "text": "Put the checkout in your page For a payment widget or a pricing table, place the checkout itself in your layout: Example: The checkout, in your page html <script src=\"https://pay.railbed.io/embed.js\" async </script <railbed-checkout checkout=\"your-slug\" </railbed-checkout 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.",
     "endLine": 53
    },
    {
     "heading": "Good to know",
     "anchor": "good-to-know",
     "level": 2,
     "startLine": 53,
     "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 ). The event's data.order and data.customer say who bought what; see event payloads. - Mark orders fulfilled in Orders once they're paid, or with Update an order.",
     "endLine": 61
    }
   ]
  },
  {
   "id": "guides/woocommerce",
   "title": "WooCommerce",
   "nav": "WooCommerce",
   "section": "Guides",
   "description": "Add Railbed card checkout to a WordPress store with the Railbed for WooCommerce plugin. Orders complete from verified payments, and their items, customers and fulfilment appear in Railbed, with no code.",
   "audience": "developers",
   "url": "https://railbed.com/docs/guides/woocommerce/",
   "path": "/docs/guides/woocommerce/",
   "markdown": "https://railbed.com/docs/guides/woocommerce.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "38bcfb10550c513d4cc9a4435daa99ea31610e035c45ec0ac3ce17775309cfd2",
   "sections": [
    {
     "heading": "What the plugin does",
     "anchor": "what-the-plugin-does",
     "level": 2,
     "startLine": 0,
     "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. From version 1.1, each checkout also sends the order's items, totals and addresses and the buyer's contact details, so the order and its customer appear in Railbed's Orders and Customers , and the plugin keeps the order's fulfilment in step with WooCommerce. See Orders, customers and fulfilment. 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.",
     "endLine": 8
    },
    {
     "heading": "Install and connect",
     "anchor": "install-and-connect",
     "level": 2,
     "startLine": 8,
     "text": "Install and connect The dashboard walks you through it: open Integrations → WooCommerce in the dashboard. It has four steps, one open at a time, and Railbed checks each one as you go (the plugin using your key, the test webhook answered, the first order in). 1. Install the plugin. Download the ZIP from that page. In WordPress, open Plugins → Add new → Upload plugin , upload it and activate it alongside WooCommerce. 2. Connect Railbed. Create an API key on the setup page (it's shown once: copy it) and add your store's webhook by typing your store's address (or pasting the webhook URL the plugin's settings show; it ends in ?wc-api=railbed_webhook ). Then open WooCommerce → Settings → Payments → Railbed , choose Test mode, paste the key into Test API key and the webhook's secret into Test webhook secret , and save. Saving checks the key. 3. Send a test webhook from the setup page, then reload the plugin settings: they say Connection verified . If something is missing, the plugin's settings list it, including why the last webhook was refused. 4. Try a test order. Tick Enable (Offer Railbed at checkout) and save. In Test mode, shoppers don't see Railbed: only store managers who are signed in, and browsers that open the preview link shown in the plugin's settings (for seven days). Open it in the browser you test with, place an order and pay it with Simulate successful payment . The order waits as Pending payment , then becomes Processing (or Completed when every item is virtual and downloadable), as with any paid order. Make a new link retires the old one.",
     "endLine": 17
    },
    {
     "heading": "Provider choice",
     "anchor": "provider-choice",
     "level": 2,
     "startLine": 17,
     "text": "Provider choice Checkout settings on the setup page decides how your WooCommerce buyers reach a card provider, as on checkout pages: Smart Routing (the best provider for each buyer, opened straight from Continue to pay), Buyers choose (the ranked list) or One provider. It covers Test and Live orders, and the plugin needs no change. Your first WooCommerce key starts it on Smart Routing.",
     "endLine": 21
    },
    {
     "heading": "Going live",
     "anchor": "going-live",
     "level": 2,
     "startLine": 21,
     "text": "Going live Add your payout wallet in Railbed. Switch the dashboard to Live : the setup page follows, and you repeat steps 2 and 3 with a live key and a live webhook (a live webhook takes a ping as its test). Then set the plugin's Payment mode to Live and save; saving checks the payout wallet, and without one the plugin says so and keeps Railbed off your checkout. 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.",
     "endLine": 25
    },
    {
     "heading": "Order status while the buyer pays",
     "anchor": "order-status-while-the-buyer-pays",
     "level": 2,
     "startLine": 25,
     "text": "Order status while the buyer pays - Pending payment while the buyer is on Railbed's page, like any payment method that sends the buyer away to pay. WooCommerce holds the stock for its usual hold time and sends no email yet. - Processing (or Completed for downloads) when Railbed confirms the payment: WooCommerce reduces stock and sends its New order email to you and Processing order email to the buyer. - On hold when money arrived but Railbed is reviewing it. Don't ship until it's confirmed. - Failed when the checkout closed unpaid (after 24 hours). If a slow provider's payment still arrives and nobody changed the order, it completes as usual. A changed or cancelled order gets a private note to review instead. From version 1.1.2, a buyer returning from a closed checkout can place a fresh order with the cart still in their browser. Both classic checkout and Checkout Blocks create a new order for that retry. If the cart is empty, the buyer returns to the shop to choose the items again. The old order keeps its original payment record.",
     "endLine": 34
    },
    {
     "heading": "Orders, customers and fulfilment",
     "anchor": "orders-customers-and-fulfilment",
     "level": 2,
     "startLine": 34,
     "text": "Orders, customers and fulfilment Each checkout sends Railbed the WooCommerce order along with the payment. Nothing needs setting up. - What's sent. The items (with SKU, quantity and the variation's attributes), fees, the discount and coupon codes, shipping and its method, tax, the billing and shipping addresses, the order number and a link back to the order, and the buyer's name, email, phone and WooCommerce customer ID. In the dashboard the order carries WooCommerce's number and an Open in WooCommerce button. - When the totals don't add up. If the lines, discount, shipping and tax don't come to the order total to the cent (tax rounding, or an extension that changes totals), the order is sent without its items and totals and shows as one line for the total. The WooCommerce order gets a private note. Payment works the same either way. - Existing payment attempts. A buyer's second try at the same WooCommerce order joins its order in Railbed. If Railbed already has a paid payment for that order, or one held for review, no new checkout is created: the buyer is asked to contact you, and the order gets a private note to review. - Fulfilment. Orders that ship start as Not fulfilled ; orders with only virtual products have nothing to ship. Marking an order Completed marks it fulfilled in Railbed, and moving it from Completed back to Processing or On hold marks it not fulfilled. Orders WooCommerce completes on payment (downloads) are marked fulfilled the same way. - Fulfilment updates run in the background and are retried four times over about two and a half hours if Railbed can't be reached. If one still fails, the order gets a private note, and Order actions → Send fulfilment status to Railbed sends it again. The dashboard shows these orders' fulfilment but doesn't change it: change it in WooCommerce. - Cancellations and refunds. Cancelling an order in WooCommerce, or recording a refund, shows on the Railbed order as Canceled or Refund recorded with the amount. It doesn't change the payment: Railbed can't send money back, so arrange any refund with the buyer yourself. These are sent in the background and retried like fulfilment; Order actions → Send cancellations and refunds to Railbed sends one again. See Report a cancellation or refund. - Orders placed before 1.1 aren't linked to a Railbed order, so their fulfilment, cancellations and refunds aren't sent. For day-to-day tasks, see Orders and fulfilment and Customers. The plugin uses the API's order fields and Update an order, so a store you build yourself can do the same.",
     "endLine": 48
    },
    {
     "heading": "Updates",
     "anchor": "updates",
     "level": 2,
     "startLine": 48,
     "text": "Updates From version 1.1.1, WordPress shows new Railbed versions on its Plugins and Updates screens like any other plugin: choose Update now , or turn on automatic updates for it. The plugin installs a download only if its checksum matches the one Railbed publishes. To check for new versions it contacts Railbed about twice a day and sends nothing about your store. To get to 1.1.1 from an earlier version, download the plugin from Integrations in the dashboard, upload it in Plugins → Add new → Upload plugin , and choose Replace current with uploaded . Settings, keys and orders carry over, and nothing needs reconnecting. Orders placed before the upgrade finish as they would have.",
     "endLine": 54
    },
    {
     "heading": "Staging copies",
     "anchor": "staging-copies",
     "level": 2,
     "startLine": 54,
     "text": "Staging copies If you copy your site (to staging, or by restoring a backup somewhere else), the copy notices its new address and pauses Railbed: no checkout, webhooks or background updates there until an administrator answers the notice at the top of the WordPress admin. Choose Same store, new address after moving your store, then send another test event. Choose A copy (staging): disconnect it on a copy: it removes the copy's keys so it can't change your real store's Railbed orders. Connect a staging copy with Test keys if you want to test there.",
     "endLine": 58
    },
    {
     "heading": "Store settings that matter",
     "anchor": "store-settings-that-matter",
     "level": 2,
     "startLine": 58,
     "text": "Store settings that matter - Deleting the plugin in WordPress removes its keys and secrets and keeps your orders' payment records. WooCommerce's REST API never returns the saved keys. - 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.",
     "endLine": 66
    },
    {
     "heading": "Moving from another gateway",
     "anchor": "moving-from-another-gateway",
     "level": 2,
     "startLine": 66,
     "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.",
     "endLine": 69
    }
   ]
  },
  {
   "id": "api",
   "title": "API reference",
   "nav": "Overview",
   "section": "API reference",
   "description": "The Railbed REST API. JSON over HTTPS, secret keys for your servers and access tokens for connected agents, scopes, idempotent creates, cursor pagination and plain error codes.",
   "audience": "developers",
   "url": "https://railbed.com/docs/api/",
   "path": "/docs/api/",
   "markdown": "https://railbed.com/docs/api.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "125db5d45432f04ab6e3e4a78e5ce4f83a66778d373063a8af853eba2e7406e2",
   "sections": [
    {
     "heading": "Base URL",
     "anchor": "base-url",
     "level": 2,
     "startLine": 0,
     "text": "Base URL Example: Base URL text https://pay.railbed.io/v1 Every request uses HTTPS. You create checkout sessions, one for each attempt at paying, and read their payments to fulfil orders. A session and its payment share one id ( pay_… ). Each session belongs to an order, which holds what was bought and where it ships, and each order to a customer. Endpoint What it does --- --- POST /v1/checkout_sessions Create a checkout session GET /v1/checkout_sessions/:id Retrieve a checkout session POST /v1/checkout_sessions/:id/start Start a checkout session and get its card providers GET /v1/payments/:id Retrieve a payment GET /v1/payments List payments POST /v1/payments/:id/simulate Simulate a payment (Test mode) GET /v1/orders/:id Retrieve an order GET /v1/orders List orders, or find one by your store's order id PATCH /v1/orders/:id Update an order's fulfilment POST /v1/orders/:id/events Report a cancellation or refund on the order's timeline GET /v1/customers/:id Retrieve a customer GET /v1/customers List customers, or find one by email",
     "endLine": 25
    },
    {
     "heading": "Authentication",
     "anchor": "authentication",
     "level": 2,
     "startLine": 25,
     "text": "Authentication Send a bearer token on every request: a secret key from your server, or the access token of an AI agent you've connected to your account. The API treats both the same way. Example: An authenticated request bash curl \"https://pay.railbed.io/v1/payments?limit=1\" \\ -H \"Authorization: Bearer rb_test_…\" The credential decides everything about the request: which account it belongs to, whether it works on Test or Live data, and which endpoints it may use. Nothing else can switch the mode. A suspended account gets 403 suspended .",
     "endLine": 109
    },
    {
     "heading": "Secret keys",
     "anchor": "secret-keys",
     "level": 3,
     "startLine": 38,
     "text": "Secret keys Keys are created and revoked in Developers, and each is shown once, when it's created. rb_test_… keys see only Test data and rb_live_… keys only Live data. A missing, malformed or revoked key gets 401 invalid_api_key . Each key has an access level, chosen when you create it: Access level Scopes What the key can do --- --- --- Full access (the default) All five scopes Use every endpoint Read only payments:read , orders:read , customers:read Retrieve and list payments, checkout sessions, orders and customers. Nothing that creates or changes Keys created before access levels existed have full access. WooCommerce keys always do, because the plugin creates checkout sessions. [!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.",
     "endLine": 54
    },
    {
     "heading": "Agent access tokens",
     "anchor": "agent-access-tokens",
     "level": 3,
     "startLine": 54,
     "text": "Agent access tokens An AI agent gets access through agent sign-in. It reads railbed.com/auth.md, a person who can manage keys approves it in the dashboard, and the agent receives its own access tokens. Each lasts about five minutes, and the agent gets the next one itself from the token endpoint that auth.md describes. A token carries the business, the mode and the access level ( Full access or Read only ) the person chose. Example: An agent's request bash curl \"https://pay.railbed.io/v1/payments?limit=1\" \\ -H \"Authorization: Bearer $ACCESS_TOKEN\" Response Cause What the agent should do --- --- --- 401 invalid_token The token expired, is malformed or wasn't issued for Railbed Get a new access token 401 agent_disconnected The agent was disconnected in the dashboard, or the person who connected it no longer manages keys for the business Ask the person to connect it again",
     "endLine": 70
    },
    {
     "heading": "Scopes",
     "anchor": "scopes",
     "level": 3,
     "startLine": 70,
     "text": "Scopes Every endpoint needs one scope. A key or agent whose access level doesn't include it gets 403 insufficient_scope , and the message names the scope. Scope Endpoints --- --- payments:read GET /v1/payments , GET /v1/payments/:id , GET /v1/checkout_sessions/:id checkouts:write POST /v1/checkout_sessions , POST /v1/checkout_sessions/:id/start , POST /v1/payments/:id/simulate orders:read GET /v1/orders , GET /v1/orders/:id orders:write PATCH /v1/orders/:id , POST /v1/orders/:id/events customers:read GET /v1/customers , GET /v1/customers/:id",
     "endLine": 82
    },
    {
     "heading": "Discovery",
     "anchor": "discovery",
     "level": 3,
     "startLine": 82,
     "text": "Discovery Every 401 from the API tells an agent where to find out how to get access, in a WWW-Authenticate header (RFC 9728): Example: A request without a credential · response 401 Unauthorized http WWW-Authenticate: Bearer resource_metadata=\"https://pay.railbed.io/.well-known/oauth-protected-resource/v1\" When a credential was sent and refused, the header adds error=\"invalid_token\" . A 403 insufficient_scope carries error=\"insufficient_scope\", scope=\"…\" , naming the scope the endpoint needs. The protected resource metadata names the API, its authorization server, the scopes and these docs. The same document is also at https://pay.railbed.io/.well-known/oauth-protected-resource . An agent starting out should read auth.md, which takes it through the rest. Example: The protected resource metadata · response 200 OK json { \"resource\": \"https://pay.railbed.io/v1\", \"resource_name\": \"Railbed API\", \"authorization_servers\": [\"https://astounding-resonance-81.authkit.app\"], \"scopes_supported\": [\"payments:read\", \"checkouts:write\", \"orders:read\", \"orders:write\", \"customers:read\"], \"bearer_methods_supported\": [\"header\"], \"resource_documentation\": \"https://railbed.com/docs/api/\" }",
     "endLine": 109
    },
    {
     "heading": "Requests and responses",
     "anchor": "requests-and-responses",
     "level": 2,
     "startLine": 109,
     "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, order and customer inside it use milliseconds; see event payloads.) - Ids are prefixed: pay_ for sessions and payments, ord_ for orders, cus_ for customers, evt_ for webhook events.",
     "endLine": 119
    },
    {
     "heading": "Idempotency",
     "anchor": "idempotency",
     "level": 2,
     "startLine": 119,
     "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. Bodies are compared including order and customer , and metadata key order doesn't matter. Without the header, every create makes a new session, and your reference alone doesn't prevent duplicates.",
     "endLine": 133
    },
    {
     "heading": "Pagination",
     "anchor": "pagination",
     "level": 2,
     "startLine": 133,
     "text": "Pagination The list endpoints ( GET /v1/payments , GET /v1/orders and GET /v1/customers ) return the newest first, up to limit (1–100, default 20) at a time: Example: A page · response 200 OK json { \"data\": [{ \"id\": \"pay_…\", \"object\": \"payment\", \"status\": \"paid\" }], \"has_more\": true, \"next_cursor\": \"pay_Q3cNtwPT0bRqGmS5eZkB\" } Pass next_cursor as starting_after to get the next page, until next_cursor is null . The order is stable, even for objects created in the same second. A limit outside 1–100, or a cursor that isn't an object from the same list in this mode, gets 400 rather than a silent first page.",
     "endLine": 149
    },
    {
     "heading": "Rate limits",
     "anchor": "rate-limits",
     "level": 2,
     "startLine": 149,
     "text": "Rate limits Each account can make about 120 requests a minute across all its keys and modes, and 12 session starts a minute . Each connected AI agent has its own 120 a minute, so an agent can't use up your servers' budget; its live session starts still count toward the account's 12. Requests with a missing or invalid secret key are limited to about 60 a minute from one address. 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.",
     "endLine": 155
    },
    {
     "heading": "Errors",
     "anchor": "errors",
     "level": 2,
     "startLine": 155,
     "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: Example: An error · response 400 Bad Request json { \"error\": { \"code\": \"invalid_amount\", \"message\": \"amount must be a decimal string between \\\"1.00\\\" and \\\"100000.00\\\".\", \"field\": \"amount\" } } Branch on code and the status, never on message , which may be reworded. Errors lists every code.",
     "endLine": 173
    },
    {
     "heading": "Versioning",
     "anchor": "versioning",
     "level": 2,
     "startLine": 173,
     "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.",
     "endLine": 176
    }
   ]
  },
  {
   "id": "api/checkout-sessions",
   "title": "Checkout sessions",
   "nav": "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.",
   "audience": "developers",
   "url": "https://railbed.com/docs/api/checkout-sessions/",
   "path": "/docs/api/checkout-sessions/",
   "markdown": "https://railbed.com/docs/api/checkout-sessions.md",
   "updated": "2026-09-27",
   "bodyLineOffset": 8,
   "markdownHash": "1c97f9f05eb363b30fb549eab2f7ec7f5e4f9de1f03e981f424e08c85607596b",
   "sections": [
    {
     "heading": "The checkout session object",
     "anchor": "the-checkout-session-object",
     "level": 2,
     "startLine": 0,
     "text": "The checkout session object Field Type Description --- --- --- id string The session's id, pay_… . The same id identifies its payment 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 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 order_id string or null The order this session is a payment attempt for, ord_… . Every session has one ( null only on records from before orders existed)",
     "endLine": 20
    },
    {
     "heading": "Create a checkout session",
     "anchor": "create-a-checkout-session",
     "level": 2,
     "startLine": 20,
     "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 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 customer object or null Who is buying: name, phone and your customer id. See Orders and customers order object or null What they're buying: items, discount, shipping, tax, addresses and your order id. See Orders and customers Return URLs must be full http:// or https:// addresses (Live mode: https:// only) with no username or password. In Live mode your account needs a payout wallet first, or the request gets 409 no_payout_wallet . Example: cURL bash curl https://pay.railbed.io/v1/checkout_sessions \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\ -H \"Content-Type: application/json\" \\ -H \"Idempotency-Key: order_1042\" \\ -d '{ \"amount\": \"49.00\", \"currency\": \"USD\", \"description\": \"Pro Membership\", \"reference\": \"order_1042\", \"customer_email\": \"buyer@example.com\", \"metadata\": { \"user_id\": \"player_1042\" }, \"success_url\": \"https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}\", \"cancel_url\": \"https://yourstore.com/cart\" }' Example: Node\\.js js const res = await fetch('https://pay.railbed.io/v1/checkout_sessions', { method: 'POST', headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} , 'Content-Type': 'application/json', 'Idempotency-Key': 'order_1042', }, body: JSON.stringify({ amount: '49.00', currency: 'USD', description: 'Pro Membership', reference: 'order_1042', customer_email: 'buyer@example.com', metadata: { user_id: 'player_1042' }, success_url: 'https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}', cancel_url: 'https://yourstore.com/cart', }), }); if (!res.ok) throw new Error((await res.json()).error.code); const session = await res.json(); Example: Python python r = requests.post( \"https://pay.railbed.io/v1/checkout_sessions\", headers={ \"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\", \"Idempotency-Key\": \"order_1042\", }, json={ \"amount\": \"49.00\", \"currency\": \"USD\", \"description\": \"Pro Membership\", \"reference\": \"order_1042\", \"customer_email\": \"buyer@example.com\", \"metadata\": {\"user_id\": \"player_1042\"}, \"success_url\": ( \"https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}\" ), \"cancel_url\": \"https://yourstore.com/cart\", }, timeout=15, ) r.raise_for_status() session = r.json() Example: PHP php <?php $ch = curl_init('https://pay.railbed.io/v1/checkout_sessions'); curl_setopt_array($ch, [ CURLOPT_POST = true, CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', 'Idempotency-Key: order_1042', ], CURLOPT_POSTFIELDS = json_encode([ 'amount' = '49.00', 'currency' = 'USD', 'description' = 'Pro Membership', 'reference' = 'order_1042', 'customer_email' = 'buyer@example.com', 'metadata' = ['user_id' = 'player_1042'], 'success_url' = 'https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}', 'cancel_url' = 'https://yourstore.com/cart', ]), ]); $session = json_decode(curl_exec($ch), true); Example: Response · response 201 Created json { \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\", \"object\": \"checkout_session\", \"url\": \"https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN\", \"status\": \"open\", \"livemode\": false, \"amount\": \"49.00\", \"currency\": \"USD\", \"description\": \"Pro Membership\", \"reference\": \"order_1042\", \"customer_email\": \"buyer@example.com\", \"created\": 1790380525, \"expires_at\": 1790466925, \"started_at\": null, \"metadata\": { \"user_id\": \"player_1042\" }, \"order_id\": \"ord_M4tQx8ZkP1vRn6WcLs2B\" } A retry with the same Idempotency-Key and body answers 200 OK with the same session.",
     "endLine": 553
    },
    {
     "heading": "Orders and customers",
     "anchor": "orders-and-customers",
     "level": 3,
     "startLine": 166,
     "text": "Orders and customers Every session belongs to an order. Send order and customer with the session, and the order has your items, totals, addresses and store order id, and the customer has the buyer's name and phone. They appear in the dashboard's Orders and Customers and in every webhook. Without them, the order has one line, your description , for the whole amount. The buyer's checkout page still shows your description and the amount . The order's details are for your records, not for the buyer to confirm. Example: cURL bash curl https://pay.railbed.io/v1/checkout_sessions \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\ -H \"Content-Type: application/json\" \\ -H \"Idempotency-Key: order_1042-attempt-1\" \\ -d '{ \"amount\": \"135.31\", \"currency\": \"USD\", \"description\": \"Order 1042\", \"reference\": \"order_1042\", \"customer_email\": \"maya@example.com\", \"customer\": { \"name\": \"Maya Okafor\", \"phone\": \"+1 503 555 0142\", \"id\": \"88\" }, \"order\": { \"id\": \"1042\", \"number\": \"1042\", \"url\": \"https://yourstore.com/admin/orders/1042\", \"store\": { \"id\": \"yourstore\", \"name\": \"yourstore.com\", \"url\": \"https://yourstore.com\" }, \"items\": [ { \"name\": \"Magnesium glycinate\", \"variant\": \"120 capsules\", \"sku\": \"HN-MG-120\", \"quantity\": 2, \"unit_amount\": \"32.00\", \"amount\": \"64.00\" }, { \"name\": \"Daily greens\", \"sku\": \"HN-DG-30\", \"quantity\": 1, \"amount\": \"54.00\" }, { \"name\": \"Shaker bottle\", \"quantity\": 1, \"amount\": \"12.00\" } ], \"discount\": \"13.00\", \"discount_code\": \"WELCOME10\", \"shipping\": \"8.95\", \"shipping_method\": \"Standard\", \"tax\": \"9.36\", \"shipping_address\": { \"name\": \"Maya Okafor\", \"line1\": \"418 Linden Avenue\", \"line2\": \"Apt 3B\", \"city\": \"Portland\", \"region\": \"OR\", \"postal_code\": \"97214\", \"country\": \"US\" } }, \"success_url\": \"https://yourstore.com/orders/1042/thanks\" }' Example: Node\\.js js const res = await fetch('https://pay.railbed.io/v1/checkout_sessions', { method: 'POST', headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} , 'Content-Type': 'application/json', 'Idempotency-Key': 'order_1042-attempt-1', }, body: JSON.stringify({ amount: '135.31', currency: 'USD', description: 'Order 1042', reference: 'order_1042', customer_email: 'maya@example.com', customer: { name: 'Maya Okafor', phone: '+1 503 555 0142', id: '88' }, order: { id: '1042', number: '1042', url: 'https://yourstore.com/admin/orders/1042', store: { id: 'yourstore', name: 'yourstore.com', url: 'https://yourstore.com', }, items: [ { name: 'Magnesium glycinate', variant: '120 capsules', sku: 'HN-MG-120', quantity: 2, unit_amount: '32.00', amount: '64.00', }, { name: 'Daily greens', sku: 'HN-DG-30', quantity: 1, amount: '54.00' }, { name: 'Shaker bottle', quantity: 1, amount: '12.00' }, ], discount: '13.00', discount_code: 'WELCOME10', shipping: '8.95', shipping_method: 'Standard', tax: '9.36', shipping_address: { name: 'Maya Okafor', line1: '418 Linden Avenue', line2: 'Apt 3B', city: 'Portland', region: 'OR', postal_code: '97214', country: 'US', }, }, success_url: 'https://yourstore.com/orders/1042/thanks', }), }); if (!res.ok) throw new Error((await res.json()).error.code); const session = await res.json(); Example: Python python r = requests.post( \"https://pay.railbed.io/v1/checkout_sessions\", headers={ \"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\", \"Idempotency-Key\": \"order_1042-attempt-1\", }, json={ \"amount\": \"135.31\", \"currency\": \"USD\", \"description\": \"Order 1042\", \"reference\": \"order_1042\", \"customer_email\": \"maya@example.com\", \"customer\": { \"name\": \"Maya Okafor\", \"phone\": \"+1 503 555 0142\", \"id\": \"88\", }, \"order\": { \"id\": \"1042\", \"number\": \"1042\", \"url\": \"https://yourstore.com/admin/orders/1042\", \"store\": { \"id\": \"yourstore\", \"name\": \"yourstore.com\", \"url\": \"https://yourstore.com\", }, \"items\": [ { \"name\": \"Magnesium glycinate\", \"variant\": \"120 capsules\", \"sku\": \"HN-MG-120\", \"quantity\": 2, \"unit_amount\": \"32.00\", \"amount\": \"64.00\", }, { \"name\": \"Daily greens\", \"sku\": \"HN-DG-30\", \"quantity\": 1, \"amount\": \"54.00\", }, {\"name\": \"Shaker bottle\", \"quantity\": 1, \"amount\": \"12.00\"}, ], \"discount\": \"13.00\", \"discount_code\": \"WELCOME10\", \"shipping\": \"8.95\", \"shipping_method\": \"Standard\", \"tax\": \"9.36\", \"shipping_address\": { \"name\": \"Maya Okafor\", \"line1\": \"418 Linden Avenue\", \"line2\": \"Apt 3B\", \"city\": \"Portland\", \"region\": \"OR\", \"postal_code\": \"97214\", \"country\": \"US\", }, }, \"success_url\": \"https://yourstore.com/orders/1042/thanks\", }, timeout=15, ) r.raise_for_status() session = r.json() Example: PHP php <?php $ch = curl_init('https://pay.railbed.io/v1/checkout_sessions'); curl_setopt_array($ch, [ CURLOPT_POST = true, CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', 'Idempotency-Key: order_1042-attempt-1', ], CURLOPT_POSTFIELDS = json_encode([ 'amount' = '135.31', 'currency' = 'USD', 'description' = 'Order 1042', 'reference' = 'order_1042', 'customer_email' = 'maya@example.com', 'customer' = [ 'name' = 'Maya Okafor', 'phone' = '+1 503 555 0142', 'id' = '88', ], 'order' = [ 'id' = '1042', 'number' = '1042', 'url' = 'https://yourstore.com/admin/orders/1042', 'store' = [ 'id' = 'yourstore', 'name' = 'yourstore.com', 'url' = 'https://yourstore.com', ], 'items' = [ [ 'name' = 'Magnesium glycinate', 'variant' = '120 capsules', 'sku' = 'HN-MG-120', 'quantity' = 2, 'unit_amount' = '32.00', 'amount' = '64.00', ], [ 'name' = 'Daily greens', 'sku' = 'HN-DG-30', 'quantity' = 1, 'amount' = '54.00', ], ['name' = 'Shaker bottle', 'quantity' = 1, 'amount' = '12.00'], ], 'discount' = '13.00', 'discount_code' = 'WELCOME10', 'shipping' = '8.95', 'shipping_method' = 'Standard', 'tax' = '9.36', 'shipping_address' = [ 'name' = 'Maya Okafor', 'line1' = '418 Linden Avenue', 'line2' = 'Apt 3B', 'city' = 'Portland', 'region' = 'OR', 'postal_code' = '97214', 'country' = 'US', ], ], 'success_url' = 'https://yourstore.com/orders/1042/thanks', ]), ]); $session = json_decode(curl_exec($ch), true); Example: Response · response 201 Created json { \"id\": \"pay_Rt5Wm2KxQ8zLb3NvYc7P\", \"object\": \"checkout_session\", \"url\": \"https://pay.railbed.io/p/pay_Rt5Wm2KxQ8zLb3NvYc7P\", \"status\": \"open\", \"livemode\": true, \"amount\": \"135.31\", \"currency\": \"USD\", \"description\": \"Order 1042\", \"reference\": \"order_1042\", \"customer_email\": \"maya@example.com\", \"created\": 1790380525, \"expires_at\": 1790466925, \"started_at\": null, \"metadata\": null, \"order_id\": \"ord_Vd3kR8mQ1xTn6LpZs0Wa\" } Retrieve the order with order_id to see what was saved.",
     "endLine": 459
    },
    {
     "heading": "The customer field",
     "anchor": "the-customer-field",
     "level": 3,
     "startLine": 459,
     "text": "The customer field Field Type Description --- --- --- email string or null The buyer's email, with the same rules as customer_email . Send either one; when you send both, they must be the same address name string or null The buyer's name, up to 120 characters. Without it, the name on the shipping (or billing) address is used phone string or null Up to 40 characters, in any format id string or null The buyer's customer id in your store, up to 64 characters. It appears in the dashboard, on the order's store.customer_id and in the customer's store_accounts Name and phone reach the customer with the buyer's email: when the session is created with it, or once the buyer gives it and starts paying. See how customers are made.",
     "endLine": 470
    },
    {
     "heading": "The order field",
     "anchor": "the-order-field",
     "level": 3,
     "startLine": 470,
     "text": "The order field Field Type Description --- --- --- id string or null Your order id, up to 64 characters. A later session with the same id joins this order instead of making another. See Store orders and retries number string or null The order number your buyers see, up to 32 characters, such as 1042 (a leading is dropped). Used only with id url string or null The order's page in your admin, up to 1,000 characters. Used only with id , and only when it's an https:// address store object or null Which of your stores the order is from. Needs id : store without it is 400 invalid_order . See below items array or null What's being bought, 1–100 items. Leave it out for a single line. See below discount string or null Taken off the items' total. Default \"0.00\" . It can be more than the items when store credit covers shipping or tax too, as long as the order still adds up to amount discount_code string or null The code the buyer used, up to 64 characters shipping string or null Shipping charged. Default \"0.00\" shipping_method string or null Up to 120 characters, such as Standard tax string or null Tax charged. Default \"0.00\" shipping_address object or null Where to ship the order. See below billing_address object or null The buyer's billing address fulfillment string or null none , unfulfilled or fulfilled . Default unfulfilled when there's a shipping address, otherwise none . See fulfilment Money in order is written like amount : a decimal string with at most two decimal places, never a number. Zero is allowed. Text fields are trimmed, and a blank one counts as left out. Each item in items : Field Type Description --- --- --- name string · required Up to 200 characters quantity integer · required A whole number from 1 to 100,000 amount string · required The line's total for all its units, such as \"64.00\" . Zero is allowed unit_amount string or null One unit's price, for display. It isn't checked against amount sku string or null Up to 64 characters variant string or null Which version, up to 120 characters, such as 120 capsules store tells your stores apart, so two of them can use the same order ids. With one store, leave it out. Field Type Description --- --- --- id string · required A stable id for the store, up to 64 characters. Keep it the same: it's how later sessions and list filters find the order platform string woocommerce , shopify or other (the default) name string or null The store's name, up to 120 characters url string or null The store's address, up to 1,000 characters. Kept only when it's http:// or https:// shipping_address and billing_address take the same fields, each optional: Field Description --- --- name Up to 120 characters line1 , line2 Up to 200 characters each city Up to 120 characters region A state, province or county, up to 120 characters postal_code Up to 32 characters country A two-letter code such as US , in any letter case. Saved in capitals An address with nothing in it counts as none. Any field that breaks these rules is 400 invalid_order , with field naming it, such as order.items[2].quantity . A customer that isn't an object, or a customer.email that differs from customer_email , is 400 invalid_customer .",
     "endLine": 523
    },
    {
     "heading": "Totals",
     "anchor": "totals",
     "level": 3,
     "startLine": 523,
     "text": "Totals With items , the parts must add up to amount exactly, to the cent: the items' amount s, less discount , plus shipping and tax . For the order above: Part Amount --- --- Magnesium glycinate × 2 64.00 Daily greens 54.00 Shaker bottle 12.00 Discount, WELCOME10 −13.00 Shipping +8.95 Tax +9.36 amount 135.31 A session whose parts don't add up is 400 invalid_order_totals , and nothing is created; the error's message shows the sum. Line amounts come before the order's discount: when your store discounts single lines, send either the discounted line totals or the whole discount in discount , not both. Without items , the order has one line named after your description , for whatever makes the parts add up: amount + discount − shipping − tax . If that comes to less than zero, the session is 400 invalid_order_totals .",
     "endLine": 541
    },
    {
     "heading": "Store orders and retries",
     "anchor": "store-orders-and-retries",
     "level": 3,
     "startLine": 541,
     "text": "Store orders and retries A session with order.id pays for an order in your store. Railbed keeps one order for each order.id (within its store.id , in each mode): - The first session creates the order. Later sessions with the same order.id join it as more attempts at paying, for example after the buyer's card was declined or their session expired. - The latest session describes the order. Its items, totals and addresses replace the order's, and so does its customer when it has an email. The order's number and fulfilment stay, and its timeline in the dashboard notes when the total or items changed. - A paid order refuses a new session. When the order is paid , or held with money you could accept as paid or that part-paid it, a new session gets 409 order_paid and nothing is created. Check the order before asking the buyer to pay again. An order held only for money you can't accept (it went somewhere other than your wallet) takes new sessions, so the buyer can pay. - Earlier sessions stay open. Joining doesn't close them, and one that's still open can still be paid. Send the buyer only to the newest session's url . A buyer who pays twice leaves two paid payments in the order's payment_ids ; refund one from your wallet. - Use a new Idempotency-Key for each attempt. Repeating a request with the same key and body returns its original session, even after the order is paid: a replay is never refused with order_paid . The key covers order and customer too, so the same key with a different order is 409 idempotency_conflict . Without order.id , every session creates a new order.",
     "endLine": 553
    },
    {
     "heading": "Retrieve a checkout session",
     "anchor": "retrieve-a-checkout-session",
     "level": 2,
     "startLine": 553,
     "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 . Example: cURL bash curl https://pay.railbed.io/v1/checkout_sessions/pay_7AAiYH0Ykt11ED4hmfiN \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" Example: Node\\.js js const session = await fetch( https://pay.railbed.io/v1/checkout_sessions/${id} , { headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} }, }).then((r) = r.json()); Example: Python python session = requests.get( f\"https://pay.railbed.io/v1/checkout_sessions/{id}\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, timeout=15, ).json() Example: PHP php <?php $ch = curl_init( 'https://pay.railbed.io/v1/checkout_sessions/' . rawurlencode($id) ); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), ], ]); $session = json_decode(curl_exec($ch), true); The response is the checkout session object. To fulfil, read the payment instead: it adds the payment and settlement details.",
     "endLine": 602
    },
    {
     "heading": "Start a checkout session",
     "anchor": "start-a-checkout-session",
     "level": 2,
     "startLine": 602,
     "text": "Start a checkout session POST /v1/checkout_sessions/:id/start For your own checkout: starts the session as the buyer would on the hosted page, and returns the card providers that can take it, each with a handoff_url to open in the buyer's browser. Starting assigns the payment's deposit address and locks the fee and the order's value in USD. 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. Example: cURL bash curl -X POST \\ https://pay.railbed.io/v1/checkout_sessions/pay_7AAiYH0Ykt11ED4hmfiN/start \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\ -H \"Content-Type: application/json\" \\ -d '{ \"country\": \"US\" }' Example: Node\\.js js const url = https://pay.railbed.io/v1/checkout_sessions/${id}/start ; const started = await fetch(url, { method: 'POST', headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} , 'Content-Type': 'application/json', }, body: JSON.stringify({ country: 'US' }), }).then((r) = r.json()); Example: Python python started = requests.post( f\"https://pay.railbed.io/v1/checkout_sessions/{id}/start\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, json={\"country\": \"US\"}, timeout=15, ).json() Example: PHP php <?php $ch = curl_init( 'https://pay.railbed.io/v1/checkout_sessions/' . rawurlencode($id) . '/start' ); curl_setopt_array($ch, [ CURLOPT_POST = true, CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS = json_encode(['country' = 'US']), ]); $started = json_decode(curl_exec($ch), true); Example: Response · response 200 OK json { \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\", \"object\": \"checkout_session\", \"url\": \"https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN\", \"status\": \"open\", \"livemode\": false, \"amount\": \"49.00\", \"currency\": \"USD\", \"description\": \"Pro Membership\", \"reference\": \"order_1042\", \"customer_email\": \"buyer@example.com\", \"created\": 1790380525, \"expires_at\": 1790466925, \"started_at\": 1790380611, \"metadata\": { \"user_id\": \"player_1042\" }, \"order_id\": \"ord_M4tQx8ZkP1vRn6WcLs2B\", \"country\": \"US\", \"providers\": [ { \"id\": \"stripe\", \"name\": \"Stripe\", \"note\": \"Card, Apple Pay or Google Pay\", \"recommended\": true, \"handoff_url\": \"https://pay.railbed.io/go/pay_7AAiYH0Ykt11ED4hmfiN?provider=stripe\" }, { \"id\": \"cashapp\", \"name\": \"Cash App\", \"note\": \"Cash App balance or card\", \"recommended\": false, \"handoff_url\": \"https://pay.railbed.io/go/pay_7AAiYH0Ykt11ED4hmfiN?provider=cashapp\" } ] } The response is the session with two more fields: Field Type Description --- --- --- country string or null The country you sent, uppercased providers array The providers that can take this payment, best match first. Each has id , name , note (a short line on how the buyer pays), recommended (true for one) and handoff_url Starting again is safe: started_at and the deposit address stay the same. Providers depend on the amount, currency and country, so fetch them when the buyer reaches your checkout, and open a handoff_url only from the buyer's click, in a new tab. Status Code When --- --- --- 400 invalid_email The session has no email and none was sent 400 invalid_country country isn't two letters 404 not_found Not a session of yours in this mode 409 no_providers No provider can take this amount and currency now. Nothing was started 409 already_paid , held , failed The payment has finished 409 unavailable The account can't take payments now (Live: no payout wallet) 410 expired , canceled The session can no longer be paid 429 rate_limited More than about 12 starts a minute 502 network_unavailable The card network didn't answer. Retry in a minute",
     "endLine": 728
    }
   ]
  },
  {
   "id": "api/payments",
   "title": "Payments",
   "nav": "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.",
   "audience": "developers",
   "url": "https://railbed.com/docs/api/payments/",
   "path": "/docs/api/payments/",
   "markdown": "https://railbed.com/docs/api/payments.md",
   "updated": "2026-10-04",
   "bodyLineOffset": 8,
   "markdownHash": "a4d777239a05c355110a76b3931d3dc0cf8539cd899bb3c8c2dc3a471203215a",
   "sections": [
    {
     "heading": "The payment object",
     "anchor": "the-payment-object",
     "level": 2,
     "startLine": 0,
     "text": "The payment object A payment has every field of its checkout session, 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 . Null when the buyer paid in crypto method string How the money that settled (or is held) arrived: card , or crypto when the buyer sent stablecoins from their own wallet (crypto payments) 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 --- --- --- network string or null Crypto payments: the network the money arrived on, such as base or polygon . Null for card, which always settles on Polygon coin string or null What was delivered, usually polygon_usdc ( polygon_usdt is also possible). Crypto payments name their network, such as base_usdc 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 transaction that delivered the money (on Polygon, or on network for crypto) txid_out string or null The 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.",
     "endLine": 28
    },
    {
     "heading": "Retrieve a payment",
     "anchor": "retrieve-a-payment",
     "level": 2,
     "startLine": 28,
     "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. What to deliver is on its order, from order_id . Example: cURL bash curl https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" Example: Node\\.js js const payment = await fetch( https://pay.railbed.io/v1/payments/${id} , { headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} }, }).then((r) = r.json()); Example: Python python payment = requests.get( f\"https://pay.railbed.io/v1/payments/{id}\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, timeout=15, ).json() Example: PHP php <?php $ch = curl_init('https://pay.railbed.io/v1/payments/' . rawurlencode($id)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), ], ]); $payment = json_decode(curl_exec($ch), true); Example: Response · response 200 OK json { \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\", \"object\": \"payment\", \"url\": \"https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN\", \"status\": \"paid\", \"livemode\": true, \"amount\": \"49.00\", \"currency\": \"USD\", \"description\": \"Pro Membership\", \"reference\": \"order_1042\", \"customer_email\": \"buyer@example.com\", \"created\": 1790380525, \"expires_at\": 1790466925, \"started_at\": 1790380611, \"metadata\": { \"user_id\": \"player_1042\" }, \"order_id\": \"ord_M4tQx8ZkP1vRn6WcLs2B\", \"paid_at\": 1790381342, \"provider\": \"stripe\", \"hold_reason\": null, \"hold_acceptable\": false, \"canceled_at\": null, \"settlement\": { \"coin\": \"polygon_usdc\", \"value_coin\": \"47.53\", \"merchant_received\": \"46.341750\", \"txid_in\": \"0x0c651ba1d59c7a32e8b1f4bd2c7e0e4f96a55d13a6b0f2d1c8e7a9b4f3d29b58\", \"txid_out\": \"0x8202d1373e0a9c4f1b6d5e2c7a8f9b0e1d2c3b4a5f6e7d8c9b0a1f2e3d7356ff\", \"payout_wallet\": \"0xF977814e90dA44bFA03b6295A0616a897441aceC\" } } A held payment looks like this in part: Example: A held payment (trimmed) · response 200 OK json { \"id\": \"pay_Kx81mQv2PzR0dT7eWcYa\", \"object\": \"payment\", \"status\": \"held\", \"amount\": \"49.00\", \"currency\": \"USD\", \"paid_at\": null, \"hold_reason\": \"The provider sent 24.50 USDC, below 90% of the order’s 49.00 USD value.\", \"hold_acceptable\": true, \"settlement\": { \"coin\": \"polygon_usdc\", \"value_coin\": \"24.50\", \"merchant_received\": null } } Reading a payment past its expires_at marks an unpaid one expired .",
     "endLine": 128
    },
    {
     "heading": "List payments",
     "anchor": "list-payments",
     "level": 2,
     "startLine": 128,
     "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 Example: cURL bash curl \"https://pay.railbed.io/v1/payments?limit=50\" \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" Example: Node\\.js js // Every payment, page by page let cursor = null; do { const url = new URL('https://pay.railbed.io/v1/payments'); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('starting_after', cursor); const page = await fetch(url, { headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} }, }).then((r) = r.json()); for (const payment of page.data) reconcile(payment); cursor = page.next_cursor; } while (cursor); Example: Python python cursor = None while True: params = {\"limit\": 100, ({\"starting_after\": cursor} if cursor else {})} page = requests.get( \"https://pay.railbed.io/v1/payments\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, params=params, timeout=15, ).json() for payment in page[\"data\"]: reconcile(payment) cursor = page[\"next_cursor\"] if not cursor: break Example: PHP php <?php $cursor = null; do { $query = http_build_query(array_filter([ 'limit' = 100, 'starting_after' = $cursor, ])); $ch = curl_init('https://pay.railbed.io/v1/payments?' . $query); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), ], ]); $page = json_decode(curl_exec($ch), true); foreach ($page['data'] as $payment) reconcile($payment); $cursor = $page['next_cursor']; } while ($cursor); Example: Response · response 200 OK json { \"data\": [ { \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\", \"object\": \"payment\", \"status\": \"paid\", \"amount\": \"49.00\", \"currency\": \"USD\" }, { \"id\": \"pay_Kx81mQv2PzR0dT7eWcYa\", \"object\": \"payment\", \"status\": \"held\", \"amount\": \"49.00\", \"currency\": \"USD\" } ], \"has_more\": true, \"next_cursor\": \"pay_Kx81mQv2PzR0dT7eWcYa\" } Each item is a full payment object (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 .",
     "endLine": 232
    },
    {
     "heading": "Simulate a payment",
     "anchor": "simulate-a-payment",
     "level": 2,
     "startLine": 232,
     "text": "Simulate a payment POST /v1/payments/:id/simulate Test mode only. Simulates an outcome for a started test payment. Simulated money goes through the same settlement checks and webhooks as a live payment. A card decline keeps the payment open for retry. Field Type Description --- --- --- outcome string · required paid (about 97% of the value arrives), underpaid (half arrives, so it's held ), declined (stays open for another provider, no failed webhook), or failed (terminal Test failure with a payment.failed webhook) Example: cURL bash curl -X POST \\ https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN/simulate \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\ -H \"Content-Type: application/json\" \\ -d '{ \"outcome\": \"paid\" }' Example: Node\\.js js const payment = await fetch( https://pay.railbed.io/v1/payments/${id}/simulate , { method: 'POST', headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} , 'Content-Type': 'application/json', }, body: JSON.stringify({ outcome: 'paid' }), }).then((r) = r.json()); Example: Python python payment = requests.post( f\"https://pay.railbed.io/v1/payments/{id}/simulate\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, json={\"outcome\": \"paid\"}, timeout=15, ).json() Example: PHP php <?php $ch = curl_init( 'https://pay.railbed.io/v1/payments/' . rawurlencode($id) . '/simulate' ); curl_setopt_array($ch, [ CURLOPT_POST = true, CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS = json_encode(['outcome' = 'paid']), ]); $payment = json_decode(curl_exec($ch), true); The response is the payment after the outcome. A declined outcome leaves it open for another provider on the same session. Simulating a finished payment returns it unchanged. Status Code When --- --- --- 400 invalid_outcome outcome is missing or not one of the four supported values 403 live_payment The payment is a Live payment 409 not_started Start the session first, with Start a checkout session",
     "endLine": 302
    }
   ]
  },
  {
   "id": "api/orders",
   "title": "Orders",
   "nav": "Orders",
   "section": "API reference",
   "description": "An order is what a buyer tried to buy, with its items, totals, addresses and customer. Every checkout session belongs to one. Read orders to fulfil them, and report fulfilment, cancellations and refunds back from your store.",
   "audience": "developers",
   "url": "https://railbed.com/docs/api/orders/",
   "path": "/docs/api/orders/",
   "markdown": "https://railbed.com/docs/api/orders.md",
   "updated": "2026-10-04",
   "bodyLineOffset": 8,
   "markdownHash": "ae90da751ba96af150ce4f8cd1ca92ddcda44f9058b3619a047503aafbd0e9cd",
   "sections": [
    {
     "heading": "The order object",
     "anchor": "the-order-object",
     "level": 2,
     "startLine": 0,
     "text": "The order object Field Type Description --- --- --- id string The order's id, ord_… object string \"order\" livemode boolean false for Test mode, true for Live mode number integer Railbed's order number, counting from 1001 in each mode. Your store's own number is store.order_number status string open , pending , paid , held , failed , expired or canceled , from its payments. See Status needs_review boolean true when it's held and waits on you: a held payment you could accept as paid, or paid payments that don't cover total . Such an order refuses new sessions with 409 order_paid fulfillment string none , unfulfilled or fulfilled . See Fulfilment fulfilled_at integer or null When it was marked fulfilled , in Unix seconds customer_id string or null Its customer, cus_… . Null until the buyer has given an email currency string The currency of every amount on the order subtotal string The items' amount s added up discount string Taken off the subtotal, and larger than it only when store credit also covered shipping or tax. \"0.00\" when there's none discount_code string or null The code or codes the buyer used shipping string Shipping charged shipping_method string or null How it ships, such as Standard tax string Tax charged total string subtotal − discount + shipping + tax : the latest session's amount amount_paid string What its paid payments in currency add up to. \"0.00\" until one is paid items array What was bought, in the order you sent it. See Items shipping_address object or null Where to send it. See Addresses billing_address object or null The buyer's billing address store object or null Where the order lives in your systems, for orders sent with order.id . See Store payment_ids array Every payment attempt at this order, newest first ( pay_… ) created integer When the order was created, in Unix seconds paid_at integer or null When its first payment became paid , in Unix seconds Amounts are decimal strings, like every price in the API. They're what the buyer was charged in currency , not what arrived in your wallet: that's on each payment. Example: An order from a store · response 200 OK json { \"id\": \"ord_Vd3kR8mQ1xTn6LpZs0Wa\", \"object\": \"order\", \"livemode\": true, \"number\": 1187, \"status\": \"paid\", \"needs_review\": false, \"fulfillment\": \"unfulfilled\", \"fulfilled_at\": null, \"customer_id\": \"cus_Hc7nQ2wVz9KpT4mRb1Ye\", \"currency\": \"USD\", \"subtotal\": \"130.00\", \"discount\": \"13.00\", \"discount_code\": \"WELCOME10\", \"shipping\": \"8.95\", \"shipping_method\": \"Standard\", \"tax\": \"9.36\", \"total\": \"135.31\", \"amount_paid\": \"135.31\", \"items\": [ { \"name\": \"Magnesium glycinate\", \"variant\": \"120 capsules\", \"sku\": \"HN-MG-120\", \"quantity\": 2, \"unit_amount\": \"32.00\", \"amount\": \"64.00\" }, { \"name\": \"Daily greens\", \"variant\": null, \"sku\": \"HN-DG-30\", \"quantity\": 1, \"unit_amount\": null, \"amount\": \"54.00\" }, { \"name\": \"Shaker bottle\", \"variant\": null, \"sku\": null, \"quantity\": 1, \"unit_amount\": null, \"amount\": \"12.00\" } ], \"shipping_address\": { \"name\": \"Maya Okafor\", \"line1\": \"418 Linden Avenue\", \"line2\": \"Apt 3B\", \"city\": \"Portland\", \"region\": \"OR\", \"postal_code\": \"97214\", \"country\": \"US\" }, \"billing_address\": null, \"store\": { \"platform\": \"other\", \"id\": \"yourstore\", \"name\": \"yourstore.com\", \"url\": \"https://yourstore.com\", \"order_id\": \"1042\", \"order_number\": \"1042\", \"order_url\": \"https://yourstore.com/admin/orders/1042\", \"customer_id\": \"88\" }, \"payment_ids\": [\"pay_Rt5Wm2KxQ8zLb3NvYc7P\"], \"created\": 1790380525, \"paid_at\": 1790381342 }",
     "endLine": 140
    },
    {
     "heading": "Items",
     "anchor": "items",
     "level": 3,
     "startLine": 106,
     "text": "Items Field Type Description --- --- --- name string What the line is variant string or null Which version, such as 120 capsules or Blue, L sku string or null Your stock-keeping code quantity integer How many units unit_amount string or null One unit's price, when it was sent. For display only amount string The line's total. The order's totals add up from these An order made without order.items has one line, named after the session's description , with quantity 1. Its amount is whatever makes the totals add up: amount + discount − shipping − tax , which is the whole amount when you send no totals.",
     "endLine": 119
    },
    {
     "heading": "Addresses",
     "anchor": "addresses",
     "level": 3,
     "startLine": 119,
     "text": "Addresses shipping_address and billing_address have the same fields, each a string or null: name , line1 , line2 , city , region (a state, province or county), postal_code and country (a two-letter code such as US ). They're stored as sent, not checked against a postal service.",
     "endLine": 123
    },
    {
     "heading": "Store",
     "anchor": "store",
     "level": 3,
     "startLine": 123,
     "text": "Store For orders you sent with order.id , store says where the order lives, so you can find it again and link back to it: Field Type Description --- --- --- platform string woocommerce , shopify or other id string The order.store.id you sent. Empty ( \"\" ) when you sent no store name string or null The store's name url string or null The store's address order_id string Your order id, as sent in order.id order_number string or null The number your buyers see order_url string or null The order's page in your admin customer_id string or null The buyer's id in your store: the customer.id you sent store is null for orders sent without order.id , and for orders from Railbed's own checkouts and payment links.",
     "endLine": 140
    },
    {
     "heading": "How orders are made",
     "anchor": "how-orders-are-made",
     "level": 2,
     "startLine": 140,
     "text": "How orders are made Every checkout session belongs to an order, and its order_id says which. The order is created in the same step as the session, so a session never exists without one. - From the API. Send order and customer when you create a session, and the order has your items, totals and addresses. Without them, the order has one line, the session's description , for the whole amount. - From your store's order id. A session sent with order.id joins the order that already has that id, if there is one. That's how a buyer's second try at paying the same order stays one order. See Store orders and retries. - From Railbed's checkouts and payment links. Checkout pages, pricing tables, buy buttons and payment links make orders too, with one item: the product, plan or what the link is for. A buyer who tries the same checkout again within a day, with the same email, price and plan, joins their unpaid order. Each payment is one attempt at paying for its order. An order can have several, in payment_ids , and more than one of them can be paid if the buyer paid twice. The API reads orders and updates their fulfilment. It doesn't create orders on their own, edit their items or delete them: an order's contents change only when a new session for the same order.id arrives.",
     "endLine": 152
    },
    {
     "heading": "Status",
     "anchor": "status",
     "level": 2,
     "startLine": 152,
     "text": "Status An order's status comes from its payments, and changes in the same step as theirs: 1. paid when its paid payments cover total (they add up to at least total in the order's currency ). 2. Otherwise held when any of its payments is held for review, or is paid but doesn't cover total (a part-payment after the order grew, or one in another currency). 3. Otherwise the latest payment's status: open , pending , failed or expired , or canceled when it's a payment link you canceled. Status Meaning What to do --- --- --- open Created, or started but not yet handed to a provider Wait pending The buyer went to a provider to pay Wait paid Payments for it arrived, passed Railbed's checks and cover total Fulfil it, once held Money arrived but failed a check, or what's paid doesn't cover total ( amount_paid ) Don't fulfil. Review it in the dashboard, and check with the buyer failed The latest attempt ended in a terminal Test failure Let the buyer try again expired The latest attempt expired unpaid Treat as abandoned; a late payment can still make it paid canceled You canceled its payment link Nothing to do The status can't be set through the API. Like a payment, an order can become paid after expired when money arrives late, and a held order becomes paid when you accept the payment. A paid order stays paid .",
     "endLine": 172
    },
    {
     "heading": "Fulfilment",
     "anchor": "fulfilment",
     "level": 2,
     "startLine": 172,
     "text": "Fulfilment fulfillment is your record of whether the order has been delivered. It doesn't affect the payment. Value Meaning --- --- none Nothing to ship: digital goods, memberships, services. The default without a shipping address unfulfilled Waiting to be shipped or delivered. The default when the order has a shipping address fulfilled Delivered or on its way. fulfilled_at says when it was marked You can set the starting value with order.fulfillment when you create the session. After that: - Orders sent with order.id get their fulfilment from your store, through Update an order. The dashboard shows it but doesn't change it, so the two never disagree. The WooCommerce plugin does this for you. - Other orders can be marked fulfilled in the dashboard's Orders once they're paid, or through the API.",
     "endLine": 187
    },
    {
     "heading": "Retrieve an order",
     "anchor": "retrieve-an-order",
     "level": 2,
     "startLine": 187,
     "text": "Retrieve an order GET /v1/orders/:id Returns an order of yours in the key's mode. Another account's order, or one in the other mode, is 404 not_found . Example: cURL bash curl https://pay.railbed.io/v1/orders/ord_Vd3kR8mQ1xTn6LpZs0Wa \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" Example: Node\\.js js const order = await fetch( https://pay.railbed.io/v1/orders/${id} , { headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} }, }).then((r) = r.json()); Example: Python python order = requests.get( f\"https://pay.railbed.io/v1/orders/{id}\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, timeout=15, ).json() Example: PHP php <?php $ch = curl_init('https://pay.railbed.io/v1/orders/' . rawurlencode($id)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), ], ]); $order = json_decode(curl_exec($ch), true); The response is the order object. To find the order behind a payment or webhook, use the payment's order_id ( orderId in webhooks); webhooks also carry the order itself.",
     "endLine": 234
    },
    {
     "heading": "List orders",
     "anchor": "list-orders",
     "level": 2,
     "startLine": 234,
     "text": "List orders GET /v1/orders Returns your orders in the key's mode, newest first by creation. Filter by customer, or find the order you sent with a given order.id . Parameter Type Description --- --- --- limit integer 1–100. Default 20 starting_after string An order id from the previous page's next_cursor customer_id string Only this customer's orders store_order_id string Only the order you sent with this order.id store_id string Only orders sent with this order.store.id . With store_order_id , the one order you sent with both; leave it out for orders sent without store Example: cURL bash curl \"https://pay.railbed.io/v1/orders?store_id=yourstore&store_order_id=1042\" \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" Example: Node\\.js js const url = new URL('https://pay.railbed.io/v1/orders'); url.searchParams.set('store_id', 'yourstore'); url.searchParams.set('store_order_id', '1042'); const page = await fetch(url, { headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} }, }).then((r) = r.json()); const order = page.data[0] ?? null; Example: Python python page = requests.get( \"https://pay.railbed.io/v1/orders\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, params={\"store_id\": \"yourstore\", \"store_order_id\": \"1042\"}, timeout=15, ).json() order = page[\"data\"][0] if page[\"data\"] else None Example: PHP php <?php $query = http_build_query([ 'store_id' = 'yourstore', 'store_order_id' = '1042', ]); $ch = curl_init('https://pay.railbed.io/v1/orders?' . $query); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), ], ]); $page = json_decode(curl_exec($ch), true); $order = $page['data'][0] ?? null; Example: Response (trimmed) · response 200 OK json { \"data\": [ { \"id\": \"ord_Vd3kR8mQ1xTn6LpZs0Wa\", \"object\": \"order\", \"number\": 1187, \"status\": \"paid\", \"fulfillment\": \"unfulfilled\", \"total\": \"135.31\" } ], \"has_more\": false, \"next_cursor\": null } Each item is a full order object (trimmed here). Page through with next_cursor as on List payments. A store_order_id matches at most one order in each mode. An invalid limit is 400 invalid_limit ; a cursor that isn't one of your orders in this mode is 400 invalid_cursor .",
     "endLine": 319
    },
    {
     "heading": "Update an order",
     "anchor": "update-an-order",
     "level": 2,
     "startLine": 319,
     "text": "Update an order PATCH /v1/orders/:id Records the order's fulfilment. This is how your store tells Railbed an order has shipped, or that it's waiting again. Marking it fulfilled or unfulfilled appears on the order's timeline in the dashboard. Field Type Description --- --- --- fulfillment string · required none , unfulfilled or fulfilled Example: cURL bash curl -X PATCH \\ https://pay.railbed.io/v1/orders/ord_Vd3kR8mQ1xTn6LpZs0Wa \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\ -H \"Content-Type: application/json\" \\ -d '{ \"fulfillment\": \"fulfilled\" }' Example: Node\\.js js const order = await fetch( https://pay.railbed.io/v1/orders/${id} , { method: 'PATCH', headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} , 'Content-Type': 'application/json', }, body: JSON.stringify({ fulfillment: 'fulfilled' }), }).then((r) = r.json()); Example: Python python order = requests.patch( f\"https://pay.railbed.io/v1/orders/{id}\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, json={\"fulfillment\": \"fulfilled\"}, timeout=15, ).json() Example: PHP php <?php $ch = curl_init('https://pay.railbed.io/v1/orders/' . rawurlencode($id)); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST = 'PATCH', CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS = json_encode(['fulfillment' = 'fulfilled']), ]); $order = json_decode(curl_exec($ch), true); The response is the updated order. - fulfilled sets fulfilled_at to now; unfulfilled and none clear it. Sending the value the order already has changes nothing, so retrying is safe. - It works on any order of yours in this mode, from any source, paid or not. Railbed doesn't check the payment here, so report only what you've actually shipped. - No webhook is sent for a fulfilment change. Status Code When --- --- --- 400 invalid_fulfillment fulfillment is missing or not one of the three 404 not_found Not an order of yours in this mode",
     "endLine": 391
    },
    {
     "heading": "Report a cancellation or refund",
     "anchor": "report-a-cancellation-or-refund",
     "level": 2,
     "startLine": 391,
     "text": "Report a cancellation or refund POST /v1/orders/:id/events Records that your store canceled the order or recorded a refund, so the order's timeline in the dashboard says so (\"Canceled in …\", \"Refund recorded in …\"). It's a note, not an instruction: the order's status still comes from its payments, and Railbed moves no money. Arrange any refund with the buyer yourself. Field Type Description --- --- --- type string · required canceled or refunded id string · required Your own id for this event, such as refund-5521 : 1 to 64 printable characters without spaces. The same id is recorded once, so a retry is safe amount string · optional refunded only: how much you refunded, in the order's currency , from \"0.01\" up to its total Example: cURL bash curl https://pay.railbed.io/v1/orders/ord_Vd3kR8mQ1xTn6LpZs0Wa/events \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\ -H \"Content-Type: application/json\" \\ -d '{ \"type\": \"refunded\", \"id\": \"refund-5521\", \"amount\": \"12.00\" }' Example: Node\\.js js const event = await fetch( https://pay.railbed.io/v1/orders/${id}/events , { method: 'POST', headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} , 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'refunded', id: 'refund-5521', amount: '12.00' }), }).then((r) = r.json()); Example: Python python event = requests.post( f\"https://pay.railbed.io/v1/orders/{id}/events\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, json={\"type\": \"refunded\", \"id\": \"refund-5521\", \"amount\": \"12.00\"}, timeout=15, ).json() Example: PHP php <?php $ch = curl_init('https://pay.railbed.io/v1/orders/' . rawurlencode($id) . '/events'); curl_setopt_array($ch, [ CURLOPT_POST = true, CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS = json_encode(['type' = 'refunded', 'id' = 'refund-5521', 'amount' = '12.00']), ]); $event = json_decode(curl_exec($ch), true); Example: The recorded event · response 201 Created json { \"id\": \"oev_Tb8vN3qLx5WmR2kZc9Hd\", \"object\": \"order_event\", \"order_id\": \"ord_Vd3kR8mQ1xTn6LpZs0Wa\", \"type\": \"refunded\", \"store_event_id\": \"refund-5521\", \"amount\": \"12.00\", \"currency\": \"USD\", \"created\": 1790467742 } - The answer is 201 Created the first time and 200 OK , with the same event, when that id was already recorded. Send a new id for each cancellation or refund. - It works on any order of yours in this mode, paid or not. No webhook is sent. - Don't put personal details in id : it's shown to anyone who can see the order. Status Code When --- --- --- 400 invalid_event_type type is missing or not canceled or refunded 400 invalid_event_id id is missing, longer than 64 characters or has spaces 400 invalid_amount amount isn't a decimal string within the order's total , or was sent with canceled 404 not_found Not an order of yours in this mode 409 event_conflict That id was already recorded with a different type or amount",
     "endLine": 479
    }
   ]
  },
  {
   "id": "api/customers",
   "title": "Customers",
   "nav": "Customers",
   "section": "API reference",
   "description": "A customer is one buyer's email address in one mode, with the contact details and addresses their orders brought. Read customers to see who bought what, and find one by email.",
   "audience": "developers",
   "url": "https://railbed.com/docs/api/customers/",
   "path": "/docs/api/customers/",
   "markdown": "https://railbed.com/docs/api/customers.md",
   "updated": "2026-09-27",
   "bodyLineOffset": 8,
   "markdownHash": "258c23017a704bcfdc05e62b2306bdd1a55726e07c1b65c76e5641345b6ff1bf",
   "sections": [
    {
     "heading": "The customer object",
     "anchor": "the-customer-object",
     "level": 2,
     "startLine": 0,
     "text": "The customer object Field Type Description --- --- --- id string The customer's id, cus_… object string \"customer\" livemode boolean false for Test mode, true for Live mode email string or null Their email, lowercased. Null after you delete their details name string or null Their name, from their latest order that had one phone string or null Their phone number, as your store sent it shipping_address object or null Their latest shipping address, in the order's address shape billing_address object or null Their latest billing address store_accounts array Their ids in your stores, one per store: platform , store_id , store_name and customer_id (the customer.id you sent). Empty when you sent none orders_count integer How many orders they have, paid or not paid_orders_count integer How many of those are paid spent object What their paid payments add up to, by currency, such as { \"USD\": \"135.31\" } . Empty until a payment is paid created integer When they first appeared, in Unix seconds last_order_at integer or null When their latest order was created, in Unix seconds erased boolean true once you've deleted their details spent adds up the amount of each of their payments that's paid , in its currency: what they paid, including a part-payment on an order that's held , and both payments when they paid one order twice. paid_orders_count counts only orders that are paid . What reached your wallet is on each payment. Example: A customer · response 200 OK json { \"id\": \"cus_Hc7nQ2wVz9KpT4mRb1Ye\", \"object\": \"customer\", \"livemode\": true, \"email\": \"maya@example.com\", \"name\": \"Maya Okafor\", \"phone\": \"+1 503 555 0142\", \"shipping_address\": { \"name\": \"Maya Okafor\", \"line1\": \"418 Linden Avenue\", \"line2\": \"Apt 3B\", \"city\": \"Portland\", \"region\": \"OR\", \"postal_code\": \"97214\", \"country\": \"US\" }, \"billing_address\": null, \"store_accounts\": [], \"orders_count\": 3, \"paid_orders_count\": 2, \"spent\": { \"USD\": \"224.31\" }, \"created\": 1787702525, \"last_order_at\": 1790380525, \"erased\": false }",
     "endLine": 52
    },
    {
     "heading": "How customers are made",
     "anchor": "how-customers-are-made",
     "level": 2,
     "startLine": 52,
     "text": "How customers are made - One customer per email address, in each mode. Case doesn't matter: Maya@Example.com and maya@example.com are one customer. Test and Live customers are separate. - The email comes from the session. A session's customer_email (or customer.email ) makes or finds the customer when it's created; a session created without one gets its customer from the email given when it starts. Railbed's checkouts and payment links use the email the buyer enters, or the one you set on the link. - The latest details win. Each order's customer.name , customer.phone and addresses replace the saved ones. Details an order leaves out keep their earlier value. - Details wait for the email. A session created without an email keeps its name, phone and addresses on the order, and saves them to the customer once the buyer gives their email and starts paying. - Orders follow the email. When a session starts with a different email from the one it was created with, its order moves to the customer with the new email. The API doesn't create, edit or delete customers: they change only through orders. The dashboard lets you keep a private note on each customer, which is never returned by the API or sent in webhooks. [!IMPORTANT] Buyers type their own email at checkout. Use a customer to see who bought what, never to decide which account in your system gets an order: resolve that from your own order, as in Fulfil orders safely.",
     "endLine": 65
    },
    {
     "heading": "Deleted customers",
     "anchor": "deleted-customers",
     "level": 2,
     "startLine": 65,
     "text": "Deleted customers When a buyer asks you to delete their data, open the customer in the dashboard's Customers and choose Delete customer data . It removes their name, email, phone and addresses from the customer, from their orders and payments, and from the bodies stored in your webhook delivery log. Their orders, amounts and payments stay for your records. It can't be undone. - The customer keeps its id , with erased: true and its contact details and addresses null . Their orders keep customer_id , and their payments' customer_email becomes null . - Their email is no longer on file, so listing by email doesn't find them, and the same email later starts a new customer. - Webhooks already delivered can't be recalled. Delete the details from your own systems too.",
     "endLine": 73
    },
    {
     "heading": "Retrieve a customer",
     "anchor": "retrieve-a-customer",
     "level": 2,
     "startLine": 73,
     "text": "Retrieve a customer GET /v1/customers/:id Returns a customer of yours in the key's mode. Another account's customer, or one in the other mode, is 404 not_found . Example: cURL bash curl https://pay.railbed.io/v1/customers/cus_Hc7nQ2wVz9KpT4mRb1Ye \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" Example: Node\\.js js const customer = await fetch( https://pay.railbed.io/v1/customers/${id} , { headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} }, }).then((r) = r.json()); Example: Python python customer = requests.get( f\"https://pay.railbed.io/v1/customers/{id}\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, timeout=15, ).json() Example: PHP php <?php $ch = curl_init('https://pay.railbed.io/v1/customers/' . rawurlencode($id)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), ], ]); $customer = json_decode(curl_exec($ch), true); The response is the customer object. An order's customer_id leads here; for their orders, list orders with customer_id .",
     "endLine": 120
    },
    {
     "heading": "List customers",
     "anchor": "list-customers",
     "level": 2,
     "startLine": 120,
     "text": "List customers GET /v1/customers Returns your customers in the key's mode, newest first by created . Pass email to find one buyer. Parameter Type Description --- --- --- limit integer 1–100. Default 20 starting_after string A customer id from the previous page's next_cursor email string Only the customer with this email, in any letter case. Matches the whole address Example: cURL bash curl -G https://pay.railbed.io/v1/customers \\ --data-urlencode \"email=maya@example.com\" \\ -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" Example: Node\\.js js const url = new URL('https://pay.railbed.io/v1/customers'); url.searchParams.set('email', 'maya@example.com'); const page = await fetch(url, { headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} }, }).then((r) = r.json()); const customer = page.data[0] ?? null; Example: Python python page = requests.get( \"https://pay.railbed.io/v1/customers\", headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"}, params={\"email\": \"maya@example.com\"}, timeout=15, ).json() customer = page[\"data\"][0] if page[\"data\"] else None Example: PHP php <?php $query = http_build_query(['email' = 'maya@example.com']); $ch = curl_init('https://pay.railbed.io/v1/customers?' . $query); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER = true, CURLOPT_HTTPHEADER = [ 'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'), ], ]); $page = json_decode(curl_exec($ch), true); $customer = $page['data'][0] ?? null; Example: Response (trimmed) · response 200 OK json { \"data\": [ { \"id\": \"cus_Hc7nQ2wVz9KpT4mRb1Ye\", \"object\": \"customer\", \"email\": \"maya@example.com\", \"name\": \"Maya Okafor\", \"paid_orders_count\": 2 } ], \"has_more\": false, \"next_cursor\": null } Each item is a full customer object (trimmed here). With email , the list has at most one customer; an empty data means no customer has that email in this mode. Page through the whole list with next_cursor as on List payments. An invalid limit is 400 invalid_limit ; a cursor that isn't one of your customers in this mode is 400 invalid_cursor .",
     "endLine": 198
    }
   ]
  },
  {
   "id": "api/errors",
   "title": "Errors",
   "nav": "Errors",
   "section": "API reference",
   "description": "Every error the Railbed API returns, with its HTTP status, what caused it and what to do next.",
   "audience": "developers",
   "url": "https://railbed.com/docs/api/errors/",
   "path": "/docs/api/errors/",
   "markdown": "https://railbed.com/docs/api/errors.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "f65f756a9624c6d7e2725e8c025dad72991dfe7a36baefafad57dd18f5e06e9f",
   "sections": [
    {
     "heading": "The error object",
     "anchor": "the-error-object",
     "level": 2,
     "startLine": 0,
     "text": "The error object Every error has an HTTP status of 400 or above and the same JSON body: Example: An error · response 409 Conflict json { \"error\": { \"code\": \"no_payout_wallet\", \"message\": \"Add a payout wallet in the dashboard before taking live payments.\" } } Field Type Description --- --- --- code string Stable and machine-readable. Branch on this message string A sentence for people. It can be reworded at any time, so show it or log it, but never parse it field string The request field at fault, when there is one: amount , metadata , order.items[0].amount , starting_after",
     "endLine": 21
    },
    {
     "heading": "Handling errors",
     "anchor": "handling-errors",
     "level": 2,
     "startLine": 21,
     "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 or agent access token is missing, malformed, expired or revoked For a secret key, check the key and its mode, and don't retry. An agent gets a new access token on invalid_token , and asks its person to connect it again on agent_disconnected 403 Not allowed for this account, key or agent Don't retry. On insufficient_scope , use a key or agent with Full access 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 Example: A small error handler js async function railbed(path, init = {}) { const res = await fetch( https://pay.railbed.io/v1${path} , { ...init, headers: { Authorization: Bearer ${process.env.RAILBED_SECRET_KEY} , 'Content-Type': 'application/json', ...init.headers, }, }); const body = await res.json(); if (res.ok) return body; const err = Object.assign(new Error(body.error.message), { status: res.status, code: body.error.code, field: body.error.field, }); err.retryable = res.status === 429 res.status = 500; err.retryAfter = Number(res.headers.get('Retry-After')) null; throw err; }",
     "endLine": 62
    },
    {
     "heading": "Every code",
     "anchor": "every-code",
     "level": 2,
     "startLine": 62,
     "text": "Every code",
     "endLine": 150
    },
    {
     "heading": "400 Bad Request",
     "anchor": "status-400",
     "level": 3,
     "startLine": 64,
     "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 or 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_customer Create customer isn't an object, or customer.email and customer_email are different addresses invalid_order Create A field in order has the wrong type, is too long or is out of range, such as a quantity that isn't a whole number from 1 to 100,000 or an item without name or amount . See field invalid_order_totals Create The items less discount plus shipping and tax don't equal amount to the cent, or without items, shipping and tax less discount come to more than amount invalid_country Start country isn't a two-letter code such as US invalid_limit Any list limit isn't a whole number from 1 to 100 invalid_cursor Any list starting_after isn't one of your payments, orders or customers (whichever you're listing) in this mode invalid_outcome Simulate outcome isn't paid , underpaid , declined or failed invalid_fulfillment Update an order fulfillment is missing or isn't none , unfulfilled or fulfilled",
     "endLine": 87
    },
    {
     "heading": "401 Unauthorized",
     "anchor": "status-401",
     "level": 3,
     "startLine": 87,
     "text": "401 Unauthorized Every 401 carries a WWW-Authenticate header that points to the API's protected resource metadata, where an agent can find out how to get access. Code Cause --- --- invalid_api_key No Authorization: Bearer header, a malformed key, or a revoked one. Create a key in Developers invalid_token An agent's access token expired, is malformed, or wasn't issued for Railbed. Get a new one from the token endpoint described in auth.md agent_disconnected The agent was disconnected in the dashboard, or the person who connected it no longer manages keys for the business. Ask an owner or developer to connect it again",
     "endLine": 97
    },
    {
     "heading": "403 Forbidden",
     "anchor": "status-403",
     "level": 3,
     "startLine": 97,
     "text": "403 Forbidden Code Cause --- --- suspended The account is suspended. Email support@railbed.com insufficient_scope The key's or agent's access level doesn't include the scope this endpoint needs, such as a Read only key creating a checkout session. The message and the WWW-Authenticate header ( error=\"insufficient_scope\", scope=\"…\" ) name the scope. Use a key or agent with Full access live_payment Simulate was called on a Live payment. Only Test payments can be simulated",
     "endLine": 105
    },
    {
     "heading": "404 Not Found",
     "anchor": "status-404",
     "level": 3,
     "startLine": 105,
     "text": "404 Not Found Code Cause --- --- not_found No such session, payment, order or customer 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",
     "endLine": 111
    },
    {
     "heading": "409 Conflict",
     "anchor": "status-409",
     "level": 3,
     "startLine": 111,
     "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 order_paid Create The store order in order.id is already paid, or held with money you could accept as paid or that part-paid it. Nothing was created. Check the order before asking the buyer to pay again. See Store orders and retries 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",
     "endLine": 125
    },
    {
     "heading": "410 Gone",
     "anchor": "status-410",
     "level": 3,
     "startLine": 125,
     "text": "410 Gone Code Cause --- --- expired The session passed its expires_at without being paid canceled The payment link was canceled in the dashboard",
     "endLine": 132
    },
    {
     "heading": "Size, type and rate",
     "anchor": "other-4xx",
     "level": 3,
     "startLine": 132,
     "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 (each connected agent has its own 120, and a business's agents share 600), 12 starts a minute, or about 60 requests a minute from one address with a missing or invalid key. Wait for Retry-After",
     "endLine": 140
    },
    {
     "heading": "5xx",
     "anchor": "status-5xx",
     "level": 3,
     "startLine": 140,
     "text": "5xx Status Code Cause --- --- --- 500 internal Something failed on our side. Retry with backoff; email support@railbed.com 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.",
     "endLine": 150
    }
   ]
  },
  {
   "id": "webhooks",
   "title": "Webhooks",
   "nav": "Overview",
   "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.",
   "audience": "developers",
   "url": "https://railbed.com/docs/webhooks/",
   "path": "/docs/webhooks/",
   "markdown": "https://railbed.com/docs/webhooks.md",
   "updated": "2026-10-04",
   "bodyLineOffset": 8,
   "markdownHash": "e3f343dcac0904dd19f73b4020d144dbf928216013a1d9cf1599799b28386188",
   "sections": [
    {
     "heading": "How webhooks work",
     "anchor": "how-webhooks-work",
     "level": 2,
     "startLine": 0,
     "text": "How webhooks work When something happens to a payment, Railbed sends a POST with a JSON event to each of your endpoints that subscribes to it. The event carries the payment, its order with the items and addresses, and the customer. The request is signed with the endpoint's secret, so your server can prove it came from Railbed and wasn't changed. Example: One delivery text POST https://yourstore.com/webhooks/railbed Content-Type: application/json User-Agent: Railbed-Webhooks/1.0 Railbed-Signature: t=1790381342,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd {\"id\":\"evt_4Qm8ZsUe2VhNc7RwTb1Y\",\"type\":\"payment.paid\",…,\"data\":{\"payment\":{…},\"order\":{…},\"customer\":{…}}} 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 The buyer entered their email and was given a way to pay payment.paid The money arrived and passed Railbed's checks. Fulfil on this payment.held Money arrived but failed a check, so it waits for your review payment.updated A paid payment's settlement details were filled in payment.failed The payment ended in a terminal Test failure payment.expired Nobody paid in time payment.canceled You canceled a payment link ping You chose Send test event (or Send ping on a Live endpoint)",
     "endLine": 28
    },
    {
     "heading": "Add an endpoint",
     "anchor": "add-an-endpoint",
     "level": 2,
     "startLine": 28,
     "text": "Add an endpoint 1. In the dashboard, choose Test or Live , then open 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. [!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.",
     "endLine": 42
    },
    {
     "heading": "Respond to deliveries",
     "anchor": "respond-to-deliveries",
     "level": 2,
     "startLine": 42,
     "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.",
     "endLine": 51
    },
    {
     "heading": "Retries",
     "anchor": "retries",
     "level": 2,
     "startLine": 51,
     "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.",
     "endLine": 57
    },
    {
     "heading": "Duplicates and order",
     "anchor": "duplicates-and-order",
     "level": 2,
     "startLine": 57,
     "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. - 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: it's always current.",
     "endLine": 65
    },
    {
     "heading": "The delivery log",
     "anchor": "the-delivery-log",
     "level": 2,
     "startLine": 65,
     "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.",
     "endLine": 75
    },
    {
     "heading": "Test events",
     "anchor": "test-events",
     "level": 2,
     "startLine": 75,
     "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\" ), order ( ord_sample , two items shipping to San Francisco) and customer ( cus_sample ), none of which exist in your account or the API. Use them to build your order handling before a real sale. - 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. That sends the real sequence of events for a real Test payment.",
     "endLine": 84
    },
    {
     "heading": "Manage endpoints",
     "anchor": "manage-endpoints",
     "level": 2,
     "startLine": 84,
     "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",
     "endLine": 92
    },
    {
     "heading": "A receiver, end to end",
     "anchor": "a-receiver-end-to-end",
     "level": 2,
     "startLine": 92,
     "text": "A receiver, end to end Example: Node\\.js · Express js import crypto from 'node:crypto'; import express from 'express'; const app = express(); // The raw body: verify exactly the bytes that were signed. const rawJson = express.raw({ type: 'application/json' }); app.post('/webhooks/railbed', rawJson, async (req, res) = { const raw = req.body.toString('utf8'); const secret = process.env.RAILBED_WEBHOOK_SECRET; const header = req.get('Railbed-Signature'); if (!verify(raw, header, secret)) return res.sendStatus(400); const event = JSON.parse(raw); const isNew = await db.events.insertIfAbsent(event.id); // UNIQUE(id) if (isNew && event.type === 'payment.paid') { await queue.add('fulfil', { paymentId: event.data.payment.id }); } res.sendStatus(200); }); function verify(raw, header, secret) { const m = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? ''); if (!m) return false; if (Math.abs(Date.now() / 1000 - Number(m[1])) 300) return false; const expected = crypto .createHmac('sha256', secret) .update( ${m[1]}.${raw} ) .digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2])); } Example: Python · Flask python import hashlib, hmac, os, re, time from flask import Flask, request app = Flask(__name__) @app.post(\"/webhooks/railbed\") def railbed_webhook(): raw = request.get_data() the raw bytes, before any JSON parsing header = request.headers.get(\"Railbed-Signature\", \"\") if not verify(raw, header, os.environ[\"RAILBED_WEBHOOK_SECRET\"]): return \"\", 400 event = request.get_json() is_new = db.events.insert_if_absent(event[\"id\"]) if is_new and event[\"type\"] == \"payment.paid\": queue.enqueue(\"fulfil\", event[\"data\"][\"payment\"][\"id\"]) return \"\", 200 def verify(raw: bytes, header: str, secret: str) - bool: m = re.fullmatch(r\"t=(\\d+),v1=([0-9a-f]{64})\", header or \"\") if not m: return False expected = hmac.new( secret.encode(), m[1].encode() + b\".\" + raw, hashlib.sha256 ).hexdigest() return ( abs(time.time() - int(m[1])) < 300 and hmac.compare_digest(expected, m[2]) ) Example: PHP php <?php $raw = file_get_contents('php://input'); $header = $_SERVER['HTTP_RAILBED_SIGNATURE'] ?? ''; if (!railbed_verify($raw, $header, getenv('RAILBED_WEBHOOK_SECRET'))) { http_response_code(400); exit; } $event = json_decode($raw, true); if (events_insert_if_absent($event['id']) && $event['type'] === 'payment.paid') { queue_fulfilment($event['data']['payment']['id']); } http_response_code(200); function railbed_verify(string $raw, string $header, string $secret): bool { if (!preg_match('/^t=(\\d+),v1=([0-9a-f]{64})$/', $header, $m)) return false; $expected = hash_hmac('sha256', $m[1] . '.' . $raw, $secret); return abs(time() - (int) $m[1]) < 300 && hash_equals($expected, $m[2]); } The fulfilment job then reads the payment and grants the order once, as in Fulfil orders safely. Verification in more languages, and a test vector to check yours against, are in Verify signatures.",
     "endLine": 191
    },
    {
     "heading": "Checklist",
     "anchor": "checklist",
     "level": 2,
     "startLine": 191,
     "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",
     "endLine": 201
    }
   ]
  },
  {
   "id": "webhooks/events",
   "title": "Event types",
   "nav": "Event types",
   "section": "Webhooks",
   "description": "Every webhook event Railbed sends, when it's sent, what the payment, order and customer look like at that moment, and the full payload reference.",
   "audience": "developers",
   "url": "https://railbed.com/docs/webhooks/events/",
   "path": "/docs/webhooks/events/",
   "markdown": "https://railbed.com/docs/webhooks/events.md",
   "updated": "2026-10-04",
   "bodyLineOffset": 8,
   "markdownHash": "efeddd9eae299db9765c52bd18708860bf1e50af8a9b4d82afca0ecdf41cdd14",
   "sections": [
    {
     "heading": "The event object",
     "anchor": "the-event-object",
     "level": 2,
     "startLine": 0,
     "text": "The event object Every delivery's body is one event: Example: payment\\.paid · response Delivered · 200 json { \"id\": \"evt_4Qm8ZsUe2VhNc7RwTb1Y\", \"type\": \"payment.paid\", \"created\": 1790381342, \"livemode\": true, \"data\": { \"payment\": { \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\", \"mode\": \"live\", \"source\": \"api\", \"checkoutId\": null, \"planLabel\": null, \"description\": \"Pro Membership\", \"reference\": \"order_1042\", \"customerEmail\": \"buyer@example.com\", \"amount\": \"49.00\", \"currency\": \"USD\", \"status\": \"paid\", \"provider\": \"stripe\", \"providerName\": \"Stripe\", \"depositAddress\": \"0x5b0e8a3f2d1c4b7a9e6f0d3c2b1a4e7f8d9c0b1a\", \"payoutWallet\": \"0xF977814e90dA44bFA03b6295A0616a897441aceC\", \"feeBps\": 150, \"valueCoin\": \"47.53\", \"merchantReceived\": \"46.341750\", \"coin\": \"polygon_usdc\", \"holdReason\": null, \"holdAcceptable\": false, \"txidIn\": \"0x0c651ba1d59c7a32e8b1f4bd2c7e0e4f96a55d13a6b0f2d1c8e7a9b4f3d29b58\", \"txidOut\": \"0x8202d1373e0a9c4f1b6d5e2c7a8f9b0e1d2c3b4a5f6e7d8c9b0a1f2e3d7356ff\", \"metadata\": { \"user_id\": \"player_1042\" }, \"customerName\": null, \"memo\": null, \"trackingUrl\": null, \"canceledAt\": null, \"successUrl\": \"https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}\", \"createdAt\": 1790380525000, \"paidAt\": 1790381342000, \"expiresAt\": 1790466925000, \"url\": \"https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN\", \"orderId\": \"ord_M4tQx8ZkP1vRn6WcLs2B\" }, \"order\": { \"id\": \"ord_M4tQx8ZkP1vRn6WcLs2B\", \"mode\": \"live\", \"number\": 1186, \"displayNumber\": \" 1186\", \"source\": \"api\", \"checkoutId\": null, \"store\": null, \"status\": \"paid\", \"needsReview\": false, \"fulfillment\": \"none\", \"fulfilledAt\": null, \"customer\": { \"id\": \"cus_Q9wLm3TzX7bKc2VnR5Hd\", \"email\": \"buyer@example.com\", \"name\": null, \"erased\": false }, \"summary\": \"Pro Membership\", \"lineCount\": 1, \"unitCount\": 1, \"currency\": \"USD\", \"subtotal\": \"49.00\", \"discount\": \"0.00\", \"discountCode\": null, \"shipping\": \"0.00\", \"shippingMethod\": null, \"tax\": \"0.00\", \"total\": \"49.00\", \"amountPaid\": \"49.00\", \"received\": \"46.341750\", \"shipTo\": null, \"billTo\": null, \"reference\": \"order_1042\", \"paymentId\": \"pay_7AAiYH0Ykt11ED4hmfiN\", \"attemptCount\": 1, \"createdAt\": 1790380525000, \"updatedAt\": 1790381342000, \"paidAt\": 1790381342000, \"items\": [ { \"name\": \"Pro Membership\", \"variant\": null, \"sku\": null, \"quantity\": 1, \"unitAmount\": \"49.00\", \"amount\": \"49.00\" } ] }, \"customer\": { \"id\": \"cus_Q9wLm3TzX7bKc2VnR5Hd\", \"mode\": \"live\", \"email\": \"buyer@example.com\", \"name\": null, \"phone\": null, \"location\": null, \"shipTo\": null, \"billTo\": null, \"storeAccounts\": [], \"firstSeenAt\": 1790380525000, \"erased\": false } } } 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 as it was when the event happened. null for ping data.order object or null The payment's order, with its items, as this event leaves it. null for ping data.customer object or null The order's customer: contact details and addresses. null for ping , and until the buyer has given an email The payment, order and customer in an event are a snapshot. Events can arrive late or out of order, so when you need the current state, read the payment or the order. The order's status already includes the event: on payment.paid it's paid . It follows all of the order's payments, so an event about one attempt can carry an order that another attempt has already paid, such as a payment.expired for a buyer's abandoned first try. Decide what to do from the event's type and the payment; use the order for what to deliver and where.",
     "endLine": 130
    },
    {
     "heading": "Events",
     "anchor": "events",
     "level": 2,
     "startLine": 130,
     "text": "Events",
     "endLine": 240
    },
    {
     "heading": "payment.started",
     "anchor": "payment-started",
     "level": 3,
     "startLine": 132,
     "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. A buyer who entered their email only now appears here first: data.customer and data.order.customer are set from it. Example: payment\\.started (trimmed) json { \"id\": \"evt_9sPq2XbLr5TtVn0KcWmE\", \"type\": \"payment.started\", \"created\": 1790380611, \"livemode\": true, \"data\": { \"payment\": { \"id\": \"pay_7AAiYH0Ykt11ED4hmfiN\", \"status\": \"open\", \"customerEmail\": \"buyer@example.com\", \"amount\": \"49.00\", \"currency\": \"USD\", \"provider\": null, \"depositAddress\": \"0x5b0e8a3f2d1c4b7a9e6f0d3c2b1a4e7f8d9c0b1a\", \"feeBps\": 150 } } } In Test mode, depositAddress is null : no real address is created.",
     "endLine": 163
    },
    {
     "heading": "payment.paid",
     "anchor": "payment-paid",
     "level": 3,
     "startLine": 163,
     "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 as paid. This is the event to fulfil from. - The full payload is the example in 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 ; 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.",
     "endLine": 173
    },
    {
     "heading": "payment.held",
     "anchor": "payment-held",
     "level": 3,
     "startLine": 173,
     "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. Example: payment\\.held (trimmed) json { \"id\": \"evt_Hq3nW8ZkT1cVbR6sYp0M\", \"type\": \"payment.held\", \"created\": 1790381342, \"livemode\": true, \"data\": { \"payment\": { \"id\": \"pay_Kx81mQv2PzR0dT7eWcYa\", \"status\": \"held\", \"amount\": \"49.00\", \"currency\": \"USD\", \"provider\": \"stripe\", \"valueCoin\": \"24.50\", \"coin\": \"polygon_usdc\", \"holdReason\": \"The provider sent 24.50 USDC, below 90% of the order’s 49.00 USD value.\", \"holdAcceptable\": true, \"paidAt\": null } } }",
     "endLine": 206
    },
    {
     "heading": "payment.updated",
     "anchor": "payment-updated",
     "level": 3,
     "startLine": 206,
     "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.",
     "endLine": 210
    },
    {
     "heading": "payment.failed",
     "anchor": "payment-failed",
     "level": 3,
     "startLine": 210,
     "text": "payment.failed The payment reached a terminal failure. Today this is sent in Test mode only , for an explicit API simulation with outcome: \"failed\" . The Test provider's declined-card action keeps the payment open and sends no failed event. Live card providers don't report declines, so a live payment the buyer never completes ends as payment.expired . After a terminal failure, let the buyer try again with a new session.",
     "endLine": 214
    },
    {
     "heading": "payment.expired",
     "anchor": "payment-expired",
     "level": 3,
     "startLine": 214,
     "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.",
     "endLine": 220
    },
    {
     "heading": "payment.canceled",
     "anchor": "payment-canceled",
     "level": 3,
     "startLine": 220,
     "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.",
     "endLine": 224
    },
    {
     "heading": "ping",
     "anchor": "ping",
     "level": 3,
     "startLine": 224,
     "text": "ping Sent only when you choose Send test event and pick ping (Test endpoints) or Send ping (Live endpoints). data.payment , data.order and data.customer are null . Use it to check the address, the secret and your signature code. Example: ping json { \"id\": \"evt_T2rVx9KcQm4NwLb7Ez0P\", \"type\": \"ping\", \"created\": 1790380800, \"livemode\": false, \"data\": { \"payment\": null, \"order\": null, \"customer\": null } }",
     "endLine": 240
    },
    {
     "heading": "Typical sequences",
     "anchor": "typical-sequences",
     "level": 2,
     "startLine": 240,
     "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 A second try at a store order payment.started → payment.expired for the first session, then payment.started → payment.paid for the second. Every event carries the same data.order.id 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. When you ship goods, deliver each order once, keyed on data.order.id : a buyer who pays two sessions for one order sends two payment.paid events for it.",
     "endLine": 255
    },
    {
     "heading": "The payment object",
     "anchor": "the-payment-object",
     "level": 2,
     "startLine": 255,
     "text": "The payment object Webhook payloads carry the payment, order and customer in the dashboard's shape: camelCase names and timestamps in milliseconds (the event's own created is Unix seconds). The API's payment 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 provider string or null The provider the buyer chose, such as stripe . Null when the buyer paid in crypto providerName string or null Its display name, such as Stripe , or the coin and network for crypto, such as USDC on Base method string card , or crypto when the buyer sent stablecoins from their own wallet network string or null Crypto payments: the network the money arrived on, such as base . Null for card depositAddress string or null The one-time address this payment is paid into: on Polygon for card, set when a Live payment starts; on network for crypto payoutWallet string or null Your wallet the payment is forwarded to, fixed when it starts feeBps integer The Railbed fee locked into this payment, in basis points of what arrives (150 = 1.5%) valueCoin string or null How much arrived at the deposit address, in coin merchantReceived string or null How much was forwarded to your wallet, in coin . null until it's reported; never estimated coin string or null What arrived, usually polygon_usdc holdReason string or null Why the payment is held, in plain words holdAcceptable boolean For a held payment: whether you can accept it as paid txidIn string or null The Polygon transaction that delivered the money txidOut string or null The Polygon transaction that forwarded it to you metadata object or null Your metadata, as sent when creating the session customerName string or null Payment links: who it's for memo string or null Payment links: the note to the payer trackingUrl string or null Payment links: the read-only tracking page canceledAt integer or null When you canceled the payment link, in ms successUrl string or null The success URL as you set it, with its {PAYMENT_ID} and {REFERENCE} placeholders left in (percent-encoded if they're in the path). The buyer's checkout fills them in createdAt integer When the payment was created, in ms paidAt integer or null When it became paid , in ms expiresAt integer When an unpaid payment expires, in ms url string The payment's hosted checkout page orderId string or null The order this payment is an attempt at, ord_… [!TIP] Want one shape everywhere? Treat the webhook as a signal: verify it, then fetch GET /v1/payments/:id (and GET /v1/orders/:id for what to deliver) and run all your checks on the API's answers.",
     "endLine": 301
    },
    {
     "heading": "The order object",
     "anchor": "the-order-object",
     "level": 2,
     "startLine": 301,
     "text": "The order object data.order is the order the payment belongs to, with its items. It has the API order's facts in camelCase, plus a few the dashboard uses. Field Type Description --- --- --- id string The order's id, ord_… . The same id the API uses mode string live or test number integer Railbed's order number, counting from 1001 in each mode displayNumber string The number to show people, such as 1042 : your store's number when you sent one, otherwise Railbed's source string Where its first payment came from: api , checkout or payment_link checkoutId string or null The checkout it came from, chk_… store object or null For orders sent with order.id : platform , id , name , url , orderId , orderNumber , orderUrl and customerId (the customer.id you sent) status string The order's status, including this event needsReview boolean true when it's held and needs you: a held payment can be accepted as paid in the dashboard, or what's paid doesn't cover total fulfillment string none , unfulfilled or fulfilled . See fulfilment fulfilledAt integer or null When it was marked fulfilled , in ms customer object or null The customer at a glance: id , email , name and erased summary string The first item and how many more lines, such as Magnesium glycinate · 120 capsules +2 lineCount integer How many item lines unitCount integer How many units across all lines currency string The currency of every amount on the order subtotal string The items' amount s added up discount string Taken off the subtotal discountCode string or null The code the buyer used shipping string Shipping charged shippingMethod string or null How it ships tax string Tax charged total string subtotal − discount + shipping + tax amountPaid string What its paid payments in currency add up to. Below total when a part-payment left it held received string or null USDC that reached your wallet from its paid payments, with six decimal places like the payment's merchantReceived . null until it's reported shipTo object or null The shipping address: name , line1 , line2 , city , region , postalCode , country billTo object or null The billing address, in the same shape reference string or null Your reference from its latest payment paymentId string or null The payment that decides its status: the paid one, else the held one, else the latest attemptCount integer How many payments it has createdAt integer When the order was created, in ms updatedAt integer When it last changed, in ms paidAt integer or null When its first payment became paid , in ms items array Each line's name , variant , sku , quantity , unitAmount (or null ) and amount , as on the API's order. The first 50 lines; lineCount says how many there are, and the API's order has them all For every payment attempt's id, read the order: the API's order lists them in payment_ids . A store order's data.order looks like this in part: Example: data\\.order for a store order (trimmed) json { \"id\": \"ord_Vd3kR8mQ1xTn6LpZs0Wa\", \"displayNumber\": \" 1042\", \"store\": { \"platform\": \"woocommerce\", \"id\": \"3f6c1a2e-8b4d-4e9a-9c7f-2d5b8e1a4c60\", \"name\": \"yourstore.com\", \"url\": \"https://yourstore.com/\", \"orderId\": \"1042\", \"orderNumber\": \"1042\", \"orderUrl\": \"https://yourstore.com/wp-admin/post.php?post=1042&action=edit\", \"customerId\": \"88\" }, \"status\": \"paid\", \"fulfillment\": \"unfulfilled\", \"total\": \"135.31\", \"shipTo\": { \"name\": \"Maya Okafor\", \"line1\": \"418 Linden Avenue\", \"line2\": \"Apt 3B\", \"city\": \"Portland\", \"region\": \"OR\", \"postalCode\": \"97214\", \"country\": \"US\" }, \"items\": [ { \"name\": \"Magnesium glycinate\", \"variant\": \"120 capsules\", \"sku\": \"HN-MG-120\", \"quantity\": 2, \"unitAmount\": \"32.00\", \"amount\": \"64.00\" } ] }",
     "endLine": 385
    },
    {
     "heading": "The customer object",
     "anchor": "the-customer-object",
     "level": 2,
     "startLine": 385,
     "text": "The customer object data.customer is the order's customer, with their contact details as they were when the event happened. It's null until the buyer has given an email. Field Type Description --- --- --- id string The customer's id, cus_… . The same id the API uses mode string live or test email string or null Their email, lowercased. null once you've deleted their details name string or null Their name phone string or null Their phone number location string or null Where they are, such as Portland, OR, US , from the shipping address or else the billing address shipTo object or null Their latest shipping address, in the order's address shape billTo object or null Their latest billing address storeAccounts array Their customer ids in your stores: platform , storeId , storeName and customerId for each firstSeenAt integer When they first appeared, in ms erased boolean true once you've deleted their details Their order counts, what they've spent and your private note are never sent in webhooks. For counts and totals, read the customer. When you delete a customer's details, they're also removed from the bodies kept in your delivery log: a resend carries null in their place. Events already delivered can't be recalled.",
     "endLine": 406
    }
   ]
  },
  {
   "id": "webhooks/signatures",
   "title": "Verify signatures",
   "nav": "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.",
   "audience": "developers",
   "url": "https://railbed.com/docs/webhooks/signatures/",
   "path": "/docs/webhooks/signatures/",
   "markdown": "https://railbed.com/docs/webhooks/signatures.md",
   "updated": "2026-09-26",
   "bodyLineOffset": 8,
   "markdownHash": "6234786035479cf36a4b146cbf747fc0ad44cb92a6799a79e7e5156e7aac3667",
   "sections": [
    {
     "heading": "How deliveries are signed",
     "anchor": "how-deliveries-are-signed",
     "level": 2,
     "startLine": 0,
     "text": "How deliveries are signed Every delivery carries a Railbed-Signature header: Example: The header text Railbed-Signature: t=1790381342,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd - t is when this attempt was signed, in Unix seconds. - v1 is the hex HMAC-SHA256 of the string {t}.{raw body} , keyed with the endpoint's signing secret: the whole whsec_… value, as text. To verify a delivery: 1. Read the header and split out t and v1 . Reject the request if the header is missing or doesn't match t=<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.",
     "endLine": 22
    },
    {
     "heading": "Verify a delivery",
     "anchor": "verify-a-delivery",
     "level": 2,
     "startLine": 22,
     "text": "Verify a delivery Each function returns true only for a genuine, recent delivery. Check yours against the test vector below. Example: Node\\.js js import crypto from 'node:crypto'; export function verifyRailbedSignature( rawBody, header, secret, toleranceSeconds = 300, ) { const match = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? ''); if (!match) return false; const [, timestamp, signature] = match; const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (age toleranceSeconds) return false; const expected = crypto .createHmac('sha256', secret) .update( ${timestamp}.${rawBody} ) .digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); } Example: Web Crypto · Cloudflare Workers, Deno, Bun, Next\\.js route handlers ts export async function verifyRailbedSignature( rawBody: string, header: string null, secret: string, toleranceSeconds = 300, ) { const match = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? ''); if (!match) return false; const [, timestamp, signature] = match; const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (age toleranceSeconds) return false; const enc = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'], ); const bytes = new Uint8Array( signature.match(/../g)!.map((h) = parseInt(h, 16)), ); const signedPayload = enc.encode( ${timestamp}.${rawBody} ); // subtle.verify compares in constant time return crypto.subtle.verify('HMAC', key, bytes, signedPayload); } Example: Python python import hashlib, hmac, re, time def verify_railbed_signature( raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300, ) - bool: match = re.fullmatch(r\"t=(\\d+),v1=([0-9a-f]{64})\", header or \"\") if not match: return False timestamp, signature = match.groups() if abs(time.time() - int(timestamp)) tolerance_seconds: return False expected = hmac.new( secret.encode(), timestamp.encode() + b\".\" + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature) Example: PHP php <?php function verify_railbed_signature( string $rawBody, string $header, string $secret, int $toleranceSeconds = 300 ): bool { if (!preg_match('/^t=(\\d+),v1=([0-9a-f]{64})$/', $header, $match)) return false; [, $timestamp, $signature] = $match; if (abs(time() - (int) $timestamp) $toleranceSeconds) return false; $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, $signature); } Example: Ruby ruby require 'openssl' require 'rack/utils' secure_compare; Rails and Sinatra already load it def verify_railbed_signature(raw_body, header, secret, tolerance_seconds = 300) match = /\\At=(\\d+),v1=([0-9a-f]{64})\\z/.match(header.to_s) return false unless match timestamp, signature = match.captures return false if (Time.now.to_i - timestamp.to_i).abs tolerance_seconds expected = OpenSSL::HMAC.hexdigest('SHA256', secret, \" {timestamp}. {raw_body}\") Rack::Utils.secure_compare(expected, signature) end Example: Go go package railbed import ( \"crypto/hmac\" \"crypto/sha256\" \"encoding/hex\" \"regexp\" \"strconv\" \"time\" ) var signatureHeader = regexp.MustCompile( ^t=(\\d+),v1=([0-9a-f]{64})$ ) func VerifySignature( rawBody []byte, header, secret string, tolerance time.Duration, ) bool { m := signatureHeader.FindStringSubmatch(header) if m == nil { return false } ts, err := strconv.ParseInt(m[1], 10, 64) if err != nil { return false } if age := time.Since(time.Unix(ts, 0)); age tolerance age < -tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(m[1] + \".\")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(m[2])) }",
     "endLine": 178
    },
    {
     "heading": "Use the raw body",
     "anchor": "use-the-raw-body",
     "level": 2,
     "startLine": 178,
     "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 Example: A complete Cloudflare Worker or Next\\.js route handler ts export async function POST(request: Request) { const raw = await request.text(); const header = request.headers.get('Railbed-Signature'); const secret = process.env.RAILBED_WEBHOOK_SECRET!; if (!(await verifyRailbedSignature(raw, header, secret))) { return new Response('Invalid signature', { status: 400 }); } const event = JSON.parse(raw); // Record event.id with a unique constraint, queue the work, then answer. return new Response(null, { status: 200 }); }",
     "endLine": 212
    },
    {
     "heading": "Test vector",
     "anchor": "test-vector",
     "level": 2,
     "startLine": 212,
     "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 Example: Reproduce it with OpenSSL bash printf '%s' '1790380800.' \\ '{\"id\":\"evt_ExampleEventId0001\",\"type\":\"ping\",\"created\":1790380800,' \\ '\"livemode\":false,\"data\":{\"payment\":null}}' \\ openssl dgst -sha256 -hmac 'whsec_4mJ9pQx2VtR7cY1nKs8LwZ3bHd6fGa0e' The timestamp is in the past, so a verifier with a five-minute tolerance rejects this header. To test with it, pass a very large tolerance (every function above takes one), or test the HMAC step on its own. Change one byte of the body and the result must be false . For an end-to-end check, choose Send test event (Test) or Send ping (Live) on your endpoint in Developers: the result shows whether your server accepted it.",
     "endLine": 237
    },
    {
     "heading": "Replays and clock skew",
     "anchor": "replays-and-clock-skew",
     "level": 2,
     "startLine": 237,
     "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 : a replay of an event you've processed then changes nothing.",
     "endLine": 241
    },
    {
     "heading": "Rolling a secret",
     "anchor": "rolling-a-secret",
     "level": 2,
     "startLine": 241,
     "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. 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.",
     "endLine": 247
    },
    {
     "heading": "Troubleshooting",
     "anchor": "troubleshooting",
     "level": 2,
     "startLine": 247,
     "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",
     "endLine": 256
    }
   ]
  },
  {
   "id": "agents",
   "title": "Build with AI agents",
   "nav": "For 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. An agent can also connect to your account through agent sign-in, with the access you choose.",
   "audience": "developers",
   "url": "https://railbed.com/docs/agents/",
   "path": "/docs/agents/",
   "markdown": "https://railbed.com/docs/agents.md",
   "updated": "2026-10-05",
   "bodyLineOffset": 8,
   "markdownHash": "e4940787fef60c41f1c4e9c015095ec1008dd367515d016c31a6c300238b750d",
   "sections": [
    {
     "heading": "Give your assistant the docs",
     "anchor": "give-your-assistant-the-docs",
     "level": 2,
     "startLine": 0,
     "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 A summary of the API and its rules, with a link to every page. Start here /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. The overview is /docs/index.md Any page, asking for Markdown A request whose Accept header prefers text/markdown , as coding agents send, gets the page's Markdown copy instead of its HTML /agents/docs.json A structured index: every page, section and endpoint, with links /auth.md Agent sign-in: how an agent asks for its own access to your account, which you approve. See Connect an agent Each page also has Copy page for AI in its sidebar, which copies the page's Markdown for pasting into a chat.",
     "endLine": 36
    },
    {
     "heading": "Any Railbed link leads here",
     "anchor": "links-lead-here",
     "level": 3,
     "startLine": 15,
     "text": "Any Railbed link leads here You can give your assistant any Railbed address, not only these docs, and it will find its way: - An assistant that asks for Markdown gets the page's Markdown copy, and every copy names /docs/llms.txt near its top, so one page leads to the rest. - Every page's HTML also carries a short note for AI assistants, following the inline llms.txt proposal. Browsers don't show it; an assistant reading the raw page gets links to the page's Markdown copy and to llms.txt . - Dashboard and checkout links ( app.railbed.io , pay.railbed.io ) draw their pages in JavaScript, so their HTML carries a hidden note pointing to these docs, and both hosts serve an /llms.txt that does too. Example: A prompt that works well text Read https://railbed.com/docs/llms.txt and the pages it links to that you need. Then add Railbed card checkout to this app: create a checkout session on the server when the user clicks Buy, redirect to its url, and fulfil the order from a verified payment.paid webhook. Use my test key from RAILBED_SECRET_KEY and my webhook secret from RAILBED_WEBHOOK_SECRET. Deduplicate on the event id. [!IMPORTANT] Keep secret keys and webhook secrets out of prompts, chats and code. Put them in your server's environment and let the assistant refer to them by name. To let an agent use the API on your account, connect it instead of pasting a key into the chat.",
     "endLine": 36
    },
    {
     "heading": "Rules worth giving an agent",
     "anchor": "rules-worth-giving-an-agent",
     "level": 2,
     "startLine": 36,
     "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. - Send the order as order , with items whose total less discount plus shipping and tax equals amount to the cent, and your order id in order.id . On 409 order_paid , don't ask the buyer to pay again. - Ship each order once, keyed on its order id, and report it with PATCH /v1/orders/:id . - Start in Test mode ( rb_test_… keys) and simulate outcomes; no money moves.",
     "endLine": 49
    },
    {
     "heading": "Connect an agent to your account",
     "anchor": "connect-an-agent",
     "level": 2,
     "startLine": 49,
     "text": "Connect an agent to your account An AI agent can use the Railbed API on your account without a secret key. You approve it once in the dashboard, it gets its own short-lived access, and you can disconnect it at any time. This is agent sign-in, and everything the agent needs to know is at railbed.com/auth.md.",
     "endLine": 93
    },
    {
     "heading": "How connecting works",
     "anchor": "agent-sign-in-steps",
     "level": 3,
     "startLine": 53,
     "text": "How connecting works 1. Ask your agent to connect to your Railbed account, and tell it the email address you sign in with. It reads railbed.com/auth.md and follows it. 2. Your agent gives you a link to approve it. It opens Connect an AI agent on app.railbed.io . Sign in with the same email address you gave the agent. The link works for a few minutes; if it has expired, ask your agent for a new one. 3. Check the business, give the agent a name you'll recognize, and choose the mode and the access level. Test and Full access are selected to start with. 4. Choose Connect agent . The page shows Your agent's code . Give it to your agent, in the conversation where you asked it to connect. The code works once, for a few minutes. 5. The agent finishes connecting and starts using the API. It's listed in Developers under AI agents , in the mode you chose. Only owners and developers of a business can connect an agent to it, the same people who can create secret keys. If a link has expired or was already used, ask the agent to start again. [!WARNING] The code lets an agent into your account. Give it only to your own agent, right after you asked that agent to connect. Never type it into a website, and never give it to someone who contacted you and asked for it, by email, message or phone, whoever they say they are. If you didn't just ask an agent to connect, close the page.",
     "endLine": 66
    },
    {
     "heading": "The access you choose",
     "anchor": "agent-access",
     "level": 3,
     "startLine": 66,
     "text": "The access you choose Choice What the agent can do --- --- Test Work with Test data only. Payments are simulated and no money moves, so it's the place to try an agent out Live Work with your real payments, orders and customers Full access Everything the API allows: create and start checkout sessions, read payments, orders and customers, and update orders Read only Look up payments, checkout sessions, orders and customers. It can't create or change anything A connection works in one mode, like a secret key. An agent connected in Test can't see Live data. It uses access tokens that last about five minutes and gets new ones itself, so there's no key for you to create, paste or rotate.",
     "endLine": 77
    },
    {
     "heading": "Disconnect an agent",
     "anchor": "disconnect-an-agent",
     "level": 3,
     "startLine": 77,
     "text": "Disconnect an agent In Developers, AI agents lists the agents connected in the current mode, with who approved each one and when it was last used. Choose Disconnect next to an agent and confirm. Its next request is refused. To use it again, it has to ask to connect again, and someone has to approve it. An agent also loses its access for good when the person who approved it leaves the business, moves to a role that can't manage keys, or has their access disabled. Putting them back doesn't restore it: an owner or developer connects the agent again.",
     "endLine": 83
    },
    {
     "heading": "For agent developers",
     "anchor": "agent-developers",
     "level": 3,
     "startLine": 83,
     "text": "For agent developers auth.md describes the whole flow, with request templates. WorkOS, which runs Railbed's sign-in, hosts the registration, claim and token endpoints it lists. The API itself is https://pay.railbed.io/v1 . - Register with the person's email address ( service_auth ). Anonymous registration isn't offered. - Give the person the claim link, wait for the code they read back to you, and complete the claim. - Exchange your identity assertion for an access token. Access tokens last about five minutes. Get the next one yourself, as auth.md describes. - Call the API with Authorization: Bearer <access token , the way a server sends a secret key. The token carries the business, the mode and the access level the person chose. Authentication lists each endpoint's scope. - On 401 invalid_token , get a new access token. On 401 agent_disconnected , ask the person to connect you again. On 403 insufficient_scope , the person chose Read only : tell them what you need and why.",
     "endLine": 93
    },
    {
     "heading": "WebMCP tools",
     "anchor": "webmcp-tools",
     "level": 2,
     "startLine": 93,
     "text": "WebMCP tools 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.",
     "endLine": 144
    },
    {
     "heading": "On railbed.com and these docs",
     "anchor": "site-tools",
     "level": 3,
     "startLine": 97,
     "text": "On railbed.com and these docs Every page on railbed.com, 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_status Current service availability, observation times and check coverage; optionally filter by service. It fetches fresh results, not saved browser history. This does not verify a payment or settlement 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",
     "endLine": 112
    },
    {
     "heading": "In the dashboard",
     "anchor": "dashboard-tools",
     "level": 3,
     "startLine": 112,
     "text": "In the dashboard When a merchant is signed in, onboarding and the Wallet , Developers , Integrations , Shopify and WooCommerce 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_onboarding_status Onboarding Where setup stands: whether the business name and payout wallet are saved, the main way to sell, and the ways the account can choose, with what each involves railbed_prepare_sales_channel Onboarding Open How will you take payments? with one way selected, for the merchant to confirm with Continue. Tools never fill in the payout wallet railbed_setup_checklist Wallet The Your first payment steps for the merchant's way to sell: each step, whether it's done, where it's done or who can do it, and the optional extras railbed_developer_status Developers The mode, the setup steps done so far, and a summary of keys, endpoints and connected AI agents railbed_list_api_keys Developers The keys, masked ( rb_test_••••••••3f9a ), with their access level and when each was created and last used railbed_list_agent_connections Developers The AI agents connected in this mode: name, access level, who approved it, and when it connected and was last used railbed_list_webhook_endpoints Developers Endpoints with their addresses, labels, events and latest delivery. Signing secrets are never included railbed_list_webhook_deliveries Developers An endpoint's recent deliveries: status, attempts, your server's answer and, if asked, the body sent railbed_send_test_webhook Developers Send a signed test event to an endpoint and report how it answered. Test endpoints take any event type; Live endpoints take ping only railbed_prepare_api_key Developers Open Create key with a name filled in. The merchant creates it; the secret is shown only to them railbed_prepare_webhook_endpoint Developers Open Add endpoint with the address, description and events filled in, for the merchant to review and save railbed_open_webhook_deliveries Developers Open an endpoint's delivery log, optionally filtered to failed or retrying deliveries, so the merchant can resend railbed_list_integrations Integrations The ways to take payments (links, checkouts, the API, store plugins), their status and where to set each up railbed_list_card_providers Integrations The card providers Railbed routes to, with their status and minimum amounts (in Test mode, the fixed test list) railbed_shopify_status Shopify Each connected store's setup steps and the checks behind them, its own Test or Live mode, ad-tracking ids set, failed syncs, and the exact values to paste into Shopify. Never the client secret or tracking tokens railbed_list_shopify_checkouts Shopify A store's recent checkouts, newest first: the buyer's email, items, total, payment status, and how far the Shopify draft or order got, with the error when a sync failed. Optionally only failed syncs railbed_send_shopify_test_event Shopify Send one test event to an ad platform or affiliate postback set up in the Ad tracking card, and report whether it was accepted. It never counts as a sale railbed_prepare_shopify_store Shopify Open Connect a store with the address filled in, for the merchant to confirm railbed_prepare_shopify_retry Shopify Open the confirmation to send a checkout whose sync failed to Shopify again railbed_prepare_shopify_mode Shopify Open the confirmation to take a store Live or back to Test. Refused, with what's missing, when the store isn't ready railbed_woocommerce_setup WooCommerce Where setup stands: the plugin download, the four steps, the store's webhook address and last delivery, and recent WooCommerce orders. Never keys or secrets Some things are deliberately left to the merchant. Tools never return a secret key or a signing secret, and they never create or revoke keys, roll secrets, remove endpoints or resend real events: those open the dashboard's own dialog for the merchant to confirm, or aren't offered at all. [!NOTE] Content returned by the delivery log and the Shopify checkout list includes data your buyers typed, such as their email, and your server's answers. Agents receive it marked as untrusted content.",
     "endLine": 144
    }
   ]
  }
 ],
 "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.",
  "Each key has an access level: Full access (every endpoint; the default) or Read only (scopes `payments:read`, `orders:read`, `customers:read`). Every endpoint needs one scope; a call outside the key's access level gets `403 insufficient_scope`, naming the scope.",
  "AI agents acting for a merchant get their own access through agent sign-in: read https://railbed.com/auth.md and follow it. Register with the merchant's email address, give them the claim link, complete the claim with the code they read back, then exchange your identity assertion for an access token (about five minutes) and renew it yourself. Send it as `Authorization: Bearer <access token>`. It carries the business, mode and access level the merchant chose. `401 invalid_token`: get a new token; `401 agent_disconnected`: ask the merchant to connect you again. Never ask a merchant to paste a secret key into a chat.",
  "Every 401 from the API carries `WWW-Authenticate: Bearer resource_metadata=\"https://pay.railbed.io/.well-known/oauth-protected-resource/v1\"`, the API's protected resource metadata (RFC 9728).",
  "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.",
  "Orders and customers: send `order` (items, discount, shipping, tax, addresses; items − discount + shipping + tax must equal `amount`) and `customer` (name, phone, your customer id) with a session. Every session belongs to an order (`ord_…`); each email is one customer (`cus_…`). Send your order id as `order.id` so a retry joins the same order; a session for an order that's already paid answers `409 order_paid`. Read them with `GET /v1/orders`, `GET /v1/orders/:id` and `GET /v1/customers/:id`; set fulfilment with `PATCH /v1/orders/:id`. An order is `paid` only when its paid payments cover its `total`.",
  "Webhook events: `payment.started`, `payment.paid`, `payment.held`, `payment.updated`, `payment.failed`, `payment.expired`, `payment.canceled`, and `ping` for tests. Each payment event carries `data.payment`, `data.order` (with up to 50 items) and `data.customer`. 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.com/docs/api/checkout-sessions/#create-a-checkout-session",
   "markdown": "https://railbed.com/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.com/docs/api/checkout-sessions/#retrieve-a-checkout-session",
   "markdown": "https://railbed.com/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.com/docs/api/checkout-sessions/#start-a-checkout-session",
   "markdown": "https://railbed.com/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.com/docs/api/payments/#retrieve-a-payment",
   "markdown": "https://railbed.com/docs/api/payments.md#retrieve-a-payment",
   "title": "Retrieve a payment"
  },
  {
   "method": "GET",
   "path": "/v1/payments",
   "page": "api/payments",
   "url": "https://railbed.com/docs/api/payments/#list-payments",
   "markdown": "https://railbed.com/docs/api/payments.md#list-payments",
   "title": "List payments"
  },
  {
   "method": "POST",
   "path": "/v1/payments/:id/simulate",
   "page": "api/payments",
   "url": "https://railbed.com/docs/api/payments/#simulate-a-payment",
   "markdown": "https://railbed.com/docs/api/payments.md#simulate-a-payment",
   "title": "Simulate a payment"
  },
  {
   "method": "GET",
   "path": "/v1/orders/:id",
   "page": "api/orders",
   "url": "https://railbed.com/docs/api/orders/#retrieve-an-order",
   "markdown": "https://railbed.com/docs/api/orders.md#retrieve-an-order",
   "title": "Retrieve an order"
  },
  {
   "method": "GET",
   "path": "/v1/orders",
   "page": "api/orders",
   "url": "https://railbed.com/docs/api/orders/#list-orders",
   "markdown": "https://railbed.com/docs/api/orders.md#list-orders",
   "title": "List orders"
  },
  {
   "method": "PATCH",
   "path": "/v1/orders/:id",
   "page": "api/orders",
   "url": "https://railbed.com/docs/api/orders/#update-an-order",
   "markdown": "https://railbed.com/docs/api/orders.md#update-an-order",
   "title": "Update an order"
  },
  {
   "method": "POST",
   "path": "/v1/orders/:id/events",
   "page": "api/orders",
   "url": "https://railbed.com/docs/api/orders/#report-a-cancellation-or-refund",
   "markdown": "https://railbed.com/docs/api/orders.md#report-a-cancellation-or-refund",
   "title": "Report a cancellation or refund"
  },
  {
   "method": "GET",
   "path": "/v1/customers/:id",
   "page": "api/customers",
   "url": "https://railbed.com/docs/api/customers/#retrieve-a-customer",
   "markdown": "https://railbed.com/docs/api/customers.md#retrieve-a-customer",
   "title": "Retrieve a customer"
  },
  {
   "method": "GET",
   "path": "/v1/customers",
   "page": "api/customers",
   "url": "https://railbed.com/docs/api/customers/#list-customers",
   "markdown": "https://railbed.com/docs/api/customers.md#list-customers",
   "title": "List customers"
  }
 ],
 "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.com/docs/webhooks/events/#payment-started",
    "markdown": "https://railbed.com/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.com/docs/webhooks/events/#payment-paid",
    "markdown": "https://railbed.com/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.com/docs/webhooks/events/#payment-held",
    "markdown": "https://railbed.com/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.com/docs/webhooks/events/#payment-updated",
    "markdown": "https://railbed.com/docs/webhooks/events.md#payment-updated"
   },
   {
    "type": "payment.failed",
    "summary": "The payment reached a terminal failure. Today this is sent in Test mode only, for an explicit API simulation with outcome: \"failed\". The Test provider's declined-card action keeps the payment open and sends no failed event. Live card providers don't report declines, so a live payment the buyer never completes ends as payment.expired. After a terminal failure, let the buyer try again with a new session.",
    "url": "https://railbed.com/docs/webhooks/events/#payment-failed",
    "markdown": "https://railbed.com/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.com/docs/webhooks/events/#payment-expired",
    "markdown": "https://railbed.com/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.com/docs/webhooks/events/#payment-canceled",
    "markdown": "https://railbed.com/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, data.order and data.customer are null. Use it to check the address, the secret and your signature code.",
    "url": "https://railbed.com/docs/webhooks/events/#ping",
    "markdown": "https://railbed.com/docs/webhooks/events.md#ping"
   }
  ]
 },
 "samples": [
  {
   "page": "user-guide/pricing-tables",
   "section": "Share or embed it",
   "url": "https://railbed.com/docs/user-guide/pricing-tables/#share-or-embed-it",
   "language": "HTML",
   "label": "Place your pricing table on a website",
   "kind": "code",
   "code": "<script src=\"https://pay.railbed.io/embed.js\" async></script>\n\n<railbed-checkout\n  checkout=\"your-pricing-table-slug\">\n</railbed-checkout>"
  },
  {
   "page": "user-guide/widgets-and-buttons",
   "section": "Add a buy button",
   "url": "https://railbed.com/docs/user-guide/widgets-and-buttons/#add-a-buy-button",
   "language": "HTML",
   "label": "A button that opens your checkout",
   "kind": "code",
   "code": "<script src=\"https://pay.railbed.io/embed.js\" async></script>\n\n<railbed-button\n  checkout=\"your-checkout-slug\"\n  label=\"Book a consultation\"\n  shape=\"rounded\">\n</railbed-button>"
  },
  {
   "page": "user-guide/widgets-and-buttons",
   "section": "Add an inline checkout",
   "url": "https://railbed.com/docs/user-guide/widgets-and-buttons/#add-an-inline-checkout",
   "language": "HTML",
   "label": "A checkout embedded in your page",
   "kind": "code",
   "code": "<script src=\"https://pay.railbed.io/embed.js\" async></script>\n\n<railbed-checkout\n  checkout=\"your-widget-slug\">\n</railbed-checkout>"
  },
  {
   "page": "overview",
   "section": "The API at a glance",
   "url": "https://railbed.com/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.com/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  \"order_id\": \"ord_M4tQx8ZkP1vRn6WcLs2B\"\n}"
  },
  {
   "page": "quickstart",
   "section": "1. Create a secret key",
   "url": "https://railbed.com/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.com/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.com/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.com/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.com/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.com/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  \"order_id\": \"ord_M4tQx8ZkP1vRn6WcLs2B\"\n}"
  },
  {
   "page": "quickstart",
   "section": "4. Confirm the payment from your server",
   "url": "https://railbed.com/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.com/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.com/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.com/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.com/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.com/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# Choose an outcome: \"paid\", \"underpaid\", \"declined\" 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.com/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', 'declined', 'failed'\n});"
  },
  {
   "page": "testing",
   "section": "Simulate an outcome from your server",
   "url": "https://railbed.com/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\", \"declined\", \"failed\"\n)"
  },
  {
   "page": "testing",
   "section": "Simulate an outcome from your server",
   "url": "https://railbed.com/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', 'declined', 'failed'\n]);"
  },
  {
   "page": "guides/hosted-checkout",
   "section": "Create the session",
   "url": "https://railbed.com/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.com/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.com/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.com/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.com/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.com/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.com/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.com/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.com/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.com/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.com/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/fulfilment",
   "section": "Ship what the order says",
   "url": "https://railbed.com/docs/guides/fulfilment/#ship-what-the-order-says",
   "language": "Node.js",
   "label": "Node.js: ship a paid order once, then report it",
   "kind": "code",
   "code": "// railbed() is the small helper from /docs/api/errors/\nasync function shipOrder(paymentId) {\n  const payment = await railbed(`/payments/${paymentId}`);\n  if (payment.status !== 'paid') return;\n  const order = await railbed(`/orders/${payment.order_id}`);\n  // The order follows its latest session: check this payment covers it\n  if (\n    payment.amount !== order.total ||\n    payment.currency !== order.currency\n  ) {\n    return flagForReview(order, payment);\n  }\n  await db.transaction(async (tx) => {\n    // UNIQUE(order_id): a second paid payment for this order ships nothing\n    const isNew = await tx.shipments.insertIfAbsent({ orderId: order.id });\n    if (!isNew) return;\n    await tx.shipments.queue({\n      to: order.shipping_address,\n      lines: order.items.map((i) => ({ sku: i.sku, quantity: i.quantity })),\n    });\n  });\n}\n\n// Later, when the parcel leaves the warehouse\nasync function markShipped(orderId) {\n  await railbed(`/orders/${orderId}`, {\n    method: 'PATCH',\n    body: JSON.stringify({ fulfillment: 'fulfilled' }),\n  });\n}"
  },
  {
   "page": "guides/no-code",
   "section": "Add a buy button to any site",
   "url": "https://railbed.com/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.com/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.com/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": "Agent access tokens",
   "url": "https://railbed.com/docs/api/#agent-access-tokens",
   "language": "cURL",
   "label": "An agent's request",
   "kind": "code",
   "code": "curl \"https://pay.railbed.io/v1/payments?limit=1\" \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\""
  },
  {
   "page": "api",
   "section": "Discovery",
   "url": "https://railbed.com/docs/api/#discovery",
   "language": "HTTP",
   "label": "A request without a credential",
   "kind": "response",
   "code": "WWW-Authenticate: Bearer resource_metadata=\"https://pay.railbed.io/.well-known/oauth-protected-resource/v1\""
  },
  {
   "page": "api",
   "section": "Discovery",
   "url": "https://railbed.com/docs/api/#discovery",
   "language": "JSON",
   "label": "The protected resource metadata",
   "kind": "response",
   "code": "{\n  \"resource\": \"https://pay.railbed.io/v1\",\n  \"resource_name\": \"Railbed API\",\n  \"authorization_servers\": [\"https://astounding-resonance-81.authkit.app\"],\n  \"scopes_supported\": [\"payments:read\", \"checkouts:write\", \"orders:read\", \"orders:write\", \"customers:read\"],\n  \"bearer_methods_supported\": [\"header\"],\n  \"resource_documentation\": \"https://railbed.com/docs/api/\"\n}"
  },
  {
   "page": "api",
   "section": "Pagination",
   "url": "https://railbed.com/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.com/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.com/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.com/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.com/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.com/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.com/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  \"order_id\": \"ord_M4tQx8ZkP1vRn6WcLs2B\"\n}"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Orders and customers",
   "url": "https://railbed.com/docs/api/checkout-sessions/#orders-and-customers",
   "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-attempt-1\" \\\n  -d '{\n    \"amount\": \"135.31\",\n    \"currency\": \"USD\",\n    \"description\": \"Order #1042\",\n    \"reference\": \"order_1042\",\n    \"customer_email\": \"maya@example.com\",\n    \"customer\": {\n      \"name\": \"Maya Okafor\",\n      \"phone\": \"+1 503 555 0142\",\n      \"id\": \"88\"\n    },\n    \"order\": {\n      \"id\": \"1042\",\n      \"number\": \"1042\",\n      \"url\": \"https://yourstore.com/admin/orders/1042\",\n      \"store\": {\n        \"id\": \"yourstore\",\n        \"name\": \"yourstore.com\",\n        \"url\": \"https://yourstore.com\"\n      },\n      \"items\": [\n        {\n          \"name\": \"Magnesium glycinate\",\n          \"variant\": \"120 capsules\",\n          \"sku\": \"HN-MG-120\",\n          \"quantity\": 2,\n          \"unit_amount\": \"32.00\",\n          \"amount\": \"64.00\"\n        },\n        {\n          \"name\": \"Daily greens\",\n          \"sku\": \"HN-DG-30\",\n          \"quantity\": 1,\n          \"amount\": \"54.00\"\n        },\n        { \"name\": \"Shaker bottle\", \"quantity\": 1, \"amount\": \"12.00\" }\n      ],\n      \"discount\": \"13.00\",\n      \"discount_code\": \"WELCOME10\",\n      \"shipping\": \"8.95\",\n      \"shipping_method\": \"Standard\",\n      \"tax\": \"9.36\",\n      \"shipping_address\": {\n        \"name\": \"Maya Okafor\",\n        \"line1\": \"418 Linden Avenue\",\n        \"line2\": \"Apt 3B\",\n        \"city\": \"Portland\",\n        \"region\": \"OR\",\n        \"postal_code\": \"97214\",\n        \"country\": \"US\"\n      }\n    },\n    \"success_url\": \"https://yourstore.com/orders/1042/thanks\"\n  }'"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Orders and customers",
   "url": "https://railbed.com/docs/api/checkout-sessions/#orders-and-customers",
   "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-attempt-1',\n  },\n  body: JSON.stringify({\n    amount: '135.31',\n    currency: 'USD',\n    description: 'Order #1042',\n    reference: 'order_1042',\n    customer_email: 'maya@example.com',\n    customer: { name: 'Maya Okafor', phone: '+1 503 555 0142', id: '88' },\n    order: {\n      id: '1042',\n      number: '1042',\n      url: 'https://yourstore.com/admin/orders/1042',\n      store: {\n        id: 'yourstore',\n        name: 'yourstore.com',\n        url: 'https://yourstore.com',\n      },\n      items: [\n        {\n          name: 'Magnesium glycinate',\n          variant: '120 capsules',\n          sku: 'HN-MG-120',\n          quantity: 2,\n          unit_amount: '32.00',\n          amount: '64.00',\n        },\n        { name: 'Daily greens', sku: 'HN-DG-30', quantity: 1, amount: '54.00' },\n        { name: 'Shaker bottle', quantity: 1, amount: '12.00' },\n      ],\n      discount: '13.00',\n      discount_code: 'WELCOME10',\n      shipping: '8.95',\n      shipping_method: 'Standard',\n      tax: '9.36',\n      shipping_address: {\n        name: 'Maya Okafor',\n        line1: '418 Linden Avenue',\n        line2: 'Apt 3B',\n        city: 'Portland',\n        region: 'OR',\n        postal_code: '97214',\n        country: 'US',\n      },\n    },\n    success_url: 'https://yourstore.com/orders/1042/thanks',\n  }),\n});\nif (!res.ok) throw new Error((await res.json()).error.code);\nconst session = await res.json();"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Orders and customers",
   "url": "https://railbed.com/docs/api/checkout-sessions/#orders-and-customers",
   "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-attempt-1\",\n    },\n    json={\n        \"amount\": \"135.31\",\n        \"currency\": \"USD\",\n        \"description\": \"Order #1042\",\n        \"reference\": \"order_1042\",\n        \"customer_email\": \"maya@example.com\",\n        \"customer\": {\n            \"name\": \"Maya Okafor\",\n            \"phone\": \"+1 503 555 0142\",\n            \"id\": \"88\",\n        },\n        \"order\": {\n            \"id\": \"1042\",\n            \"number\": \"1042\",\n            \"url\": \"https://yourstore.com/admin/orders/1042\",\n            \"store\": {\n                \"id\": \"yourstore\",\n                \"name\": \"yourstore.com\",\n                \"url\": \"https://yourstore.com\",\n            },\n            \"items\": [\n                {\n                    \"name\": \"Magnesium glycinate\",\n                    \"variant\": \"120 capsules\",\n                    \"sku\": \"HN-MG-120\",\n                    \"quantity\": 2,\n                    \"unit_amount\": \"32.00\",\n                    \"amount\": \"64.00\",\n                },\n                {\n                    \"name\": \"Daily greens\",\n                    \"sku\": \"HN-DG-30\",\n                    \"quantity\": 1,\n                    \"amount\": \"54.00\",\n                },\n                {\"name\": \"Shaker bottle\", \"quantity\": 1, \"amount\": \"12.00\"},\n            ],\n            \"discount\": \"13.00\",\n            \"discount_code\": \"WELCOME10\",\n            \"shipping\": \"8.95\",\n            \"shipping_method\": \"Standard\",\n            \"tax\": \"9.36\",\n            \"shipping_address\": {\n                \"name\": \"Maya Okafor\",\n                \"line1\": \"418 Linden Avenue\",\n                \"line2\": \"Apt 3B\",\n                \"city\": \"Portland\",\n                \"region\": \"OR\",\n                \"postal_code\": \"97214\",\n                \"country\": \"US\",\n            },\n        },\n        \"success_url\": \"https://yourstore.com/orders/1042/thanks\",\n    },\n    timeout=15,\n)\nr.raise_for_status()\nsession = r.json()"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Orders and customers",
   "url": "https://railbed.com/docs/api/checkout-sessions/#orders-and-customers",
   "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-attempt-1',\n  ],\n  CURLOPT_POSTFIELDS => json_encode([\n    'amount' => '135.31',\n    'currency' => 'USD',\n    'description' => 'Order #1042',\n    'reference' => 'order_1042',\n    'customer_email' => 'maya@example.com',\n    'customer' => [\n      'name' => 'Maya Okafor',\n      'phone' => '+1 503 555 0142',\n      'id' => '88',\n    ],\n    'order' => [\n      'id' => '1042',\n      'number' => '1042',\n      'url' => 'https://yourstore.com/admin/orders/1042',\n      'store' => [\n        'id' => 'yourstore',\n        'name' => 'yourstore.com',\n        'url' => 'https://yourstore.com',\n      ],\n      'items' => [\n        [\n          'name' => 'Magnesium glycinate',\n          'variant' => '120 capsules',\n          'sku' => 'HN-MG-120',\n          'quantity' => 2,\n          'unit_amount' => '32.00',\n          'amount' => '64.00',\n        ],\n        [\n          'name' => 'Daily greens',\n          'sku' => 'HN-DG-30',\n          'quantity' => 1,\n          'amount' => '54.00',\n        ],\n        ['name' => 'Shaker bottle', 'quantity' => 1, 'amount' => '12.00'],\n      ],\n      'discount' => '13.00',\n      'discount_code' => 'WELCOME10',\n      'shipping' => '8.95',\n      'shipping_method' => 'Standard',\n      'tax' => '9.36',\n      'shipping_address' => [\n        'name' => 'Maya Okafor',\n        'line1' => '418 Linden Avenue',\n        'line2' => 'Apt 3B',\n        'city' => 'Portland',\n        'region' => 'OR',\n        'postal_code' => '97214',\n        'country' => 'US',\n      ],\n    ],\n    'success_url' => 'https://yourstore.com/orders/1042/thanks',\n  ]),\n]);\n$session = json_decode(curl_exec($ch), true);"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Orders and customers",
   "url": "https://railbed.com/docs/api/checkout-sessions/#orders-and-customers",
   "language": "JSON",
   "label": "Response",
   "kind": "response",
   "code": "{\n  \"id\": \"pay_Rt5Wm2KxQ8zLb3NvYc7P\",\n  \"object\": \"checkout_session\",\n  \"url\": \"https://pay.railbed.io/p/pay_Rt5Wm2KxQ8zLb3NvYc7P\",\n  \"status\": \"open\",\n  \"livemode\": true,\n  \"amount\": \"135.31\",\n  \"currency\": \"USD\",\n  \"description\": \"Order #1042\",\n  \"reference\": \"order_1042\",\n  \"customer_email\": \"maya@example.com\",\n  \"created\": 1790380525,\n  \"expires_at\": 1790466925,\n  \"started_at\": null,\n  \"metadata\": null,\n  \"order_id\": \"ord_Vd3kR8mQ1xTn6LpZs0Wa\"\n}"
  },
  {
   "page": "api/checkout-sessions",
   "section": "Retrieve a checkout session",
   "url": "https://railbed.com/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.com/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.com/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.com/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.com/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.com/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.com/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.com/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.com/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  \"order_id\": \"ord_M4tQx8ZkP1vRn6WcLs2B\",\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.com/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.com/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.com/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.com/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.com/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  \"order_id\": \"ord_M4tQx8ZkP1vRn6WcLs2B\",\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.com/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.com/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.com/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.com/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.com/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.com/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.com/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.com/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.com/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.com/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/orders",
   "section": "The order object",
   "url": "https://railbed.com/docs/api/orders/#the-order-object",
   "language": "JSON",
   "label": "An order from a store",
   "kind": "response",
   "code": "{\n  \"id\": \"ord_Vd3kR8mQ1xTn6LpZs0Wa\",\n  \"object\": \"order\",\n  \"livemode\": true,\n  \"number\": 1187,\n  \"status\": \"paid\",\n  \"needs_review\": false,\n  \"fulfillment\": \"unfulfilled\",\n  \"fulfilled_at\": null,\n  \"customer_id\": \"cus_Hc7nQ2wVz9KpT4mRb1Ye\",\n  \"currency\": \"USD\",\n  \"subtotal\": \"130.00\",\n  \"discount\": \"13.00\",\n  \"discount_code\": \"WELCOME10\",\n  \"shipping\": \"8.95\",\n  \"shipping_method\": \"Standard\",\n  \"tax\": \"9.36\",\n  \"total\": \"135.31\",\n  \"amount_paid\": \"135.31\",\n  \"items\": [\n    {\n      \"name\": \"Magnesium glycinate\",\n      \"variant\": \"120 capsules\",\n      \"sku\": \"HN-MG-120\",\n      \"quantity\": 2,\n      \"unit_amount\": \"32.00\",\n      \"amount\": \"64.00\"\n    },\n    {\n      \"name\": \"Daily greens\",\n      \"variant\": null,\n      \"sku\": \"HN-DG-30\",\n      \"quantity\": 1,\n      \"unit_amount\": null,\n      \"amount\": \"54.00\"\n    },\n    {\n      \"name\": \"Shaker bottle\",\n      \"variant\": null,\n      \"sku\": null,\n      \"quantity\": 1,\n      \"unit_amount\": null,\n      \"amount\": \"12.00\"\n    }\n  ],\n  \"shipping_address\": {\n    \"name\": \"Maya Okafor\",\n    \"line1\": \"418 Linden Avenue\",\n    \"line2\": \"Apt 3B\",\n    \"city\": \"Portland\",\n    \"region\": \"OR\",\n    \"postal_code\": \"97214\",\n    \"country\": \"US\"\n  },\n  \"billing_address\": null,\n  \"store\": {\n    \"platform\": \"other\",\n    \"id\": \"yourstore\",\n    \"name\": \"yourstore.com\",\n    \"url\": \"https://yourstore.com\",\n    \"order_id\": \"1042\",\n    \"order_number\": \"1042\",\n    \"order_url\": \"https://yourstore.com/admin/orders/1042\",\n    \"customer_id\": \"88\"\n  },\n  \"payment_ids\": [\"pay_Rt5Wm2KxQ8zLb3NvYc7P\"],\n  \"created\": 1790380525,\n  \"paid_at\": 1790381342\n}"
  },
  {
   "page": "api/orders",
   "section": "Retrieve an order",
   "url": "https://railbed.com/docs/api/orders/#retrieve-an-order",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl https://pay.railbed.io/v1/orders/ord_Vd3kR8mQ1xTn6LpZs0Wa \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\""
  },
  {
   "page": "api/orders",
   "section": "Retrieve an order",
   "url": "https://railbed.com/docs/api/orders/#retrieve-an-order",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const order = await fetch(`https://pay.railbed.io/v1/orders/${id}`, {\n  headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` },\n}).then((r) => r.json());"
  },
  {
   "page": "api/orders",
   "section": "Retrieve an order",
   "url": "https://railbed.com/docs/api/orders/#retrieve-an-order",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "order = requests.get(\n    f\"https://pay.railbed.io/v1/orders/{id}\",\n    headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n    timeout=15,\n).json()"
  },
  {
   "page": "api/orders",
   "section": "Retrieve an order",
   "url": "https://railbed.com/docs/api/orders/#retrieve-an-order",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$ch = curl_init('https://pay.railbed.io/v1/orders/' . rawurlencode($id));\ncurl_setopt_array($ch, [\n  CURLOPT_RETURNTRANSFER => true,\n  CURLOPT_HTTPHEADER => [\n    'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),\n  ],\n]);\n$order = json_decode(curl_exec($ch), true);"
  },
  {
   "page": "api/orders",
   "section": "List orders",
   "url": "https://railbed.com/docs/api/orders/#list-orders",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl \"https://pay.railbed.io/v1/orders?store_id=yourstore&store_order_id=1042\" \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\""
  },
  {
   "page": "api/orders",
   "section": "List orders",
   "url": "https://railbed.com/docs/api/orders/#list-orders",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const url = new URL('https://pay.railbed.io/v1/orders');\nurl.searchParams.set('store_id', 'yourstore');\nurl.searchParams.set('store_order_id', '1042');\nconst page = await fetch(url, {\n  headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` },\n}).then((r) => r.json());\nconst order = page.data[0] ?? null;"
  },
  {
   "page": "api/orders",
   "section": "List orders",
   "url": "https://railbed.com/docs/api/orders/#list-orders",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "page = requests.get(\n    \"https://pay.railbed.io/v1/orders\",\n    headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n    params={\"store_id\": \"yourstore\", \"store_order_id\": \"1042\"},\n    timeout=15,\n).json()\norder = page[\"data\"][0] if page[\"data\"] else None"
  },
  {
   "page": "api/orders",
   "section": "List orders",
   "url": "https://railbed.com/docs/api/orders/#list-orders",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$query = http_build_query([\n  'store_id' => 'yourstore',\n  'store_order_id' => '1042',\n]);\n$ch = curl_init('https://pay.railbed.io/v1/orders?' . $query);\ncurl_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$order = $page['data'][0] ?? null;"
  },
  {
   "page": "api/orders",
   "section": "List orders",
   "url": "https://railbed.com/docs/api/orders/#list-orders",
   "language": "JSON",
   "label": "Response (trimmed)",
   "kind": "response",
   "code": "{\n  \"data\": [\n    {\n      \"id\": \"ord_Vd3kR8mQ1xTn6LpZs0Wa\",\n      \"object\": \"order\",\n      \"number\": 1187,\n      \"status\": \"paid\",\n      \"fulfillment\": \"unfulfilled\",\n      \"total\": \"135.31\"\n    }\n  ],\n  \"has_more\": false,\n  \"next_cursor\": null\n}"
  },
  {
   "page": "api/orders",
   "section": "Update an order",
   "url": "https://railbed.com/docs/api/orders/#update-an-order",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl -X PATCH \\\n  https://pay.railbed.io/v1/orders/ord_Vd3kR8mQ1xTn6LpZs0Wa \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"fulfillment\": \"fulfilled\" }'"
  },
  {
   "page": "api/orders",
   "section": "Update an order",
   "url": "https://railbed.com/docs/api/orders/#update-an-order",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const order = await fetch(`https://pay.railbed.io/v1/orders/${id}`, {\n  method: 'PATCH',\n  headers: {\n    Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({ fulfillment: 'fulfilled' }),\n}).then((r) => r.json());"
  },
  {
   "page": "api/orders",
   "section": "Update an order",
   "url": "https://railbed.com/docs/api/orders/#update-an-order",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "order = requests.patch(\n    f\"https://pay.railbed.io/v1/orders/{id}\",\n    headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n    json={\"fulfillment\": \"fulfilled\"},\n    timeout=15,\n).json()"
  },
  {
   "page": "api/orders",
   "section": "Update an order",
   "url": "https://railbed.com/docs/api/orders/#update-an-order",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$ch = curl_init('https://pay.railbed.io/v1/orders/' . rawurlencode($id));\ncurl_setopt_array($ch, [\n  CURLOPT_CUSTOMREQUEST => 'PATCH',\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(['fulfillment' => 'fulfilled']),\n]);\n$order = json_decode(curl_exec($ch), true);"
  },
  {
   "page": "api/orders",
   "section": "Report a cancellation or refund",
   "url": "https://railbed.com/docs/api/orders/#report-a-cancellation-or-refund",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl https://pay.railbed.io/v1/orders/ord_Vd3kR8mQ1xTn6LpZs0Wa/events \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"type\": \"refunded\", \"id\": \"refund-5521\", \"amount\": \"12.00\" }'"
  },
  {
   "page": "api/orders",
   "section": "Report a cancellation or refund",
   "url": "https://railbed.com/docs/api/orders/#report-a-cancellation-or-refund",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const event = await fetch(`https://pay.railbed.io/v1/orders/${id}/events`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({ type: 'refunded', id: 'refund-5521', amount: '12.00' }),\n}).then((r) => r.json());"
  },
  {
   "page": "api/orders",
   "section": "Report a cancellation or refund",
   "url": "https://railbed.com/docs/api/orders/#report-a-cancellation-or-refund",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "event = requests.post(\n    f\"https://pay.railbed.io/v1/orders/{id}/events\",\n    headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n    json={\"type\": \"refunded\", \"id\": \"refund-5521\", \"amount\": \"12.00\"},\n    timeout=15,\n).json()"
  },
  {
   "page": "api/orders",
   "section": "Report a cancellation or refund",
   "url": "https://railbed.com/docs/api/orders/#report-a-cancellation-or-refund",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$ch = curl_init('https://pay.railbed.io/v1/orders/' . rawurlencode($id) . '/events');\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(['type' => 'refunded', 'id' => 'refund-5521', 'amount' => '12.00']),\n]);\n$event = json_decode(curl_exec($ch), true);"
  },
  {
   "page": "api/orders",
   "section": "Report a cancellation or refund",
   "url": "https://railbed.com/docs/api/orders/#report-a-cancellation-or-refund",
   "language": "JSON",
   "label": "The recorded event",
   "kind": "response",
   "code": "{\n  \"id\": \"oev_Tb8vN3qLx5WmR2kZc9Hd\",\n  \"object\": \"order_event\",\n  \"order_id\": \"ord_Vd3kR8mQ1xTn6LpZs0Wa\",\n  \"type\": \"refunded\",\n  \"store_event_id\": \"refund-5521\",\n  \"amount\": \"12.00\",\n  \"currency\": \"USD\",\n  \"created\": 1790467742\n}"
  },
  {
   "page": "api/customers",
   "section": "The customer object",
   "url": "https://railbed.com/docs/api/customers/#the-customer-object",
   "language": "JSON",
   "label": "A customer",
   "kind": "response",
   "code": "{\n  \"id\": \"cus_Hc7nQ2wVz9KpT4mRb1Ye\",\n  \"object\": \"customer\",\n  \"livemode\": true,\n  \"email\": \"maya@example.com\",\n  \"name\": \"Maya Okafor\",\n  \"phone\": \"+1 503 555 0142\",\n  \"shipping_address\": {\n    \"name\": \"Maya Okafor\",\n    \"line1\": \"418 Linden Avenue\",\n    \"line2\": \"Apt 3B\",\n    \"city\": \"Portland\",\n    \"region\": \"OR\",\n    \"postal_code\": \"97214\",\n    \"country\": \"US\"\n  },\n  \"billing_address\": null,\n  \"store_accounts\": [],\n  \"orders_count\": 3,\n  \"paid_orders_count\": 2,\n  \"spent\": { \"USD\": \"224.31\" },\n  \"created\": 1787702525,\n  \"last_order_at\": 1790380525,\n  \"erased\": false\n}"
  },
  {
   "page": "api/customers",
   "section": "Retrieve a customer",
   "url": "https://railbed.com/docs/api/customers/#retrieve-a-customer",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl https://pay.railbed.io/v1/customers/cus_Hc7nQ2wVz9KpT4mRb1Ye \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\""
  },
  {
   "page": "api/customers",
   "section": "Retrieve a customer",
   "url": "https://railbed.com/docs/api/customers/#retrieve-a-customer",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const customer = await fetch(`https://pay.railbed.io/v1/customers/${id}`, {\n  headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` },\n}).then((r) => r.json());"
  },
  {
   "page": "api/customers",
   "section": "Retrieve a customer",
   "url": "https://railbed.com/docs/api/customers/#retrieve-a-customer",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "customer = requests.get(\n    f\"https://pay.railbed.io/v1/customers/{id}\",\n    headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n    timeout=15,\n).json()"
  },
  {
   "page": "api/customers",
   "section": "Retrieve a customer",
   "url": "https://railbed.com/docs/api/customers/#retrieve-a-customer",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$ch = curl_init('https://pay.railbed.io/v1/customers/' . rawurlencode($id));\ncurl_setopt_array($ch, [\n  CURLOPT_RETURNTRANSFER => true,\n  CURLOPT_HTTPHEADER => [\n    'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),\n  ],\n]);\n$customer = json_decode(curl_exec($ch), true);"
  },
  {
   "page": "api/customers",
   "section": "List customers",
   "url": "https://railbed.com/docs/api/customers/#list-customers",
   "language": "cURL",
   "label": "cURL",
   "kind": "code",
   "code": "curl -G https://pay.railbed.io/v1/customers \\\n  --data-urlencode \"email=maya@example.com\" \\\n  -H \"Authorization: Bearer $RAILBED_SECRET_KEY\""
  },
  {
   "page": "api/customers",
   "section": "List customers",
   "url": "https://railbed.com/docs/api/customers/#list-customers",
   "language": "Node.js",
   "label": "Node.js",
   "kind": "code",
   "code": "const url = new URL('https://pay.railbed.io/v1/customers');\nurl.searchParams.set('email', 'maya@example.com');\nconst page = await fetch(url, {\n  headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` },\n}).then((r) => r.json());\nconst customer = page.data[0] ?? null;"
  },
  {
   "page": "api/customers",
   "section": "List customers",
   "url": "https://railbed.com/docs/api/customers/#list-customers",
   "language": "Python",
   "label": "Python",
   "kind": "code",
   "code": "page = requests.get(\n    \"https://pay.railbed.io/v1/customers\",\n    headers={\"Authorization\": f\"Bearer {os.environ['RAILBED_SECRET_KEY']}\"},\n    params={\"email\": \"maya@example.com\"},\n    timeout=15,\n).json()\ncustomer = page[\"data\"][0] if page[\"data\"] else None"
  },
  {
   "page": "api/customers",
   "section": "List customers",
   "url": "https://railbed.com/docs/api/customers/#list-customers",
   "language": "PHP",
   "label": "PHP",
   "kind": "code",
   "code": "<?php\n$query = http_build_query(['email' => 'maya@example.com']);\n$ch = curl_init('https://pay.railbed.io/v1/customers?' . $query);\ncurl_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$customer = $page['data'][0] ?? null;"
  },
  {
   "page": "api/customers",
   "section": "List customers",
   "url": "https://railbed.com/docs/api/customers/#list-customers",
   "language": "JSON",
   "label": "Response (trimmed)",
   "kind": "response",
   "code": "{\n  \"data\": [\n    {\n      \"id\": \"cus_Hc7nQ2wVz9KpT4mRb1Ye\",\n      \"object\": \"customer\",\n      \"email\": \"maya@example.com\",\n      \"name\": \"Maya Okafor\",\n      \"paid_orders_count\": 2\n    }\n  ],\n  \"has_more\": false,\n  \"next_cursor\": null\n}"
  },
  {
   "page": "api/errors",
   "section": "The error object",
   "url": "https://railbed.com/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.com/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.com/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.com/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.com/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.com/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      \"orderId\": \"ord_M4tQx8ZkP1vRn6WcLs2B\"\n    },\n    \"order\": {\n      \"id\": \"ord_M4tQx8ZkP1vRn6WcLs2B\",\n      \"mode\": \"live\",\n      \"number\": 1186,\n      \"displayNumber\": \"#1186\",\n      \"source\": \"api\",\n      \"checkoutId\": null,\n      \"store\": null,\n      \"status\": \"paid\",\n      \"needsReview\": false,\n      \"fulfillment\": \"none\",\n      \"fulfilledAt\": null,\n      \"customer\": {\n        \"id\": \"cus_Q9wLm3TzX7bKc2VnR5Hd\",\n        \"email\": \"buyer@example.com\",\n        \"name\": null,\n        \"erased\": false\n      },\n      \"summary\": \"Pro Membership\",\n      \"lineCount\": 1,\n      \"unitCount\": 1,\n      \"currency\": \"USD\",\n      \"subtotal\": \"49.00\",\n      \"discount\": \"0.00\",\n      \"discountCode\": null,\n      \"shipping\": \"0.00\",\n      \"shippingMethod\": null,\n      \"tax\": \"0.00\",\n      \"total\": \"49.00\",\n      \"amountPaid\": \"49.00\",\n      \"received\": \"46.341750\",\n      \"shipTo\": null,\n      \"billTo\": null,\n      \"reference\": \"order_1042\",\n      \"paymentId\": \"pay_7AAiYH0Ykt11ED4hmfiN\",\n      \"attemptCount\": 1,\n      \"createdAt\": 1790380525000,\n      \"updatedAt\": 1790381342000,\n      \"paidAt\": 1790381342000,\n      \"items\": [\n        {\n          \"name\": \"Pro Membership\",\n          \"variant\": null,\n          \"sku\": null,\n          \"quantity\": 1,\n          \"unitAmount\": \"49.00\",\n          \"amount\": \"49.00\"\n        }\n      ]\n    },\n    \"customer\": {\n      \"id\": \"cus_Q9wLm3TzX7bKc2VnR5Hd\",\n      \"mode\": \"live\",\n      \"email\": \"buyer@example.com\",\n      \"name\": null,\n      \"phone\": null,\n      \"location\": null,\n      \"shipTo\": null,\n      \"billTo\": null,\n      \"storeAccounts\": [],\n      \"firstSeenAt\": 1790380525000,\n      \"erased\": false\n    }\n  }\n}"
  },
  {
   "page": "webhooks/events",
   "section": "payment.started",
   "url": "https://railbed.com/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.com/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.com/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, \"order\": null, \"customer\": null }\n}"
  },
  {
   "page": "webhooks/events",
   "section": "The order object",
   "url": "https://railbed.com/docs/webhooks/events/#the-order-object",
   "language": "JSON",
   "label": "data.order for a store order (trimmed)",
   "kind": "response",
   "code": "{\n  \"id\": \"ord_Vd3kR8mQ1xTn6LpZs0Wa\",\n  \"displayNumber\": \"#1042\",\n  \"store\": {\n    \"platform\": \"woocommerce\",\n    \"id\": \"3f6c1a2e-8b4d-4e9a-9c7f-2d5b8e1a4c60\",\n    \"name\": \"yourstore.com\",\n    \"url\": \"https://yourstore.com/\",\n    \"orderId\": \"1042\",\n    \"orderNumber\": \"1042\",\n    \"orderUrl\": \"https://yourstore.com/wp-admin/post.php?post=1042&action=edit\",\n    \"customerId\": \"88\"\n  },\n  \"status\": \"paid\",\n  \"fulfillment\": \"unfulfilled\",\n  \"total\": \"135.31\",\n  \"shipTo\": {\n    \"name\": \"Maya Okafor\",\n    \"line1\": \"418 Linden Avenue\",\n    \"line2\": \"Apt 3B\",\n    \"city\": \"Portland\",\n    \"region\": \"OR\",\n    \"postalCode\": \"97214\",\n    \"country\": \"US\"\n  },\n  \"items\": [\n    {\n      \"name\": \"Magnesium glycinate\",\n      \"variant\": \"120 capsules\",\n      \"sku\": \"HN-MG-120\",\n      \"quantity\": 2,\n      \"unitAmount\": \"32.00\",\n      \"amount\": \"64.00\"\n    }\n  ]\n}"
  },
  {
   "page": "webhooks/signatures",
   "section": "Verify a delivery",
   "url": "https://railbed.com/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.com/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.com/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.com/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.com/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.com/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.com/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.com/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'"
  }
 ]
}
