USDC payouts
Move value between fiat and USDC through one API: pay USDC out to a wallet (fiat → USDC on-ramp) or cash USDC back out to a bank account (USDC → fiat off-ramp). Every flow spans two rails, and PAID refuses to call a flow settled until both legs carry a real provider reference — no fabricated settlement, ever.
Two directions, one flow API
| Direction | Endpoint | Legs |
|---|---|---|
| USDC → bank account (off-ramp) | POST /v1/stablecoins/off-ramp | USDC burn, then a fiat payout over ACH or wire |
| Fiat → USDC wallet (on-ramp) | POST /v1/stablecoins/on-ramp | Fiat charge (ACH / card / RTP / FedNow / wire), then USDC credit |
Amounts are always in smallest units: fiat_amount in cents, stable_amount in USDC minor units (USDC has 6 decimals, so 250000000 = 250 USDC). The idempotency_key lives in the request
body — retrying with the same key returns the existing flow instead of creating a new one.
Supported chains today: ethereum, base, solana, polygon, arbitrum.
CUSTODY_MODE): monitor (default) allows and logs, enforce refuses any merchant without a per-merchant custody grant — synchronously,
before any charge, mint, or burn — and open allows all. The refusal's status
code differs by direction: an off-ramp custody refusal returns 422, while an
on-ramp custody refusal returns 403 with a message directing the merchant to
settle provider-direct. Whether the ramps are open for your account is a platform-side
setting, not something you toggle from the API.Cash USDC out to a bank account
Create an off-ramp flow with the amount, the fiat rail (fiat_target: ACH, WIRE, or RTP), and the bank instrument the
payout should land on. dest_instrument_id is required for the fiat leg to
dispatch — without it the flow stops after the burn and waits.
curl https://api.trustfabric.ai/v1/stablecoins/off-ramp \
-H "Authorization: Bearer $PAID_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "payout-2026-07-06-001",
"source_stablecoin": "USDC",
"source_chain": "base",
"stable_amount": 250000000,
"fiat_currency": "USD",
"fiat_target": "ACH",
"dest_instrument_id": "ti_9f2c41d8"
}'The API replies 201 with the flow. Fees are echoed back in basis points on
the flow itself, so the quote is inspectable rather than implied:
{
"flow_id": "offramp_3f9c2ab417d05e88",
"flow_type": "OFF_RAMP",
"status": "INITIATED",
"idempotency_key": "payout-2026-07-06-001",
"source_asset": "USDC",
"source_chain": "base",
"source_amount": 250000000,
"dest_asset": "USD",
"dest_chain": "",
"dest_amount": 249500000,
"fx_rate": 1,
"fx_spread_bps": 15,
"max_slippage_bps": 0,
"network_fee_bps": 5,
"total_fee_bps": 20,
"merchant_id": "acct_1a2b3c4d",
"created_at": "2026-07-06T17:04:12Z",
"updated_at": "2026-07-06T17:04:12Z",
"version": 1,
"fiat_target": "ACH",
"dest_instrument_id": "ti_9f2c41d8"
}This is a USD payout, so no FX resolution happens and rate_snapshot_id is
absent. A non-USD payout normally carries it — see rate-snapshot provenance below.
The two-leg lifecycle
An off-ramp flow moves through these states:
| Status | Meaning |
|---|---|
INITIATED | Flow accepted and persisted; the burn has not been confirmed yet. |
BURNING | The USDC burn is confirmed — crypto_leg_ref holds the on-chain reference. The fiat payout leg dispatches from here through the same payout engine as every other platform payout. |
SETTLED | The fiat payout completed with a real provider reference (fiat_leg_ref); settled_at is set. |
FAILED | A leg failed terminally. |
On-ramp flows use the mirror-image states: INITIATED → MINTING → SETTLED.
SETTLED when both fiat_leg_ref and crypto_leg_ref are present; attempting to settle with a leg missing is refused with "settlement refused: cross-rail connection not proven on every leg". If a payout
provider fails or has not confirmed, the flow stays at BURNING for retry —
it is never advanced on a reference that doesn't exist.Rate-snapshot provenance
When a flow needs FX (any non-USD fiat leg), the rate is not a bare live-provider call you have to take on faith. The orchestrator resolves it through a deterministic rate-snapshot engine:
- If a valid snapshot already exists for the pair, it is replayed — two quotes for the same pair are provably identical even if the live provider's rate moved in between.
- Only when no replayable snapshot exists does PAID call the FX provider once, then immediately persists the response as a snapshot (24-hour validity) so the next quote replays this exact rate.
Each snapshot has a deterministic rate_<hash> id and stores the
provider's canonical response payload alongside its SHA-256 hash; validation recomputes
the hash, so a rate can be proven after the fact. The flow's rate_snapshot_id field records which snapshot its fx_rate came
from — set once at creation, never changed.
rate_snapshot_id is empty/absent when no FX resolution happened:
- same-currency flows (a USD payout needs no FX),
- flow types with no fiat leg (cross-chain transfers, swaps, yield),
- flows created before the column existed (migration 196) or on deployments without the snapshot engine wired — the production API wires it,
- degraded FX resolution — the snapshot store write or the provider call failed at quote time, so the rate is applied but unrecorded.
Fees
Current constants, applied to dest_amount and echoed on every flow as fx_spread_bps, network_fee_bps, and total_fee_bps:
| Flow | FX spread | Network fee |
|---|---|---|
| Off-ramp (USDC → fiat) | 15 bps (non-USD: provider spread + 15 bps) | 5 bps (burn cost) |
| On-ramp (fiat → USDC) | 10 bps (non-USD: provider spread + 10 bps) | Per destination chain: 1 bp on Base/Solana, 2 bps on Polygon, 50 bps on Ethereum, 10 bps otherwise |
Pay USDC out to a wallet
The on-ramp converts a fiat charge into USDC delivered to a destination address. For
chargeable sources (ACH, CARD, RTP, FEDNOW) the fiat leg is charged synchronously — merchant-direct, through the
same authorize-and-capture pipeline as a normal payment — before anything is minted. A
charge failure returns 422 and no flow is created. WIRE is not
accepted on this endpoint (422) — a caller-supplied wire confirmation cannot
be verified here. To on-ramp by wire, create a stablecoin virtual bank account instead:
wire deposits to it are confirmed by signed Circle Mint webhook and minted automatically.
curl https://api.trustfabric.ai/v1/stablecoins/on-ramp \
-H "Authorization: Bearer $PAID_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "onramp-2026-07-06-001",
"fiat_currency": "USD",
"fiat_amount": 250000,
"fiat_source": "ACH",
"dest_stablecoin": "USDC",
"dest_chain": "base",
"dest_address": "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B"
}'Tracking flows
# One flow by id — persistence-backed, survives redeploys
curl https://api.trustfabric.ai/v1/stablecoins/flows/offramp_3f9c2ab417d05e88 \
-H "Authorization: Bearer $PAID_SECRET_KEY"
# Off-ramp flows for your account created since the last deployment —
# returns { "data": [...], "total": n }. The list reads the in-process
# cache; fetch a specific flow by id for a persistence-backed lookup.
curl "https://api.trustfabric.ai/v1/stablecoins/flows?flow_type=OFF_RAMP" \
-H "Authorization: Bearer $PAID_SECRET_KEY"
# Supported stablecoins, chains, and cross-chain routes
curl https://api.trustfabric.ai/v1/stablecoins/supported \
-H "Authorization: Bearer $PAID_SECRET_KEY"Flows are tenant-isolated: another merchant's flow id returns 404. The list
endpoint reads the orchestrator's in-process cache, so it covers flows created since the
last deployment; the by-id endpoint falls back to the database, so keep the flow_id if you need a lookup that outlives a redeploy. In the
dashboard, Dashboard → Stablecoin creates and tracks
flows, and the off-ramp's fiat leg shows up in Dashboard → Payouts as a payout with idempotency key offramp_payout_<flow_id>.
What is gated or limited today
- Custody: both ramps are refused under
CUSTODY_MODE=enforcefor merchants without a custody grant (default mode ismonitor). See the callout above. - Burn automation: off-ramp and cross-chain burns are not broadcast by
PAID on your behalf by default. Automated burns exist behind
CIRCLE_AUTOMATED_LEGS=1plus per-chain treasury wallet configuration, and only move PAID's own treasury funds — never a third party's. - Fiat rails:
ACHandWIREpayouts dispatch through the platform payout engine (currently routed via Dwolla).RTPis accepted but has no payout provider mapped yet — the payout stays processing and the flow staysBURNINGrather than being fabricated into a settlement. - Sanctions screening: every mutating stablecoin route runs geo/sanctions screening middleware before the handler.
- Offline development: in non-production environments without Circle credentials, on-chain legs fall back to a deterministic offline simulator. In production, missing configuration refuses the leg instead — a live flow never fabricates an on-chain result.
GET /v1/stablecoins/yield are indicative
placeholders (provision_status: "MOCK") pending live integrations — don't
build treasury logic on them yet.