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

# Update the embed domain allowlist

> Add, remove, or replace the domains your embedded widgets may load on. Call this from your
onboarding backend when a client attaches a CNAME, so their payout widget stops failing with
`Token domain mismatch`.

Send at least one of `allowedDomains`, `addDomains`, `removeDomains`, or `allowAllDomains`.
`allowedDomains` replaces the whole list and **cannot be combined** with `addDomains` or
`removeDomains`; sending both returns `400`. When `addDomains` and `removeDomains` are sent
together, additions are applied first, so a domain in both is removed.

Send bare hostnames. A scheme, port or path is **rejected**, not stripped, so
`https://pay.client.com`, `pay.client.com:3000` and `pay.client.com/app` all return `400`.
Entries are lowercased and de-duplicated, every domain must contain a dot (so `localhost`
is rejected), and the resulting list may not exceed 250 entries.

`removeDomains` matches on the exact stored string: removing `*.client.com` deletes that
wildcard entry and leaves any specific subdomain entries in place.

Sending `allowedDomains: []` is valid and clears the list, but `addDomains: []` or
`removeDomains: []` alone returns `400`, since neither would change anything.

The response is the full settings object as stored, not just what you sent.

> **`allowAllDomains: true` turns the domain check off entirely.** It is not a convenience
> flag for a stubborn `Token domain mismatch`. With it set, an embed token is accepted from
> any origin, browser requests are no longer required to send an `Origin` header, and the
> per-domain list stops being consulted. Anyone who obtains a token can then use it from a
> site you do not control. Prefer adding the specific domain.




## OpenAPI

