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.

Example data
Payee's account type

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

dec_example000010 · policy v7 · valid for 5 minutes

Ranked options

  1. 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.

  2. 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.

Your platform

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

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.

Decide mode · what crosses to Unirail POST /v1/route_decisions · POST /v1/outcomes

How a Decide mode payment works

  1. 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.

  2. Pay from your backend

    @unirail/executor runs the same provider adapters Unirail runs, inside your process, with credentials from your own vault.

  3. Report the outcome

    Send POST /v1/outcomes the 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.

  4. 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.

Decide and report 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("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.

QuestionDecideExecute via Unirail
What Unirail receivesThe 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 providerYour 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 credentialsThey 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.
OutcomesYour 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.