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

DirectionEndpointLegs
USDC → bank account (off-ramp)POST /v1/stablecoins/off-rampUSDC burn, then a fiat payout over ACH or wire
Fiat → USDC wallet (on-ramp)POST /v1/stablecoins/on-rampFiat 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 gate. Converting a customer's fiat to USDC (or their USDC back to fiat) is money transmission, so both ramps sit behind a server-side custody policy (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.

usdc-payout.sh
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:

201-created.json
{
  "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:

StatusMeaning
INITIATEDFlow accepted and persisted; the burn has not been confirmed yet.
BURNINGThe 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.
SETTLEDThe fiat payout completed with a real provider reference (fiat_leg_ref); settled_at is set.
FAILEDA leg failed terminally.

On-ramp flows use the mirror-image states: INITIATEDMINTINGSETTLED.

Settlement is a proof, not a status update. A fiat↔USDC flow can only reach 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:

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

FlowFX spreadNetwork 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.

usdc-onramp.sh
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

track-flows.sh
# 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=enforce for merchants without a custody grant (default mode is monitor). 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=1 plus per-chain treasury wallet configuration, and only move PAID's own treasury funds — never a third party's.
  • Fiat rails: ACH and WIRE payouts dispatch through the platform payout engine (currently routed via Dwolla). RTP is accepted but has no payout provider mapped yet — the payout stays processing and the flow stays BURNING rather 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.
Yield strategies returned by GET /v1/stablecoins/yield are indicative placeholders (provision_status: "MOCK") pending live integrations — don't build treasury logic on them yet.