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

# Sandbox & Testing

> Build and test against simulated rails before going live.

Spritz provides a sandbox so you can exercise the full money-movement lifecycle
(onboarding, conversion, settlement, cards) against simulated rails, without moving
real funds.

## Base URL

The sandbox is a separate environment with its own base URL and its own credentials:

```
https://sandbox.spritz.finance
```

Use your sandbox integrator credentials here, exactly as you would in production. When
you're ready to go live, switch the base URL to `https://platform.spritz.finance` and
swap in your production credentials.

## Skipping identity verification

Real users must complete identity verification before they can move money. In the
sandbox you can skip it to keep test flows fast:

```bash theme={null}
# Simulate a successful US verification for the current user
# Add body-specific HMAC headers and the user's Authorization as documented in Authentication.
curl -X POST https://sandbox.spritz.finance/v1/sandbox/bypass-kyc \
  -H "Content-Type: application/json" \
  -d '{ "country": "US" }'

# Use the same signed POST with one of these bodies for other outcomes:
# { "country": "EU" }  -> verified EEA user
# { "failed": true }   -> failed verification
```

Exactly one of `country` or `failed: true` is required. An empty body is a `400` —
there is no default.

### Capability groups

`country` selects a **capability group**: the set of offerings a verified user gets.
It is not an ISO country code. `EU` is the EEA as a whole, because every member state
sees the same offerings — passing a real country code like `DE` or `ES` is rejected
with a `400`.

| Group | Unlocks                                                                                                                       | Sandbox                        |
| ----- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| `US`  | Linked-bank ACH debit, ACH push and wire on-ramps. Payouts via ACH, RTP and push-to-debit-card. Crypto card.                  | Available                      |
| `EU`  | On-ramp and payout via SEPA credit transfer. Crypto card. Gated behind [EU regional compliance](/guides/regional-compliance). | Available                      |
| `CA`  | Canadian offerings.                                                                                                           | `501` — no sandbox fixture yet |
| `GB`  | UK offerings.                                                                                                                 | `501` — no sandbox fixture yet |

A `501` means the group is real but sandbox cannot simulate it yet; it is distinct from
the `400` an unrecognised value gets, and it is not worth retrying.

A temporary verification-service failure returns `503` with code
`SANDBOX_KYC_UNAVAILABLE` and `retryable: true`. Do not treat this as a failed KYC
decision. Read `GET /v1/users/me`; continue if the requested capability is already
active, otherwise retry the same sandbox request with bounded backoff.

The bypass changes verification state; it does not override product configuration.
For an ACH-debit-enabled integrator, the US bypass makes the user's
`fiat_to_crypto` / `ach_debit` capability active with no separate terms requirement.
Other products or regions can still have requirements, including EEA regional
compliance. Read [`GET /v1/users/me`](/api-reference) after the bypass to see each
capability's `status` and outstanding `requirements`, and
[`GET /v1/on-ramps/supported-pairs`](/api-reference) for the rail, network and token
combinations the group can actually use.

The bypass response is a legacy verification document with no public stable schema.
Do not store or branch on it. Treat a `2xx` as “simulation accepted,” then read the
typed user profile and capabilities from `GET /v1/users/me`.

<Warning>
  Sandbox-only endpoints return `403` in production. Don't build production flows that
  depend on them.
</Warning>

## Simulating the rails

The sandbox also lets you drive the parts of a flow that normally depend on banks and
networks, so you can test the unhappy paths on purpose.

Sandbox-plan ACH deposits are isolated from accumulated program-control halts and
other test users. One simulated return cannot silently make later scenarios fail for an
unrelated user. Use the named profiles below to request each observable outcome; do not
try to reproduce production decision logic with Plaid test data.

### Link a bank account without Plaid Link

First bypass KYC with `{ "country": "US" }` and confirm the user's ACH debit capability
is active. The sandbox bank-link endpoint rejects users in any other capability group.

Use the sandbox link endpoint when an automated test cannot open Plaid Link:

