Quickstart
Create an organisation and a test key, install the SDK, link and register accounts, quote a route, pay it, and verify the webhook that follows.
This walks through one payment in the sandbox environment, from your backend. Every call uses a secret key, so none of this code belongs in a browser or a mobile app.
Create an organisation and a test key
Sign in at app.unirail.dev and create an organisation. It starts with two environments: sandbox in test mode and production in live mode.
In sandbox, open API keys and create a secret key. It looks like ur_test_sk_… and is shown once, so put it straight into your own secrets manager as UNIRAIL_SECRET_KEY.
Connect a provider sandbox
Unirail calls providers with your credentials, read from your Infisical at call time. Follow Connect Infisical with OIDC to bind the sandbox environment to a folder in your vault, then add a connection under Connections, for example Yapily with provider environment sandbox. The connection's check tells you which credential keys it found.
Install the SDK
npm i @unirail/sdkimport { createUnirail } from "@unirail/sdk";
export const unirail = createUnirail({ apiKey: process.env.UNIRAIL_SECRET_KEY! });createUnirail refuses anything that isn't a secret key, and pins the API version the SDK was built against (see versioning).
Create a customer
A customer is your user, by reference. Pass your own user id as externalId; calling again with the same id returns the same customer.
const customer = await unirail.customers.create(
{ externalId: user.id, name: user.name, email: user.email, country: "GB" },
{ context: { idempotencyKey: `customer:${user.id}` } },
);
// Store customer.id (cus_…) on your user row.Link the payer's bank account
Show your user the banks this environment can reach, then start a link session for the one they pick.
const { data: banks } = await unirail.institutions.list({ country: "GB", q: "monzo" });
const session = await unirail.linkSessions.create(
{
customer: customer.id,
country: "GB",
institution: banks[0].id,
returnUri: "https://example.com/bank-link/done",
},
{ context: { idempotencyKey: `link:${attempt.id}` } },
);
if (session.nextAction?.kind === "redirect") {
// Send the user to their bank. They come back to returnUri.
}When the user lands on your return URI, hand Unirail the query parameters it received:
const linked = await unirail.linkSessions.advance({
id: session.id,
returnParams: Object.fromEntries(new URL(request.url).searchParams),
});
// linked.status === "completed" → linked.accounts holds acct_… idsSome providers open their own SDK instead of redirecting (nextAction.kind === "sdk"), and some banks ask the user to approve elsewhere. Link sessions lists every action and what to do with it.
Register the payee's account
A payee who gave you their account details doesn't need to link anything. Register the details as a payto:// address:
const payee = await unirail.accounts.create(
{
holderName: "Ada Lovelace",
payto: "payto://scan/040004/12345678?receiver-name=Ada%20Lovelace",
},
{ context: { idempotencyKey: `payee:${contact.id}` } },
);
// payee.addresses[0].masked === "•••• 5678"Unirail encrypts the identifier and only ever returns the masked form and a fingerprint.
Quote the routes
const result = await unirail.routeQuotes.create({
from: linked.accounts[0],
to: payee.id,
amount: { value: "5000", asset: "iso4217:GBP" }, // £50.00, in minor units
});
for (const quote of result.quotes) {
console.log(quote.label, quote.legs.map((leg) => leg.rail), quote.totalCost.payer, quote.eta.p50Seconds);
}
for (const route of result.infeasible) {
console.log(route.rail, route.reasons.map((reason) => reason.explanation.message));
}Show the quotes to your user, labelled Cheapest, Fastest or Recommended, and let them pick. If nothing is feasible, result.suggestion is link-another-account.
Pay the approved quote
Bind your user's approval to the quote's id and digest, for example by deriving a passkey challenge from them. Then create the payment intent with both:
const intent = await unirail.paymentIntents.create(
{
quote: quote.id,
digest: quote.digest,
returnUri: "https://example.com/pay/done",
reference: "Dinner",
metadata: { paymentId: payment.id },
},
{ context: { idempotencyKey: `payment:${payment.id}` } },
);
if (intent.nextAction?.kind === "redirect") {
// The payer approves at their bank, then returns to returnUri.
}After the user returns, call unirail.paymentIntents.advance({ id: intent.id, returnParams }). From then on Unirail drives the legs, and you find out how it ends from events. If the quote expired or what the user approved no longer holds, the call fails with quote_expired or quote_changed: quote again and ask again.
Verify the webhook
In the dashboard, open Webhooks, add your endpoint URL and copy its signing secret (whsec_…) into your secrets as UNIRAIL_WEBHOOK_SECRET. Verify every delivery against the raw body:
import { webhooks } from "@unirail/sdk";
export async function POST(request: Request) {
const event = await webhooks.verify({
body: await request.text(),
headers: request.headers,
secret: process.env.UNIRAIL_WEBHOOK_SECRET!,
});
// Deliveries can repeat: dedupe on the webhook-id header.
if (await seen(request.headers.get("webhook-id"))) return new Response(null, { status: 204 });
switch (event.type) {
case "payment_intent.succeeded":
case "payment_intent.failed":
await updatePayment(event.data.object);
break;
}
return new Response(null, { status: 204 });
}webhooks.verify throws if the signature doesn't match or the timestamp is more than five minutes off.
Next
- Route quotes explains labels, legs, custody and the digest.
- Events and webhooks covers replaying events you missed.
- Going live is the checklist for the
productionenvironment.