Skip to main content
An off-ramp converts crypto to fiat and settles it to a user’s destination account, such as a bank account. This guide walks the US flow end to end — USDC on Base to a US bank account over ACH or RTP — and every status, error, and edge you’ll meet along the way.

The mental model: two resources, two lifecycles

An off-ramp is two legs, and the API models them as two resources:
  1. The off-ramp quote (GET /v1/off-ramp-quotes/{id}) covers the crypto leg — the price, the fees, and the user’s on-chain payment.
  2. The off-ramp (GET /v1/off-ramps/{id}) covers the fiat leg — the payout to the destination account, created once the crypto payment is confirmed on-chain.
Each has its own status enum, and they move independently. Don’t collapse them into one status field in your database — a quote can be completed while its off-ramp is still in_flight, and refunds live on the off-ramp, not the quote. Both lifecycles are spelled out below.

Before you start

  • Authenticate as an integrator acting for a user. See Authentication.
  • The user is verified and their crypto_to_fiat capability is active. Drive this from the capabilities array on GET /v1/users/me, not from KYC status alone — capabilities tell you exactly what’s blocking a product and why. See Onboarding.
  • Decide how the user pays (below).

Decide how the user pays

There are two ways to fund an off-ramp, and the choice shapes your UX: Signed transaction (recommended). You create a quote, fetch ready-to-sign transaction parameters, and the user’s wallet signs and submits the payment on-chain. Spritz sees the transaction the moment it confirms and starts the fiat payout immediately. Use this when your product can produce a signed on-chain transaction — it’s what the overwhelming majority of Spritz’s own app users do, and it’s the rest of this guide. Auto-ramp addresses. Every payable account gets a unique deposit address (the same address on every EVM chain), and any crypto sent to it auto-converts to that account. List them with GET /v1/auto-ramp-addresses?accountId={accountId}. The trade-offs: no pre-committed quote (fees are computed on arrival, not agreed up front), no instant feedback (you learn from a payment.created webhook or by polling GET /v1/off-ramps), and slower settlement, because the deposit must be detected before processing starts. Use this when you can’t sign transactions — or as a secondary “send from anywhere” option alongside the primary flow.

Step 1 — Add the bank account

Paying out to the user’s debit card instead? Add it with POST /v1/debit-cards — card entry runs in an Evervault iframe so card numbers never reach your servers — then continue from step 2 with rail: "push_to_card". See Push to debit card.
The account comes back active with the rails it supports:
institution ({ name, logo }) appears once the institution resolves — treat it as optional enrichment, and render the label when it’s absent.
Routing numbers are validated; account numbers cannot be. No upfront check exists for an account number — a typo’d one is accepted by the API, looks healthy, and is only rejected by the bank at payout time, coming back as an ACH return. Mobile users thumb-typing ten digits off a photo of a cheque are the common case.Build a confirmation step: after entry, show the number back with the last four digits hidden (12345••••) and make the user re-enter those four from their statement or cheque. Block paste on that field so it’s a genuine second read of the source, and hide the digits you’re asking them to reproduce — showing the last four and then asking for the last four only tests whether they can read your screen. A full double-entry of the whole number is stronger still, at more friction. This small piece of UI kills most of this error class, and it matters: returns are a normal part of ACH that Spritz handles, but a sustained high return rate is something we’ll ask you to fix.
List accounts with GET /v1/bank-accounts (a bare array, not a paginated envelope) and read per-account limits with GET /v1/bank-accounts/{id}/payment-limits:
dailyRemaining is shared across the user’s accounts, not per account. All three are decimal strings. Remove an account with DELETE /v1/bank-accounts/{id}; later reads of it return 404.

Step 2 — Create the quote

The fields that bite people:
  • amountMode decides what amount means. output (the default) means the destination receives exactly that amount, fees on top — input.amount is what the user pays in total. input means the user sends exactly that USD value and the destination receives what’s left after fees. Choose deliberately; this is the number-one source of “why is the amount different” tickets. EUR destinations require input, and their output is an estimate (output.estimated: true); the settled amount is reported by the off-ramp resource.
  • tokenAddress is required on every chain except Bitcoin, Dash, and XRP. There is no native-token fallback — omitting it is rejected with a 400. Different tokens carry different fee tiers, and USDC on a low-cost network (Base, Polygon, Arbitrum, Optimism, Avalanche, Solana) is the cheapest way to pay.
  • amount is always a decimal string"100.00", never 100. In input mode the amount must exceed the fees, or creation fails with 400 — note the detail text (“Amount must be greater than zero”) blames the input, not the fee: if you see it on a positive amount, your amount is at or below the fee floor.
  • rail picks the fiat settlement rail. For US bank accounts: ach_standard (one to two banking days, cheapest), ach_same_day (same banking day before the cutoff), and rtp (near-instant, around the clock including weekends and holidays, costs more). See Settlement timing and End-user pricing. Pick a rail the destination’s supportedRails lists.

Read fulfillment before you go further

fulfillment tells you how the quote gets paid:
  • sign_transaction — call POST /v1/off-ramp-quotes/{id}/transaction for parameters, sign, and submit on-chain (step 3). This is what you’ll get on EVM chains and Solana.
  • send_to_address — send exactly sendTo.amount of sendTo.token to sendTo.address before sendTo.expiresAt (Bitcoin, Dash, Tron). No transaction parameters exist for these quotes; calling the transaction endpoint returns a 422.
