A payment routing API that works from masked metadata
Decide mode is a payment routing API for teams that keep customer details in house. Your backend says what the payment is and gets back which provider to use, what it will cost and how long it should take. Then it calls that provider with your own credentials.
A request carries the operation, the countries, the currency, the amount or an amount band, the bank, the account type and, optionally, an account fingerprint you compute with your own key. Names, account numbers, emails and provider credentials never reach Unirail, because the API has no field for them and rejects any field it doesn't know.
Try a masked request
Change the request to see a different decision. Each response came from Unirail's routing engine running against an example setup with Plaid and Yapily connected for the UK and Enable Banking for 14 euro-area countries. The contracts and outcome history are made up.
The engine doesn't model cross-border payments yet. It checks coverage in the payer's country and treats every payment as domestic.
POST /v1/route_decisions
{
"operation": "banking.pis.single",
"amount": {
"asset": "iso4217:GBP",
"band": {
"min": "0",
"max": "10000"
}
},
"payer": {
"country": "GB",
"scheme": "scan"
},
"payee": {
"country": "GB",
"scheme": "scan"
}
}
This is the complete request, with no field for a name, an email or an account number. If you add
accountRef to the payer or payee, as a fingerprint you compute with maskAccount, Unirail can
recognise the same account next time without learning which account it is.
Decision
Routed to Yapily
Ranked options
-
1YapilyChosen
- Cost
- £0.15 per payment
- Your Yapily contract, tier 1 of 1
- Time
- 11 s typical
- 9 in 10 within 1 min 14 s
- Success
- 95% likely
- 1,205 of 1,262 succeeded
Why it ranks here
- The ranking weighs cost, success likelihood and any existing link equally.
- It's the cheapest of the providers that can run this.
- Your routing policy lists it second in the UK. Unirail uses that order to break ties.
- Yapily takes at most £15,000 per payment.
What your app must do
- Give the user a way to revoke yapily's access.
The payer approves through a redirect to their bank.
-
2Plaid
- Cost
- £0.18 per payment
- Your Plaid contract, tier 2 of 2
- Time
- 6 s typical
- 9 in 10 within 41 s
- Success
- 98% likely
- 1,912 of 1,960 succeeded
Why it ranks here
- It's the fastest of the providers that can run this.
- It's the most likely to succeed of the providers that can run this.
- Your routing policy lists it first in the UK. Unirail uses that order to break ties.
The payer approves through the provider's own SDK or a redirect to their bank.
Providers ruled out and why
- Enable Banking
- Enable Banking doesn't offer pay by bank in the UK.
- Enable Banking doesn't cover the UK in GBP.
- Your routing policy doesn't send payments in the UK to Enable Banking.
The full JSON response
{
"id": "dec_example000010",
"object": "route_decision",
"status": "routed",
"operation": "banking.pis.single",
"rail": "yapily",
"connection": "con_exYapily1",
"rank": 1,
"score": 1.954,
"estimatedCost": {
"amount": {
"amount": "0.15",
"currency": "GBP"
},
"breakdown": [
{
"amount": {
"amount": "0.15",
"currency": "GBP"
},
"kind": "unit",
"label": "tier 1 of 1",
"unit": "payment"
}
],
"deal": "dl_example_yapily",
"source": "contract"
},
"eta": {
"p50Seconds": 11,
"p90Seconds": 74
},
"successLikelihood": 0.9541139240506329,
"userActions": [
"redirect"
],
"obligations": [
{
"code": "O-REVOKE-SURFACE",
"explanation": {
"key": "obligation.O-REVOKE-SURFACE",
"message": "Give the user a way to revoke yapily's access.",
"vars": {
"stage": "pre-initiation",
"provider": "yapily"
}
},
"stage": "pre-initiation"
}
],
"reasons": [
{
"code": "preference",
"explanation": {
"key": "decision.preference",
"message": "Ranked for recommended.",
"vars": {
"preference": "recommended"
}
}
},
{
"code": "best",
"explanation": {
"key": "decision.cheapest",
"message": "Cheapest of the feasible rails.",
"vars": {}
}
},
{
"code": "cost",
"explanation": {
"key": "decision.cost.contract",
"message": "Costs 0.15 GBP per payment under dl_example_yapily (tier 1 of 1).",
"vars": {
"deal": "dl_example_yapily",
"price": "0.15 GBP",
"tier": "tier 1 of 1",
"unit": "payment"
}
}
},
{
"code": "reliability",
"explanation": {
"key": "decision.reliability",
"message": "1205 of 1262 reported outcomes succeeded (95% likely).",
"vars": {
"attempts": "1262",
"likelihood": "95%",
"successes": "1205"
}
}
},
{
"code": "policy",
"explanation": {
"key": "decision.policyOrder",
"message": "Policy order 1 in GB.",
"vars": {
"country": "GB",
"order": "1"
}
}
},
{
"code": "R-AMT",
"explanation": {
"key": "restriction.amount",
"message": "This route carries at most GBP 15000.00 per payment.",
"vars": {
"perPayment": "GBP 15000.00"
}
}
}
],
"alternatives": [
{
"connection": "con_exPlaid01",
"estimatedCost": {
"amount": {
"amount": "0.18",
"currency": "GBP"
},
"breakdown": [
{
"amount": {
"amount": "0.18",
"currency": "GBP"
},
"kind": "unit",
"label": "tier 2 of 2",
"unit": "payment"
}
],
"deal": "dl_example_plaid",
"source": "contract"
},
"eta": {
"p50Seconds": 6,
"p90Seconds": 41
},
"obligations": [],
"rail": "plaid",
"rank": 2,
"reasons": [
{
"code": "best",
"explanation": {
"key": "decision.fastest",
"message": "Fastest of the feasible rails.",
"vars": {}
}
},
{
"code": "best",
"explanation": {
"key": "decision.likeliest",
"message": "Most likely to succeed of the feasible rails.",
"vars": {}
}
},
{
"code": "cost",
"explanation": {
"key": "decision.cost.contract",
"message": "Costs 0.18 GBP per payment under dl_example_plaid (tier 2 of 2).",
"vars": {
"deal": "dl_example_plaid",
"price": "0.18 GBP",
"tier": "tier 2 of 2",
"unit": "payment"
}
}
},
{
"code": "reliability",
"explanation": {
"key": "decision.reliability",
"message": "1912 of 1960 reported outcomes succeeded (98% likely).",
"vars": {
"attempts": "1960",
"likelihood": "98%",
"successes": "1912"
}
}
},
{
"code": "policy",
"explanation": {
"key": "decision.policyOrder",
"message": "Policy order 0 in GB.",
"vars": {
"country": "GB",
"order": "0"
}
}
}
],
"score": 1.808,
"successLikelihood": 0.9750254841997962,
"userActions": [
"sdk",
"redirect"
]
}
],
"infeasible": [
{
"connection": "con_exEnable1",
"rail": "enablebanking",
"reasons": [
{
"code": "R-CAPABILITY",
"explanation": {
"key": "capability.notInCountry",
"message": "enablebanking doesn't offer banking.pis.single in GB.",
"vars": {
"country": "GB",
"operation": "banking.pis.single",
"rail": "enablebanking"
}
}
},
{
"code": "R-COVERAGE",
"explanation": {
"key": "coverage.country",
"message": "enablebanking doesn't cover GB in GBP.",
"vars": {
"country": "GB",
"rail": "enablebanking",
"currency": "GBP"
}
}
},
{
"code": "R-POLICY",
"explanation": {
"key": "policy.notBound",
"message": "This environment's policy doesn't route payment in GB through enablebanking.",
"vars": {
"country": "GB",
"rail": "enablebanking",
"role": "payment"
}
}
}
]
}
],
"capability": {
"id": "banking.pis.single",
"jurisdiction": "GB",
"role": "payment"
},
"providerChoice": {
"reason": "hidden",
"visible": false
},
"policyVersion": 7,
"snapshot": "snap_9e6621887557d7ab340533fef55cc4fe",
"createdAt": "2026-10-18T09:00:00.000Z",
"expiresAt": "2026-10-18T09:05:00.000Z",
"digest": "sha256:1309e691f1512c57904ba23b5d8fa0b75178c1423b94c91643c3e6537272bb8d"
}
What Unirail receives and what stays with you
A decision only needs to know what kind of payment this is. Everything under "Your platform" stays in your process. Only the list under "Unirail receives" is sent to Unirail.
Stays with you
- Your customers' names, emails, devices and IP addresses
- Account numbers, sort codes and IBANs
- Provider credentials, read by your own backend
- What the provider returns
- The key you fingerprint accounts with
Your backend
@unirail/executor calls the chosen provider with your credentials, in your process.
Unirail receives
- The operation, as a capability id
- Payer and payee countries
- The currency and the amount or an amount band
- Bank ids
- Account types (
scan,iban) - Account fingerprints you compute
After the payment
Your backend reports the status and latency, the fee if you know it and a short failure code if the attempt failed.
POST /v1/route_decisions · POST /v1/outcomes
How a Decide mode payment works
-
Ask for a decision
Send the masked request to
POST /v1/route_decisions. You get back the provider, its estimated cost, the ETA and success likelihood, the ranked alternatives and the providers ruled out with reasons. -
Pay from your backend
@unirail/executorruns the same provider adapters Unirail runs, inside your process, with credentials from your own vault. -
Report the outcome
Send
POST /v1/outcomesthe decision id, the provider you used, the status (succeeded, failed or cancelled), the latency and, if you have them, the fee and a short failure code. Reporting the same status twice returns the original record. -
Unirail updates success rates and usage
Each outcome updates the success rate for that provider, operation and bank. It also counts towards this month's usage under your contract. The next decision uses both.
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("payto://scan/040004/12345678", process.env.UNIRAIL_FINGERPRINT_KEY),
},
payee: { country: "GB", scheme: "scan" },
preference: "recommended",
});
if (decision.status === "routed") {
// Your backend calls decision.rail with your own credentials (@unirail/executor),
// then reports what happened so the next decision learns from it.
const started = Date.now();
const result = await callProvider(decision); // your code
await unirail.outcomes.create({
decision: decision.id,
rail: decision.rail,
status: result.ok ? "succeeded" : "failed",
latencyMs: Date.now() - started,
});
}
Field names match the published v1 contract (RouteDecisionRequest, OutcomeRequest). Amounts are strings in minor units ("5000" is £50.00). The full list is in the API reference.
Decide or Execute
You choose a mode for each environment. Decide is the default for regulated platforms.
| Question | Decide | Execute via Unirail |
|---|---|---|
| What Unirail receives | The operation, countries, currency, amount or amount band, bank ids, payto scheme names and account fingerprints. Fingerprints are HMACs under your own key. Unirail can't reverse them. | The full request for link sessions, route quotes and payment intents. |
| Who calls the provider | Your backend, through @unirail/executor. It runs the same adapters in your process with credentials from your own vault. | Unirail's API, reading credentials from your vault through OIDC federation. |
| Provider credentials | They never leave your platform. | Unirail keeps them in memory for at most five minutes while it calls the provider. It never writes them to disk or a database. |
| Outcomes | Your backend reports masked outcomes (status, latency, cost). They update success rates and contract usage. | Unirail records them directly. |
What Unirail can learn from masked metadata
Masked metadata still describes your business. From Decide requests Unirail can see which operations you run and when, in which countries and currencies, the amounts or bands, which banks are involved, which provider was chosen, how each attempt went and an opaque fingerprint for each account. Over time that builds a picture of your volume and your users' bank mix. Your agreement with us should cover what Unirail may do with it.
On a small platform, an exact amount with a timestamp can point to one person. Sending amount bands hides the exact figure. Running the routing engine in your own network keeps individual decisions away from Unirail entirely. The security page lists what crosses the boundary in each mode.
Common questions
What is Decide mode?
Decide mode lets you use Unirail's routing without sending it names, account numbers, credentials or the payment itself. Your backend sends masked metadata to POST /v1/route_decisions and gets back the provider, cost, ETA and success likelihood. It then calls that provider with your own credentials and reports the outcome to POST /v1/outcomes.
What happens if a request includes a name or an account number?
The API refuses it with a 400 invalid_request. Any field the request schema doesn't define is an error. There are no free-text fields either, because every string is an enum or a pattern-checked id. A full payto:// address in the scheme field fails its pattern check.
How do account fingerprints work?
maskAccount in @unirail/sdk computes fp_ plus a base64url HMAC-SHA256 of the account's canonical payto address, keyed with a secret of at least 32 characters that you keep in your own secrets manager. Unirail can't reverse a fingerprint. Two platforms get different fingerprints for the same account because each uses its own key. Rotating your key changes every fingerprint. That resets per-account history but doesn't touch success rates.
Does the payer need to link their bank first?
The payer doesn't need an earlier bank link. They approve the payment at their bank inside the provider's flow.
How long is a decision valid?
Each decision is valid for five minutes. It carries an expiry and a digest of the operation, amount, parties, chosen provider, cost and policy version. Bind your user's approval to the decision's id and digest.
Can we move a flow to Execute mode later?
Yes, because both modes share one control plane, one capability catalogue and one cost model. You can start with Decide and move single flows to Execute, or back, without changing the shape of your integration.
Can the metadata stay inside our network too?
It can if you run @unirail/router yourself against the routing snapshot. Unirail then sees the outcomes you report but never the decisions. Self-hosting is by arrangement.
Make your first decision in test mode
With early access you can compute a fingerprint with maskAccount and call POST /v1/route_decisions from your backend without sending a name or an account number.