Skip to main content
Connect ships with a sandbox that runs the real API against isolated balances and the payment provider’s own test environment. This page tells you exactly where the boundary of the simulation is, then walks through a test plan that exercises every part of an integration before you switch keys.

Environments

The API key prefix is the whole switch. Paths, request bodies, response shapes, validation and error codes are identical in both modes. Both keys are stored against your company and can coexist. Generate the sandbox key in the Connect console and keep it in an environment variable:

What sandbox covers

Seeded balances. Your company’s sandbox wallet is created and seeded with 10,000onthefirstrequestauthenticatedwithasandboxkey.Everyaccountyoucreatewithasandboxkeygetsitsownsandboxwalletseededwith10,000 on the first request authenticated with a sandbox key. Every account you create with a sandbox key gets its own sandbox wallet seeded with 10,000. Seeding happens once and never overwrites an existing balance, so test transfers accumulate like a real ledger instead of resetting. Accounts. A sandbox account is a real SideShift account tagged sandbox: true, provisioned on the payment provider’s official sandbox with a provider id in the usual biz_ form. Older sandbox accounts that carried a simulated sim_biz_ id are migrated the next time an account or token flow needs the provider account. Your code should only ever hold sideshiftAccountId, which is stable across that migration. Transfers. All three directions run for real against the sandbox ledger: amount validation, idempotency, daily limits, source-balance checks and the transfer record all behave as in production. The only replacement is the outbound provider payout, which returns a simulated sim_txn_ id in paymentTransferId. Because no provider payout happens, a transfer with destinationBalance: "withdrawal" credits the destination’s sandbox wallet, the same as "wallet" does. Reads are partitioned. GET /transfers, GET /transfers/{transferId} and GET /accounts/balance filter on the mode of the key, so sandbox and live data never appear in the same response. Pay-in. The pay-in widget and hosted checkout are wired to the provider’s sandbox and accept cards only. Alternative payment methods are production-only, and a checkout session created with a sandbox key cannot enable them. Webhooks. Events produced under a sandbox key are delivered to the sandbox webhook URL and signed with the sandbox secret. If no sandbox webhook is configured the event is not delivered; SideShift never falls back to the live URL or secret.

What sandbox does not simulate

  • Withdrawals and bank links. The payment provider does not support payouts in its sandbox. The payout widget shows account, verification and balance status, but the withdraw, bank-link and payout-submission controls are unavailable, and none of the withdrawal.* events fire.
  • Withdrawal-ready balance. GET /accounts/balance never queries the provider in sandbox, so withdrawableBalanceCents (and its alias balanceCents) reads 0. Use walletBalanceCents to assert sandbox transfers; totalBalanceCents is not the authoritative number here.
  • Moving funds to the withdrawal balance. POST /accounts/withdrawal-balance has no sandbox implementation.
  • Identity verification. GET /accounts/{id}/verifications returns 404 VERIFICATION_NOT_FOUND under a sandbox key; verification records are not created there.
  • Cancelling a transfer. Sandbox transfers settle immediately and never hold a provider reservation, so DELETE /transfers/{transferId} always returns 409 TRANSFER_NOT_CANCELLABLE.
  • Side effects. Leaderboard and notification side effects are not triggered.
Sandbox keeps the documented API contract stable wherever an operation is supported. That is not the same as every production provider capability having sandbox parity, and the list above is the difference.

Test cards

Card pay-in in sandbox goes through the payment provider’s sandbox, which recognises these numbers. Use any future expiry date and any three-digit CVC.

Test plan

Work through this in order. Each step names the response you should see, so a mismatch points at a problem in your integration rather than in your expectations.
1

Configure the sandbox

In the Connect console: generate an sk_test_ key, add the domain you will embed on, and configure a sandbox webhook URL with its own secret. Confirm the key works:
Expect 200 with data.accounts: []. A 403 EMBED_NOT_ENABLED means Connect has not been enabled for your company yet.
2

Accounts

Create two accounts, so you can test user-to-user transfers later.
Then check each of these:
  • The first call returns 201 with created: true. Repeating it returns 200 with created: false and alreadyExists: true, and the same sideshiftAccountId.
  • Sending the same externalId with a different email returns the existing account, not a new one.
  • PATCH /accounts/{id} with { "externalId": "usr_alice_v2" } returns data.updated: ["externalId"].
  • GET /accounts/balance?sideshiftAccountId=… shows walletBalanceCents: 1000000 (the $10,000 seed) and withdrawableBalanceCents: 0.
  • DELETE /accounts with { "externalIds": ["usr_alice_v2"] } returns deletedCount: 1. This detaches the account from your integration without deleting history. Re-create it before continuing.
3

Transfers