````yaml /openapi/connect.yaml patch /domains
openapi: 3.1.0
info:
  title: SideShift Connect
  version: 1.0.0
  description: >
    Embed payment infrastructure directly into your platform. Create accounts
    for your users, transfer funds in any direction, and drop in pre-built
    payout and pay-in widgets — all through a single API.


    ## Setup Guide


    ### 1. Generate an API Key


    Go to [Settings → Connect](https://app.sideshift.app/settings?tab=embed) and
    click **Generate API Key**.


    Copy it immediately — keys are only shown once.


    - `sk_live_*` — Production (real money)

    - `sk_test_*` — Sandbox (isolated balances, simulated payouts)


    > **Never expose your API key in client-side code.** All API calls must be
    made from your backend.


    ### 2. Add Allowed Domains


    In Settings → Connect, add the domains where you'll embed widgets:


    | Pattern | Matches |

    |---------|---------|

    | `app.example.com` | Exact match |

    | `*.example.com` | All subdomains |

    | `localhost` | Any port (auto-allowed for development) |


    ### 3. Create User Accounts


    Every user who needs access to payments needs a SideShift Connect account:


    ```bash

    curl -X POST https://app.sideshift.app/api/embed/accounts/create \
      -H "x-api-key: sk_live_YOUR_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "email": "jane@example.com", "name": "Jane Creator", "externalId": "usr_123" }'
    ```


    Store the returned `sideshiftAccountId` — you'll need it for everything
    else.


    ### 4. Generate a Widget Token


    Tokens authenticate embedded widget sessions. Generate them server-side:


    ```bash

    curl -X POST https://app.sideshift.app/api/embed/auth/token \
      -H "x-api-key: sk_live_YOUR_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "sideshiftAccountId": "acct_abc123", "widgetType": "both" }'
    ```


    The response includes `widgetUrls.payout` and `widgetUrls.payin` — use these
    as iframe sources.


    ### 5. Embed the Widget


    **iframe (recommended):**

    ```html

    <iframe
      src="WIDGET_URL"
      width="100%" height="500"
      frameborder="0"
      allow="payment; camera; microphone"
      style="border:0; border-radius:12px"
    ></iframe>

    ```

    The iframe is the most reliable method — it works in any framework, needs no
    build tooling, and avoids dependency conflicts. The `allow="payment; camera;
    microphone"` attribute is required for KYC/identity verification inside the
    widget.


    **White-label verification and withdrawals:**

    The payout widget uses custom, theme-aware identity-verification,
    withdrawal, fee, payout-method, history, and account-reset interfaces. It
    does not render provider UI or SideShift product artwork. The KYC title,
    progress rail, and withdrawal title can be removed independently with widget
    URL query parameters:

    ```html

    <iframe
      src="WIDGET_URL?showKycHeader=false&showKycSteps=false&showWithdrawHeader=false&kycButton=Verify%20identity&kycTitle=Identity%20check&kycVerifiedTitle=Identity%20confirmed"
      width="100%"
      height="500"
      frameborder="0"
      allow="payment; camera; microphone"
    ></iframe>

    ```

    Labels use the same camel-case names shown above and should be URL-encoded.


    ### 6. Transfer Funds


    Move money between your company and user accounts:


    ```bash

    curl -X POST https://app.sideshift.app/api/embed/accounts/transfer \
      -H "x-api-key: sk_live_YOUR_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "toAccountId": "acct_abc123",
        "amountCents": 5000,
        "idempotencyKey": "payout-001",
        "metadata": {
          "obligationType": "creator_agreement",
          "obligationReference": "agreement-001",
          "description": "Approved payment for completed creator deliverable",
          "approvalReference": "approval-001"
        }
      }'
    ```


    ### 7. Set Up Webhooks


    Configure a webhook endpoint in Settings → Connect to receive
    `transfer.completed`, `deposit.*`, and `withdrawal.*` events. Always verify
    the signature:


    ```js

    const crypto = require("crypto");

    function verify(payload, timestamp, signature, secret) {
      const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex");
      return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    }

    ```


    ---


    ## Authentication


    Include your API key in the `x-api-key` header on every request:


    ```

    x-api-key: sk_live_your_key_here

    ```


    Rotate your key anytime from Settings → Connect. The old key is invalidated
    immediately.


    ## Sandbox


    Use `sk_test_*` keys to test without moving real money. Sandbox balances are
    fully isolated from live. Webhooks still fire so you can validate your full
    pipeline. All API endpoints behave identically (same validation, same error
    codes, same response shapes).


    ### Sandbox behavior


    - `paymentAccountId` values are prefixed with `sim_biz_*` (simulated)

    - Company → User transfers are simulated — no real payout is executed, but
    the internal ledger is updated normally

    - User → Company and User → User transfers work identically to production

    - Webhook events are delivered to your configured endpoint

    - Leaderboard and notification side effects are **not** triggered


    ### Payout widget in sandbox


    When a widget token is generated with a `sk_test_*` key, the payout widget
    automatically uses SideShift's sandbox payout environment. No additional
    configuration is required.


    > **Important:** The payout widget shows "Pending Balance from your
    platform" instead of the actual balance in sandbox mode. This is expected.


    When you transfer funds with a `sk_test_*` key, the sandbox wallet is
    credited correctly on the internal ledger. However, the payout widget's
    withdrawal UI cannot display the real balance because the company ID is
    simulated (`sim_biz_*`) and the widget relies on the real payments
    infrastructure to resolve balances.


    - The `passedInBalance` field (if set) appears as a display-only "Pending
    Balance" label

    - The withdrawal flow (bank account linking, payout initiation) is not fully
    functional in sandbox


    **In production** with `sk_live_*` keys, transfers call the real payments
    API, funds land in the creator's real wallet, and the balance + withdrawal
    UI works normally.


    **To verify sandbox transfers are working**, use the balance API — this is
    the source of truth:


    ```bash

    GET /accounts/balance?sideshiftAccountId=ACCOUNT_ID

    ```


    The `balanceCents` and `transactions` in the response accurately reflect all
    sandbox transfers.


    ### Testing checklist


    1. Generate `sk_test_*` key and store securely

    2. Create at least two sandbox accounts

    3. Test all three transfer directions (company→user, user→company,
    user→user)

    4. Verify balances via the balance API after each transfer

    5. Replay a transfer with the same `idempotencyKey` — confirm no duplicate

    6. Confirm webhook arrives and signature verification passes

    7. Trigger error cases (insufficient balance, invalid account) and verify
    your handling

    8. Test widget token generation and embedding


    ### Go-live checklist


    Before switching to `sk_live_*`:


    1. Store your live API key in a production secrets manager

    2. Confirm production domains in Settings → Connect (remove dev wildcards)

    3. Verify webhook endpoint uses HTTPS and validates signatures

    4. Add retry handling with idempotency keys in your backend

    5. Attach commercial evidence metadata to every transfer

    6. Run a small live test (e.g. $0.50 transfer) before full volume


    ## Idempotency


    Always include an `idempotencyKey` on transfer requests. Replaying a request
    with the same key returns the original successful result instead of creating
    a duplicate.


    ## Rate Limits


    | Endpoint | Limit |

    |----------|-------|

    | Account creation | 100/hour |

    | Token generation | 30/min per account |

    | Transfers | 60/min (configurable) |

    | General | 100/min |


    Exceeding limits returns `429` with a `Retry-After` header.


    ## Base URL


    `https://app.sideshift.app/api/embed`
  contact:
    name: SideShift Support
    url: https://app.sideshift.app
servers:
  - url: https://app.sideshift.app/api/embed
    description: Production / Sandbox (determined by API key prefix)
security:
  - apiKeyAuth: []
tags:
  - name: Accounts
    description: >-
      Create and manage user accounts. Each user gets a `sideshiftAccountId` and
      a wallet for receiving and sending funds.
  - name: Verifications
    description: >-
      Read identity verification status and required actions for connected
      accounts.
  - name: Transfers
    description: >-
      Move funds between your company and user accounts. Supports company→user,
      user→company, and user→user directions.
  - name: Tokens
    description: >-
      Generate short-lived access tokens that authenticate embedded widget
      sessions.
  - name: Domains
    description: Read and update the domains your embedded widgets are allowed to load on.
  - name: Checkout
    description: >-
      Create public hosted checkout links that settle into your SideShift
      wallet.
  - name: Webhooks
    description: >
      Receive real-time event notifications when transfers, deposits, and
      withdrawals complete or fail. Configure endpoints in Settings → Connect.


      **Available events:** `deposit.pending`, `deposit.confirmed`,
      `deposit.failed`, `transfer.completed`, `withdrawal.created`,
      `withdrawal.updated`, `withdrawal.completed`, `withdrawal.failed`,
      `account.risk_flagged`


      `withdrawal.completed` and `withdrawal.failed` are derived aliases of
      `withdrawal.updated`, filtered to `status === "completed"` and to `status`
      in (`failed`, `denied`) respectively. Subscribe to the pair if you only
      want the terminal outcome; subscribe to `withdrawal.updated` for the full
      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`. A later config save that omits `events` keeps the
      subscription list you last set.


      All webhook deliveries include `x-sideshift-signature` and
      `x-sideshift-timestamp` headers for signature verification. Non-2xx
      responses are retried with exponential backoff (up to 5 attempts).
      Webhooks fire in both sandbox and production modes.
paths:
  /domains:
    patch:
      tags:
        - Domains
      summary: Update the embed domain allowlist
      description: >
        Add, remove, or replace the domains your embedded widgets may load on.
        Call this from your

        onboarding backend when a client attaches a CNAME, so their payout
        widget stops failing with

        `Token domain mismatch`.


        Send at least one of `allowedDomains`, `addDomains`, `removeDomains`, or
        `allowAllDomains`.

        `allowedDomains` replaces the whole list and **cannot be combined** with
        `addDomains` or

        `removeDomains`; sending both returns `400`. When `addDomains` and
        `removeDomains` are sent

        together, additions are applied first, so a domain in both is removed.


        Send bare hostnames. A scheme, port or path is **rejected**, not
        stripped, so

        `https://pay.client.com`, `pay.client.com:3000` and `pay.client.com/app`
        all return `400`.

        Entries are lowercased and de-duplicated, every domain must contain a
        dot (so `localhost`

        is rejected), and the resulting list may not exceed 250 entries.


        `removeDomains` matches on the exact stored string: removing
        `*.client.com` deletes that

        wildcard entry and leaves any specific subdomain entries in place.


        Sending `allowedDomains: []` is valid and clears the list, but
        `addDomains: []` or

        `removeDomains: []` alone returns `400`, since neither would change
        anything.


        The response is the full settings object as stored, not just what you
        sent.


        > **`allowAllDomains: true` turns the domain check off entirely.** It is
        not a convenience

        > flag for a stubborn `Token domain mismatch`. With it set, an embed
        token is accepted from

        > any origin, browser requests are no longer required to send an
        `Origin` header, and the

        > per-domain list stops being consulted. Anyone who obtains a token can
        then use it from a

        > site you do not control. Prefer adding the specific domain.
      operationId: updateEmbedDomains
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                allowedDomains:
                  type: array
                  items:
                    type: string
                  description: >
                    Replace the entire allowlist with these domains. Cannot be
                    combined with

                    `addDomains` or `removeDomains`.
                addDomains:
                  type: array
                  items:
                    type: string
                  description: Add these domains to the existing allowlist.
                removeDomains:
                  type: array
                  items:
                    type: string
                  description: Remove these domains from the existing allowlist.
                allowAllDomains:
                  type: boolean
                  description: >
                    Disable domain checking entirely. See the warning above
                    before setting this

                    to `true`.
            examples:
              addOneDomain:
                summary: Attach a client CNAME
                value:
                  addDomains:
                    - pay.client.com
              removeOneDomain:
                summary: Detach a client CNAME
                value:
                  removeDomains:
                    - pay.client.com
              replaceList:
                summary: Set the allowlist explicitly
                value:
                  allowedDomains:
                    - pay.client.com
                    - '*.partner.example.com'
      responses:
        '200':
          description: Updated domain settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/EmbedDomainSettings'
              example:
                success: true
                data:
                  allowedDomains:
                    - pay.client.com
                    - '*.partner.example.com'
                  allowAllDomains: false
        '400':
          description: >
            No recognised field was sent, `allowedDomains` was combined with
            `addDomains` or

            `removeDomains`, a list contained a non-string or an invalid domain
            pattern, or the

            result would exceed 250 domains.
        '401':
          description: Missing or invalid API key
        '403':
          description: Domain not allowed or Connect not enabled
components:
  schemas:
    EmbedDomainSettings:
      type: object
      properties:
        allowedDomains:
          type: array
          items:
            type: string
          description: >
            Domains your widgets may load on. A leading `*.` matches the base
            domain **and** any

            subdomain at any depth, so `*.example.com` covers `example.com`,
            `pay.example.com`

            and `a.b.example.com`. Only a leading `*.` is a wildcard;
            `pay.*.com` and a bare `*`

            are rejected.
        allowAllDomains:
          type: boolean
          description: When true, the domain check is bypassed entirely.
  securitySchemes:
    apiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: >-
        Your SideShift Connect API key (`sk_live_*` or `sk_test_*`). Generate
        from Settings → Connect.

````