```bash theme={null}
# Add body-specific HMAC headers and the user's Authorization as documented in Authentication.
curl -X POST https://sandbox.spritz.finance/v1/sandbox/bank-accounts/link \
  -H "Content-Type: application/json" \
  -d '{ "simulation": { "code": "ownership_matched" } }'
```

This creates a Plaid Sandbox item and runs the normal token exchange, account sync,
ownership check, funding-source creation, event, and cache-invalidation paths.

Successful profiles accept `simulation.account: "primary" | "secondary"`. It defaults
to `primary`; use `secondary` to link a distinct matched bank for the same user. Link
both before destructive tests such as repeated returns.

| `simulation.code`        | Response                                                       | Funding source                                        | What to test in your UI                                                                              |
| ------------------------ | -------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `ownership_pending`      | `200`                                                          | `pending`, ownership not yet resolved                 | Show “We’re verifying this bank account”; disable deposits and offer a refresh action.               |
| `ownership_matched`      | `200`                                                          | `active`, ownership `matched`                         | Continue to limits and deposit entry.                                                                |
| `ownership_uncertain`    | `200`                                                          | `review_required`, reason `ownership_review_required` | Show review/pending; do not allow a deposit.                                                         |
| `ownership_mismatch`     | `200`                                                          | `ineligible`, reason `ownership_mismatch`             | Explain that the account owner did not match; offer another bank.                                    |
| `link_failed`            | `503`, code `PLAID_LINK_FAILED`, `retryable: true`             | No bank account or funding source is created          | Keep the user on linking; offer retry or another bank.                                               |
| `duplicate_identity`     | `409`, code `PLAID_DUPLICATE_IDENTITY`, `retryable: false`     | No bank account or funding source is created          | Stop linking. Tell the user to use their existing account or contact support.                        |
| `duplicate_bank_account` | `409`, code `PLAID_DUPLICATE_BANK_ACCOUNT`, `retryable: false` | No active bank account or funding source is created   | Stop linking. Tell the user to use the account where this bank is already linked or contact support. |

Ownership mismatch and review profiles do not claim the bank account. A different user
who owns that bank can still link it successfully.

Link the same successful account slot a second time to test duplicate handling. The
second request returns `409` with `code: "PLAID_ACCOUNT_ALREADY_LINKED"`; it does not
create another funding source and does not change the active source. Close Link and
re-list funding sources instead of retrying.

To test unlinking, delete the bank with `DELETE /v1/bank-accounts/{accountId}`.
Its funding source remains readable with `status: "deleted"`. Linking the same healthy
account slot again returns the original bank-account and funding-source IDs, restores
the source to `active`, and leaves exactly one active source. Treat those IDs as stable;
do not create a second local payment method.

### Simulate deposit decision outcomes

Prepare a direct deposit, then send its `preparationId` to the sandbox create endpoint
with one public outcome profile. These profiles select client-visible behavior; they do
not identify the production provider input, policy rule, or threshold that caused it.

```bash theme={null}
# Add body-specific HMAC headers and the user's Authorization as documented in Authentication.
curl -X POST https://sandbox.spritz.finance/v1/sandbox/deposits/direct \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: your-unique-key" \
  -d '{
    "preparationId": "prep_...",
    "riskSimulation": { "profile": "review_required" }
  }'
```

| `riskSimulation.profile`         | Public code                   | What to do                                                                                                                                         |
| -------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `review_required`                | `risk_review_required`        | Show review/support. Do not loop or immediately retry.                                                                                             |
| `rejected`                       | `risk_rejected`               | Show payment unavailable. Do not retry the same bank and amount.                                                                                   |
| `source_temporarily_unavailable` | `risk_rerouted`               | Offer another bank, or wait until the source's `availableAt`.                                                                                      |
| `decision_unavailable`           | `risk_evaluation_unavailable` | Show a temporary error. Prepare again and retry later.                                                                                             |
| `high_priority_available`        | No error; deposit created     | Show the returned instant and settlement portions and the blended fee.                                                                             |
| `high_priority_downgraded`       | No error; deposit created     | Continue with standard timing. Render the fee and timing from the create response; do not ask the user to retry solely to recover instant release. |