Run every direction and verify the ledger after each one with GET /accounts/balance, reading walletBalanceCents.Company to user (omit fromAccountId):
Expect status: "completed", direction: "company_to_user", destinationBalance: "withdrawal" and a paymentTransferId starting sim_txn_. Alice’s wallet rises by 2,500 and your company balance (GET /accounts/balance with no sideshiftAccountId) falls by 2,500.User to company (omit toAccountId). Settles into your company wallet; passing destinationBalance: "withdrawal" here is rejected with 400 VALIDATION_ERROR.
User to user:
Also confirm the failure paths:
  • amountCents larger than the source wallet returns 400 INSUFFICIENT_BALANCE.
  • amountCents: 0 returns 400 AMOUNT_TOO_SMALL with details.minimumAmountCents: 1.
  • amountCents: 10000001 returns 400 AMOUNT_TOO_LARGE with details.maximumAmountCents: 10000000.
  • A toAccountId you did not create returns 404 ACCOUNT_NOT_FOUND.
  • An idempotencyKey shorter than 8 characters returns 400 MISSING_REQUIRED_FIELD.
  • metadata with a non-string value returns 400 INVALID_METADATA.
Then list what you did: GET /transfers?sideshiftAccountId=ACCT_ALICE returns all three, newest first, and GET /transfers?metadata[orderId]=… filters on metadata you attached.
4

Idempotency replay

Send the company-to-user request from the previous step again, byte for byte. Expect 200 with the same transferId and no change in either balance.Now send the same idempotencyKey with amountCents: 2600. Expect 409 DUPLICATE_TRANSFER with the message This idempotencyKey was already used for a different transfer. Keys are permanent within your integration and mode; a key you used in sandbox does not collide with the same key in live.
5

Cancel

In sandbox this always returns 409 TRANSFER_NOT_CANCELLABLE, because the transfer settled synchronously. That is the expected result and confirms your handler treats 409 as “already terminal” rather than as a retryable error.In live, cancel only succeeds in the narrow window where a withdrawal-destination transfer holds a reservation that was never submitted to the provider, typically after a request failed part-way. A successful cancel returns the transfer with status: "failed" and failureReason: "Cancelled before provider settlement", credits the source wallet back and releases the daily-limit reservation. It never refunds a settled payout.
6

Atomic batch abort

Build a batch where one item cannot be funded, and confirm nothing moved.
Expect 400 AMOUNT_TOO_LARGE (the failing item’s own code) with details.atomic: true, details.phase: "preflight", details.failedIndex: 1 and a details.results array in which item 0 succeeded preflight and item 1 carries the error. Both balances are unchanged: preflight validates every item and the combined source balance before any money moves.Replay the exact request. Expect the same stored abort, not a second attempt. To retry for real, send new per-item keys and a new batch key.Then run a batch that succeeds (two small amounts, atomic: true). Expect failureCount: 0, atomic: true and one results[] entry per item. Finally run the same shape without atomic and include one bad item; expect 200 with successCount: 1, failureCount: 1 and the failure described inline at results[i].error.
7

Balance verification

After the steps above, reconcile from the API rather than from your own arithmetic. GET /accounts/balance?sideshiftAccountId=…&limit=50 returns the ledger newest-first with type (transfer_in, transfer_out, deposit, withdrawal, fee, other), amountCents (signed), balanceAfterCents and a summary block. Pending and failed entries are excluded from the summary. Read walletBalanceCents for the sandbox balance.
8

Widgets, tokens and domain binding on localhost

Mint a token for Alice with widgetType: "both" and embed widgetUrls.payout on a page served from http://localhost:3000. It loads: localhost, 127.0.0.1, 0.0.0.0 and ::1 pass the widget’s domain check on any port without being on your allowlist.Then check the binding rules, because they are where staging deployments usually break:
  • Minting with an empty allowlist and no Origin header returns 400 DOMAIN_NOT_ALLOWED. Add a domain first.
  • targetDomain set to a domain that is not on your allowlist returns 400 DOMAIN_NOT_ALLOWED.
  • A token minted with targetDomain: "pay.example.com" and embedded on app.example.com shows Access Denied - Token domain mismatch unless app.example.com (or *.example.com) is also on your allowlist. The widget checks the token’s binding first and your allowlist second.
  • expiresInSeconds: 30 returns 400 VALIDATION_ERROR; the minimum is 60.
  • A token for an account you do not own returns 404 ACCOUNT_NOT_FOUND.
Finally, mint a token with expiresInSeconds: 60, wait, and confirm your page handles session:expired by minting a new token and reloading the frame.
9

Escrow pay-in with test cards

Mint a token with widgetType: "payin" and escrowMode: true, embed widgetUrls.payin, add 4242 4242 4242 4242 as a payment method and deposit $50.
  • The widget posts payin:deposit_initiated then payin:deposit_completed.
  • Your sandbox webhook receives deposit.pending and then deposit.confirmed, both with data.sideshiftAccountId set to Alice’s id and data.externalId set to usr_alice. netAmountCents is what was credited to your company wallet; amountCents includes the processing fee.
  • Your company sandbox wallet rises by netAmountCents.
Repeat with 4000 0000 0000 0002 and expect payin:deposit_failed and a deposit.failed event carrying metadata.failureReason. Repeat with the 3-D Secure card to exercise the challenge flow inside the iframe (this is where a missing allow="payment" attribute shows up).A user’s ordinary, non-escrow pay-in into their own wallet does not produce deposit events for you; see Webhooks.
10

Hosted checkout

