> ## Documentation Index
> Fetch the complete documentation index at: https://docs.spritz.finance/llms.txt
> Use this file to discover all available pages before exploring further.

# Off-ramp: crypto to bank

> Convert stablecoins and other tokens to fiat and settle to a user's bank account.

An [off-ramp](/guides/definitions#off-ramp) converts crypto to fiat and settles it to a
user's destination account, such as a [bank account](/guides/definitions#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](/guides/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](/guides/onboarding#3-read-capabilities).
* 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

<Tip>
  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](/guides/use-cases/push-to-debit).
</Tip>

```bash theme={null}
curl -X POST https://platform.spritz.finance/v1/bank-accounts \
  -H "Content-Type: application/json" \
  # plus your integrator signing headers and the user's Authorization (see Authentication)
  -d '{
    "type": "us",
    "ownership": "personal",
    "routingNumber": "021000021",
    "accountNumber": "123456789",
    "accountSubtype": "checking"
  }'
```

The account comes back `active` with the rails it supports:

```json theme={null}
{
  "id": "6a43ac369288351e982157b9",
  "status": "active",
  "statusReason": null,
  "accountHolderName": "Jane M Doe",
  "supportedRails": ["ach_standard", "rtp"],
  "label": "Bank Account •••• 6789",
  "createdAt": "2026-09-03T15:47:51.729Z",
  "fundingSourceId": null,
  "type": "us",
  "currency": "USD",
  "accountNumberLast4": "6789",
  "routingNumberLast4": "0021",
  "accountSubtype": "checking"
}
```

`institution` (`{ name, logo }`) appears once the institution resolves — treat it as
optional enrichment, and render the `label` when it's absent.

<Warning>
  **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](/guides/ach-returns). 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.
</Warning>

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`:

```json theme={null}
{ "transactionLimit": "20000.00", "dailyLimit": "150000.00", "dailyRemaining": "150000.00" }
```

`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

```bash theme={null}
curl -X POST https://platform.spritz.finance/v1/off-ramp-quotes \
  -H "Content-Type: application/json" \
  # plus your integrator signing headers and the user's Authorization
  -d '{
    "accountId": "6a43ac369288351e982157b9",
    "amount": "100.00",
    "amountMode": "output",
    "rail": "ach_standard",
    "chain": "base",
    "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
  }'
```

