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

# Webhooks

## Quick start

The shortest path from zero to a verified delivery:

1. **Create an endpoint.** `POST /v0/webhooks` with a public URL — save the `secret` from the response, you'll need it to verify signatures.
2. **Verify the signature** on incoming requests with `HMAC-SHA256(secret, raw_body)` against the `X-Superbank-Signature` header (jump to [Verifying signatures](#verifying-signatures)).
3. **Return `200` quickly**, then process asynchronously. Anything 2xx counts; anything else triggers a retry.

## The basics

### Supported events

| Event                        | When it fires                        |
| ---------------------------- | ------------------------------------ |
| `account.created`            | A new account was provisioned        |
| `account.updated`            | Account balance or status changed    |
| `account.deleted`            | An account was deactivated           |
| `payment.created`            | A new payment was created            |
| `payment.updated`            | A payment status changed             |
| `settlement_request.created` | A new settlement request was created |
| `settlement_request.updated` | A settlement request status changed  |

### Envelope and headers

Every delivery uses the same JSON envelope. Field names are **snake\_case**; the `data` object varies by event type. The full payload for each event is in [Event payload reference](#event-payload-reference) at the bottom.

```json theme={null}
{
  "event": "<event_type>",
  "data": { ... },
  "timestamp": "2026-01-26T15:48:08.700Z"
}
```

| Header                  | Description                           |
| ----------------------- | ------------------------------------- |
| `Content-Type`          | `application/json`                    |
| `X-Superbank-Signature` | HMAC-SHA256 signature: `sha256=<hex>` |
| `X-Superbank-Event`     | Event type (e.g., `payment.updated`)  |

## Verifying signatures

Compute `sha256=HMAC-SHA256(secret, request_body)` over the **raw request body** and compare it to the `X-Superbank-Signature` header using a constant-time comparison. Verify before parsing — see [Common pitfalls](#common-pitfalls).

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

    function verifyWebhookSignature(secret, payload, signatureHeader) {
      const expectedSignature = crypto
        .createHmac('sha256', secret)
        .update(payload)
        .digest('hex');

      const received = signatureHeader.replace('sha256=', '');
      return crypto.timingSafeEqual(
        Buffer.from(expectedSignature, 'hex'),
        Buffer.from(received, 'hex')
      );
    }

    // Express middleware example — note `express.raw` so the body stays bytes
    app.post('/webhooks/superbank', express.raw({ type: 'application/json' }), (req, res) => {
      const signature = req.headers['x-superbank-signature'];
      const isValid = verifyWebhookSignature(
        process.env.WEBHOOK_SECRET,
        req.body,
        signature
      );

      if (!isValid) {
        return res.status(401).send('Invalid signature');
      }

      const event = JSON.parse(req.body);
      // Handle event...
      res.status(200).send('OK');
    });
    ```
  </Tab>

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

    def verify_webhook_signature(secret: str, payload: bytes, signature_header: str) -> bool:
        expected = hmac.new(
            secret.encode('utf-8'),
            payload,
            hashlib.sha256
        ).hexdigest()

        received = signature_header.replace('sha256=', '')
        return hmac.compare_digest(expected, received)

    # Flask example — `request.data` is the raw bytes, not the parsed JSON
    @app.route('/webhooks/superbank', methods=['POST'])
    def handle_webhook():
        signature = request.headers.get('X-Superbank-Signature')
        is_valid = verify_webhook_signature(
            os.environ['WEBHOOK_SECRET'],
            request.data,
            signature
        )

        if not is_valid:
            return 'Invalid signature', 401

        event = request.get_json()
        # Handle event...
        return 'OK', 200
    ```
  </Tab>
</Tabs>

## Reliability

### Retry policy

If your endpoint returns a non-2xx response or times out (30 seconds), Superbank retries with exponential backoff:

| Attempt | Delay      | Cumulative  |
| ------- | ---------- | ----------- |
| 1       | Immediate  | —           |
| 2       | 1 minute   | 1 minute    |
| 3       | 5 minutes  | 6 minutes   |
| 4       | 15 minutes | 21 minutes  |
| 5       | 1 hour     | \~1.3 hours |
| 6       | 1 day      | \~1.3 days  |
| 7       | 2 days     | \~3.3 days  |
| 8       | 4 days     | \~7.3 days  |
| 9       | 1 week     | \~14.3 days |
| 10      | 2 weeks    | \~28.3 days |

After 10 failed attempts, the delivery is marked as permanently failed.

### Common pitfalls

<Note>
  * **Verify before parse.** Run signature verification on the raw bytes *before* `JSON.parse`.
    Frameworks that auto-parse JSON (Express default, NestJS body parser) will silently re-serialize
    the body and your HMAC will never match — use `express.raw` / `request.data` / equivalent to hold
    onto the original bytes. - **Return 200 fast, process async.** Heavy work on the request thread
    blows past the 30-second timeout and triggers retries. Acknowledge, then enqueue. - **Expect
    at-least-once delivery.** Retries are real — the same event can land twice. Make handlers
    idempotent on the event `id` (or on `data.id` + status transition). - **Don't filter by source
    IP.** Egress IPs change without notice; rely on the signature.
</Note>

## Testing

### Test deliveries

Test deliveries land at your endpoint with two markers your handler should expect:

* **`data.test: true`** — every test payload sets a top-level `test: true` inside `data`. Branch on it if you want to short-circuit business logic for test events.
* **Sentinel resource IDs** — IDs use the prefix `00000000-0000-0000-0000-...`, with the last digit identifying the resource type (`...001` settlement request, `...002` outbound payment, etc.). Allow-list these prefixes if your handler validates IDs against your database.

Headers and signature are computed exactly as in production, so a handler that verifies signatures accepts test deliveries without any code branch.

### From your local machine with ngrok

[ngrok](https://ngrok.com) creates a public tunnel to your localhost so sandbox deliveries land directly on your dev box.

```bash theme={null}
# 1. Start your handler (whichever port it binds to)
node server.js

# 2. Tunnel it
ngrok http 3000

# 3. Register the public URL it prints
curl --request POST \
  --url https://api-sandbox.superbank.co/v0/webhooks \
  --header 'Content-Type: application/json' \
  --header 'X-Api-Key: YOUR_API_KEY' \
  --data '{ "url": "https://abc123.ngrok-free.app/webhooks/superbank" }'
```

### From a browser with webhook.site

[webhook.site](https://webhook.site) gives you an instant public URL to inspect deliveries without writing any handler code — useful for eyeballing payloads before you write parsing logic.

```bash theme={null}
# Register the unique URL it generates
curl --request POST \
  --url https://api-sandbox.superbank.co/v0/webhooks \
  --header 'Content-Type: application/json' \
  --header 'X-Api-Key: YOUR_API_KEY' \
  --data '{ "url": "https://webhook.site/YOUR_UNIQUE_ID" }'
```

Save the `secret` from the response, then trigger a sandbox event (e.g., `POST /v0/settlement-requests`) and watch the delivery land in the webhook.site browser tab.

## Event payload reference

The `data` object differs per event. Expand the relevant section for a worked example. All examples carry production-shape fields; sandbox and production payloads have identical shape.

<AccordionGroup>
  <Accordion title="payment.created">
    ```json theme={null}
    {
      "event": "payment.created",
      "data": {
        "id": "04621f85-bd40-46a9-a9a9-9fe14be09354",
        "type": "PAYIN",
        "status": "PENDING",
        "fee": "0.50000000",
        "source": {
          "account_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
          "amount": "100.00000000",
          "currency": "USDC",
          "rail": "SOLANA",
          "wallet_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
          "transaction_hash": null
        },
        "destination": {
          "account_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
          "amount": "100.00000000",
          "currency": "USD",
          "rail": "ACH",
          "wallet_address": null,
          "transaction_hash": null
        },
        "created_at": "2026-01-26T14:12:08.354Z"
      },
      "timestamp": "2026-01-26T14:12:08.700Z"
    }
    ```
  </Accordion>

  <Accordion title="payment.updated">
    ```json theme={null}
    {
      "event": "payment.updated",
      "data": {
        "id": "04621f85-bd40-46a9-a9a9-9fe14be09354",
        "type": "PAYIN",
        "status": "COMPLETED",
        "fee": "0.50000000",
        "settlement_request_id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
        "source": {
          "account_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
          "amount": "100.00000000",
          "currency": "USDC",
          "rail": "SOLANA",
          "wallet_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
          "transaction_hash": "5aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ5aB6c"
        },
        "destination": {
          "account_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
          "amount": "100.00000000",
          "currency": "USD",
          "rail": "ACH",
          "wallet_address": null,
          "transaction_hash": null
        },
        "updated_at": "2026-01-26T15:48:08.670Z"
      },
      "timestamp": "2026-01-26T15:48:08.700Z"
    }
    ```
  </Accordion>

  <Accordion title="settlement_request.created and settlement_request.updated">
    **`settlement_request.created`**

    ```json theme={null}
    {
      "event": "settlement_request.created",
      "data": {
        "id": "39760060-846e-4d5a-8583-7ee62553f79b",
        "type": "STABLECOIN_TO_STABLECOIN",
        "payment_reason": "PERSONAL_TRANSFERS",
        "status": "REQUEST_STARTED",
        "amount": "20.00000000",
        "external_id": "txn_abc123",
        "metadata": { "user_id": "usr_42", "source": "mobile_app" },
        "source": null,
        "destination": {
          "currency": "USDC",
          "rail": "SOLANA",
          "is_third_party": true,
          "wallet_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
          "beneficiary": {
            "type": "BUSINESS",
            "business_name": "Acme Corp",
            "address": { "country_code": "US" }
          }
        },
        "payment_instructions": {
          "currency": "USDC",
          "rail": "SOLANA",
          "prefunded_wallet_address": "AWE1XaAdRuxzjqy8Q7q75MFbPTs1W6Zbp3zvYWcDGjTj"
        },
        "outbound_payment": null,
        "inbound_payment": null,
        "created_at": "2026-01-26T15:45:10.047Z",
        "updated_at": "2026-01-26T15:45:10.047Z",
        "processing_at": null,
        "completed_at": null,
        "reconciliation_expected_at": null,
        "failure_code": null,
        "failure_reason": null
      },
      "timestamp": "2026-01-26T15:45:10.100Z"
    }
    ```

    **`settlement_request.updated`**

    ```json theme={null}
    {
      "event": "settlement_request.updated",
      "data": {
        "id": "39760060-846e-4d5a-8583-7ee62553f79b",
        "type": "STABLECOIN_TO_STABLECOIN",
        "payment_reason": "PERSONAL_TRANSFERS",
        "status": "SETTLEMENT_COMPLETED",
        "previous_status": "FUNDS_SENT",
        "amount": "20.00000000",
        "external_id": "txn_abc123",
        "metadata": { "user_id": "usr_42", "source": "mobile_app" },
        "source": null,
        "destination": {
          "currency": "USDC",
          "rail": "SOLANA",
          "is_third_party": true,
          "wallet_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
          "beneficiary": {
            "type": "BUSINESS",
            "business_name": "Acme Corp",
            "address": { "country_code": "US" }
          }
        },
        "payment_instructions": {
          "currency": "USDC",
          "rail": "SOLANA",
          "prefunded_wallet_address": "AWE1XaAdRuxzjqy8Q7q75MFbPTs1W6Zbp3zvYWcDGjTj"
        },
        "outbound_payment": {
          "id": "2a9d2a2c-d97b-4973-baf1-78e31d37a024",
          "type": "PAYOUT",
          "status": "COMPLETED",
          "amount": "20.00000000",
          "currency": "USDC",
          "created_at": "2026-01-26T15:45:51.589Z"
        },
        "inbound_payment": null,
        "created_at": "2026-01-26T15:45:10.047Z",
        "updated_at": "2026-01-26T15:48:08.670Z",
        "processing_at": "2026-01-26T15:45:51.594Z",
        "completed_at": "2026-01-26T15:48:08.657Z",
        "reconciliation_expected_at": "2026-01-29T15:45:51.594Z",
        "failure_code": null,
        "failure_reason": null
      },
      "timestamp": "2026-01-26T15:48:08.700Z"
    }
    ```
  </Accordion>

  <Accordion title="account.created">
    ```json theme={null}
    {
      "event": "account.created",
      "data": {
        "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
        "currency_code": "USDC",
        "rail": "SOLANA",
        "wallet_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
        "name": "USDC Account",
        "status": "ACTIVE",
        "balance": "0.00000000",
        "available_balance": "0.00000000",
        "created_at": "2026-01-26T14:12:08.354Z"
      },
      "timestamp": "2026-01-26T14:12:08.700Z"
    }
    ```
  </Accordion>

  <Accordion title="account.updated">
    ```json theme={null}
    {
      "event": "account.updated",
      "data": {
        "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
        "currency_code": "USDC",
        "rail": "SOLANA",
        "wallet_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
        "name": "USDC Account",
        "status": "ACTIVE",
        "balance": "1500.00000000",
        "available_balance": "1200.00000000",
        "reserved": "200.00000000",
        "updated_at": "2026-01-26T15:48:08.670Z"
      },
      "timestamp": "2026-01-26T15:48:08.700Z"
    }
    ```
  </Accordion>

  <Accordion title="account.deleted">
    ```json theme={null}
    {
      "event": "account.deleted",
      "data": {
        "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
        "currency_code": "USDC",
        "rail": "SOLANA",
        "wallet_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
        "deleted_at": "2026-01-26T16:00:00.000Z"
      },
      "timestamp": "2026-01-26T16:00:00.100Z"
    }
    ```
  </Accordion>
</AccordionGroup>

## Next steps

<Card>
  Looking for the end-to-end on-ramping flow? See **[Real-Time
  On-Ramping](/platform/guides/real-time-on-ramping)** — the section *Detecting Completion* shows
  where webhook events fit into the settlement lifecycle.
</Card>
