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

# Webhooks

> Every Connect event with its payload, how deliveries are signed and retried, and how to replay them.

Connect notifies your backend about deposits, completed transfers, withdrawal status
changes and risk flags by POSTing a signed JSON event to a URL you configure. This page is
the complete contract: the events, their exact payloads, the headers, the retry policy,
and the endpoints for testing, inspecting and replaying deliveries.

## Setup

Configure webhooks in the Connect console under **Embed Widgets**. There are two
independent endpoints:

| Endpoint        | Receives events produced by            |
| --------------- | -------------------------------------- |
| Live webhook    | `sk_live_` keys and live user activity |
| Sandbox webhook | `sk_test_` keys and sandbox activity   |

Each has its own URL, its own signing secret and its own subscription list. A secret is
generated on first save and shown as `whsec_` followed by 64 hex characters. The URL must
be `http` or `https`. Saving preserves what you do not change: a save that only changes
the URL keeps the existing secret and subscription list.

Sandbox events are delivered only to the sandbox webhook and signed only with the sandbox
secret. If no sandbox webhook is configured, sandbox events are not delivered; they never
fall back to the live URL or secret.

### Subscriptions

An endpoint registered without an explicit event list is subscribed to `deposit.pending`,
`deposit.confirmed`, `deposit.failed`, `withdrawal.created`, `withdrawal.completed`,
`withdrawal.failed` and `transfer.completed`. `withdrawal.updated` is left out of that
default because its intermediate transitions are noise for anyone who has not asked for
them, and `account.risk_flagged` is opt-in.

A `withdrawal.completed` subscription implies `withdrawal.failed`: if you asked to hear
that a payout landed, you hear about the bounce too. Handle unknown `type` values by
acknowledging and ignoring them.

## Events

Nine events are available.

| Event                  | Fires when                                                               |
| ---------------------- | ------------------------------------------------------------------------ |
| `deposit.pending`      | A deposit into a wallet you own was initiated and is awaiting settlement |
| `deposit.confirmed`    | A deposit settled and the wallet was credited                            |
| `deposit.failed`       | A deposit was declined or failed                                         |
| `transfer.completed`   | A transfer you initiated completed, in any direction                     |
| `withdrawal.created`   | A user you have paid initiated a withdrawal                              |
| `withdrawal.updated`   | That withdrawal's `status` changed, at every transition                  |
| `withdrawal.completed` | The withdrawal reached `completed`                                       |
| `withdrawal.failed`    | The withdrawal reached `failed` or `denied`                              |
| `account.risk_flagged` | SideShift raised a risk flag on one of your accounts                     |

### Envelope

Every delivery is a POST with this JSON body:

```json theme={"system"}
{
  "id": "evt_5c1e0f4a-…",
  "type": "deposit.confirmed",
  "timestamp": "2026-09-05T12:00:00.000Z",
  "data": { }
}
```

`id` is unique per event and is the value to deduplicate on; it is also sent as the
`X-Sideshift-Event-Id` header. `timestamp` is when this delivery was built, so a replay
carries the same `id` with a newer `timestamp`.

`data` always contains `sideshiftAccountId`, `currency` (`"usd"`) and `status`. It
contains `externalId` whenever the account has one: the value you set on
`POST /accounts/create` or `PATCH /accounts/{id}`. This holds for every event, including
the `withdrawal.*` family. Money fields (`amountCents`, `feeCents`, `netAmountCents`,
`paymentId`) are present on every event where money moved and absent on
`account.risk_flagged`. Integrations in legacy mode additionally receive `whopPaymentId`
as an alias of `paymentId`.

### Deposit events

Deposit events describe money arriving in a wallet **you** own. They fire on three
funding paths:

* **Escrow pay-in.** One of your users deposits through a pay-in widget whose token was
  minted with `escrowMode: true`. The money is credited to your company wallet (or the
  escrow destination you named). `data.sideshiftAccountId` is the depositing user.
* **Hosted checkout.** A payer completes a session from `POST /checkout/sessions`.
  `deposit.confirmed` carries `metadata.source: "connect_hosted_checkout"` and
  `metadata.checkoutSessionId`, plus the metadata you attached to the session.
  `depositId` is the session id.