```json theme={null}
{
  "id": "6a99…",
  "fulfillment": "sign_transaction",
  "status": "created",
  "createdAt": "2026-09-03T16:00:00.000Z",
  "input": {
    "amount": "102.00",
    "currency": "USD",
    "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    "chain": "base"
  },
  "output": {
    "amount": "100.00",
    "currency": "USD",
    "rail": "ach_standard",
    "accountId": "6a43ac369288351e982157b9",
    "estimated": false,
    "exchangeRate": null
  },
  "fees": { "amount": "2.00", "currency": "USD" },
  "sendTo": null,
  "confirmation": null,
  "offRampId": null
}
```

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](/guides/pricing), 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](/guides/timing) and [End-user pricing](/guides/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

```bash theme={null}
curl -X POST https://platform.spritz.finance/v1/off-ramp-quotes/{quoteId}/transaction \
  -H "Content-Type: application/json" \
  # plus your integrator signing headers and the user's Authorization
  -d '{ "senderAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18" }'
```

`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:

| Field                        | Use                                                                                             |
| ---------------------------- | ----------------------------------------------------------------------------------------------- |
| `contractAddress`            | The `to` of the transaction                                                                     |
| `calldata`                   | The `data`                                                                                      |
| `method`                     | The contract method, e.g. `payWithToken`                                                        |
| `value`                      | Native value in wei — `null` for ERC-20 payments                                                |
| `requiredTokenInput`         | Exact `inputToken` amount in smallest units (USDC has 6 decimals, so `"100000000"` is 100 USDC) |
| `inputToken` / `outputToken` | Token contract addresses (same for direct payments; different on swap paths)                    |
| `chain`                      | The chain to submit on                                                                          |

On Solana you get a base64 `transactionSerialized` ready to sign and submit to
`recipientAddress`.

<Warning>
  **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.
</Warning>

## 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:

```json theme={null}
{
  "id": "6a99…",
  "quoteId": "6a99…",
  "status": "in_flight",
  "createdAt": "2026-09-03T16:00:20.000Z",
  "completedAt": null,
  "input": { "amount": "102.00", "token": "USDC", "chain": "base" },
  "output": {
    "amount": "100.00",
    "currency": "USD",
    "accountId": "6a43ac369288351e982157b9",
    "accountName": "Chase •••• 6789",
    "rail": "ach_standard"
  },
  "fiatDestination": "bank_account",
  "fees": { "amount": "2.00", "currency": "USD" },
  "transaction": { "hash": "0xabc123…", "explorerUrl": "https://basescan.org/tx/0xabc123…" }
}
```

### 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)

| `status`              | Meaning                                                                                                                                                                               | What to do                                                                                                        |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `created`             | Quote exists, no transaction seen yet                                                                                                                                                 | Wait for the user to submit                                                                                       |
| `transaction_pending` | The payment transaction is seen, awaiting confirmations                                                                                                                               | Nothing — transient                                                                                               |
| `confirmed`           | The payment is confirmed on-chain; fiat payout starts                                                                                                                                 | Watch the off-ramp                                                                                                |
| `completed`           | Crypto leg fully settled                                                                                                                                                              | Terminal                                                                                                          |
| `transaction_failed`  | The on-chain transaction reverted or ran out of gas (wallets usually catch a missing allowance in simulation before broadcast — nothing lands on-chain and the quote stays `created`) | Terminal for this quote — show the failure, let the user retry with a new quote. Check the ERC-20 allowance first |
| `insufficient_funds`  | The wallet didn't cover `requiredTokenInput`                                                                                                                                          | Terminal for this quote — ask the user to top up and retry with a new quote                                       |
| `expired`             | The quote wasn't fulfilled in time                                                                                                                                                    | Terminal — create a new quote                                                                                     |
| `failed`              | The quote failed validation after creation                                                                                                                                            | Terminal — create a new quote; contact support if it persists                                                     |
| `refunded`            | The payment was refunded                                                                                                                                                              | Terminal — reconcile against the off-ramp                                                                         |

### Off-ramp statuses (fiat leg)

| `status`           | Meaning                                           | What to do                                                                                                           |
| ------------------ | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `awaiting_funding` | Record exists, waiting for the crypto to land     | Transient                                                                                                            |
| `queued`           | Payout queued for submission                      | Transient (in sandbox the fiat leg parks here — no real payout is submitted)                                         |
| `in_flight`        | Payout submitted to the banking rail              | Show "on its way" with the rail's [timing](/guides/timing); never promise a delivery time the rail doesn't guarantee |
| `completed`        | Fiat delivered to the destination                 | Terminal — show completion and `completedAt`                                                                         |
| `failed`           | The payout failed (for example, a bank rejection) | Terminal — surface it and offer a [refund](#refunds) path or support                                                 |
| `canceled`         | Canceled before settlement                        | Terminal                                                                                                             |
| `reversed`         | The payout was reversed after settlement          | Terminal — reconcile with support                                                                                    |
| `refunded`         | The fiat was refunded                             | Terminal                                                                                                             |

Poll `GET /v1/off-ramp-quotes/{id}` and `GET /v1/off-ramps/{id}`, list with
`GET /v1/off-ramps`, or — better — react to [webhooks](/guides/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](/guides/webhooks#off-ramp-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:

| Milestone event                                                                                       | User message                                                                                                                                 | CTA                                                    |
| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| Quote `created` (no event — your UI moment)                                                           | "Review the transaction"                                                                                                                     | Confirm / cancel                                       |
| Quote `transaction_pending` (no event)                                                                | "Confirming your payment…"                                                                                                                   | None — wait                                            |
| Quote `transaction_failed` / `insufficient_funds` (no event — the user's wallet rejected the payment) | "The payment didn't go through. Check your wallet has enough USDC and that you've approved the spend, then try again."                       | Try again (new quote)                                  |
| Quote `expired` (no event)                                                                            | "This quote expired. Prices move, so here's a fresh one."                                                                                    | Review new quote                                       |
| `offramp.confirmed`                                                                                   | "Payment received — your payout to {accountName} has started"                                                                                | Optional: view on explorer (`transaction.explorerUrl`) |
| `offramp.inFlight`                                                                                    | "Your money is on its way to {accountName}" (name the rail's [timing](/guides/timing) — never promise a deadline the rail doesn't guarantee) | None — wait                                            |
| `offramp.completed`                                                                                   | "Your money has arrived"                                                                                                                     | View account / done                                    |
| `offramp.failed`                                                                                      | "The payout couldn't be completed. Your funds are safe — we'll re-issue or refund it."                                                       | Retry payout / contact support                         |
| `offramp.refunded` / `offramp.reversed`                                                               | "This payout was returned. The funds are back with us — here's what happens next."                                                           | Contact support                                        |

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:

```bash theme={null}
curl -X POST https://platform.spritz.finance/v1/off-ramps/{offRampId}/refund \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  # plus your integrator signing headers and the user's Authorization
  -d '{ "method": "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](/guides/idempotency) and retry safely.

## Errors you'll actually meet

All errors are [RFC 9457 problem responses](/guides/errors). Branch on `status`, `type`,
and `code` — never on the human-readable `detail`.

| Case                                                  | Status        | Notes                                                                                                                                                           |
| ----------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Missing or invalid auth                               | `401`         | `detail` is deliberately generic (`Authentication failed`). Check clock drift first — timestamps more than 5 minutes off are the common cause of mystery 401s   |
| Capability not active                                 | `403`         | Title `Capability Not Available`, with `capability`, `capabilityStatus`, and `nextRequirement` extensions. Drive the user through the requirement's `actionUrl` |
| Missing `tokenAddress`                                | `400`         | Required on every chain except Bitcoin, Dash, and XRP                                                                                                           |
| Malformed `amount` or `accountId`                     | `400`         | `amount` must be a decimal string; `accountId` is a 24-char hex id                                                                                              |
| Unsupported rail / currency / amount mode combination | `400`         | Check the destination's `supportedRails` and [What we support](/guides/supported)                                                                               |
| Unknown or foreign quote / off-ramp / bank account    | `404`         | Another user's resources are indistinguishable from missing ones — never a `403`                                                                                |
| Transaction params for a `send_to_address` quote      | `422`         | Send crypto to `sendTo.address` instead                                                                                                                         |
| Refund of a non-failed or ineligible off-ramp         | `422`         | Only `failed` off-ramps refund, integrator payments reissue via `method: "account"`                                                                             |
| Concurrent refund                                     | `409`         | One refund in flight per off-ramp; retry after it settles                                                                                                       |
| Idempotency-key reuse with a changed body             | `422`         | `urn:problem-type:idempotency-conflict` — replay only byte-identical requests                                                                                   |
| Provider or network outage                            | `502` / `503` | Retryable — back off and retry                                                                                                                                  |

## 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](/guides/sandbox#testing-the-off-ramp).

## 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`

## Related

<CardGroup cols={2}>
  <Card title="On-ramp: fiat to crypto" icon="arrow-right-to-bracket" href="/guides/use-cases/on-ramp">
    The reverse direction: fiat in, crypto out.
  </Card>

  <Card title="Webhooks" icon="bell" href="/guides/webhooks">
    Track payment status changes without polling.
  </Card>

  <Card title="Settlement timing" icon="clock" href="/guides/timing">
    How long each rail takes.
  </Card>

  <Card title="End-user pricing" icon="receipt" href="/guides/pricing">
    Fee tiers by rail, token, and size.
  </Card>
</CardGroup>
