Providers & rails
What this covers / who it's for. Which capabilities Stables executes itself and which it offloads to the underlying finance rail; what the platform owns vs what the provider owns; how provider truth reaches our records (webhooks + reconciliation); and why our view of a status can briefly lag the provider's. Read this to reason about latency, trust boundaries, and "who do we call when X is wrong".
Multiple providers, one neutral seam
Stables integrates external finance providers behind a single neutral capability seam — domain
code asks for a capability (onboard a customer, create a wallet, execute a payout), never for a
named vendor (rails module README; registry:
app-modules/rails/config/rails.php). Which provider actually serves a capability is chosen at
runtime by the tenant's jurisdiction, so different providers can serve the same capability
in different regions (ADR D94, superseding D60's "replaceable, not concurrent"). The engineering
provider-integration page carries the machinery.
Five providers exist in the codebase today, at several ship-states — this is a genuinely multi-provider platform now, not a single-rail one:
| Provider | Serves | Ship-state |
|---|---|---|
| Conduit | The money rail — accounts, deposits, payouts, orders, virtual accounts — for every jurisdiction; plus onboarding everywhere Sumsub is not primary | Live — the incumbent, still authoritative for all financial state except custody (now routed to Utila, pre-product) |
| Sumsub | Onboarding (KYB/KYC + the beneficial-owner ownership graph) — primary for GB + EU | Live but pre-product — code-live, production-gated (below) |
| Utila | Crypto custody (MPC wallets) — primary for all six jurisdictions | Live-in-code, pre-product — routed as the custody primary (D98), deploy-gated; not serving real production custody yet |
| Fingerprint | Fraud / device intelligence — device-risk screening on login, sign-up, and payout — primary for all six jurisdictions | Live-in-code, deploy-gated — the screening feature ships default-disabled + central-context screening config-gated off (below); pre-product |
| SEON | Fraud / device intelligence — same screening capability — permitted (secondary) in all six jurisdictions | Live-in-code, deploy-gated — as above; pre-product |
Which provider serves a capability (the routing rule)
Availability follows the same regulatory-first logic as plans & entitlements: a tenant's jurisdiction must license a provider for a capability before that provider can serve it. Routing is per (capability group × jurisdiction) — capabilities are grouped (onboarding, custody, fiat rails, …), and each group resolves to one provider for a given jurisdiction. If no licensed provider can serve a group for a tenant's jurisdiction — an unlicensed region, a data-residency mismatch, or a suspended jurisdiction — the action is refused outright, never quietly redirected to some default provider. An operator can pin a specific tenant to one of its jurisdiction's permitted providers, but can never pin one the jurisdiction forbids (compliance beats commerce).
Onboarding — live multi-provider (Sumsub for GB/EU)
Onboarding is the first capability where two providers serve concurrently. For GB and EU tenants, KYB/KYC and the beneficial-owner ownership graph route to Sumsub (its primary); ZA/NG/KE/US stay on Conduit, which also remains a permitted fallback everywhere (ADR D95).
Individuals and businesses are peer onboarding subjects (ADR D110). The client DECLARES which — the
vocabulary is individual (KYC) or business (KYB) — on both the requirements read and the application
submit; there is no silent default (previously every applicant was silently created as an individual, so KYB
was impossible). Sumsub serves both subjects (each with its own dashboard verification level); the Conduit
adapter is reference-only and serves only the business subject (it fails closed for an individual). An
individual's application is approved on their own verification without a beneficial-owner gate; a business
still requires its ≥25% ownership graph complete. A GB/EU tenant genuinely onboards through Sumsub today — but this is code-live and
pre-product: it is not serving real production traffic, held behind three go-live gates a
product person should know about
(10-onboarding-identity.md):
- The frontend must branch on the verification mode. For a Sumsub route, verification hands back an embedded-WebSDK token rather than a hosted redirect URL; the SPA (a separate frontend repo) must mount the WebSDK accordingly, or the live KYC flow breaks.
- EU/UK data residency + a DPA must be bound before real EU/GB personal data routes to Sumsub (the grant shipped with residency enforcement deliberately off, matching Conduit's incumbent posture — an accepted pre-product deferral).
- A stuck-application runbook must land: the KYB approval gate never rejects or re-opens, so a ≥25% owner who never verifies leaves an application pending indefinitely with no SLA or operator override today. (The other half of this gap — the KYB approval gate trusting a locally mirrored owner set that a missed owner update could leave stale — is now closed: an hourly sweep re-pulls the authoritative ownership graph for every open application; see the onboarding module README.)
Custody — now routed to Utila (all six jurisdictions)
Crypto custody now routes to Utila, an MPC custodian with no human co-sign ceremony —
approvals are API votes evaluated by a policy engine, not a URL a person visits. The full path
shipped — the adapter, the webhooks, the in-app approval quorum, and the real forward — and the
go-live grant (ADR D98) routes Custody to Utila across all six jurisdictions (ZA/NG/KE/GB/EU/US).
Conduit is retained only as an override-only escape hatch (an explicit per-tenant pin for
legacy Conduit-modeled flows) — not an automatic fallback: Conduit cannot serve custody
transactions, so a custody move under a Conduit override fails loud rather than mis-routing money.
This is live-in-code + deploy-gated (a deploy provisions the Utila vaults + per-vault secrets and
runs the grant) and pre-product — not serving real production custody money yet. An EU
data-residency + DPA binding and an accepted travel-rule field drop remain go-live carry-overs
(20-custody.md §5a; ADR D98) — noted, not
restated here. Introducing Utila also reshaped the custody contract away from Conduit's hosted-URL
signer/quorum ceremony toward neutral API-vote approvals (ADR D96) — likewise code-live but
pre-product (no live tenant relies on it).
The inbound half is now built too — Utila signs its webhooks with a different (asymmetric) scheme, and the platform accepts, verifies, and stores those deliveries. When a Utila custody transaction changes, its webhook is a lightweight "something changed" nudge, so the platform reads the authoritative transaction back from Utila and records our own copy from that — never trusting amounts off the raw notification (keeping money exact). A transaction's AML-screening outcome opens a compliance case when it comes back deny or alert — and, by deliberate design, opening a case never freezes the account or blocks the transfer; stopping money stays a separate human decision (Compliance & trust). Like the outbound adapter, this feed is live-in-code + deploy-gated: custody now routes to Utila (D98), but no real custody webhook is processed until the deploy-time write-path steps (vault + secrets) land — pre-product.
Fraud / device intelligence — a segregated, off-umbrella capability
Fraud / device-intelligence screening is a genuinely new capability, served by two engines — Fingerprint (primary) and SEON (permitted) — across all six jurisdictions (ADR D102; fraud module README). It is deliberately off the money-rail umbrella: a device-risk verdict never touches funds, so it is routed on its own seam, kept separate from the capabilities that move money. The verdict shields the sensitive moments — login, sign-up, and payout — with two deliberately different postures: a screening outage on login / sign-up fails open (proceed, never brick a login), while a payout fails closed to step-up and records the skip rather than sliding money through silently. A deny blocks the action and opens a compliance case (Compliance & trust); the full failure behaviour is on Dependencies & failure modes. Like the other new providers this is live-in-code + deploy-gated: it stays inert until ops enables the screening feature — noted, not restated here.
Conduit configuration facts
Two ship-state facts about the money rail a product person should know:
- The base URL defaults to Conduit's sandbox. A deployment must explicitly point at
production (
packages/stables/conduit-sdk/config/conduit.php) — the default is sandbox precisely so a misconfigured environment can never accidentally touch production money. Sandbox and production are fully isolated (separate keys, separate data). - Outbound calls time out after 30 seconds by default (same config) — the ceiling on how long a synchronous provider-backed action can hang before failing.
What is offloaded (the capability surface)
The rails module defines 13 capability contracts — the complete, enumerable list of what the
provider does for us (app-modules/rails/src/Contracts/):
| Capability | What the provider executes |
|---|---|
| Customer onboarding | Creating/updating the business customer record on the rail (KYB) |
| Identity verification | KYC checks on persons, via the provider's KYC vendor |
| Customer management | Reading/maintaining the provider-side customer |
| Wallets | Custodial wallet creation and lifecycle |
| Wallet signers | Registering signing parties on a wallet |
| Signing quorum | Co-sign / quorum rules for wallet actions |
| Virtual accounts | Fiat virtual account issuance |
| Orders | FX / conversion order execution |
| Payouts | Outbound payment execution |
| Transactions | The provider-side transaction ledger (deposits included) |
| Whitelist recipients | Approved-recipient (whitelist) management |
| Registered addresses | Registered crypto address management |
| Documents | Receiving the KYB/KYC documents we forward |
Fraud / device-risk screening (AssessesFraudRisk, served by Fingerprint/SEON) is not one of
these 13 — it is a separate, off-umbrella capability, segregated from the money-rail contracts
precisely because a device-risk verdict never moves money (fraud module
README; ADR D102).
The ownership split (handoff model, V1)
| The platform owns | The provider owns |
|---|---|
| The durable record: a local mirror of every provider entity, plus everything the provider never sees (teams, roles, preferences, cases, notifications) | Custody of funds and wallets |
| The audit trail — every action, denial, and failure, tamper-evident (Compliance & trust) | Actual money movement (payouts, orders, deposits settle on the rail) |
| Authorization and dual control — RBAC, maker/checker, co-sign approval flows run here before anything reaches the rail | The KYC vendor relationship and verification decisioning |
| The encrypted document vault (we store first, then forward — Where data lives) | SAR/CTR filing with regulators in the V1 handoff model — we prepare and record filings, the provider files (Compliance & trust) |
Every outbound call is logged
Every request to the provider is recorded per attempt in the append-only central vendor
request log:
bodies are encrypted at rest, known-sensitive fields (credentials, tax IDs, account numbers, …)
are redacted before persisting, and oversized bodies are truncated (16 KB cap). The log is
transport telemetry, not the compliance record — it is pruned on a 90-day window (the
authoritative KYB/KYC record lives in the document vault + onboarding mirror). Source:
app-modules/rails/config/rails.php, rails README.
How provider truth comes back (and why our view can lag)
Provider-backed actions are submit-then-listen: we submit, the provider answers "accepted", and the real outcome (KYB progression, wallet created, payout settled, order filled) arrives later as a webhook (Async by design).
Inbound webhook mechanics (webhooks README):
- Signed, with a replay window. Deliveries are signature-verified, fail-closed — each provider
brings its own scheme (Conduit and Sumsub sign differently), and Conduit deliveries older than
300 seconds (or dated in the future) are rejected (
app-modules/webhooks/config/webhook-client.php). - Exactly-once. Duplicate deliveries (same source + event id) are detected and skipped, so a provider retry can never double-apply a status change.
- Processed on a dedicated queue (
webhooks), separate from all other background work.
Consequence: our view is eventually consistent with the provider's. Normally the lag is seconds (webhook delivery + queue processing). If the webhooks queue is backed up or its worker is down, statuses freeze at their last-known value while the provider moves on — see Dependencies & failure modes.
The healing layers (when a webhook never arrives)
- The provider retries. Conduit redelivers failed webhooks on a retry ladder
(30s → 2m → 15m → 1h → 4h), covering transient drops
(
docs/tracking/conduit-funding-deferrals.md). - The hourly reconciliation sweep. Every hour, mirrors that have gone stale (no update within their expected window and not in a terminal state) are re-read directly from the provider API and healed (provider-mirror README, ADR D59). Limit: the sweep heals coarse status only. Some webhook-only sub-states (e.g. a deposit held for sender information) are not exposed by the provider's read API, so a permanently lost webhook for those specific transitions is not sweep-recoverable — the retry ladder is the cover for them (funding README).
- Drift records. When reconciliation finds the provider and our mirror disagree, the divergence is recorded as a drift record for operator review — field names and coarse change markers only, never raw PII values. Unresolved drift is never auto-deleted; it is live evidence until an operator resolves it (provider-mirror README).
What this means in practice
- A status shown in Stables is the last thing the provider told us — trust it to seconds under normal operation, to about an hour under webhook loss (coarse states), with the named sub-state exceptions above.
- "The provider says X but we show Y" is expected transiently; if it persists past the hourly sweep, look for a drift record — that is the designed operator surface for divergence.
- Anything involving custody, settlement, or KYC decisioning is a provider conversation; anything involving who was allowed to do what, when, and what we recorded is ours.