The mental model: two resources, two lifecycles
An off-ramp is two legs, and the API models them as two resources:- 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. - 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.
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_fiatcapability isactive. Drive this from the capabilities array onGET /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 withGET /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
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.
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
amountModedecides whatamountmeans.output(the default) means the destination receives exactly that amount, fees on top —input.amountis what the user pays in total.inputmeans 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 requireinput, and theiroutputis an estimate (output.estimated: true); the settled amount is reported by the off-ramp resource.tokenAddressis required on every chain except Bitcoin, Dash, and XRP. There is no native-token fallback — omitting it is rejected with a400. 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.amountis always a decimal string —"100.00", never100. Ininputmode the amount must exceed the fees, or creation fails with400— 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.railpicks 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), andrtp(near-instant, around the clock including weekends and holidays, costs more). See Settlement timing and End-user pricing. Pick a rail the destination’ssupportedRailslists.
Read fulfillment before you go further
fulfillment tells you how the quote gets paid:
sign_transaction— callPOST /v1/off-ramp-quotes/{id}/transactionfor parameters, sign, and submit on-chain (step 3). This is what you’ll get on EVM chains and Solana.send_to_address— send exactlysendTo.amountofsendTo.tokentosendTo.addressbeforesendTo.expiresAt(Bitcoin, Dash, Tron). No transaction parameters exist for these quotes; calling the transaction endpoint returns a422.
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.
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’sconfirmation 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 thepayment.createdwebhook and confirm it matches. - The off-ramp’s
quoteId(null for auto-ramp-address deposits, which have no quote).
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 theofframp.* 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: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 onstatus, 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:- Auth first. HMAC-sign
GET /v1/users/meuntil it returns200. Don’t move on until it does — everything else assumes signing works. - Create a user, store the
userIdandak_…key. - Bypass KYC (
POST /v1/sandbox/bypass-kycwith{ "country": "US" }), then read capabilities fromGET /v1/users/me. - Register your webhook endpoint and verify signatures — early, while there’s nothing at stake. It makes every later step observable.
- 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.
- On-ramp: accept terms → create an auto-ramp account → confirm
status: "active"→ read the deposit instructions → simulate a deposit. - 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
amountModechosen deliberately,tokenAddressalways sent, andamountas a decimal string -
fulfillmentis branched on:sign_transactionfetches parameters,send_to_addressshows address, amount, andexpiresAt - The ERC-20 approve of
contractAddressforrequiredTokenInputhappens 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,
2xxfast, then fetch the resource - Missed webhooks are recoverable by polling
GET /v1/off-rampsandGET /v1/integrator/webhooks/deliveries - Refunds send
Idempotency-Keyand handle409/422
Related
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.