> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sideshift.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Configuration

> API keys, allowed domains, webhooks and rate limits for a Connect integration.

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](https://app.sideshift.app/settings?tab=embed).
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:

```
sk_live_EXAMPLE-key_replace_with_yourown
```

| Prefix     | Mode                                             |
| ---------- | ------------------------------------------------ |
| `sk_live_` | Production, real money                           |
| `sk_test_` | Sandbox, isolated balances and simulated payouts |

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:

```bash theme={"system"}
curl https://app.sideshift.app/api/embed/domains \
  -H "x-api-key: $SIDESHIFT_CONNECT_KEY"
```

<Warning>
  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.
</Warning>

### 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:

<Steps>
  <Step title="Create the new key">
    Both keys now authenticate.
    Nothing breaks while you roll the new value out.
  </Step>

  <Step title="Deploy it everywhere">
    Update every service, worker and scheduled job that talks to Connect.
  </Step>

  <Step title="Revoke the old key">
    Revocation takes effect on the next request that presents it.
  </Step>
</Steps>

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.

<Warning>
  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](/connect/widgets).
</Warning>

## Allowed domains

The allowlist names the domains your widgets may render on.

### How patterns match

| Pattern           | Matches                                                 |
| ----------------- | ------------------------------------------------------- |
| `app.example.com` | Exactly `app.example.com`                               |
| `*.example.com`   | `example.com`, `pay.example.com`, and `a.b.example.com` |

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.

<Note>
  `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.
</Note>

### 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`.

```bash theme={"system"}
curl -X PATCH https://app.sideshift.app/api/embed/domains \
  -H "x-api-key: $SIDESHIFT_CONNECT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "addDomains": ["pay.client.com"] }'
```

`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.

<Warning>
  `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.
</Warning>

<Note>
  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.
</Note>

## 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.

| Event                  | Fires when                                           |
| ---------------------- | ---------------------------------------------------- |
| `deposit.pending`      | A deposit was initiated and is awaiting confirmation |
| `deposit.confirmed`    | A deposit confirmed and funds are available          |
| `deposit.failed`       | A deposit failed or was rejected                     |
| `withdrawal.created`   | A user requested a withdrawal                        |
| `withdrawal.updated`   | A withdrawal changed status, at every transition     |
| `withdrawal.completed` | A withdrawal reached `completed`                     |
| `withdrawal.failed`    | A withdrawal reached `failed` or `denied`            |
| `transfer.completed`   | A transfer between accounts completed                |
| `account.risk_flagged` | A risk signal was raised on a Connect account        |

`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:

```
requested → awaiting_payment → in_transit → completed | failed | canceled | denied
```

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.

<Tip>
  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.
</Tip>

### Payload

Every delivery is a POST with a JSON body:

```json theme={"system"}
{
  "id": "evt_abc123def456",
  "type": "deposit.confirmed",
  "timestamp": "2026-03-29T12:00:00.000Z",
  "data": {
    "depositId": "dep_xyz789",
    "sideshiftAccountId": "acct_a1b2c3d4e5f6",
    "externalId": "usr_123",
    "amountCents": 10000,
    "feeCents": 329,
    "netAmountCents": 9671,
    "currency": "usd",
    "status": "confirmed"
  }
}
```

`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.

| Header                  | Contents                                       |
| ----------------------- | ---------------------------------------------- |
| `x-sideshift-signature` | HMAC-SHA256 of the signed payload, hex encoded |
| `x-sideshift-timestamp` | Unix timestamp in seconds at send time         |
| `x-sideshift-event-id`  | Same value as `id` in the body                 |

The signed payload is the timestamp, a literal dot, and the raw request body:

```
HMAC-SHA256(`${timestamp}.${rawBody}`, secret)
```

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.

<CodeGroup>
  ```js Node.js theme={"system"}
  const crypto = require('crypto');

  function verify(rawBody, timestamp, signature, secret) {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex');

    const a = Buffer.from(signature, 'utf8');
    const b = Buffer.from(expected, 'utf8');
    // timingSafeEqual throws on a length mismatch, so check length first.
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={"system"}
  import hashlib
  import hmac

  def verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
      signed = timestamp.encode() + b"." + raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(signature, expected)
  ```
</CodeGroup>

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.

<Note>
  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.
</Note>

### 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.

```bash theme={"system"}
curl "https://app.sideshift.app/api/embed/webhook-logs?eventType=transfer.completed&success=false" \
  -H "x-api-key: $SIDESHIFT_CONNECT_KEY"
```

`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

| Surface              | Default | Window      |
| -------------------- | ------- | ----------- |
| Connect API requests | 100     | Per minute  |
| Token generation     | 100     | Per minute  |
| Transfers            | 1000    | Per UTC day |

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.

<Note>
  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.
</Note>

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.
The maximum is whatever your integration is configured for, defaulting to $200,000, and a system ceiling of $100,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:

```json theme={"system"}
{
  "success": false,
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded. Try again in 37 seconds."
  }
}
```

<Warning>
  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.
</Warning>

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.
