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,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 taggedsandbox: 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/balancenever queries the provider in sandbox, sowithdrawableBalanceCents(and its aliasbalanceCents) reads0. UsewalletBalanceCentsto assert sandbox transfers;totalBalanceCentsis not the authoritative number here. - Moving funds to the withdrawal balance.
POST /accounts/withdrawal-balancehas no sandbox implementation. - Identity verification.
GET /accounts/{id}/verificationsreturns404 VERIFICATION_NOT_FOUNDunder 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 returns409 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 Expect
sk_test_ key, add the domain you will embed on,
and configure a sandbox webhook URL with its own secret. Confirm the key works: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
201withcreated: true. Repeating it returns200withcreated: falseandalreadyExists: true, and the samesideshiftAccountId. - Sending the same
externalIdwith a different email returns the existing account, not a new one. PATCH /accounts/{id}with{ "externalId": "usr_alice_v2" }returnsdata.updated: ["externalId"].GET /accounts/balance?sideshiftAccountId=…showswalletBalanceCents: 1000000(the $10,000 seed) andwithdrawableBalanceCents: 0.DELETE /accountswith{ "externalIds": ["usr_alice_v2"] }returnsdeletedCount: 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
Expect User to user:Also confirm the failure paths:
GET /accounts/balance, reading walletBalanceCents.Company to user (omit fromAccountId):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.amountCentslarger than the source wallet returns400 INSUFFICIENT_BALANCE.amountCents: 0returns400 AMOUNT_TOO_SMALLwithdetails.minimumAmountCents: 1.amountCents: 10000001returns400 AMOUNT_TOO_LARGEwithdetails.maximumAmountCents: 10000000.- A
toAccountIdyou did not create returns404 ACCOUNT_NOT_FOUND. - An
idempotencyKeyshorter than 8 characters returns400 MISSING_REQUIRED_FIELD. metadatawith a non-string value returns400 INVALID_METADATA.
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
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
Originheader returns400 DOMAIN_NOT_ALLOWED. Add a domain first. targetDomainset to a domain that is not on your allowlist returns400 DOMAIN_NOT_ALLOWED.- A token minted with
targetDomain: "pay.example.com"and embedded onapp.example.comshowsAccess Denied - Token domain mismatchunlessapp.example.com(or*.example.com) is also on your allowlist. The widget checks the token’s binding first and your allowlist second. expiresInSeconds: 30returns400 VALIDATION_ERROR; the minimum is 60.- A token for an account you do not own returns
404 ACCOUNT_NOT_FOUND.
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_initiatedthenpayin:deposit_completed. - Your sandbox webhook receives
deposit.pendingand thendeposit.confirmed, both withdata.sideshiftAccountIdset to Alice’s id anddata.externalIdset tousr_alice.netAmountCentsis what was credited to your company wallet;amountCentsincludes the processing fee. - Your company sandbox wallet rises by
netAmountCents.
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
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 fromstatus: "open"tostatus: "paid".- Your sandbox webhook receives
deposit.confirmedwithmetadata.source: "connect_hosted_checkout",metadata.checkoutSessionIdand yourorderId. - Requesting
allowedPaymentMethods: ["paypal"]under a sandbox key does not enable it; sandbox checkout is cards only.
11
Webhooks: signature verification
Your handler must verify every delivery before trusting it. The signed payload is the
Reject a bad signature with a
X-Sideshift-Timestamp header, a literal dot, and the raw request body. Sign the
bytes you received, not a re-serialised object.- Node.js
- Python
- Go
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 If your endpoint answers with a non-2xx the call returns
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: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 The replay re-signs the original payload with your current secret and a fresh
timestamp, keeps the same event id, and returns Each entry has
eventId from a delivery you received and replay it: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: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 The number of seconds is in the message. There is no
429 RATE_LIMITED: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
Swappingsk_test_ for sk_live_ is the whole migration on your side. Before you do:
Keys and secrets
Keys and secrets
- 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.
Domains
Domains
- 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.appor*.github.ioallows every tenant on that platform; list your own hostnames instead.allowAllDomainsis off unless you have a WebView case that genuinely needs it.
Webhooks
Webhooks
- 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 returns200quickly. - You are subscribed to the terminal withdrawal events (
withdrawal.completedimplieswithdrawal.failed) and totransfer.completed. - Unknown event types are acknowledged with
200, not rejected with400(a4xxends delivery without retry).
Transfers
Transfers
- Every transfer sends an
idempotencyKeyderived from your own record id, and retries reuse it. - Every live transfer attaches commercial-evidence metadata (
obligationType,obligationReference,description,approvalReference;programIdorcontractIdforcampaign). New integrations enforce this. - Your daily transfer amount limit, if configured, matches expected volume.
- Reconciliation reads balances back from
GET /accounts/balancerather than summing your own records.
Widgets
Widgets
- Tokens are minted per session with the shortest lifetime that works, and
session:expiredis handled. - The iframe carries
allow="payment; camera; microphone". - Completed withdrawals are confirmed from
withdrawal.completed, not from a widget event.
First live transfer
First live transfer
- Run one small live transfer (for example $0.50) to a test account you control, confirm the
transfer.completedwebhook, then confirm the withdrawal flow in the payout widget end to end before opening up volume.