The first four profiles return `409` and create no deposit. A create attempt consumes
its preparation. Reuse the same idempotency key and body only to recover after a
timeout; prepare again for a new user attempt.

Use both high-priority profiles with a high-priority preparation.
`high_priority_available` returns the quoted instant split.
`high_priority_downgraded` returns `200`, preserves `requestedPriority: "high"`, and
sets:

```json theme={null}
{
  "priority": "normal",
  "releaseDecisionMode": "after_settlement",
  "instantPortionUsd": "0.00",
  "settlementPortionUsd": "500.00"
}
```

The returned regular, instant, and total fee fields are also recalculated for the
actual release timing. Treat the create response as authoritative.

### Simulate refund and crypto-release states

Prepare a **normal-priority** deposit for `refunded`, `release_failed`,
`release_stalled`, or `full_delivery`, then create it with one lifecycle profile:

```bash theme={null}
# Add body-specific HMAC headers and the user's Authorization as documented in Authentication.
curl -X POST https://sandbox.spritz.finance/v1/sandbox/deposits/direct \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: your-unique-key" \
  -d '{
    "preparationId": "prep_...",
    "lifecycleSimulation": { "profile": "release_failed" }
  }'
```

| `lifecycleSimulation.profile` | Created state                                                                  | What to test                                                                                                              |
| ----------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `refunded`                    | `status: "refunded"`, `debitStatus: "settled"`, `releaseStatus: "not_started"` | Show that a refund was initiated. Do not imply it already reached the bank.                                               |
| `release_failed`              | `status: "failed"`, `debitStatus: "settled"`, `releaseStatus: "failed"`        | Show “not delivered,” not “pending.” Do not promise a refund.                                                             |
| `release_stalled`             | `status: "processing"`, `debitStatus: "settled"`, `releaseStatus: "queued"`    | Keep delivery pending with no guessed deadline; reconcile by API read.                                                    |
| `full_delivery`               | `status: "completed"`, `debitStatus: "settled"`, `releaseStatus: "completed"`  | Capture `achDebit.delivered` and re-read limits. Use this profile for the per-user exposure sequence below.               |
| `partial_then_full_delivery`  | `status: "completed"`, `debitStatus: "settled"`, `releaseStatus: "completed"`  | Capture `achDebit.deliveryProgress` followed by `achDebit.delivered` for one deposit and test ordering and deduplication. |

These profiles create a real sandbox deposit record and make the resulting state
available from both `GET /v1/deposits/{depositId}` and `GET /v1/deposits/`. They do not
call a money-movement provider. The `refunded` profile also records the same durable
`achDebit.refunded` semantic event used by the production refund path.

Use the create response itself to assert `authorized`. Use `release_stalled` to assert
the `processing` / `queued` UI.

For deterministic partial and full delivery events, prepare a **high-priority**
`500.00` deposit whose preparation contains positive `instantPortionUsd` and
`settlementPortionUsd`, then create it with
`lifecycleSimulation.profile: "partial_then_full_delivery"`. The final create response
is completed, while the semantic webhook snapshots preserve the intermediate partial
state and exact amounts. The profile emits the sequence
`achDebit.authorized` → `achDebit.deliveryProgress` → `achDebit.delivered` without
calling a crypto or bank provider.

Separately run `riskSimulation.profile: "high_priority_available"` to exercise the
provider-backed sandbox execution path. Poll the deposit every 2 seconds for up to 5
minutes and assert the final `confirmedReleasedAmountUsd`; do not require its transient
partial state to last long enough for polling. The five-minute value is a test-harness
observation bound, not a user delivery promise.

### Simulate program pauses

Use the sandbox prepare endpoint with a normal deposit body plus one state:

```bash theme={null}
# Add body-specific HMAC headers and the user's Authorization as documented in Authentication.
curl -X POST https://sandbox.spritz.finance/v1/sandbox/deposits/direct/prepare \
  -H "Content-Type: application/json" \
  -d '{
    "sourceId": "fs_...",
    "address": "YourUsersSolanaWallet",
    "network": "solana",
    "asset": "USDC",
    "quoteType": "exact_output",
    "amountUsd": "25.00",
    "priority": "normal",
    "programControlSimulation": { "state": "halted" }
  }'
```

| `programControlSimulation.state` | Response                                                  | UI behavior                                                                                        |
| -------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `new_user_paused`                | `409`, `ach_debit_new_user_paused` for a new sandbox user | Show temporary unavailability; offer another supported on-ramp. Do not expose user classification. |
| `halted`                         | `409`, `ach_debit_program_halted`                         | Show temporary ACH debit unavailability. Do not retry in a loop.                                   |

No preparation or deposit is created in either blocked case.

### Simulate per-user open exposure

Use one fresh user and one matched source. This sequence exercises the public
`open_exposure` result without exposing or hardcoding a tier threshold:

1. Read `limitsByPriority.normal` and assert `available: true`.
2. Prepare `normal.maxAmountUsd` with `priority: "normal"`.
3. Create it through `/v1/sandbox/deposits/direct` with
   `lifecycleSimulation.profile: "full_delivery"`. Assert `status: "completed"`,
   `debitStatus: "settled"`, `releaseStatus: "completed"`, and
   `confirmedReleasedAmountUsd` equal to the prepared principal.
4. Re-read limits and repeat steps 2–3 at the new `normal.maxAmountUsd` while normal
   remains available. Use a fresh preparation and idempotency key each time.
5. The final normal block must return `available: false`, `maxAmountUsd: "0.00"`,
   `reason: "open_exposure"`, `suggestedAction: "auto_ramp"`, a non-null `clearsAt`,
   and `clearsAtIsEstimate: true`.

Do not assert the number of deposits or infer a private exposure ceiling from their
amounts. The current public limits decide each iteration. Use a fresh user for another
run because completed deposits intentionally remain inside that user's open-exposure
window.

### Simulate program and instant capacity

Two sandbox-only endpoints let you force the public `aggregate_exposure`,
`rail_halted`, partial-instant, and high-to-normal outcomes:

Instant scenarios require Spritz to enable high priority on the sandbox integrator.
There is no public self-service switch. Ask Spritz for an enabled sandbox credential;
if `limitsByPriority.high.available` remains false, record instant scenarios as blocked
by configuration rather than failed.

| Method | Path                                 | Purpose                                                                          |
| ------ | ------------------------------------ | -------------------------------------------------------------------------------- |
| `GET`  | `/v1/sandbox/ach-debit/exposure`     | Read your sandbox integrator's current cap and open-exposure values.             |
| `POST` | `/v1/sandbox/ach-debit/exposure/cap` | Replace `aggregateCapW2Usd` and `aggregateCapW1Usd` for that sandbox integrator. |

The W1/W2 names are API field names for two isolated sandbox capacity controls. The
endpoint does not describe how production policy is calculated. Never hardcode the
returned values into product behavior.

<Warning>
  Cap changes affect every user under the authenticated sandbox integrator. Run these
  scenarios serially against a dedicated test integrator. Read both original values
  first and restore both in a `finally` block. If restoration fails, stop testing and
  restore them before another deposit test runs.
</Warning>

Use this acceptance sequence:

1. Read and save the original response.
2. Read an active source's baseline `limitsByPriority`.
3. With high priority enabled, prepare an amount no greater than both
   `high.maxAmountUsd` and `high.maxEarlyReleaseAmountUsd`, then create with
   `riskSimulation.profile: "high_priority_available"`. Assert `early_full` and the full
   principal in `instantPortionUsd`. This is the deterministic full-instant case.