Open data.url in a browser, pay with 4242 4242 4242 4242, and confirm:
  • GET https://app.sideshift.app/api/connect/checkout/{id} (no auth) moves from status: "open" to status: "paid".
  • Your sandbox webhook receives deposit.confirmed with metadata.source: "connect_hosted_checkout", metadata.checkoutSessionId and your orderId.
  • Requesting allowedPaymentMethods: ["paypal"] under a sandbox key does not enable it; sandbox checkout is cards only.
Details of the session object and the embedded variant are on Guides.
11

Webhooks: signature verification

Your handler must verify every delivery before trusting it. The signed payload is the X-Sideshift-Timestamp header, a literal dot, and the raw request body. Sign the bytes you received, not a re-serialised object.
Reject a bad signature with a 4xx. Deduplicate on the X-Sideshift-Event-Id header (the same value as id in the body), because retries and replays reuse it.
12

Webhooks: test endpoint

Prove every handler end to end without moving money. The endpoint delivers one synthetic event of any subscribable type to your sandbox webhook, signed with the sandbox secret and recorded in the delivery log like a real one. It requires a sandbox key; a live key gets 403 VALIDATION_ERROR.
Each payload has the shape of the real event, with metadata.test: "true" and metadata.sandbox: "true" so your receiver can tell a rehearsal apart. Fields that do not apply to the chosen event are ignored. Run it once per event type you subscribe to, and for withdrawal.updated once per status you branch on:
If your endpoint answers with a non-2xx the call returns 502 WEBHOOK_DELIVERY_FAILED, which makes it a usable signature-verification harness: a wrong secret shows up here, not in production. The same 502 is returned, with the reason in message, when no sandbox webhook is configured (No webhook configured). The event is delivered whether or not your sandbox endpoint is subscribed to it, so you can test a handler before switching its subscription on. An unknown eventType or status returns 400 VALIDATION_ERROR.
13

Webhooks: replay and delivery logs

Take an eventId from a delivery you received and replay it:
The replay re-signs the original payload with your current secret and a fresh timestamp, keeps the same event id, and returns replayed: true with the statusCode your endpoint answered. Your handler should recognise the id and treat the second delivery as a no-op.Then inspect the log:
Each entry has eventId, eventType, success, statusCode, error, attemptNumber, the payload that was sent and createdAt. Filter with eventId, eventType, success=true|false or paymentId. Logs are partitioned by mode, so a sandbox key only sees sandbox deliveries.
14

Error handling

Make sure your client distinguishes these classes. The full list is on Errors.
15

Rate-limit handling

The default limits are 100 requests per minute and 100 widget tokens per hour per company, each counted separately for sandbox and live, plus 1,000 transfers per UTC day. Exceeding one returns 429 RATE_LIMITED:
The number of seconds is in the message. There is no Retry-After header, and the X-RateLimit-* headers computed by the limiter are not forwarded on Connect responses, so back off on the status code and the message, add jitter so a fleet of workers does not retry in lockstep, and always send an idempotencyKey so a retried transfer cannot double-pay. Exercise this deliberately: fire 101 requests in a minute with the sandbox key and confirm your client waits rather than hammering.

Go-live checklist

Swapping sk_test_ for sk_live_ is the whole migration on your side. Before you do:
  • The live key is in a production secrets manager, not in source control or a browser bundle.
  • Every service, worker and scheduled job that talks to Connect reads the same secret.
  • The sandbox key is not used anywhere in production code paths.
  • The allowlist is owned by the integration, not by the key, so whatever you added to make sandbox work is already live. Remove preview hostnames, tunnels and broad wildcards.
  • *.vercel.app or *.github.io allows every tenant on that platform; list your own hostnames instead.
  • allowAllDomains is off unless you have a WebView case that genuinely needs it.
  • The live webhook URL uses HTTPS and its secret is stored server-side.
  • The handler verifies the signature, rejects stale timestamps, deduplicates on X-Sideshift-Event-Id, and returns 200 quickly.
  • You are subscribed to the terminal withdrawal events (withdrawal.completed implies withdrawal.failed) and to transfer.completed.
  • Unknown event types are acknowledged with 200, not rejected with 400 (a 4xx ends delivery without retry).
  • Every transfer sends an idempotencyKey derived from your own record id, and retries reuse it.
  • Every live transfer attaches commercial-evidence metadata (obligationType, obligationReference, description, approvalReference; programId or contractId for campaign). New integrations enforce this.
  • Your daily transfer amount limit, if configured, matches expected volume.
  • Reconciliation reads balances back from GET /accounts/balance rather than summing your own records.
  • Tokens are minted per session with the shortest lifetime that works, and session:expired is handled.
  • The iframe carries allow="payment; camera; microphone".
  • Completed withdrawals are confirmed from withdrawal.completed, not from a widget event.
  • Run one small live transfer (for example $0.50) to a test account you control, confirm the transfer.completed webhook, then confirm the withdrawal flow in the payout widget end to end before opening up volume.

Support handoff template

When something needs SideShift’s help, this is what lets support act on the first message. Never include your API key or a webhook secret.
The status page is worth a glance before writing; an active incident on the component you are hitting answers the question faster than a ticket.