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

# Testing

> The sandbox, what it does and does not simulate, a complete test plan, and the go-live checklist.

Connect ships with a sandbox that runs the real API against isolated balances and the
payment provider's own test environment. This page tells you exactly where the boundary
of the simulation is, then walks through a test plan that exercises every part of an
integration before you switch keys.

## Environments

The API key prefix is the whole switch. Paths, request bodies, response shapes,
validation and error codes are identical in both modes.

|                                                | `sk_live_`                                                        | `sk_test_`                                             |
| ---------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------ |
| Balances                                       | Real wallets and the live payment provider                        | Separate sandbox wallets and a separate sandbox ledger |
| Accounts                                       | Provisioned on the live payment provider                          | Provisioned on the payment provider's official sandbox |
| Transfers                                      | Real money, provider payouts for withdrawal-destination transfers | Simulated on the sandbox ledger; no provider payout    |
| Pay-in                                         | Every configured payment method                                   | Cards only                                             |
| Withdrawals, bank links, identity verification | Real                                                              | Not available                                          |
| Webhooks                                       | Your live endpoint and secret                                     | Your sandbox endpoint and secret, never the live one   |
| Rate limits and daily limits                   | Counted per mode                                                  | Counted per mode                                       |
| Commercial-evidence metadata on transfers      | Required when your integration enforces it                        | Optional                                               |

Both keys are stored against your company and can coexist. Generate the sandbox key in
the Connect console and keep it in an environment variable:

```bash theme={"system"}
export SIDESHIFT_CONNECT_KEY="sk_test_YOUR_KEY"
```

## What sandbox covers

**Seeded balances.** Your company's sandbox wallet is created and seeded with $10,000
on the first request authenticated with a sandbox key. Every account you create with a
sandbox key gets its own sandbox wallet seeded with $10,000. Seeding happens once and
never overwrites an existing balance, so test transfers accumulate like a real ledger
instead of resetting.

**Accounts.** A sandbox account is a real SideShift account tagged `sandbox: true`,
provisioned on the payment provider's official sandbox with a provider id in the usual
`biz_` form. Older sandbox accounts that carried a simulated `sim_biz_` id are migrated
the next time an account or token flow needs the provider account. Your code should only
ever hold `sideshiftAccountId`, which is stable across that migration.

**Transfers.** All three directions run for real against the sandbox ledger: amount
validation, idempotency, daily limits, source-balance checks and the transfer record all
behave as in production. The only replacement is the outbound provider payout, which
returns a simulated `sim_txn_` id in `paymentTransferId`. Because no provider payout
happens, a transfer with `destinationBalance: "withdrawal"` credits the destination's
sandbox wallet, the same as `"wallet"` does.

**Reads are partitioned.** `GET /transfers`, `GET /transfers/{transferId}` and
`GET /accounts/balance` filter on the mode of the key, so sandbox and live data never
appear in the same response.

**Pay-in.** The pay-in widget and hosted checkout are wired to the provider's sandbox
and accept cards only. Alternative payment methods are production-only, and a checkout
session created with a sandbox key cannot enable them.

**Webhooks.** Events produced under a sandbox key are delivered to the sandbox webhook
URL and signed with the sandbox secret. If no sandbox webhook is configured the event is
not delivered; SideShift never falls back to the live URL or secret.

### What sandbox does not simulate

* **Withdrawals and bank links.** The payment provider does not support payouts in its
  sandbox. The payout widget shows account, verification and balance status, but the
  withdraw, bank-link and payout-submission controls are unavailable, and none of the
  `withdrawal.*` events fire.
* **Withdrawal-ready balance.** `GET /accounts/balance` never queries the provider in
  sandbox, so `withdrawableBalanceCents` (and its alias `balanceCents`) reads `0`. Use
  `walletBalanceCents` to assert sandbox transfers; `totalBalanceCents` is not the
  authoritative number here.
* **Moving funds to the withdrawal balance.** `POST /accounts/withdrawal-balance` has no
  sandbox implementation.
* **Identity verification.** `GET /accounts/{id}/verifications` returns
  `404 VERIFICATION_NOT_FOUND` under a sandbox key; verification records are not created
  there.
