Skip to content

Money movement

PaymentsBeta

Railor sends payouts through the provider accounts you connect — Railor never holds funds. Every payment is evaluated against your active policy, routed across eligible providers, submitted with an idempotency key recorded before the call, and tracked to settlement by provider webhooks and reconciliation.

Test mode and live mode

  • A rail_test_… key creates test payments; a rail_live_… key creates live ones. They never mix.
  • Test payments run against a provider's sandbox API when you've connected its sandbox, and against Railor's simulator otherwise.
  • Live payments require all of: the deployment's live switch, your workspace approved for live by Railor (with per-payment and daily limits), a production connection, and a provider Railor has approved for live payouts after sandbox evidence.

Quickstart

# 1. Save who you're paying (validated, encrypted, deduplicated)
curl http://localhost:3000/v1/beneficiaries \
  -H "Authorization: Bearer rail_test_your_key_here" -H "Content-Type: application/json" \
  -d '{"holder_type":"business","holder_name":"Dubai Supplier LLC","country":"AE","currency":"AED",
       "method":"iban","details":{"iban":"AE070331234567890123456","bank_name":"Emirates Bank"}}'

# 2. Create the payment: policy check + route plan. Nothing is sent yet.
curl http://localhost:3000/v1/payments \
  -H "Authorization: Bearer rail_test_your_key_here" -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-2041" \
  -d '{"beneficiary_id":"ben_…","intent":{"source_entity_country":"IN","source_asset":"USDC",
       "source_network":"base","destination_country":"AE","destination_currency":"AED","amount":1000}}'

# 3. Send it
curl -X POST http://localhost:3000/v1/payments/pay_…/submit -H "Authorization: Bearer rail_test_your_key_here"

Test keys only ever create test payments. Retrying a create with the same Idempotency-Key returns the original payment.

Payment states

requires_approval
Your policy wants an independent approval first (Approvals in the app).
ready
Allowed by policy and routed. Nothing has been sent — call submit.
blocked
Policy denied it, or no eligible provider can execute it. The reason is on the payment.
submitting
Railor is calling the provider right now.
awaiting_funds
The provider accepted it and is waiting for the source funds (deposit instructions on the payment).
processing
The provider has it and is moving the money.
completed
The provider reports the payout settled.
failed
Every provider tried said no — definitively. The last rejection is on the payment.
returned
It settled and then came back (the beneficiary bank returned it).
unknown
A provider call timed out or errored ambiguously. Railor never re-sends this elsewhere; reconciliation asks the provider (replaying the same idempotency key) until it knows.
cancelled
Cancelled before any provider had it.

Routing

Eligibility and your policy are gates — a provider that doesn't pass is excluded with its reason. The rest are scored on health 25 · reliability 25 · cost 20 · speed 15 · limits 10 · preference 5 (or the Cheapest / Fastest / Most reliable presets). A dimension with no real data drops out and lowers the reported confidence rather than being guessed. On a definitive rejection Railor tries the next provider; on an ambiguous one it never does. POST /v1/routes shows the plan without creating anything.

Price check

POST /v1/prices (and Price check in the app) answers “send X, how much arrives, through whom?”. Each row carries its basis, because the numbers are not equally strong:

exact
A live quote from your own connected account (Wise, Airwallex) — the price you'd actually pay.
live_public
A live quote anyone can get from the provider's API (Wise's public quote). Your account's price can differ.
published
The provider's published fee schedule (PayZoll, Skydo), applied at the reference rate, with the date it was read and a link to it.
market_estimate
Consumer prices Wise's comparison feed collected from other providers' public sites, dated. Opt in with include_market.

Rows are ranked by recipient_amount. A quote missing a cost component (Airwallex's FX quote has no transfer fee in it) is marked cost_complete: false and listed after complete ones; market estimates are listed last and never counted as the best price.

Sandbox scenarios

In test mode, when a provider isn't connected in sandbox, Railor's simulator picks the outcome from the amount's cents:

.13
Rejected: insufficient funds — retryable, so routing falls back to the next provider
.66
Rejected: compliance — never retried elsewhere
.55
Accepted, awaiting funds, then processing, then completed
.77
Completed, then returned by the beneficiary bank
.99
Outcome unknown (simulated timeout), resolved by reconciliation
other
Processing, then completed about 20 seconds later

Webhooks

Add an endpoint under Developers (or POST /v1/webhook_endpoints). Every event is signed; verify before trusting it:

import { createHmac, timingSafeEqual } from "node:crypto"

export function verifyRailor(secret: string, header: string, rawBody: string) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")))
  const t = Number(parts.t)
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false   // stale → reject
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
  return expected.length === parts.v1?.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}

// header: request.headers["railor-signature"]  →  "t=1790000000,v1=5f2…"

Events: payment.created, payment.requires_approval, payment.ready, payment.blocked, payment.submitted, payment.awaiting_funds, payment.processing, payment.completed, payment.failed, payment.returned, payment.cancelled, payment.unknown.