* **Your own wallet top-up.** You fund your own SideShift wallet through the SideShift
  app. `data.sideshiftAccountId` is your own company id and
  `metadata.source: "wallet_topup"`.

A user's ordinary pay-in into their own wallet (a non-escrow pay-in widget) is that user's
money, not yours, and produces no deposit event to you.

`amountCents` is the gross amount charged to the payer, `feeCents` is the processing fee,
and `netAmountCents` is what was credited to the wallet.

<AccordionGroup>
  <Accordion title="deposit.pending">
    Sent the moment a deposit is initiated, before the underlying payment settles. Fires
    for escrow pay-ins and your own wallet top-ups. Use it to mark the deposit as
    in-flight; nothing has been credited yet.

    ```json theme={"system"}
    {
      "id": "evt_…",
      "type": "deposit.pending",
      "timestamp": "2026-09-05T12:00:00.000Z",
      "data": {
        "depositId": "esc_dep_…",
        "sideshiftAccountId": "acct_a1b2c3d4e5f6",
        "externalId": "usr_123",
        "amountCents": 10329,
        "feeCents": 329,
        "netAmountCents": 10000,
        "currency": "usd",
        "paymentId": "pay_…",
        "status": "pending",
        "metadata": { "originalPaymentId": "pay_…" }
      }
    }
    ```
  </Accordion>

  <Accordion title="deposit.confirmed">
    Sent when the payment settles and the wallet is credited. Card payments usually
    confirm within seconds; bank rails can take business days after `deposit.pending`.
    This is the event to credit on.

    ```json theme={"system"}
    {
      "id": "evt_…",
      "type": "deposit.confirmed",
      "timestamp": "2026-09-05T12:00:03.000Z",
      "data": {
        "depositId": "cks_…",
        "sideshiftAccountId": "acct_a1b2c3d4e5f6",
        "externalId": "usr_123",
        "amountCents": 5185,
        "feeCents": 185,
        "netAmountCents": 5000,
        "currency": "usd",
        "paymentId": "pay_…",
        "status": "confirmed",
        "metadata": {
          "source": "connect_hosted_checkout",
          "checkoutSessionId": "cks_…",
          "orderId": "order_1"
        }
      }
    }
    ```
  </Accordion>

  <Accordion title="deposit.failed">
    Sent when an escrow deposit is declined or fails. `feeCents` and `netAmountCents` are
    `0`, and the reason is in `metadata.failureReason` (`"Unknown"` when the provider gave
    none).

    ```json theme={"system"}
    {
      "id": "evt_…",
      "type": "deposit.failed",
      "timestamp": "2026-09-05T12:00:03.000Z",
      "data": {
        "depositId": "pay_…",
        "sideshiftAccountId": "acct_a1b2c3d4e5f6",
        "externalId": "usr_123",
        "amountCents": 5000,
        "feeCents": 0,
        "netAmountCents": 0,
        "currency": "usd",
        "paymentId": "pay_…",
        "status": "failed",
        "metadata": { "failureReason": "card_declined" }
      }
    }
    ```
  </Accordion>
</AccordionGroup>

### transfer.completed

Sent when a transfer you initiated through `POST /accounts/transfer` or the batch
endpoint completes, in any direction. `sideshiftAccountId` is the **destination**
account; `externalId` is the destination's external id, and is absent when the
destination is your own company. `feeCents` is always `0` and `netAmountCents` equals
`amountCents`. `paymentId` is the payment provider's transfer id when there was one
(withdrawal-destination transfers), otherwise the `transferId`.

`metadata` is your transfer metadata plus `transferId`, `direction`,
`destinationBalance`, `fromAccountId` and `toAccountId`.

```json theme={"system"}
{
  "id": "embed-transfer:etr_9f2c…",
  "type": "transfer.completed",
  "timestamp": "2026-09-05T12:00:00.000Z",
  "data": {
    "sideshiftAccountId": "acct_a1b2c3d4e5f6",
    "externalId": "usr_123",
    "amountCents": 5000,
    "feeCents": 0,
    "netAmountCents": 5000,
    "currency": "usd",
    "paymentId": "txn_…",
    "status": "completed",
    "metadata": {
      "obligationType": "creator_agreement",
      "obligationReference": "agreement-001",
      "description": "Approved payment for completed creator deliverable",
      "approvalReference": "approval-001",
      "transferId": "etr_9f2c…",
      "direction": "company_to_user",
      "destinationBalance": "withdrawal",
      "fromAccountId": "YOUR_COMPANY_ID",
      "toAccountId": "acct_a1b2c3d4e5f6"
    }
  }
}
```

