https://app.sideshift.app/api/embed.
API keys
A key is a prefix plus 32 characters of base64url:
The random part is base64url, so it can contain
- and _.
If you validate keys before sending them, do not write a regex that only accepts letters and digits.
Send the key in the x-api-key header on every request:
Several keys at once
An integration can hold more than one key per mode. Each has its own id, name and creation time, and each is revoked independently. That makes rotation a two-step operation rather than a cutover:1
Create the new key
Both keys now authenticate.
Nothing breaks while you roll the new value out.
2
Deploy it everywhere
Update every service, worker and scheduled job that talks to Connect.
3
Revoke the old key
Revocation takes effect on the next request that presents it.
sk_test_ key cannot move real money, so develop against it and keep the live key out of anything but production.
Allowed domains
The allowlist names the domains your widgets may render on.How patterns match
A leading
*. is the only wildcard, and it is more generous than it looks.
It matches the base domain itself, and it matches subdomains at any depth rather than one level.
So *.example.com already covers example.com, and you do not need a second entry for nested subdomains.
That generosity is the reason to be careful with shared hosting.
*.vercel.app or *.github.io allows every tenant of that platform, not just yours.
Matching is case-insensitive, and stored entries are lowercased and de-duplicated.
What is rejected
Entries are validated on write, and an invalid entry fails the request rather than being cleaned up:- A scheme, port or path is rejected, not stripped.
https://pay.example.com,pay.example.com:3000andpay.example.com/appall return400. Send bare hostnames. - Every entry must contain a dot, so
localhostand*.comare both rejected. - The wildcard must lead.
pay.*.comand a bare*are not patterns. - The list may not exceed 250 entries.
localhost cannot be added to the list, but it does not need to be.
Requests from localhost, 127.0.0.1 and 0.0.0.0 are accepted for development on any port, once the API key itself is valid.Managing the list from your backend
GET /api/embed/domains returns the current settings.
PATCH /api/embed/domains changes them, and takes at least one of allowedDomains, addDomains, removeDomains or allowAllDomains.
allowedDomains replaces the whole list, and cannot be combined with addDomains or removeDomains.
Sending it alongside either one returns 400 rather than guessing what you meant.
When addDomains and removeDomains arrive together, additions are applied first, so a domain that appears in both ends up removed.
removeDomains matches the exact stored string.
Removing *.client.com deletes that wildcard entry and leaves pay.client.com in place if you added it separately.
allowedDomains: [] is a valid way to clear the list.
addDomains: [] or removeDomains: [] on their own return 400, since neither would change anything.
The response is the full settings object as stored, not an echo of what you sent, so use it to confirm the result rather than assuming it.
The allowlist controls where your widgets are allowed to render.
It is checked when the widget page loads, and on API-key requests that arrive with a browser
Origin or Referer header.
A server-to-server call carries neither, so the allowlist never constrains your own backend - the API key is what authenticates there.Treat it as the control over which sites may host your widgets, and treat short-lived, server-minted tokens as the control over who may use a session.
Do not lean on the allowlist as the only thing standing between a leaked token and a hostile page.Webhooks
Configure the endpoint in Settings → Connect. A signing secret is generated on first save and shown aswhsec_ followed by 64 hex characters.
Saving preserves what you do not send: a save that only changes the URL keeps your existing secret and subscription list rather than resetting them.
Events
Nine events are available.withdrawal.completed and withdrawal.failed are filtered views of withdrawal.updated.
Subscribe to the pair if you only care about terminal outcomes, or to withdrawal.updated for the whole lifecycle:
events array is subscribed to deposit.pending, deposit.confirmed, deposit.failed, withdrawal.created, withdrawal.completed, withdrawal.failed and transfer.completed.
withdrawal.updated is deliberately left out of that default, because the intermediate transitions are noise for anyone who has not asked for them.
Payload
Every delivery is a POST with a JSON body:id is unique per event and is the key to deduplicate on.
data varies by event type, and money fields are absent on events where no money moved, such as account.risk_flagged.
Verifying the signature
Three headers accompany every delivery.
The signed payload is the timestamp, a literal dot, and the raw request body:
JSON.stringify on a parsed body reorders nothing but reformats whitespace, and the signature will not match.
Delivery and retries
A delivery is one attempt followed by retries, all inside a single dispatch. The default is 3 attempts total with a 10 second timeout each, both configurable per endpoint. Backoff is exponential, starting at 1 second and doubling, capped at 30 seconds. Any 2xx counts as success. A 4xx other than 429 is treated as a permanent rejection and is not retried, so returning400 to an event you do not recognise throws it away rather than deferring it.
Return 200 quickly and process asynchronously.
The clock runs on your handler, and a slow database write is indistinguishable from an outage.
Retries are best effort, not a queue with a guarantee attached.
Reconcile against the API for anything that must be exactly right - read the account balance back rather than adding up the amounts you were told about.
Sandbox events
A separate sandbox endpoint can be configured, with its own URL, secret and subscription list. Events produced bysk_test_ keys go there, events from sk_live_ keys go to the live endpoint.
If no sandbox endpoint is configured, sandbox events fall back to the live one, which is rarely what you want in a staging environment.
Inspecting deliveries
GET /api/embed/webhook-logs returns delivery attempts for your integration, filterable by eventId, eventType and success, with limit and offset for paging.
POST /api/embed/webhooks/{eventId}/replay re-sends a logged event to your current endpoint, which is how you recover from an outage without asking anyone to reproduce the original activity.
POST /api/embed/webhooks/test sends a synthetic deposit.confirmed so you can prove your handler and signature check work end to end.
It requires a sandbox key and returns 403 for a live one.
Rate limits
The per-minute request limit is configurable per integration.
Live and sandbox are counted separately, so sandbox testing cannot exhaust your production budget.
Token generation keeps its own counter on top of the general one, so minting widget tokens in a burst can hit that limit while your other calls are still fine.
The stored field for the token limit is named
tokensPerHour, but the limiter it feeds runs a one-minute window.
Read the configured number as tokens per minute, not per hour.Per-transfer amounts
The minimum transfer is 200,000, and a system ceiling of 100,000 is the effective maximum unless you have arranged otherwise. Rejections come back asAMOUNT_TOO_SMALL or AMOUNT_TOO_LARGE, and the daily limit as DAILY_LIMIT_EXCEEDED.
When you are limited
You get429 with the standard error envelope:
429, add jitter so a fleet of workers does not retry in lockstep, and always send an idempotencyKey on transfers so a retry cannot double-pay.
Behaviours worth knowing
These are the ones that get mistaken for bugs in your own code.*.example.com already covers example.com.
It also covers a.b.example.com.
If you assumed one subdomain level, your list is broader than you think.
A domain with a scheme or port is rejected, not cleaned up.
https://pay.example.com fails validation instead of being stored as pay.example.com.
allowedDomains and addDomains cannot be sent together.
The request fails rather than merging them.
A 4xx from your webhook handler ends the delivery.
Only 429 and 5xx are retried, so an unrecognised event type should still get a 200.
Rate limit headers are not there to read.
Handle the 429 itself.