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

# Guides

> Testing in sandbox, what your users see when they withdraw or add funds, and how to read the errors.

These are the questions that come up after the widget renders: what sandbox is actually doing, what your users see when they withdraw or add funds, and what the errors mean.

Setup and the API surface are on [Connect](/connect).
Embedding, theming and the event list are on [Embedding and customization](/connect/widgets).

## Sandbox testing

The only switch is the API key prefix.
An `sk_test_` key sets `sandbox: true` on everything downstream of it, including the widget tokens you mint with it, so no code of yours changes between environments.

What that flag actually does is narrower than "nothing is real", and the boundary is not where most people assume.

### What a test key changes

| Operation        | Under `sk_test_`                                                                                                                                                                                                         |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Account creation | A real account is created and tagged `sandbox`. It gets a simulated payment-account id (`sim_biz_...`) instead of a provider-issued one.                                                                                 |
| Wallet balance   | Seeded once with \$10,000. Seeding happens on first use and never overwrites an existing balance, so test transfers accumulate like a real ledger instead of resetting.                                                  |
| Transfers        | Fully executed against a separate sandbox ledger. Amount validation, daily limits, idempotency and balance checks all run for real. Only the outbound provider call is replaced, returning a simulated `sim_txn_...` id. |
| Reads            | Partitioned. `GET /transfers`, `GET /transfers/{id}` and `GET /accounts/balance` all filter on the sandbox flag, so sandbox and live data can never appear in the same response.                                         |
| Webhooks         | Delivered. If you configure a separate sandbox webhook URL and secret it is used; otherwise deliveries fall back to your live webhook config.                                                                            |

<Note>
  Sandbox balances live in a different wallet document and a different ledger collection from live balances.
  That isolation is what makes the seeded \$10,000 safe, and it is also why the exceptions below behave the way they do.
</Note>

### What is not simulated

A sandbox account never receives a real payment-account id, and the operations below resolve the real one.
They do not fail gracefully into a simulation - they fail.

* **Identity verification.** The verification API resolves the live payment account and returns `404 NO_WHOP_ACCOUNT` for a sandbox account. You cannot exercise KYC in sandbox.
* **Withdrawals.** Same lookup, same 404. And because `withdrawal.created` and `withdrawal.updated` are re-emitted from the payments provider's own webhooks, none of the `withdrawal.*` events fire in sandbox.
* **Moving funds to the withdrawal balance.** `POST /accounts/withdrawal-balance` has no sandbox branch at all and calls the provider directly.
* **Withdrawable balance.** In sandbox the provider balance is never fetched, so `GET /accounts/balance` reports a withdrawable balance of `0` and the payout widget has nothing to show as withdrawal-ready. Your seeded \$10,000 is a wallet balance, which is a different number.

<Warning>
  Adding funds is not sandboxed.
  A deposit made in a sandbox session is a real charge, and on settlement it credits the account's **live** wallet, not its sandbox wallet - the top-up ledger path does not branch on the sandbox flag.
  The deposit will not appear in a sandbox balance response, because that response reads the sandbox wallet.
  Do not run deposits with a test key.
</Warning>

### Testing webhook delivery

Since the events tied to real money movement are the ones you cannot trigger in sandbox, there is a dedicated endpoint that signs and delivers one without moving anything:

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/embed/webhooks/test \
  -H "x-api-key: sk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "eventType": "deposit.confirmed", "amountCents": 10000 }'