For live transfers that settle to the withdrawal-ready balance, the event id is
deterministic (`embed-transfer:` followed by the `transferId`), so a transfer recovered by
SideShift's reconciliation after a lost response delivers with the same id and your
deduplication holds. Other transfers use a random id.

### Withdrawal events

Withdrawal events describe a user moving money **out** of their SideShift account to an
external payout method through the payout widget. They are sourced from the payment
provider's own withdrawal events and are live-only; there are no withdrawals in sandbox.

**Routing.** A withdrawal event fans out to every integration that has previously settled
a completed transfer to that user, because each of them has a ledger-reconciliation
interest in the outcome. The set is resolved once per withdrawal and reused for its later
status updates.

**Status enum.** `status` is passed through from the payment provider:

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

`completed`, `failed`, `canceled` and `denied` are terminal. There is no per-transition
history on the provider side, so `metadata.previousStatus` on `withdrawal.updated` is
how you reconstruct the path.

**Shared payload.** All four events carry the same `data` shape:

| Field                                                                                                                          | Meaning                                                                                                                                                                                                                                                                      |
| ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sideshiftAccountId`, `externalId`                                                                                             | The withdrawing user, and their external id when set                                                                                                                                                                                                                         |
| `amountCents`, `feeCents`, `netAmountCents`                                                                                    | Gross, fee, and what the user receives                                                                                                                                                                                                                                       |
| `paymentId`                                                                                                                    | The withdrawal id (also `metadata.withdrawalId`)                                                                                                                                                                                                                             |
| `status`                                                                                                                       | Current status                                                                                                                                                                                                                                                               |
| `markupRevenueCents`                                                                                                           | Your withdrawal-markup earnings on this withdrawal, your layer only. Sent only to the integration that owns the account, and only when you charge a markup on the rail. Provisional until `completed`. See [withdrawal fee markups](/connect/guides#withdrawal-fee-markups). |
| `metadata.withdrawalId`                                                                                                        | Always present                                                                                                                                                                                                                                                               |
| `metadata.speed`, `metadata.traceCode`, `metadata.estimatedAvailability`, `metadata.destinationInfo`, `metadata.payoutTokenId` | Present when the provider supplied them. `destinationInfo` is the payout method's nickname or payer name.                                                                                                                                                                    |
| `metadata.withdrawalCreatedAt`, `metadata.withdrawalCompletedAt`                                                               | ISO timestamps, when known                                                                                                                                                                                                                                                   |
| `metadata.previousStatus`                                                                                                      | On status transitions                                                                                                                                                                                                                                                        |
| `metadata.errorCode`, `metadata.errorMessage`                                                                                  | On `failed` and `denied`, when the provider supplied a reason                                                                                                                                                                                                                |

<AccordionGroup>
  <Accordion title="withdrawal.created">
    Sent once when the user initiates the withdrawal. `status` is typically `requested`.

    ```json theme={"system"}
    {
      "id": "evt_…",
      "type": "withdrawal.created",
      "timestamp": "2026-09-05T12:00:00.000Z",
      "data": {
        "sideshiftAccountId": "acct_a1b2c3d4e5f6",
        "externalId": "usr_123",
        "amountCents": 10000,
        "feeCents": 150,
        "netAmountCents": 9850,
        "currency": "usd",
        "paymentId": "wdr_…",
        "status": "requested",
        "markupRevenueCents": 50,
        "metadata": {
          "withdrawalId": "wdr_…",
          "speed": "instant",
          "destinationInfo": "Chase ••••1234",
          "payoutTokenId": "ptok_…",
          "withdrawalCreatedAt": "2026-09-05T12:00:00.000Z"
        }
      }
    }
    ```
  </Accordion>

  <Accordion title="withdrawal.updated">
    Sent each time `status` changes. Pure metadata refreshes with no status change are not
    relayed. `canceled` is delivered on this event only.

    ```json theme={"system"}
    {
      "id": "evt_…",
      "type": "withdrawal.updated",
      "timestamp": "2026-09-05T12:05:00.000Z",
      "data": {
        "sideshiftAccountId": "acct_a1b2c3d4e5f6",
        "externalId": "usr_123",
        "amountCents": 10000,
        "feeCents": 150,
        "netAmountCents": 9850,
        "currency": "usd",
        "paymentId": "wdr_…",
        "status": "in_transit",
        "metadata": {
          "withdrawalId": "wdr_…",
          "previousStatus": "awaiting_payment",
          "traceCode": "…",
          "estimatedAvailability": "2026-09-05T12:30:00.000Z"
        }
      }
    }
    ```
  </Accordion>

  <Accordion title="withdrawal.completed">
    A derived alias of `withdrawal.updated` filtered to `status: "completed"`. The two
    events fire together when a withdrawal completes. Subscribe to this if you only want
    the terminal success.

    ```json theme={"system"}
    {
      "id": "evt_…",
      "type": "withdrawal.completed",
      "timestamp": "2026-09-05T12:30:00.000Z",
      "data": {
        "sideshiftAccountId": "acct_a1b2c3d4e5f6",
        "externalId": "usr_123",
        "amountCents": 10000,
        "feeCents": 150,
        "netAmountCents": 9850,
        "currency": "usd",
        "paymentId": "wdr_…",
        "status": "completed",
        "markupRevenueCents": 50,
        "metadata": {
          "withdrawalId": "wdr_…",
          "previousStatus": "in_transit",
          "withdrawalCompletedAt": "2026-09-05T12:30:00.000Z"
        }
      }
    }
    ```
  </Accordion>

  <Accordion title="withdrawal.failed">
    A derived alias of `withdrawal.updated` for `failed` (the payout was returned, usually
    by the receiving bank) and `denied` (SideShift or the provider refused it). `status`
    is **not** normalised, so read it to tell the two apart. A `canceled` withdrawal is not
    a failure and does not produce this event.

    ```json theme={"system"}
    {
      "id": "evt_…",
      "type": "withdrawal.failed",
      "timestamp": "2026-09-06T09:00:00.000Z",
      "data": {
        "sideshiftAccountId": "acct_a1b2c3d4e5f6",
        "externalId": "usr_123",
        "amountCents": 10000,
        "feeCents": 150,
        "netAmountCents": 9850,
        "currency": "usd",
        "paymentId": "wdr_…",
        "status": "failed",
        "metadata": {
          "withdrawalId": "wdr_…",
          "previousStatus": "in_transit",
          "errorCode": "account_closed",
          "errorMessage": "The receiving account is closed"
        }
      }
    }
    ```
  </Accordion>
</AccordionGroup>

### account.risk\_flagged

Sent once per risk flag when SideShift detects an account-level risk signal on one of
your accounts. Today the only signal is `duplicate_identity_profile`: the same identity
verification profile was observed on more than one Connect account under your
integration. Use it as a stop-and-review signal before sending further transfers.

Bank-account duplicate detection is not emitted, because SideShift does not receive raw
bank details or a stable fingerprint from the payout provider;
`bankAccountFingerprintAvailable` is always `false` today.

```json theme={"system"}
{
  "id": "evt_…",
  "type": "account.risk_flagged",
  "timestamp": "2026-09-05T12:00:00.000Z",
  "data": {
    "sideshiftAccountId": "acct_a1b2c3d4e5f6",
    "externalId": "usr_123",
    "currency": "usd",
    "status": "flagged",
    "riskFlagId": "duplicate_identity_…",
    "riskType": "duplicate_identity_profile",
    "riskReasons": ["same_identity_profile_seen_on_multiple_connect_accounts"],
    "severity": "high",
    "duplicateOfAccountIds": ["acct_z9y8x7w6v5u4"],
    "identityProfileId": "idpf_…",
    "bankAccountFingerprintAvailable": false,
    "metadata": {
      "kycProvider": "whop",
      "bankAccountFingerprintAvailable": "false"
    }
  }
}
```

## Delivery

### Headers

| 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                 |
| `Content-Type`          | `application/json`                             |

### Signature

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

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

Sign the bytes you received, not a re-serialised object; `JSON.stringify` on a parsed body
changes whitespace and the signature will not match. Compare with a constant-time function
and reject stale timestamps, since a signature on its own stays valid forever. Reference
implementations in Node.js, Python and Go are on
[Testing](/connect/testing#webhooks-signature-verification).

### Retries

A delivery is a short series of attempts inside a single dispatch:

| Rule                  | Value                                                                             |
| --------------------- | --------------------------------------------------------------------------------- |
| Attempts              | 3 in total by default (configurable per endpoint)                                 |
| Timeout per attempt   | 10 seconds by default (configurable per endpoint)                                 |
| Wait between attempts | 1 second, then 2 seconds                                                          |
| Success               | Any `2xx`                                                                         |
| Retried               | `5xx`, `429`, connection errors and timeouts                                      |
| Not retried           | Any other `4xx`. Returning `400` to an event you do not recognise throws it away. |

There is no long-running retry queue behind these attempts. If all of them fail, the
delivery is logged as failed and you recover it with the replay endpoint below. Return
`200` quickly and process asynchronously; the timeout runs on your handler, so a slow
database write is indistinguishable from an outage.

### Deduplication

Deliveries can arrive more than once: a retry after a timeout your handler actually
served, a replay you triggered, or a reconciled transfer redelivered under its
deterministic id. Deduplicate on `X-Sideshift-Event-Id` and treat a repeat as a no-op.

<Note>
  Webhooks are a notification, not a ledger. Reconcile anything that must be exactly right
  against the API: read `GET /accounts/balance` and `GET /transfers` rather than summing
  the amounts you were told about.
</Note>

## Managing deliveries

### Delivery logs

`GET /api/embed/webhook-logs` returns delivery attempts for your integration and mode,
newest first.

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

| Query parameter   | Meaning                                      |
| ----------------- | -------------------------------------------- |
| `eventId`         | Exact match on the event id                  |
| `eventType`       | One of the nine event types                  |
| `success`         | `true` or `false`                            |
| `paymentId`       | Exact match on `data.paymentId`              |
| `limit`, `offset` | Paging. `limit` defaults to 20, maximum 100. |

```json theme={"system"}
{
  "success": true,
  "data": {
    "logs": [
      {
        "id": "log_…",
        "eventId": "evt_…",
        "eventType": "transfer.completed",
        "success": false,
        "statusCode": 503,
        "error": "HTTP 503: Service Unavailable",
        "attemptNumber": 3,
        "payload": { "sideshiftAccountId": "acct_…", "amountCents": 5000, "status": "completed" },
        "createdAt": "2026-09-05T12:00:05.000Z"
      }
    ],
    "pagination": { "total": 1, "limit": 20, "offset": 0, "hasMore": false }
  }
}
```

`payload` is the `data` object that was sent. A replayed delivery is logged as a new
entry.

### Replay

`POST /api/embed/webhooks/{eventId}/replay` re-sends a previously dispatched event to
your current endpoint. It is how you recover from an outage without asking anyone to
reproduce the original activity.

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/embed/webhooks/EVENT_ID/replay \
  -H "x-api-key: $SIDESHIFT_CONNECT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "logId": "log_…" }'
```

The body is optional. Without `logId` the most recent delivery of that event id in your
mode is replayed. The payload is re-signed with your **current** secret and a fresh
timestamp, the event id is unchanged, and the attempt is logged with `replay: true`.

```json theme={"system"}
{
  "success": true,
  "data": {
    "eventId": "evt_…",
    "replayed": true,
    "sandbox": false,
    "statusCode": 200,
    "attemptNumber": 1,
    "replayedFromLogId": "log_…"
  }
}
```

| Failure                                               | Response                      |
| ----------------------------------------------------- | ----------------------------- |
| No prior delivery, or the log entry is not yours      | `404 VALIDATION_ERROR`        |
| No webhook URL configured, or the webhook is disabled | `400 WEBHOOK_NOT_CONFIGURED`  |
| Your endpoint did not accept the replay               | `502 WEBHOOK_DELIVERY_FAILED` |

### Test delivery

`POST /api/embed/webhooks/test` signs and delivers one synthetic event of any of the nine
types to your sandbox webhook, shaped like the real event and logged like a real
delivery. It requires a sandbox key. Pass `eventType` (default `deposit.confirmed`) and,
for `withdrawal.updated`, a `status`. The full request body, response and failure modes
are on [Testing](/connect/testing#webhooks-test-endpoint).
