Skip to main content
Four things decide how a Connect integration behaves in production: the keys that authenticate it, the domains its widgets may render on, the endpoint that receives its events, and the limits it runs into under load. All four live in Settings → Connect. Domains can also be read and written from your own backend, which matters if you onboard client domains programmatically. The API base URL is 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:
Keys are shown once, at creation, and only a masked preview is stored afterwards. SideShift keeps a SHA-256 hash, not the key, so a lost key cannot be recovered - it can only be revoked and replaced.

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.
Revoking a key without naming it revokes every key in that mode, which is the right move if you believe a key has leaked and you would rather break your own traffic than leave it valid. Live and sandbox keys are separate credentials against separate balances. A sk_test_ key cannot move real money, so develop against it and keep the live key out of anything but production.
The key is a bearer credential with full access to your integration. Never ship it to a browser, a mobile binary, or anything a user can read. Mint short-lived widget tokens on your backend instead, as described in Embedding and customization.

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:3000 and pay.example.com/app all return 400. Send bare hostnames.
  • Every entry must contain a dot, so localhost and *.com are both rejected.
  • The wildcard must lead. pay.*.com and 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.
allowAllDomains: true turns the domain check off entirely. Tokens are then accepted from any origin, and browser requests no longer need to send an Origin header. It exists for cases with no meaningful origin, such as native app WebViews. It is not a fix for a stubborn Token domain mismatch - add the specific domain instead.
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 as whsec_ 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:
An endpoint registered without an explicit 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.
A withdrawal.completed subscription implies withdrawal.failed. If you asked to hear that a payout landed, you hear about the bounce too, without changing your subscription. Handle unknown type values by ignoring them and this stays a no-op until you implement it.

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:
Sign the bytes you received, not a re-serialized object. JSON.stringify on a parsed body reorders nothing but reformats whitespace, and the signature will not match.
Reject anything that fails, and reject stale timestamps as well - a signature stays valid forever on its own, so the timestamp is what limits a replay.

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 returning 400 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 by sk_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.
The daily transfer count is a durable per-day counter rather than a best-effort one, so it holds across instances and restarts. An amount-based daily limit can also be set on an integration, in addition to the count.

Per-transfer amounts

The minimum transfer is 0.01.Themaximumiswhateveryourintegrationisconfiguredfor,defaultingto0.01. The maximum is whatever your integration is configured for, defaulting to 200,000, and a system ceiling of 100,000appliesontopofthatso100,000 applies on top of that - so 100,000 is the effective maximum unless you have arranged otherwise. Rejections come back as AMOUNT_TOO_SMALL or AMOUNT_TOO_LARGE, and the daily limit as DAILY_LIMIT_EXCEEDED.

When you are limited

You get 429 with the standard error envelope:
Do not build your backoff around response headers. The Connect API does not currently return Retry-After, and X-RateLimit-* headers do not survive to the client on these endpoints. The 429 status and the seconds named in the message are what you have.
Back off on 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.