```

`deposit.confirmed` is the only supported event type.
The payload is always marked `sandbox: true` and carries `metadata.test = "true"`, so your handler can tell it apart.
A non-2xx from your endpoint comes back as `502 WEBHOOK_DELIVERY_FAILED` rather than being retried silently, which makes it a usable signature-verification harness.

### Before you switch keys

Swapping `sk_test_` for `sk_live_` is the whole migration on your side.

The one thing worth checking first is your allowed-domains list.
It belongs to the integration rather than to a key, so whatever you added to get sandbox working - a preview deployment, a tunnel hostname, a broad wildcard - is already live.
Remove those entries before you send real money through the same list.

## Withdrawals

The payout widget is what your user sees.
It loads a balance, gates the first withdrawal behind identity verification, then collects a payout method and an amount.

<Steps>
  <Step title="Balance">
    The widget reads the account's provider balance and breaks it into **Available**, **Pending** and **Held in reserve**.
    Only Available is withdrawable.
    If you passed a `passedInBalance` when creating the account, it renders separately as a display-only tile and is never spendable.
  </Step>

  <Step title="Identity verification">
    Required before the first withdrawal, and enforced in the widget rather than at the API.
    The button reads **Complete verification** until the account is verified, **Pending approval** while a submission is under review, and **Withdraw** once cleared.
    If the provider asks for more information the button is replaced by a banner with a Resolve action.

    Verification runs in an iframe when the provider allows framing, and otherwise hands off to the user's phone by QR code.
    This is why the host iframe needs `allow="payment; camera; microphone"` - without it the camera step fails inside the frame instead of falling back.
  </Step>

  <Step title="Payout method">
    Added inline, in the same panel, with no redirect and no second window.
    The available destinations and the fields each one needs come from the payments provider's catalog, so the form is rendered from a schema rather than hardcoded.
    Categories include instant bank transfer, next-day bank transfer, wire, digital wallets and crypto, and which ones appear depends on the account's country and currency.
    Methods can be renamed and removed from the same dropdown.
  </Step>

  <Step title="Amount and confirmation">
    Fees, exchange rate, estimated delivery and estimated amount received are quoted per method before the user confirms.
    SideShift does not impose its own minimum or maximum here - the limits are the selected method's, and the widget deliberately does not hide methods or block submission on a local minimum, because the provider returns the actionable error on submit.
  </Step>
</Steps>

### States

| Status             | Meaning                                                                                         |
| ------------------ | ----------------------------------------------------------------------------------------------- |
| `requested`        | Submitted, not yet picked up.                                                                   |
| `awaiting_payment` | Accepted, waiting to be funded.                                                                 |
| `in_transit`       | Sent to the destination.                                                                        |
| `completed`        | Delivered.                                                                                      |
| `failed`, `denied` | Terminal failure. `withdrawal.failed` carries `metadata.errorCode` and `metadata.errorMessage`. |
| `canceled`         | Terminal, and not treated as a failure - it does not produce a `withdrawal.failed` event.       |

There is no per-transition history.
The provider exposes the current status plus `created_at` and `estimated_arrival`, and nothing more, so the timeline in the widget is reconstructed from the current status rather than replayed.
If you need the full lifecycle, subscribe to `withdrawal.updated` rather than to the terminal aliases.

<Note>
  Some countries require manual review before a first payout.
  A withdrawal from a restricted country returns `403 SIDESHIFT_APPROVAL_REQUIRED` with the country in the payload, and the account is flagged for review.
  The user sees this inline in the withdrawal sheet.
</Note>

## Adding funds

The pay-in widget lists the account's saved payment methods, takes an amount, and charges the selected method.
No identity verification is involved - that gate is on withdrawal only.

Cards, US bank accounts, SEPA debit and Cash App can be saved as methods.
Adding one opens a hosted checkout inside the widget, and the saved method is vaulted by the payments provider rather than by you.

### Amount

Three query parameters control the amount field, and they are pay-in only:

| Parameter       | Effect                                                                                                        |
| --------------- | ------------------------------------------------------------------------------------------------------------- |
| `defaultAmount` | Prefills the field. In cents.                                                                                 |
| `quickAmounts`  | Comma-separated list of preset buttons, in cents. Defaults to $100, $500, $1,000 and $2,500.                  |
| `lockAmount`    | `true` makes the amount read-only. This also suppresses the preset buttons, regardless of `showQuickAmounts`. |

The minimum deposit for a Connect account is $1, and the maximum is $200,000.
Connect accounts are exempt from the higher first-deposit floor that applies elsewhere on SideShift, so you should not see a \$500 minimum; if you do, an override has been set on the account.
The input itself accepts amounts below the minimum, so the server is the real gate and a rejection there is expected rather than a bug in your parameters.

### Fees

The processing fee is fetched per payment method and **added on top** of the amount.
The wallet is credited the amount the user typed; the card or bank is charged amount plus fee.
Defaults are 2.9% + \$0.40 for cards and 1.0% for bank transfers, and a per-company rate can replace them, which is why the widget reads the rate from the API rather than computing it.
The server recalculates the fee when it charges, so treat the figure in the breakdown as a quote.

<Warning>
  `payin:deposit_completed` is not settlement.
  It fires as soon as the charge is accepted, including when the status is still `processing` or a bank transfer has only just started.
  Credit the user's balance on the `deposit.confirmed` webhook, not on this event.
</Warning>

## Troubleshooting

The widget renders failures as a full-panel state with the raw reason as its message, so the string your user reads is usually the exact string below.

<AccordionGroup>
  <Accordion title="Access Denied - Token domain mismatch">
    The most common integration error, and the least self-explanatory.

    Every token is bound to a single domain at mint time, stored in the token's audience.
    The binding is chosen in this order: the `targetDomain` you passed, otherwise the `Origin` of the request that minted the token, otherwise the first entry in your allowed-domains list.
    That last fallback is the trap - a token minted from a backend that sent no `Origin` and passed no `targetDomain` gets bound to whichever domain happens to sit first in your list, which is frequently not the page you are embedding on.

    When the widget loads, the page it is framed in is compared against that binding.
    A mismatch is not fatal on its own: the check then falls back to your integration's allowed-domains list, and passes if the embedding domain is on it.
    You only see the error when both checks fail.

    Two fixes, and the first is usually the right one:

    * **Add the domain to your allowed domains.** This fixes existing tokens without re-minting them, because the fallback check reads the list live.
    * **Pass `targetDomain` when minting**, set to the domain you are embedding on. A `targetDomain` that is not already on the allowed list is rejected at mint time with `400 DOMAIN_NOT_ALLOWED`, so this is a stricter version of the first fix rather than a way around it.

    ```bash theme={"system"}
    curl -X PATCH https://app.sideshift.app/api/embed/domains \
      -H "x-api-key: sk_live_YOUR_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "addDomains": ["app.example.com", "*.clients.example.com"] }'
    ```

    Matching is exact, with `*.` as the only wildcard.
    The usual near-miss is `www`: a bare `example.com` entry does not cover `www.example.com`, which needs either its own entry or `*.example.com`.
    Requests from `localhost` and loopback addresses are accepted without being listed, which is why a mismatch so often appears for the first time on a staging deploy rather than in development.

    Settings → Connect in the dashboard edits the same list.
  </Accordion>

  <Accordion title="Access Denied - Token expired, Session expired, Session not found or revoked">
    Three distinct causes with the same remedy.

    `Token expired` is the token itself passing its expiry.
    `Session expired` is the server-side session record passing its expiry while the token still verifies, at which point the record is deleted.
    `Session not found or revoked` means the record is already gone - cleaned up after expiry, or revoked deliberately.

    Tokens default to one hour and cannot exceed 24 hours.
    The widget refreshes its provider credentials on its own, but that refresh never extends the embed token's own lifetime, so expiry can only be resolved from your side.

    Handle the `session:expired` event, mint a fresh token, and reload the frame.
    See the [event list](/connect/widgets#events) for the message shape.
  </Accordion>

  <Accordion title="Widget Not Available - the payout/payin widget is not enabled">
    Your integration has per-widget permissions and the one you asked for is off.
    Minting a token for a widget you are not permitted to use fails earlier, with `403 WIDGET_NOT_PERMITTED` and the message `Widget type 'payout' is not enabled for this integration`.

    This is an account setting rather than a request parameter, so it needs SideShift to change it.
  </Accordion>

  <Accordion title="Account Setup Required, or the account belongs to a different integration">
    Payment operations through a widget require the account to have been created through the Connect API by the integration whose token is presenting it.

    `NOT_EMBED_ACCOUNT` means the account exists but was not created this way.
    `INTEGRATOR_MISMATCH` means it was created by a different integration.
    An account merely linked to your integration rather than created by it can be read, but cannot transact - the payout widget shows it a prompt to withdraw in the SideShift app instead of a withdraw button.
  </Accordion>

  <Accordion title="Camera or microphone blocked during verification">
    Identity verification needs the camera, and an iframe only gets it if the parent grants it.

    Set `allow="payment; camera; microphone"` on the iframe.
    Without it the browser blocks the capture inside the frame while the same flow works in a full tab, which makes it look like a verification problem rather than an embedding one.

    The step falls back to a QR-code handoff where the provider does not permit framing, so a working QR path is not evidence that the `allow` attribute is set.
  </Accordion>

  <Accordion title="Authentication and authorization errors from the API">
    | Code                 | Status | Message and cause                                                                                                                                                                      |
    | -------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `API_KEY_MISSING`    | 401    | `API key is required. Include x-api-key header.`                                                                                                                                       |
    | `INVALID_API_KEY`    | 401    | `Invalid API key`. Also returned for a revoked key. The prefix selects which key index is searched, so a mistyped or truncated prefix fails here rather than as a malformed-key error. |
    | `EMBED_NOT_ENABLED`  | 403    | Connect is not enabled for the company, or its authorization was disabled.                                                                                                             |
    | `DOMAIN_NOT_ALLOWED` | 403    | `Domain not authorized: {domain}. Add this domain to your allowed domains list.` The browser sent an `Origin` that is not on the list.                                                 |
    | `DOMAIN_NOT_ALLOWED` | 403    | `Origin header is required for browser requests`. A browser user-agent reached the API with no `Origin`. Call the API from your backend.                                               |
    | `RATE_LIMITED`       | 429    | `Rate limit exceeded. Try again in N seconds.` Defaults are 100 requests per minute and 100 tokens per hour, counted separately for sandbox and live.                                  |
    | `VALIDATION_ERROR`   | 400    | `expiresInSeconds must be at least 60 seconds`, or `cannot exceed 86400 seconds (24 hours)`.                                                                                           |
  </Accordion>

  <Accordion title="Transfer and balance errors">
    | Code                   | Status | Cause                                                                                                                                                                       |
    | ---------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `INSUFFICIENT_BALANCE` | 400    | The source wallet cannot cover the amount.                                                                                                                                  |
    | `AMOUNT_TOO_SMALL`     | 400    | Below \$0.01.                                                                                                                                                               |
    | `AMOUNT_TOO_LARGE`     | 400    | Above your configured per-transfer maximum, or above the system maximum of \$100,000.                                                                                       |
    | `DAILY_LIMIT_EXCEEDED` | 400    | Your integration's daily transfer **amount** limit, counted over the UTC day and separately for sandbox and live.                                                           |
    | `RATE_LIMITED`         | 429    | Also returned for the daily transfer **count** limit, which does not surface as `DAILY_LIMIT_EXCEEDED`.                                                                     |
    | `ACCOUNT_NOT_FOUND`    | 400    | The source or destination account has no payment account configured.                                                                                                        |
    | `DUPLICATE_TRANSFER`   | 409    | `This idempotencyKey was already used for a different transfer`. Reusing a key with identical amount, accounts, direction and metadata returns the original result instead. |

    Amounts are integer cents.
    A non-integer is rejected with the message `Amount must be an integer (cents)`, but it arrives under `AMOUNT_TOO_LARGE` rather than a code of its own, so match on the message rather than inferring the cause from the code.
  </Accordion>
</AccordionGroup>