4. To test normal program capacity, set `aggregateCapW2Usd` to the current
   `openExposureW2Usd` plus a small headroom below the source's baseline maximum. Keep
   W1 unchanged. Re-read limits: the returned normal maximum should shrink. Preparing
   that maximum succeeds; one cent above returns `aggregate_exposure`.
5. Set `aggregateCapW2Usd` to `"0.00"`. Normal becomes unavailable with
   `reason: "rail_halted"` and `suggestedAction: "auto_ramp"`.
6. Restore the original W2 cap. Then set `aggregateCapW1Usd` to
   `committedExposureW1Usd` plus a small desired instant portion. Keep W2 unchanged.
   Re-read limits and verify `high.maxEarlyReleaseAmountUsd` is constrained while normal remains
   available. Prepare and create above that portion with `high_priority_available`;
   assert `early_partial`, the constrained `instantPortionUsd`, and the remainder in
   `settlementPortionUsd`.
7. Prepare another high-priority amount above that portion, then set W1 to the current
   `committedExposureW1Usd` before create. Create succeeds but changes to normal timing;
   render the create response without retrying.
8. Restore both original cap values, even if any assertion fails.

### Simulate ACH returns

Prepare a normal direct deposit, then create it with a supported ACH return code:

```bash theme={null}
# Add body-specific HMAC headers and the user's Authorization as documented in Authentication.
curl -X POST https://sandbox.spritz.finance/v1/sandbox/deposits/direct \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: your-unique-key" \
  -d '{
    "preparationId": "prep_...",
    "returnSimulation": { "code": "R10" }
  }'
```

The deposit is created normally. The simulated bank then returns it asynchronously.
Poll the deposit until `status: "returned"` and reconcile the
`achDebitReturn.created` webhook or `GET /v1/integrator/ach-debit/returns`.

Sandbox accepts `R01` through `R39`, plus `R45` and `R51`. All 41 codes run through the same
provider-backed return path and have been exercised end to end. Use a fresh sandbox user
for each independent return so a prior source or user action cannot affect the next case.

Start with `R01`, `R05`, and `R10` as representative provider-path tracers. Assert that
the return record contains a valid `userAction`, then route that value through the same
handler as every other code. Test all four `userAction` branches with fixtures.

Production return escalation policy is intentionally not public. Always branch on the
returned `userAction`, never recreate a threshold from return history. Test
`userAction: "restricted"` with a return-record fixture in your frontend contract
suite.

<Warning>
  A simulated return can report `completed`, or an aggregate `failed`/`refunded` crypto
  release outcome, before changing to `returned`. Keep processing later deposit and
  return updates. After `returned`, boundedly reconcile the funding source until it is
  `disabled`; a cached source read can lag briefly.
</Warning>

Use exactly one of `riskSimulation`, `returnSimulation`, or `lifecycleSimulation`. See
[ACH returns](/guides/ach-returns) for the frontend contract.

### ACH debit release checklist

Before production, record a pass for each row. Use fresh users except where a scenario
explicitly tests repeated behavior.

| Area             | Required scenarios                                                                                                                                    |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| User             | Successful US verification, failed verification, missing ACH capability                                                                               |
| Bank link        | Ownership matched, uncertain, mismatch, complete link failure, duplicate identity, duplicate bank, same-user duplicate, unlink/relink, blocked relink |
| Funding source   | `pending`, `active`, `review_required`, `ineligible`, `disabled`, `deleted`                                                                           |
| Amount           | Exact minimum, one cent below minimum, exact maximum, one cent above maximum                                                                          |
| Priority         | Normal, full instant, partial instant, high downgraded to normal, high unavailable                                                                    |
| Deposit decision | Review, reject, source temporarily unavailable, decision unavailable, high priority available, high priority downgraded                               |
| Capacity         | User/open exposure, aggregate exposure, rail halt, instant portion constrained, instant capacity exhausted                                            |
| Program          | New-user pause and integration halt                                                                                                                   |
| Lifecycle        | Authorized, processing/queued, partial confirmation, full confirmation, refund, release failure, return after completion                              |
| Returns          | All 41 supported codes, every `userAction`, and an unknown-code fallback that still follows `userAction`                                              |
| Webhooks         | Valid signature, `2xx`, `4xx`, `5xx`, timeout, stable semantic-event identity, duplicates, out-of-order handling, delivery-log recovery               |
| Reconciliation   | `GET /v1/deposits/`, deposit read, return list/read, funding-source refresh after missed webhooks                                                     |
| Communications   | Authorized, partial delivery, full delivery, failed remainder, refund initiated, return action                                                        |

