Integrate Railbed into an existing app

The short path for adding Railbed to an app that already sells. Who handles the keys, the smallest safe webhook receiver, what each status means, safe retries, what the API can't do yet, reconciliation and what buyers see.

On this page

Who does what

Two secrets connect your app to Railbed, and a person who owns or develops the business handles both in the dashboard. Your coding agent never needs to see them: it writes code that reads them from your server's environment by name.

StepWhoWhere
Create a secret key: rb_test_… to build, rb_live_… to go liveAn owner or developerDevelopers → Create key
Add the webhook endpointAn owner or developer, or an assistant through the MCP server with your confirmationDevelopers → Add endpoint
Reveal the endpoint's signing secret (whsec_…)An owner or developerThe endpoint's card → Reveal signing secret
Put both into the app's server configuration, such as RAILBED_SECRET_KEY and RAILBED_WEBHOOK_SECRETAn owner or developerYour host's secret settings or environment
Write and test the codeYou or your coding agentYour app

Keep both secrets out of chats, prompts and repositories. If one ends up somewhere it shouldn't, replace it in Developers (create a new key and revoke the old one, or roll the endpoint's secret), then update your server. For configuration checks, Secret keys gives the key's format and How deliveries are signed the signing secret's.

An assistant connected through the MCP server can add or edit the endpoint (you confirm each change in Railbed), read payments and orders, and list an endpoint's deliveries with each one's outcome and HTTP status. It never returns secret keys or signing secrets.

Secret keys and agent tokens

The API accepts two kinds of credential. A coding agent connected through agent sign-in gets its own access tokens, which last about five minutes and which it renews itself. Approved in Test with Full access, it can create sessions, simulate payments and read the results while you build, with no key to paste. Your production server uses a secret key: it belongs to the business and works until someone revokes it. An agent's access ends for good when the person who approved it leaves the business or loses the role that manages keys, so don't run production on it.

The minimum safe receiver

  1. Read the request's raw body before any JSON parsing, and verify Railbed-Signature against it with the signing secret. Reject a bad signature, or a timestamp more than five minutes from your clock, with a 4xx. See Verify signatures.
  2. Deduplicate on the event id: record it with a unique constraint together with the event's effects (or with the queued job that carries them out), and answer a duplicate with 2xx. Recorded on its own first, an event whose handling then failed would be skipped when it comes again.
  3. Beyond its id, take only the event's type, livemode and data.payment.id (and, for a Test event, its payment's metadata.sample, to skip samples). An endpoint only receives events of its own mode, so a livemode that doesn't match your key's mode means the server has the other mode's key or secret: fail the delivery so it shows in the log.
  4. Read the payment with GET /v1/payments/:id and act on that answer. Fulfil only when it says paid, once per payment, after matching it to your saved order (the checks).
  5. Answer 2xx within 10 seconds, only after the event is handled. A slower answer, or a redirect, counts as a failure and is retried. If handling fails, answer 5xx and the same event comes again.

If fulfilment can take more than a second or two, record the event, answer 2xx and run step 4 in a background job instead, as the receivers on the Webhooks page do.

Node.js: after the signature is verified
// railbed() is the helper from /docs/api/errors/,
// fulfilFromPayment() is from /docs/guides/fulfilment/
const LIVE = process.env.RAILBED_SECRET_KEY.startsWith('rb_live_');

async function handleEvent(event) {
  if (event.livemode !== LIVE) {
    // This endpoint's mode isn't the key's: fail, so the delivery log shows it
    throw new Error('mode mismatch: check RAILBED_SECRET_KEY and RAILBED_WEBHOOK_SECRET');
  }
  if (!event.type.startsWith('payment.')) return; // ping, or a type you don't handle
  let payment;
  try {
    payment = await railbed(`/payments/${event.data.payment.id}`);
  } catch (err) {
    // Test samples from "Send test event" carry an id the API doesn't know
    const sample = event.data.payment.metadata?.sample === 'true';
    if (err.status === 404 && !event.livemode && sample) return;
    throw err; // answer 5xx, so the event is retried
  }
  await fulfilFromPayment(payment); // does nothing unless status is 'paid'
}

Every status, and what to do

StatusWhat it meansWhat to do
openCreated, or started but not yet at a providerWait
pendingThe buyer went to a provider to pay, or was shown a crypto addressWait
paidThe money arrived and passed Railbed's checksFulfil, once
heldMoney arrived but failed a checkDon't fulfil. Review it: when hold_acceptable is true, Accept as paid in the dashboard makes it paid and sends payment.paid
failedA terminal failure (Test mode only today)Close the attempt. A retry needs a new session
expiredNot paid in time: 24 hours for API sessionsClose it on your side, but keep listening: money that arrives late still settles, and the payment becomes paid or held
Anything elseA status this table doesn't listTreat it as not paid and keep reconciling

How payments work explains each status. A read right after an event can already be past it: Reading the payment back lists what to expect after each one. Right after payment.started, for example, the payment still reads open.

Railbed only adds to v1, and your code should expect that: ignore fields and event types it doesn't know, branch on the error codes it knows and fall back on the HTTP status for others, and never treat a status other than paid as paid. See Versioning.

Retry creates safely

