Sandbox testing
The only switch is the API key prefix. Ansk_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
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.
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_ACCOUNTfor a sandbox account. You cannot exercise KYC in sandbox. - Withdrawals. Same lookup, same 404. And because
withdrawal.createdandwithdrawal.updatedare re-emitted from the payments provider’s own webhooks, none of thewithdrawal.*events fire in sandbox. - Moving funds to the withdrawal balance.
POST /accounts/withdrawal-balancehas no sandbox branch at all and calls the provider directly. - Withdrawable balance. In sandbox the provider balance is never fetched, so
GET /accounts/balancereports a withdrawable balance of0and the payout widget has nothing to show as withdrawal-ready. Your seeded $10,000 is a wallet balance, which is a different number.
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: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
Swappingsk_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.1
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.2
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.3
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.
4
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.
States
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.
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.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:
The minimum deposit for a Connect account 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.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.Access Denied - Token domain mismatch
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 Matching is exact, with
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
targetDomainwhen minting, set to the domain you are embedding on. AtargetDomainthat is not already on the allowed list is rejected at mint time with400 DOMAIN_NOT_ALLOWED, so this is a stricter version of the first fix rather than a way around it.
*. 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.Access Denied - Token expired, Session expired, Session not found or revoked
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 for the message shape.Widget Not Available - the payout/payin widget is not enabled
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.Account Setup Required, or the account belongs to a different integration
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.Camera or microphone blocked during verification
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.Transfer and balance errors
Transfer and balance errors
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.