Decide mode
Unirail decides which provider an operation should use and what it will cost, from masked metadata. Your backend calls the provider with your own credentials and reports how it went.
You hold the licences, the provider contracts and the compliance duties, and you may not want payments or personal data passing through anyone else. In Decide mode Unirail never sees either: your backend asks for a routing decision with metadata only, calls the chosen provider itself, and reports the outcome.
import { createUnirail, maskAccount } from "@unirail/sdk";
const unirail = createUnirail({ apiKey: process.env.UNIRAIL_SECRET_KEY! });
const decision = await unirail.routeDecisions.create({
operation: "banking.pis.single",
amount: { value: "5000", asset: "iso4217:GBP" },
payer: { country: "GB", scheme: "scan", accountRef: await maskAccount(payer.payto, process.env.UNIRAIL_FINGERPRINT_KEY!) },
payee: { country: "GB", scheme: "scan" },
});
if (decision.status === "routed") {
// decision.rail, decision.estimatedCost, decision.eta, decision.alternatives…
}What you send
Every field is metadata. The request is strict: an unknown field (a name, an account number, an email) is refused with invalid_request rather than ignored.
| Field | What it is |
|---|---|
operation | A capability id: banking.pis.single, banking.ais.account-details, identity.kyc.document… |
amount | { value, asset } in minor units, or { asset, band: { min, max } } if you'd rather not share the exact figure. Required for payments. |
payer, payee | country, and optionally institution (an ins_… id or { rail, key } with the provider's own bank id), scheme (the payto scheme name, such as scan or iban, never the address), accountRef and linkedRails (rails that already hold a link for this account). |
preference | cheapest, fastest or recommended. Defaults to your routing policy's default. |
constraints | excludeRails, and custodyAdmitted to narrow (never widen) what your policy admits. |
Account fingerprints
accountRef lets Unirail recognise "this account again" without knowing the account. Compute it in your backend with a key only you hold:
const accountRef = await maskAccount("payto://scan/040004/12345678", process.env.UNIRAIL_FINGERPRINT_KEY!);
// "fp_…": HMAC-SHA256 of the canonical payto URI, base64urlUse 32 or more random characters from your own secrets manager and never send the key anywhere. The same account typed differently (spacing, case, a receiver name or BIC in the URI) gives the same ref; without the key nobody can work back to the account, and refs from two platforms can't be matched.
What comes back
| Field | Meaning |
|---|---|
status | routed, or infeasible when no connected provider can do it, with reasons. |
rail, connection | The provider to use and your connection to it. |
estimatedCost | What the next unit costs you: from your active deal (source: "contract", with the tier it falls in), else the provider's list price, else unknown. Decimal major units, so 0.035 is 3.5 pence. |
eta, successLikelihood | From the outcomes you've reported. |
reasons | Why this rail ranks where it does, each with a key and an English message. |
alternatives | The other feasible rails, best first, in the same shape. |
infeasible | Every connected rail that can't do it, and why (not in this country, policy stage, custody, scheme…). |
providerChoice | Whether your user may pick from the alternatives themselves. |
digest, expiresAt | Bind your user's approval to id + digest, as with route quotes. Decisions last 5 minutes. |
Calling the provider yourself
@unirail/executor runs Unirail's provider adapters inside your backend, with credentials from your own environment or vault, and does the masking and reporting for you:
import { createExecutor } from "@unirail/executor";
const executor = createExecutor({
unirail,
credentials: { plaid: () => ({ CLIENT_ID: env.PLAID_CLIENT_ID, SECRET: env.PLAID_SECRET }) },
connections: { plaid: { providerEnvironment: "production", config: { clientName: "Acme" }, webhookUrl: "https://api.acme.com/hooks/plaid" } },
fingerprintKey: env.UNIRAIL_FINGERPRINT_KEY,
});
const started = await executor.pay({
paymentId: payment.id, // your id: the provider idempotency key derives from it
amount: { value: "5000", asset: "iso4217:GBP" },
payer: { country: "GB", payto: payerPayto, name: payerName },
payee: { country: "GB", payto: payeePayto, name: payeeName },
returnUri: "https://app.acme.com/paid",
});
// started.nextAction: usually a redirect to the payer's bank. When they come back:
const done = await executor.refresh(started.payment, { returnParams, input });Names and payto URIs stay in your process; Unirail receives the scheme and the fingerprint. executor.link and executor.verify work the same way for linking accounts and identity checks.
Reporting outcomes
If you call providers without the executor, report each final status so reliability and deal usage stay current:
await unirail.outcomes.create(
{ decision: decision.id, rail: decision.rail, status: "succeeded", latencyMs: 4200 },
{ context: { idempotencyKey: `outcome:${decision.id}:succeeded` } },
);status is succeeded, failed or cancelled; add providerFee if you know what the provider charged and failureCode (a short code such as rejected, never the provider's message) on failures. Reporting the same status for the same decision twice returns the first record.
Running the decision engine yourself
The engine is a package, @unirail/router. If you'd rather not send even the metadata, run it in your own infrastructure against your environment's routing snapshot:
import { createRouter } from "@unirail/router";
let snapshot = await unirail.routingSnapshot.get({});
let router = createRouter(snapshot);
const decision = await router.decide({ operation: "banking.pis.single", amount, payer, payee });The snapshot holds your routing policy, your connections' declared capabilities, your active deals with this month's usage, and reliability from your outcomes. It contains no credentials and nothing about your users. Refetch it after refreshAfter (a minute); version changes only when its content does. Unirail never sees these decisions, so outcomes for them include context: { operation, country }.