Escrow mode
By default the pay-in widget funds the user’s wallet. Escrow mode turns it into a way for your users to pay you: deposits made through the widget are credited to your company wallet, and you learn who paid from the webhook. Mint the token withescrowMode: true:
- Escrow is a pay-in feature.
widgetTypemust bepayinorboth; anything else returns400 VALIDATION_ERRORwithEscrow mode is only available for payin widgets. - Funds go to your company wallet unless you pass
escrowDestinationCompanyId, which must name an existing SideShift company or the mint fails with400 ESCROW_DESTINATION_INVALID. - The depositing user’s
sideshiftAccountId(andexternalId) appear on thedeposit.pending,deposit.confirmedanddeposit.failedevents, so you can attribute the payment.netAmountCentsis what landed in your wallet;amountCentsincludes the processing fee the payer was charged on top. - New sessions use Whop for all pay-ins. Set
threeDsLevelon the token request tomandate_challenge,mandate_if_required,frictionless_if_required, ornull. The default isfrictionless_if_required;nullalso uses frictionless 3DS. This controls card setup. Payment-mode checkouts use their plan policy. Whop can still require authentication for risk or recovery. For payments of $1,000 or more, Whop usesmandate_if_requiredunlessmandate_challengeis selected. allowFraudBypassis deprecated. It maps to the default Whop preference on new sessions; an explicitthreeDsLeveltakes precedence. Existing Stripe sessions can finish until their original expiry (at most 24 hours). Cards saved only in Stripe must be added again in Whop. Existing Whop cards remain available.
Hosted checkout
A hosted checkout is a public payment page (or an embeddable checkout) that settles into a SideShift wallet, created from your backend with one call. Use it when you want to take a payment without embedding the pay-in widget, or to charge someone who does not have a Connect account at all.Create a session
amountCents: the payer is charged
totalChargedCents and the wallet is credited amountCents.
Hosted page or embedded
Redirect the payer tourl for the full-page checkout. Once the payment is verified the
page shows a receipt and, if you set successUrl, redirects there.
To embed instead, render embedUrl in an iframe on your own page. When SideShift has
verified the payment, the embedded checkout posts a message to the parent window:
event.origin is
https://app.sideshift.app before acting on it. Treat it as a UI signal; the source of
truth is the webhook.
Confirming payment
Two server-side signals confirm a session was paid:- Webhook.
deposit.confirmedarrives withmetadata.source: "connect_hosted_checkout",metadata.checkoutSessionIdand your session metadata.depositIdis the session id andsideshiftAccountIdis the destination wallet. - Public read.
GET https://app.sideshift.app/api/connect/checkout/{sessionId}needs no key and returns the session’s public view, includingstatus(open,paid,expiredorcancelled), the amounts,description,companyNameand the redirect URLs. An unknown id returns404with{ "success": false, "error": "Checkout session not found", "code": "NOT_FOUND" }.
Withdrawal fee markups
When a user withdraws, the fee they pay on each payout rail has two layers: SideShift’s platform fee, and a markup you can add on top. Your layer is charged to the user as part of one combined fee and paid to your SideShift wallet. Markups are set per rail in the Connect console under withdrawal fees. Each rail takes apercentage_fee (at most 10) and a fixed_fee_usd (at most 100); either may be left
empty.
How it works:
- The payment provider holds one markup per rail on each connected account, so SideShift pushes the sum of its layer and yours. Every account you create gets the combined markup applied at creation; changing your markup re-syncs existing accounts.
- When a withdrawal reports its markup fee, SideShift splits it back into the two layers proportionally to the configured rates. Your share is floored and SideShift absorbs the rounding cent. If the charged fee deviates from what the two layers imply by more than 2 cents or 2 percent, or the rail cannot be identified, the split is refused and nothing is paid onward.
- Your share is reported as
markupRevenueCentson thewithdrawal.*events, only to the integration that owns the account. It is provisional until the withdrawal reachescompleted, when it is credited to your wallet. A withdrawal that fails, is canceled or is denied before completing credits nothing; one that fails after completing reverses the credit.
White-label domains
A platform that hosts a payout widget on each client’s own domain has two things to get right: every client hostname must be on your allowlist, and each token must be bound to the hostname it will be embedded on.1
Register the client's hostname when they attach it
From your onboarding backend, as soon as the CNAME is set:The list holds up to 250 entries. Prefer exact hostnames over wildcards on shared
infrastructure;
*.yourplatform.app is fine, *.vercel.app is not.2
Bind each token to the embedding hostname
Pass A
targetDomain when minting, set to the domain the widget will load on:targetDomain that is not on the allowlist is rejected at mint time with
400 DOMAIN_NOT_ALLOWED, so this is a stricter version of the allowlist rather than
a way around it. The www. and bare forms of a hostname are treated as twins.3
Remove hostnames when clients leave
removeDomains matches the exact stored string:targetDomain matters: without it, a token minted from a backend that sent no
Origin is bound to whichever domain happens to be first on your list. When the widget
loads on a different client’s domain, the token’s binding does not match, and the widget
falls back to checking your allowlist. That fallback passes as long as the domain is
listed, so you would rarely notice, but it means the binding is not doing any work.
Binding every token to its own client’s domain makes a leaked token useless anywhere else.
GET /api/embed/domains returns the current list, which lets your onboarding backend
verify what a client’s CNAME is registered as.
Moving funds to the withdrawal balance (deprecated)
Transfers settle into the destination’s withdrawable balance, so this call is only needed for money that sits in an account’s internal SideShift wallet: a pay-in through the widget or an invoice, or a transfer sent with the deprecateddestinationBalance: "wallet".
POST /accounts/withdrawal-balance moves that money into the withdrawable balance. The
endpoint keeps working for existing integrations; new ones should not need it.
idempotencyKeyis required and must be at least 8 characters, the same as a transfer.- The account must have been created by your integration; a linked account returns
403 WIDGET_NOT_PERMITTED. - The amount follows the per-transfer limits.
- The account must have completed identity verification. If it has not, the call returns
400 TRANSFER_FAILEDwith the messageAccount must complete identity verification before funds can be moved into the withdrawal balance. - Insufficient wallet funds return
400 INSUFFICIENT_BALANCE; an account with no withdrawal account yet returns400 ACCOUNT_NOT_FOUND. - There is no fee, and no sandbox implementation: this call reaches the payment provider directly, so test it with a live key and a small amount.
Account fallback emails
Every SideShift account has a unique email. When you create a Connect account for an address that already belongs to a SideShift user outside your integration, SideShift cannot hand you that user, so by default it provisions a separate account under a fallback address instead.enableFallbacks defaults to true. With it on, POST /accounts/create tries in order:
externalId, if you sent one and an account with it exists under your integration. That account is returned regardless of email.- The email itself, if an account with it exists under your integration. Returned
with
200. - Fallback slots. For each numbered slot SideShift first tries a provider alias of the
original address (
jane+1@example.com), then a deterministic SideShift-managed address onsideshift.app. An existing account under your integration at either address is reused; otherwise a new one is created there. A run examines at most 100 slots and stops after 3 payment-account provisioning failures.
emailModified: true and originalEmail (your normalised
request email) alongside the resolved email:
sideshiftAccountId and the resolved email. Send the original email and a stable
externalId on later calls; the externalId lookup returns the same account without
re-running the fallback search.
Sub-account currency conversion (pilot)
Multi-currency is off by default. Brands opt in through the API, separately for live and sandbox. Fund managed sub-accounts in USD, then convert within those accounts. The multi-currency guide covers activation, funding estimates, precision, conversion, payouts, opt-out, and safe retries with complete request and response examples.Duplicate-account checks
CallPOST /api/embed/accounts/check from your backend with exactly one of
email, externalId, or sideshiftAccountId:
exists includes unverified
accounts; isKycVerified is a separate stored verification fact, not permission to withdraw.
An approved identity remains verified if the payment account has an open information request.
If a selector matches multiple accounts, a positive flag wins. Email matches the stored
Connect email; use the external ID or SideShift account ID when creation used a fallback email.
For an Earn-style signup flow:
- Check your stable
externalIdor email before provisioning to reuse an existing account. - Otherwise create a Connect account and have the user complete verification in the payout widget.
- Check again using its
sideshiftAccountId. AhasDuplicateVerifiedIdentity: trueresult means another account in your integration is verified against the same identity profile. Route that signup to your duplicate-account review instead of granting access. - Store the SideShift account ID against your user with a unique constraint. Subscribe to
account.risk_flaggedfor duplicate identities detected after onboarding, and recheck before enabling spending or rewards.
null means unknown, not false. Wait or review when identity/country data is unavailable;
retry failed requests instead of treating them as no match. Verification data is synchronized
by provider events and can lag. A new email cannot identify the person behind it before KYC,
and separate provider identity profiles are not guaranteed to identify the same person.
This check is read-only, does not reserve an identity, and does not provide atomic uniqueness
across concurrent signups. Your integration must enforce its onboarding and access policy.
geoRestricted applies the existing payment-region country list and your company’s
restricted-region override to the stored KYC country. It does not use the IP of your API
server, check VPN usage, or change Connect’s withdrawal rules. Missing country yields null
unless your company has an override. Sandbox matches only sandbox accounts and returns
isKycVerified: false with both duplicate and geo flags null.