One rule: save the exact request body with its Idempotency-Key before the first call, and send that same body on every retry.

  • 409 idempotency_in_progress: the first request with that key hasn't finished. Wait Retry-After seconds and send the same request again.
  • 409 idempotency_conflict: the body differs from the first one. Never retry it with the same key.

The key's fingerprint covers amount, currency, description, the buyer's email (customer_email or customer.email), reference, success_url, cancel_url and metadata (key order doesn't matter; spaces inside values do), plus every field of order and customer when the request sent either. Values are compared after normalization, so "49" and "49.00" match, but rebuilding a retry from current data turns a changed email, a renamed item or a new return-URL domain into a conflict.

If a create timed out before you saved the pay_… id, retry it with the same key and body within 24 hours: you get the session if it was made, or it's created then if it wasn't. After 24 hours, or if the key was lost, GET /v1/payments?reference=… lists the sessions created with your reference, so check it before creating another. Idempotency has the full rules.

What the API can't do yet

Cancel a checkout session

There's no cancel endpoint for checkout sessions. To close one, mark it closed in your own system and stop offering its url. Its hosted page stays payable until the session's expires_at, 24 hours after it was created, and a buyer who started paying before then can still finish later. So when a session you closed turns paid, honour it for that session: fulfil it, or refund it from your wallet. When you create a newer session for the same purchase, send the buyer only to the newest url.

Refunds

There's no refund endpoint, and Railbed can't reverse a settled payment. Refund the buyer yourself, then record the refund on the order with POST /v1/orders/:id/events, type refunded, under your own stable event id. The record changes no status, sends no webhook and moves no money; it puts the refund on the order's timeline for your team. Record a refund has the steps and code.

Reconcile in the background

Webhooks can miss: a server that was down for longer than the retries, or a deploy that dropped a request. Run a background job that rechecks your saved, unresolved payment ids with GET /v1/payments/:id: everything open, pending or held, and anything that expired in the last two days.

Budget it. The limit is about 120 requests a minute per mode, shared by all of the account's secret keys. It's approximate, and responses carry no rate-limit headers today, so stay well below it: for example, one pass every 5 minutes over a bounded batch, and buyer status screens that read Railbed at most once per payment every 10 seconds. On a 429, wait Retry-After seconds. Check unfinished orders in the background has the schedule.

What buyers can pay with

Buyers pay by card, or with another method the provider they use supports, such as Apple Pay, Google Pay, PayPal or a bank transfer, always on the provider's own page. Each provider in a start response has an id and a note saying how buyers pay with it, as Railbed's checkout shows it. Some of today's:

idnote
stripeCard, Apple Pay or Google Pay
paypalPayPal balance or card
cashappCash App balance or card
coinbaseCard or Coinbase account
rampnetworkCard, Apple Pay or bank transfer

Which providers a buyer sees depends on their country, the amount, the currency and each provider's availability, so the way to know is what the hosted page or the start response offers. When the merchant turns on crypto payments, the hosted page also lets buyers send stablecoins; card stays first, and an API session reaches crypto only on its hosted url.

Minimum amounts

Sessions accept amounts from "1.00", but each provider has its own minimum. When an amount is below every eligible provider's minimum, POST /v1/checkout_sessions/:id/start answers 409 no_providers and starts nothing, and the hosted page tells the buyer that no card provider can take the amount right now. The minimums are the providers' and they change: in Live mode, Integrations → Card providers in the dashboard shows each one's current minimum (Test mode shows a fixed list). Check your lowest price against it.

What the buyer sees

A hosted checkout involves three tabs:

  1. Your page. The buyer chooses to buy, and you send them to the session's url.
  2. Railbed's checkout tab. It shows your business, the description and the price, takes the buyer's email and, from the buyer's click, opens the chosen provider in a new tab. It stays open and updates by itself.
  3. The provider's tab. The buyer pays there. Providers don't send buyers back: their last page may be the provider's own or another company's.

Once the verified settlement marks the payment paid, Railbed's tab sends the buyer to your success_url. If the buyer closed that tab, no redirect happens, so your pages should get the status from your server. If the browser blocks the new tab, the provider opens in the same tab, and the buyer sees your page only if they come back. Hosted checkout has the details.

  • Card statement. The provider charges the card under its own terms and sets the statement descriptor, so the charge carries the provider's name. Say so near your buy button or in your order email, so buyers recognize the charge.
  • Receipts. Railbed doesn't email buyers a receipt. Its checkout shows an on-screen confirmation when there's no success_url, and the provider may send its own receipt to the email the buyer gave. Send your own order confirmation from your app.

Test without moving money

  • Use a Test key and a Test endpoint. Everything is simulated, and Test webhooks are real, signed HTTP requests to your endpoint.
  • Start a session and call POST /v1/payments/:id/simulate with paid, underpaid, declined or failed to run each path through the same checks and events as a live payment. Testing has the test plan.
  • Send test event in Developers sends a sample with a made-up payment: its metadata.sample is "true" and its pay_… id isn't in the API, so a receiver that re-reads it gets 404 not_found. Acknowledge a sample with 2xx instead of failing, or the delivery shows as failed. See Test events.
  • A coding agent connected through agent sign-in, in Test with Full access, can create and simulate payments on its own.

Next

Updated · This page as Markdown