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".
One provider today: Conduit
Stables integrates exactly one external finance rail: Conduit. The provider registry is built
to take a second provider as an additive entry (the architecture is replaceable, not
concurrent multi-provider, in V1 — ADR D60), but today every regulated capability below runs on
Conduit (rails module README; registry:
app-modules/rails/config/rails.php).
Two ship-state facts 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 |
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 HMAC-signature-verified; a delivery whose
signed timestamp is more than 300 seconds old (or in the future) is 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.