The sandbox does not currently provide a deterministic debit-submission failure,
unknown return-code, ACH notice-of-change, or access-restored profile. Record these as
contract-tested rather than sandbox-executed:

| State                    | Public contract test                                                                                                                                                              |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Debit submission failure | Feed a deposit fixture with `debitStatus: "failed"` through the UI/notification logic. The deposit is terminal; show no retry button.                                             |
| Unknown return code      | Feed an arbitrary `returnCode` through the return handler and prove the UI branches only on `userAction`.                                                                         |
| Restricted return action | Feed a return fixture with `userAction: "restricted"`; stop ACH deposits and show support. Do not encode a return-count threshold.                                                |
| ACH notice of change     | There is no dedicated public NOC object or user action. Treat `account.updated` as a refresh trigger; show nothing unless the refreshed public bank/funding-source state changed. |
| Access restored          | Prove notification requires an active ACH capability, an active funding source, and `limitsByPriority.normal.available: true`.                                                    |

### Test webhook failure and recovery

Use a disposable HTTPS receiver such as Webhook.site; you do not need to deploy test
infrastructure. Register it for `achDebitReturn.created`, configure it to return `503`,
then run an `R01` return simulation.

Expect three signed `POST` attempts with the same payload. Then read
`GET /v1/integrator/webhooks/deliveries`: the matching final record has
`success: false`, `responseStatus: 503`, and no `error`. Reconcile the return through
`GET /v1/integrator/ach-debit/returns/{id}` rather than waiting for another webhook.