Quotes are time-bound. For send_to_address the deadline is explicit (sendTo.expiresAt); for sign_transaction, fetch parameters, sign, and submit promptly rather than holding a quote across a long session. An expired quote can’t be fulfilled — create a new one.

Step 3 — Get parameters, approve, sign, submit

senderAddress is the wallet that will send the payment. It’s optional on EVM chains and required for Solana (which also accepts an optional feePayer). On EVM you get back everything needed to build the transaction: On Solana you get a base64 transactionSerialized ready to sign and submit to recipientAddress.
Approve the allowance first. The payment contract pulls tokens out of the user’s wallet, so the wallet must have approved contractAddress to spend at least requiredTokenInput of inputToken before the payment transaction will succeed. Treat it as a standard approve-then-execute pair. Forgetting the approve leg is the single most common reason a first integration reverts — if your transaction is failing for no apparent reason, check the allowance before anything else.

Step 4 — Track both lifecycles

Once the transaction is on-chain, watch the quote for the crypto leg and the off-ramp for the fiat leg. After the payment is detected, the quote’s confirmation carries the transactionHash and an explorerUrl you can link the user to. The off-ramp resource itself:

Correlating a quote to its off-ramp

The two resources link by id, in both directions:
  • The quote’s offRampId (null until the fiat leg exists) — poll the quote, or take the off-ramp id from the payment.created webhook and confirm it matches.
  • The off-ramp’s quoteId (null for auto-ramp-address deposits, which have no quote).
List off-ramps with GET /v1/off-ramps?accountId={accountId} (cursor-paginated — { data, hasMore, nextCursor }, newest first, not a bare array like GET /v1/bank-accounts).

Quote statuses (crypto leg)

Off-ramp statuses (fiat leg)

Poll GET /v1/off-ramp-quotes/{id} and GET /v1/off-ramps/{id}, list with GET /v1/off-ramps, or — better — react to webhooks: payment.* events fire per off-ramp on your integrator-level subscription. The observed sequence is payment.created and payment.updated when the off-ramp is created, payment.updated as it moves, and payment.completed when fiat lands; payment.refunded covers refunds. There is no payment.failed event — a failure arrives as payment.updated, so always fetch the off-ramp for the authoritative state instead of inferring it from the event name. You’ll also see account.updated fire on the destination bank account as its state changes.

Talking to the user

Subscribe to the offramp.* milestone events — each one is a notification moment with a copy-ready snapshot (amounts, destination name, rail, explorer link). (Rolling out now — subscribable today, firing as the emitters deploy; until then, track with payment.*.) Suggested copy per milestone: Two rules behind the copy: don’t show raw statuses or error codes to users, and never say “completed” for the quote leg alone — money hasn’t arrived until the off-ramp is completed (i.e. the offramp.completed milestone fires).

Refunds

A failed off-ramp can be reissued to its destination account:
Only failed off-ramps are refundable, and only method: "account" (reissue the payout) is available to integrator-created off-ramps. Anything else returns a 422. Refunds are serialized per off-ramp — a concurrent attempt gets a 409 — so send an Idempotency-Key and retry safely.

Errors you’ll actually meet

All errors are RFC 9457 problem responses. Branch on status, type, and code — never on the human-readable detail.

Sandbox testing

The sandbox covers this entire API and the fiat side — but there is no testnet for the off-ramp chain leg, so a real end-to-end run means a small mainnet transaction with real USDC. No approval is needed in sandbox; in production, quote creation requires live-money approval first. The full procedure, including how to get test funds returned, is in Sandbox & Testing.

A build order that works

The fastest path from zero to a working end-to-end sandbox demo:
  1. Auth first. HMAC-sign GET /v1/users/me until it returns 200. Don’t move on until it does — everything else assumes signing works.
  2. Create a user, store the userId and ak_… key.
  3. Bypass KYC (POST /v1/sandbox/bypass-kyc with { "country": "US" }), then read capabilities from GET /v1/users/me.
  4. Register your webhook endpoint and verify signatures — early, while there’s nothing at stake. It makes every later step observable.
  5. Off-ramp: add a dummy bank account (valid routing number, any account number) → create a quote → fetch transaction parameters → sign and submit a small mainnet transaction with real USDC → watch both lifecycles move. Budget a handful of pennies, and ask us to return them when you’re done.
  6. On-ramp: accept terms → create an auto-ramp account → confirm status: "active" → read the deposit instructions → simulate a deposit.
  7. Go live: swap the base URL and credentials for production.

Integration checklist

  • Bank-account entry validates the routing number and confirms the account number with a masked re-entry step
  • Quotes are created with amountMode chosen deliberately, tokenAddress always sent, and amount as a decimal string
  • fulfillment is branched on: sign_transaction fetches parameters, send_to_address shows address, amount, and expiresAt
  • The ERC-20 approve of contractAddress for requiredTokenInput happens before the payment transaction
  • Quote and off-ramp statuses are stored as two separate lifecycles
  • Failures (transaction_failed, insufficient_funds, expired, failed) surface retry-with-new-quote, never a silent spinner
  • Webhooks are triggers: verify the signature, 2xx fast, then fetch the resource
  • Missed webhooks are recoverable by polling GET /v1/off-ramps and GET /v1/integrator/webhooks/deliveries
  • Refunds send Idempotency-Key and handle 409/422

On-ramp: fiat to crypto

The reverse direction: fiat in, crypto out.

Webhooks

Track payment status changes without polling.

Settlement timing

How long each rail takes.

End-user pricing

Fee tiers by rail, token, and size.