* **Cancelling a transfer.** Sandbox transfers settle immediately and never hold a
  provider reservation, so `DELETE /transfers/{transferId}` always returns
  `409 TRANSFER_NOT_CANCELLABLE`.
* **Side effects.** Leaderboard and notification side effects are not triggered.

<Note>
  Sandbox keeps the documented API contract stable wherever an operation is supported. That
  is not the same as every production provider capability having sandbox parity, and the
  list above is the difference.
</Note>

## Test cards

Card pay-in in sandbox goes through the payment provider's sandbox, which recognises
these numbers. Use any future expiry date and any three-digit CVC.

| Number                | Result                                                                     |
| --------------------- | -------------------------------------------------------------------------- |
| `4242 4242 4242 4242` | Succeeds                                                                   |
| `4000 0000 0000 0002` | Declined immediately                                                       |
| `4000 0000 0000 0341` | Attaches, then the charge fails (a delayed decline)                        |
| `5385 3083 6013 5181` | Requires 3-D Secure. Complete the challenge with the password `Checkout1!` |

## Test plan

Work through this in order. Each step names the response you should see, so a mismatch
points at a problem in your integration rather than in your expectations.

<Steps>
  <Step title="Configure the sandbox">
    In the Connect console: generate an `sk_test_` key, add the domain you will embed on,
    and configure a **sandbox** webhook URL with its own secret. Confirm the key works:

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

    Expect `200` with `data.accounts: []`. A `403 EMBED_NOT_ENABLED` means Connect has not
    been enabled for your company yet.
  </Step>

  <Step title="Accounts">
    Create two accounts, so you can test user-to-user transfers later.

    ```bash theme={"system"}
    curl -X POST https://app.sideshift.app/api/embed/accounts/create \
      -H "x-api-key: $SIDESHIFT_CONNECT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "email": "alice@example.com", "name": "Alice", "externalId": "usr_alice" }'
    ```

    Then check each of these:

    * The first call returns `201` with `created: true`. Repeating it returns `200` with
      `created: false` and `alreadyExists: true`, and the same `sideshiftAccountId`.
    * Sending the same `externalId` with a different email returns the existing account,
      not a new one.
    * `PATCH /accounts/{id}` with `{ "externalId": "usr_alice_v2" }` returns
      `data.updated: ["externalId"]`.
    * `GET /accounts/balance?sideshiftAccountId=…` shows `walletBalanceCents: 1000000`
      (the \$10,000 seed) and `withdrawableBalanceCents: 0`.
    * `DELETE /accounts` with `{ "externalIds": ["usr_alice_v2"] }` returns
      `deletedCount: 1`. This detaches the account from your integration without deleting
      history. Re-create it before continuing.
  </Step>

  <Step title="Transfers" id="transfers">
    Run every direction and verify the ledger after each one with
    `GET /accounts/balance`, reading `walletBalanceCents`.

    **Company to user** (omit `fromAccountId`):

    ```bash theme={"system"}
    curl -X POST https://app.sideshift.app/api/embed/accounts/transfer \
      -H "x-api-key: $SIDESHIFT_CONNECT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "toAccountId": "ACCT_ALICE", "amountCents": 2500, "idempotencyKey": "test-c2u-001" }'
    ```

    Expect `status: "completed"`, `direction: "company_to_user"`,
    `destinationBalance: "withdrawal"` and a `paymentTransferId` starting `sim_txn_`.
    Alice's wallet rises by 2,500 and your company balance (`GET /accounts/balance` with
    no `sideshiftAccountId`) falls by 2,500.

    **User to company** (omit `toAccountId`). Settles into your company wallet; passing
    `destinationBalance: "withdrawal"` here is rejected with `400 VALIDATION_ERROR`.

    ```bash theme={"system"}
    -d '{ "fromAccountId": "ACCT_ALICE", "amountCents": 1000, "idempotencyKey": "test-u2c-001" }'
    ```

    **User to user**:

    ```bash theme={"system"}
    -d '{ "fromAccountId": "ACCT_ALICE", "toAccountId": "ACCT_BOB", "amountCents": 500,
          "idempotencyKey": "test-u2u-001" }'
    ```

    Also confirm the failure paths:

    * `amountCents` larger than the source wallet returns `400 INSUFFICIENT_BALANCE`.
    * `amountCents: 0` returns `400 AMOUNT_TOO_SMALL` with `details.minimumAmountCents: 1`.
    * `amountCents: 10000001` returns `400 AMOUNT_TOO_LARGE` with
      `details.maximumAmountCents: 10000000`.
    * A `toAccountId` you did not create returns `404 ACCOUNT_NOT_FOUND`.
    * An `idempotencyKey` shorter than 8 characters returns `400 MISSING_REQUIRED_FIELD`.
    * `metadata` with a non-string value returns `400 INVALID_METADATA`.

    Then list what you did: `GET /transfers?sideshiftAccountId=ACCT_ALICE` returns all
    three, newest first, and `GET /transfers?metadata[orderId]=…` filters on metadata you
    attached.
  </Step>

  <Step title="Idempotency replay">
    Send the company-to-user request from the previous step again, byte for byte. Expect
    `200` with the same `transferId` and no change in either balance.

    Now send the same `idempotencyKey` with `amountCents: 2600`. Expect
    `409 DUPLICATE_TRANSFER` with the message `This idempotencyKey was already used for a
            different transfer`. Keys are permanent within your integration and mode; a key you
    used in sandbox does not collide with the same key in live.
  </Step>

  <Step title="Cancel">
    ```bash theme={"system"}
    curl -X DELETE https://app.sideshift.app/api/embed/transfers/TRANSFER_ID \
      -H "x-api-key: $SIDESHIFT_CONNECT_KEY"
    ```

    In sandbox this always returns `409 TRANSFER_NOT_CANCELLABLE`, because the transfer
    settled synchronously. That is the expected result and confirms your handler treats
    409 as "already terminal" rather than as a retryable error.

    In live, cancel only succeeds in the narrow window where a withdrawal-destination
    transfer holds a reservation that was never submitted to the provider, typically after
    a request failed part-way. A successful cancel returns the transfer with
    `status: "failed"` and `failureReason: "Cancelled before provider settlement"`, credits
    the source wallet back and releases the daily-limit reservation. It never refunds a
    settled payout.
  </Step>

  <Step title="Atomic batch abort">
    Build a batch where one item cannot be funded, and confirm nothing moved.

    ```bash theme={"system"}
    curl -X POST https://app.sideshift.app/api/embed/accounts/transfer/batch \
      -H "x-api-key: $SIDESHIFT_CONNECT_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "atomic": true,
        "idempotencyKey": "test-atomic-001",
        "transfers": [
          { "toAccountId": "ACCT_ALICE", "amountCents": 1000, "idempotencyKey": "test-atomic-001-a" },
          { "toAccountId": "ACCT_BOB", "amountCents": 999999999, "idempotencyKey": "test-atomic-001-b" }
        ]
      }'
    ```

    Expect `400 AMOUNT_TOO_LARGE` (the failing item's own code) with
    `details.atomic: true`, `details.phase: "preflight"`, `details.failedIndex: 1` and a
    `details.results` array in which item 0 succeeded preflight and item 1 carries the
    error. Both balances are unchanged: preflight validates every item and the combined
    source balance before any money moves.

    Replay the exact request. Expect the same stored abort, not a second attempt. To retry
    for real, send new per-item keys and a new batch key.

    Then run a batch that succeeds (two small amounts, `atomic: true`). Expect
    `failureCount: 0`, `atomic: true` and one `results[]` entry per item. Finally run the
    same shape without `atomic` and include one bad item; expect `200` with
    `successCount: 1`, `failureCount: 1` and the failure described inline at
    `results[i].error`.
  </Step>

  <Step title="Balance verification">
    After the steps above, reconcile from the API rather than from your own arithmetic.
    `GET /accounts/balance?sideshiftAccountId=…&limit=50` returns the ledger newest-first
    with `type` (`transfer_in`, `transfer_out`, `deposit`, `withdrawal`, `fee`, `other`),
    `amountCents` (signed), `balanceAfterCents` and a `summary` block. Pending and failed
    entries are excluded from the summary. Read `walletBalanceCents` for the sandbox
    balance.
  </Step>

  <Step title="Widgets, tokens and domain binding on localhost">
    Mint a token for Alice with `widgetType: "both"` and embed `widgetUrls.payout` on a
    page served from `http://localhost:3000`. It loads: `localhost`, `127.0.0.1`,
    `0.0.0.0` and `::1` pass the widget's domain check on any port without being on your
    allowlist.

    Then check the binding rules, because they are where staging deployments usually
    break:

    * Minting with an empty allowlist and no `Origin` header returns
      `400 DOMAIN_NOT_ALLOWED`. Add a domain first.
    * `targetDomain` set to a domain that is not on your allowlist returns
      `400 DOMAIN_NOT_ALLOWED`.
    * A token minted with `targetDomain: "pay.example.com"` and embedded on
      `app.example.com` shows `Access Denied - Token domain mismatch` unless
      `app.example.com` (or `*.example.com`) is also on your allowlist. The widget checks
      the token's binding first and your allowlist second.
    * `expiresInSeconds: 30` returns `400 VALIDATION_ERROR`; the minimum is 60.
    * A token for an account you do not own returns `404 ACCOUNT_NOT_FOUND`.

    Finally, mint a token with `expiresInSeconds: 60`, wait, and confirm your page
    handles `session:expired` by minting a new token and reloading the frame.
  </Step>

  <Step title="Escrow pay-in with test cards">
    Mint a token with `widgetType: "payin"` and `escrowMode: true`, embed
    `widgetUrls.payin`, add `4242 4242 4242 4242` as a payment method and deposit \$50.

    * The widget posts `payin:deposit_initiated` then `payin:deposit_completed`.
    * Your sandbox webhook receives `deposit.pending` and then `deposit.confirmed`, both
      with `data.sideshiftAccountId` set to Alice's id and `data.externalId` set to
      `usr_alice`. `netAmountCents` is what was credited to your company wallet;
      `amountCents` includes the processing fee.
    * Your company sandbox wallet rises by `netAmountCents`.

    Repeat with `4000 0000 0000 0002` and expect `payin:deposit_failed` and a
    `deposit.failed` event carrying `metadata.failureReason`. Repeat with the 3-D Secure
    card to exercise the challenge flow inside the iframe (this is where a missing
    `allow="payment"` attribute shows up).

    A user's ordinary, non-escrow pay-in into their own wallet does not produce deposit
    events for you; see [Webhooks](/connect/webhooks#deposit-events).
  </Step>

  <Step title="Hosted checkout">
    ```bash theme={"system"}
    curl -X POST https://app.sideshift.app/api/embed/checkout/sessions \
      -H "x-api-key: $SIDESHIFT_CONNECT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "amountCents": 5000, "description": "Test order", "metadata": { "orderId": "order_1" } }'
    ```

    Open `data.url` in a browser, pay with `4242 4242 4242 4242`, and confirm:

    * `GET https://app.sideshift.app/api/connect/checkout/{id}` (no auth) moves from
      `status: "open"` to `status: "paid"`.
    * Your sandbox webhook receives `deposit.confirmed` with
      `metadata.source: "connect_hosted_checkout"`, `metadata.checkoutSessionId` and your
      `orderId`.
    * Requesting `allowedPaymentMethods: ["paypal"]` under a sandbox key does not enable
      it; sandbox checkout is cards only.

    Details of the session object and the embedded variant are on
    [Guides](/connect/guides#hosted-checkout).
  </Step>

  <Step title="Webhooks: signature verification" id="webhooks-signature-verification">
    Your handler must verify every delivery before trusting it. The signed payload is the
    `X-Sideshift-Timestamp` header, a literal dot, and the raw request body. Sign the
    bytes you received, not a re-serialised object.

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

        function verifySideshiftWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
          const timestamp = headers['x-sideshift-timestamp'];
          const signature = headers['x-sideshift-signature'];
          if (!timestamp || !signature) return false;

          const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
          if (Number.isNaN(age) || age > toleranceSeconds) return false;

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

          const a = Buffer.from(signature, 'utf8');
          const b = Buffer.from(expected, 'utf8');
          return a.length === b.length && crypto.timingSafeEqual(a, b);
        }
        ```
      </Tab>

      <Tab title="Python">
        ```python theme={"system"}
        import hashlib
        import hmac
        import time

        def verify_sideshift_webhook(raw_body: bytes, headers: dict, secret: str, tolerance: int = 300) -> bool:
            timestamp = headers.get("x-sideshift-timestamp", "")
            signature = headers.get("x-sideshift-signature", "")
            if not timestamp or not signature:
                return False
            if int(time.time()) - int(timestamp) > tolerance:
                return False
            signed = timestamp.encode() + b"." + raw_body
            expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
            return hmac.compare_digest(signature, expected)
        ```
      </Tab>

      <Tab title="Go">
        ```go theme={"system"}
        package webhooks

        import (
        	"crypto/hmac"
        	"crypto/sha256"
        	"encoding/hex"
        	"strconv"
        	"time"
        )

        func VerifySideshiftWebhook(rawBody []byte, timestamp, signature, secret string, tolerance time.Duration) bool {
        	ts, err := strconv.ParseInt(timestamp, 10, 64)
        	if err != nil || time.Since(time.Unix(ts, 0)) > tolerance {
        		return false
        	}
        	mac := hmac.New(sha256.New, []byte(secret))
        	mac.Write([]byte(timestamp + "."))
        	mac.Write(rawBody)
        	expected := hex.EncodeToString(mac.Sum(nil))
        	return hmac.Equal([]byte(signature), []byte(expected))
        }
        ```
      </Tab>
    </Tabs>

    Reject a bad signature with a `4xx`. Deduplicate on the `X-Sideshift-Event-Id`
    header (the same value as `id` in the body), because retries and replays reuse it.
  </Step>

  <Step title="Webhooks: test endpoint" id="webhooks-test-endpoint">
    Prove every handler end to end without moving money. The endpoint delivers one
    synthetic event of any subscribable type to your sandbox webhook, signed with the
    sandbox secret and recorded in the delivery log like a real one. It requires a sandbox
    key; a live key gets `403 VALIDATION_ERROR`.

    ```bash theme={"system"}
    curl -X POST https://app.sideshift.app/api/embed/webhooks/test \
      -H "x-api-key: $SIDESHIFT_CONNECT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "eventType": "deposit.confirmed", "amountCents": 10000, "feeCents": 329 }'
    ```

    ```json theme={"system"}
    {
      "success": true,
      "data": {
        "eventType": "deposit.confirmed",
        "delivered": true,
        "sandbox": true,
        "depositId": "dep_test_…",
        "paymentId": "pay_test_…",
        "sideshiftAccountId": "YOUR_COMPANY_ID",
        "amountCents": 10000,
        "feeCents": 329,
        "netAmountCents": 9671,
        "status": "confirmed"
      }
    }
    ```

    | Field                                       | Meaning                                                                                    |
    | ------------------------------------------- | ------------------------------------------------------------------------------------------ |
    | `eventType`                                 | Any of the nine event types. Defaults to `deposit.confirmed`.                              |
    | `sideshiftAccountId`, `externalId`          | Placed in the payload. The account defaults to your company id.                            |
    | `amountCents`, `feeCents`, `netAmountCents` | Default to 10000, 0, and `amountCents - feeCents`.                                         |
    | `depositId`                                 | `deposit.*` events only. Minted when omitted.                                              |
    | `paymentId`                                 | The payment, transfer or withdrawal id to echo. Minted when omitted.                       |
    | `status`                                    | `withdrawal.updated` only. One of the seven withdrawal statuses; defaults to `in_transit`. |
    | `metadata`                                  | String values, merged into the payload's `metadata`.                                       |

    Each payload has the shape of the real event, with `metadata.test: "true"` and
    `metadata.sandbox: "true"` so your receiver can tell a rehearsal apart. Fields that do
    not apply to the chosen event are ignored. Run it once per event type you subscribe
    to, and for `withdrawal.updated` once per status you branch on:

    ```bash theme={"system"}
    -d '{ "eventType": "withdrawal.updated", "status": "failed", "paymentId": "wdr_test_1" }'
    ```

    If your endpoint answers with a non-2xx the call returns `502 WEBHOOK_DELIVERY_FAILED`,
    which makes it a usable signature-verification harness: a wrong secret shows up here,
    not in production. The same `502` is returned, with the reason in `message`, when no
    sandbox webhook is configured (`No webhook configured`). The event is delivered whether
    or not your sandbox endpoint is subscribed to it, so you can test a handler before
    switching its subscription on. An unknown `eventType` or `status` returns
    `400 VALIDATION_ERROR`.
  </Step>

  <Step title="Webhooks: replay and delivery logs">
    Take an `eventId` from a delivery you received and replay it:

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

    The replay re-signs the original payload with your current secret and a fresh
    timestamp, keeps the same event id, and returns `replayed: true` with the
    `statusCode` your endpoint answered. Your handler should recognise the id and treat
    the second delivery as a no-op.

    Then inspect the log:

    ```bash theme={"system"}
    curl "https://app.sideshift.app/api/embed/webhook-logs?eventType=deposit.confirmed&limit=20" \
      -H "x-api-key: $SIDESHIFT_CONNECT_KEY"
    ```

    Each entry has `eventId`, `eventType`, `success`, `statusCode`, `error`,
    `attemptNumber`, the `payload` that was sent and `createdAt`. Filter with `eventId`,
    `eventType`, `success=true|false` or `paymentId`. Logs are partitioned by mode, so a
    sandbox key only sees sandbox deliveries.
  </Step>

  <Step title="Error handling">
    Make sure your client distinguishes these classes. The full list is on
    [Errors](/connect/errors).

    | Response                                                                                                                                           | Meaning                                                            | Client action                                                                                            |
    | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
    | `400` validation codes (`VALIDATION_ERROR`, `MISSING_REQUIRED_FIELD`, `INVALID_METADATA`, `AMOUNT_TOO_SMALL`, `AMOUNT_TOO_LARGE`, `INVALID_EMAIL`) | Your request is malformed                                          | Fix the request. Do not retry unchanged.                                                                 |
    | `400 INSUFFICIENT_BALANCE`, `400 DAILY_LIMIT_EXCEEDED`                                                                                             | Funds or limits                                                    | Fund the source or wait for the next UTC day. Retrying does not help.                                    |
    | `401`, `403`                                                                                                                                       | Credentials, permissions or domain                                 | Configuration problem. Alert, do not retry.                                                              |
    | `404 ACCOUNT_NOT_FOUND`                                                                                                                            | The account or transfer is not yours, or does not exist            | Check the id. Also returned deliberately instead of 403 for other integrators' accounts.                 |
    | `409 DUPLICATE_TRANSFER` with `details.conflictType: "idempotency"`, or without `details`                                                          | Key reused with a different request                                | Never retry. Generate a new key for a genuinely new transfer.                                            |
    | `409 DUPLICATE_TRANSFER` with `details.conflictType: "source_serialization"`                                                                       | Another withdrawal from the same source is settling                | Retryable. Wait, then retry with the same key.                                                           |
    | `409 TRANSFER_RECONCILIATION_REQUIRED`                                                                                                             | An earlier attempt is awaiting recovery                            | Retryable. Retry with the same key after a delay.                                                        |
    | `202 TRANSFER_FAILED`                                                                                                                              | The provider accepted the payout but local finalisation is pending | Treat as in-flight. Poll `GET /transfers/{transferId}` or retry with the same key. Never send a new key. |
    | `409 TRANSFER_NOT_CANCELLABLE`, `409 BATCH_ABORTED`                                                                                                | Terminal state reached                                             | Read `details` and move on.                                                                              |
    | `429 RATE_LIMITED`                                                                                                                                 | Per-minute, token-per-hour, or daily transfer-count limit          | Back off with jitter.                                                                                    |
    | `500 INTERNAL_ERROR`, `500 TRANSFER_FAILED`, `502`                                                                                                 | Server side                                                        | Retry with the same idempotency key after a delay.                                                       |
  </Step>

  <Step title="Rate-limit handling">
    The default limits are 100 requests per minute and 100 widget tokens per hour per
    company, each counted separately for sandbox and live, plus 1,000 transfers per UTC
    day. Exceeding one returns `429 RATE_LIMITED`:

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

    The number of seconds is in the message. There is no `Retry-After` header, and the
    `X-RateLimit-*` headers computed by the limiter are not forwarded on Connect
    responses, so back off on the status code and the message, add jitter so a fleet of
    workers does not retry in lockstep, and always send an `idempotencyKey` so a retried
    transfer cannot double-pay. Exercise this deliberately: fire 101 requests in a minute
    with the sandbox key and confirm your client waits rather than hammering.
  </Step>
</Steps>

## Go-live checklist

Swapping `sk_test_` for `sk_live_` is the whole migration on your side. Before you do:

<AccordionGroup>
  <Accordion title="Keys and secrets">
    * The live key is in a production secrets manager, not in source control or a browser bundle.
    * Every service, worker and scheduled job that talks to Connect reads the same secret.
    * The sandbox key is not used anywhere in production code paths.
  </Accordion>

  <Accordion title="Domains">
    * The allowlist is owned by the integration, not by the key, so whatever you added to make sandbox work is already live. Remove preview hostnames, tunnels and broad wildcards.
    * `*.vercel.app` or `*.github.io` allows every tenant on that platform; list your own hostnames instead.
    * `allowAllDomains` is off unless you have a WebView case that genuinely needs it.
  </Accordion>

  <Accordion title="Webhooks">
    * The live webhook URL uses HTTPS and its secret is stored server-side.
    * The handler verifies the signature, rejects stale timestamps, deduplicates on `X-Sideshift-Event-Id`, and returns `200` quickly.
    * You are subscribed to the terminal withdrawal events (`withdrawal.completed` implies `withdrawal.failed`) and to `transfer.completed`.
    * Unknown event types are acknowledged with `200`, not rejected with `400` (a `4xx` ends delivery without retry).
  </Accordion>

  <Accordion title="Transfers">
    * Every transfer sends an `idempotencyKey` derived from your own record id, and retries reuse it.
    * Every live transfer attaches commercial-evidence metadata (`obligationType`, `obligationReference`, `description`, `approvalReference`; `programId` or `contractId` for `campaign`). New integrations enforce this.
    * Your daily transfer amount limit, if configured, matches expected volume.
    * Reconciliation reads balances back from `GET /accounts/balance` rather than summing your own records.
  </Accordion>

  <Accordion title="Widgets">
    * Tokens are minted per session with the shortest lifetime that works, and `session:expired` is handled.
    * The iframe carries `allow="payment; camera; microphone"`.
    * Completed withdrawals are confirmed from `withdrawal.completed`, not from a widget event.
  </Accordion>

  <Accordion title="First live transfer">
    * Run one small live transfer (for example \$0.50) to a test account you control, confirm the `transfer.completed` webhook, then confirm the withdrawal flow in the payout widget end to end before opening up volume.
  </Accordion>
</AccordionGroup>

## Support handoff template

When something needs SideShift's help, this is what lets support act on the first
message. Never include your API key or a webhook secret.

```text theme={"system"}
Integration: <company name> (company id: <YOUR_COMPANY_ID>)
Mode: sandbox | live
When: <ISO timestamp with timezone>

What I called:
  <METHOD> https://app.sideshift.app/api/embed/<path>
  Body (redacted): { ... }

What I got:
  HTTP <status>
  { "success": false, "error": { "code": "<CODE>", "message": "<message>", "details": { ... } } }

Ids involved:
  sideshiftAccountId: <acct_…>   externalId: <usr_…>
  transferId: <etr_…>            idempotencyKey: <…>
  eventId (webhooks): <…>        checkout session id: <…>

What I expected instead: <one or two sentences>
Already checked: <status page, webhook-logs, GET /transfers/{id}, replay, …>
```

The [status page](/connect/status) is worth a glance before writing; an active incident
on the component you are hitting answers the question faster than a ticket.