Repeat with a receiver that returns `2xx` to verify the accepted path. See
[Webhooks](/guides/webhooks#retries-and-delivery-outcomes) for `4xx`, timeout, and
transport-error handling.

To test an unknown outcome, configure the receiver to delay its response for 10 seconds.
Expect three signed attempts. The delivery record has `success: false`,
`responseStatus: 504`, and a non-empty `error`. Do not treat that as proof the event was
unprocessed; reconcile the return and make the handler safe to repeat.

### Prove push deduplication and ordering

Register the receiver for all five `achDebit.*` communication events. Capture the
`achDebit.authorized`, `achDebit.deliveryProgress`, and `achDebit.delivered` payloads
from one `partial_then_full_delivery` lifecycle simulation. Then run the captured
payloads through the same durable handler used in production:

1. Process the same `achDebit.deliveryProgress` payload twice. Assert one webhook-inbox
   row and one push-outbox row for its `eventId`.
2. Process `achDebit.delivered`, then process the earlier
   `achDebit.deliveryProgress` payload. Assert the lower `sequence` is recorded as stale
   and creates no push.
3. Process the same `achDebit.delivered` payload again after a simulated timeout. Assert
   its stable `eventId` still creates no second push.
4. Fail one semantic delivery, read its exact `payload` from
   `GET /v1/integrator/webhooks/deliveries`, and feed that payload into the handler.
   Assert it follows the same inbox and sequence rules.

Do not implement this test by fetching the deposit and comparing its current status.
The semantic payload, `eventId`, and `sequence` are the notification occurrence; API
reads are only for current UI reconciliation.

### Reset a funding source

`DELETE /v1/sandbox/funding-sources/{fundingSourceId}` removes a funding source so you
can re-run a clean flow. A source disabled after a return cannot be linked again once
removed. Use a fresh sandbox user for independent return scenarios, or link both
deterministic account slots before testing repeated returns on one user.

The `/v1/sandbox/bills/*` endpoints similarly simulate bill activation and verification
challenges.

See the [API Reference](/api-reference) for the full set of sandbox endpoints and their
request shapes.

## Testing the off-ramp

The sandbox covers the off-ramp API and the fiat side, with one deliberate exception
worth planning around.

### There is no testnet — the chain leg is real mainnet

We'll be straight with you about this one, because it affects how you plan your test
cycle: there is no test chain for the off-ramp. The way to exercise a real off-ramp end
to end is a **small mainnet transaction with real USDC** — \$1 is plenty. Yes, real money,
even in sandbox. What you get for it is a full live flow with nothing stubbed, which is
worth a great deal more than a mock right before you launch.

Two things take the sting out:

* The USDC lands in a Spritz wallet. Tell us what you sent and we'll return it.
* **No real fiat is paid out in sandbox.** You don't need a real bank account — add a
  dummy one. The routing number must be genuinely valid (we validate it, so use a real
  bank's), but the account number can be anything. Sandbox is the only place a made-up
  account number is the right answer.

One more small-transaction nuance: sandbox pricing is not the published production
pricing — always read the fee from the quote's `fees`/`input` fields rather than assuming
the [rate card](/guides/pricing) applies to a test run.

<Note>
  In **production**, quote creation is gated on live-money approval: until your
  production integrator is approved for live testing, `POST /v1/off-ramp-quotes` fails
  with `400` and detail `The integrator is not approved to move live money
      (sandbox_simulated_money_only)`. That gate does not apply to the sandbox environment.
</Note>

### Where a sandbox off-ramp stops

The crypto leg runs for real and the quote reaches `confirmed`. The fiat leg is
simulated: the off-ramp is created and queues (`awaiting_funding` → `queued`), and
`payment.created`/`payment.updated` webhooks fire — but no payout is submitted, so the
record never reaches `in_flight` or `completed` and there is no `payment.completed`
event in sandbox. Test those states in production with a small live run.

### Terms acceptance is headless

Where production asks the user to complete the provider's hosted terms flow, the sandbox
accepts any opaque `agreementId`:

```bash theme={null}
# Add body-specific HMAC headers and the user's Authorization as documented in Authentication.
curl -X POST https://sandbox.spritz.finance/v1/users/me/terms \
  -H "Content-Type: application/json" \
  -d '{ "agreementId": "00000000-0000-0000-0000-000000000000" }'
```

The provider activates the customer asynchronously afterwards, so a small retry window
applies before auto-ramp account creation succeeds — see
[On-ramp](/guides/use-cases/on-ramp#before-you-start).

## Testing the on-ramp

Simulate a fiat deposit arriving at an auto-ramp account — no real funds move, but the
full conversion path runs, the on-ramp settles to `completed`, and real `onramp.*`
webhooks fire:

```bash theme={null}
# Add body-specific HMAC headers and the user's Authorization as documented in Authentication.
curl -X POST https://sandbox.spritz.finance/v1/sandbox/auto-ramp-accounts/{id}/deposit \
  -H "Content-Type: application/json" \
  -d '{ "amount": "10.00" }'
```

The response carries the resulting `onRampId`, fees, and output; reconcile it through
`GET /v1/on-ramps/{id}`.

## Going to production

<Steps>
  <Step title="Swap the base URL">
    Point requests at `https://platform.spritz.finance`.
  </Step>

  <Step title="Use production credentials">
    Sandbox and production credentials are separate. Move your production integrator
    key and secret into your secrets manager.
  </Step>

  <Step title="Complete production approval">
    Live money movement is gated per integrator. If quote creation answers `400` with
    `sandbox_simulated_money_only` (or another live-money block reason), your production
    approval isn't finished — [talk to us](https://help.spritz.finance/en/).
  </Step>

  <Step title="Run real verification">
    Remove any sandbox KYC bypass; real users complete
    [identity verification](/guides/quickstart) before moving money.
  </Step>
</Steps>
