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

# Webhooks

> Signed, retried events with the exact payloads Rach sends.

Webhooks push real-time events to your backend. This page documents the **exact payload
Rach sends** for every event, the headers, and how to verify signatures. **Payments** and
**CaaS** each have their own configuration and signature scheme.

## Payments webhooks

### Configure your endpoint

Set your webhook URL (and enable wallet/deposit events) in the **Payments dashboard** under
**Developers → Webhooks**. The dashboard shows your signing secret (`whsec_…`) once — store
it to verify signatures.

<Warning>
  The `webhook_secret` (`whsec_…`) is shown **only** when you set or rotate it in the
  dashboard. Store it — you need it to verify signatures. Rotating it invalidates the old one.
</Warning>

### Headers on every delivery

| Header                | Value                                               |
| --------------------- | --------------------------------------------------- |
| `X-Webhook-Signature` | `HMAC-SHA256(rawBody, webhook_secret)`, hex-encoded |
| `X-Webhook-Event`     | The event name (e.g. `payment.confirmed`)           |
| `X-Webhook-ID`        | The delivery id — **dedupe on this**                |
| `Content-Type`        | `application/json`                                  |

### Events & exact payloads

There are two payload shapes: **checkout/remittance** events are flat; **wallet (WaaS)**
events wrap the data in an `event` + `data` envelope.

<Tabs>
  <Tab title="payment.confirmed / payment.failed">
    Sent for Crypto Gateway checkout sessions. Flat payload:

    ```json theme={null}
    {
      "event": "payment.confirmed",
      "session_id": "cs_1a2b3c",
      "reference": "order_1001",
      "amount": "50",
      "currency": "USD",
      "status": "paid",
      "payment_method": "crypto",
      "network": "ETH",
      "tx_hash": "0x9f8e7d6c5b4a...",
      "created_at": "2026-08-14T12:00:00Z",
      "paid_at": "2026-08-14T12:41:00Z",
      "metadata": { "cart_id": "9f3" },
      "timestamp": 1755172860
    }
    ```

    `network`, `tx_hash`, `paid_at` and `metadata` are omitted when not applicable.
    `payment.failed` carries the same shape with `status: "failed"` and no `paid_at`.
  </Tab>

  <Tab title="wallet.deposit.detected">
    Sent the moment a [monitored](/products/wallet-monitoring) WaaS address receives funds.
    This **records a pending deposit — it does not credit**. Enveloped payload:

    ```json theme={null}
    {
      "event": "wallet.deposit.detected",
      "data": {
        "customer_id": "eb0a15c6-57f7-49d5-9b55-fcebaa99ba6d",
        "deposit_id": 48213,
        "address": "0x3B7A3DfC43A6D91f2F6dFEeE2ad9def7141c0586",
        "network": "POL",
        "currency": "USDT",
        "amount": "10",
        "tx_hash": "pending_1755345600_0x3B7A3D",
        "hash_pending": true,
        "confirmations": 0,
        "status": "detected",
        "detected_at": "2026-08-16T12:00:00Z",
        "detected_by": "monitor"
      }
    }
    ```

    <Info>
      Dedupe on `deposit_id` (the stable idempotency key), **not** `tx_hash` — at this stage
      `tx_hash` is a `pending_<ts>_<addr>` placeholder. **Do not credit** on `detected`; wait
      for `wallet.deposit.confirmed` with `safe_to_credit: true`.
    </Info>
  </Tab>

  <Tab title="wallet.deposit.confirmed">
    Sent once the deposit reaches the required confirmations. **This is the event that
    credits** — and only when `safe_to_credit` is `true`. Note `tx_hash` is **still the
    `pending_…` placeholder** here; the real chain hash arrives later via `hash_resolved`.

    ```json theme={null}
    {
      "event": "wallet.deposit.confirmed",
      "data": {
        "customer_id": "eb0a15c6-57f7-49d5-9b55-fcebaa99ba6d",
        "deposit_id": 48213,
        "address": "0x3B7A3DfC43A6D91f2F6dFEeE2ad9def7141c0586",
        "network": "POL",
        "currency": "USDT",
        "amount": "10",
        "tx_hash": "pending_1755345600_0x3B7A3D",
        "confirmations": 128,
        "status": "confirmed",
        "safe_to_credit": true,
        "detected_at": "2026-08-16T12:00:00Z",
        "confirmed_at": "2026-08-16T12:03:20Z"
      }
    }
    ```

    <Warning>
      Credit the customer **only** when `safe_to_credit` is `true`. Key the credit on
      `deposit_id` so a re-delivery is a no-op.
    </Warning>
  </Tab>

  <Tab title="wallet.deposit.hash_resolved">
    Delivered **later**, once the real on-chain hash is known. Same `deposit_id` — use it to
    upgrade the `pending_…` placeholder you stored to the real explorer-lookupable hash:

    ```json theme={null}
    {
      "event": "wallet.deposit.hash_resolved",
      "data": {
        "customer_id": "eb0a15c6-57f7-49d5-9b55-fcebaa99ba6d",
        "deposit_id": 48213,
        "tx_hash": "0x9a1f5c2b…",
        "detected_at": "2026-08-16T12:00:00Z"
      }
    }
    ```

    <Info>
      This event does **not** credit and does not carry `amount`/`currency` — it only backfills
      the hash. Match it to the deposit you already recorded by `deposit_id`.
    </Info>
  </Tab>

  <Tab title="wallet.transfer.*">
    Sent as a WaaS wallet transfer you initiated advances (e.g. `wallet.transfer.confirmed`):

    ```json theme={null}
    {
      "event": "wallet.transfer.confirmed",
      "data": {
        "tx_hash": "0x1a2b3c...",
        "customer_id": "cust_001",
        "network": "ETH",
        "currency": "USDT",
        "from_address": "0xAbC123...",
        "to_address": "0xRecipient...",
        "amount": "25.00",
        "gas_fee": "0.00031",
        "status": "confirmed",
        "confirmed_at": "2026-08-14T12:06:00Z",
        "block_number": 20553114
      }
    }
    ```
  </Tab>
</Tabs>

### Deposit `data` — field reference

| Field                  | Type         | Notes                                                                                                         |
| ---------------------- | ------------ | ------------------------------------------------------------------------------------------------------------- |
| `customer_id`          | string       | **The only guaranteed field.** Your Rach customer UUID — the deposit's owner.                                 |
| `deposit_id`           | integer      | **The idempotency key — dedupe on this, not `tx_hash`.**                                                      |
| `address`              | string       | The receiving derived address.                                                                                |
| `network` / `currency` | string       | e.g. `POL` / `USDT`.                                                                                          |
| `amount`               | string       | A decimal **string**, e.g. `"10"` (never a number).                                                           |
| `tx_hash`              | string       | Provisional `pending_<ts>_<addr>` on `detected`/`confirmed`; the real chain hash arrives via `hash_resolved`. |
| `hash_pending`         | bool         | `true` = the real hash will arrive later.                                                                     |
| `confirmations`        | integer      | Chain confirmations so far.                                                                                   |
| `detected_at`          | string (ISO) | Stable key linking `detected` → `confirmed` → `hash_resolved` for the same deposit.                           |
| `confirmed_at`         | string (ISO) | Present on `confirmed`.                                                                                       |
| `safe_to_credit`       | bool         | **The gate** — credit the balance only on `confirmed` when this is `true`.                                    |
| `status`               | string       | `"detected"` or `"confirmed"`.                                                                                |

<Warning>
  **Gotchas**

  * **`tx_hash` is not a real hash at detect/confirm time** — it's a `pending_…` placeholder.
    Don't key anything on it; use `deposit_id`. The real hash backfills via
    `hash_resolved` (same `deposit_id`).
  * **Crediting is gated on `safe_to_credit: true`** on the `confirmed` event. `detected`
    only records a pending deposit.
  * **Everything except `customer_id` is technically optional** in the serializer, so a
    malformed/partial event won't `400` — but a genuine deposit carries the full set above.
  * **The handler is idempotent** — re-delivering the same `deposit_id` is acknowledged with
    `200 {"status":"success","message":"already_recorded"}`.
</Warning>

### Verify the signature

Compute the HMAC over the **raw** body and compare in constant time:

<CodeGroup>
  ```js Node (Express) theme={null}
  import express from 'express';
  import crypto from 'node:crypto';

  const app = express();
  app.use('/webhooks/rach', express.raw({ type: '*/*' })); // RAW body

  app.post('/webhooks/rach', (req, res) => {
    const sig = req.header('X-Webhook-Signature');
    const expected = crypto
      .createHmac('sha256', process.env.RACH_WEBHOOK_SECRET)
      .update(req.body) // Buffer of the raw body
      .digest('hex');

    if (!sig || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
      return res.status(401).send('bad signature');
    }

    const { event } = JSON.parse(req.body.toString());
    // dedupe on req.header('X-Webhook-ID'), then process async
    res.sendStatus(200);
  });
  ```

  ```python Python (Flask) theme={null}
  import hmac, hashlib, os
  from flask import Flask, request, abort

  app = Flask(__name__)

  @app.post("/webhooks/rach")
  def rach_webhook():
      raw = request.get_data()  # raw bytes
      expected = hmac.new(
          os.environ["RACH_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256
      ).hexdigest()
      if not hmac.compare_digest(request.headers.get("X-Webhook-Signature", ""), expected):
          abort(401)
      # dedupe on request.headers["X-Webhook-ID"], then process
      return "", 200
  ```

  ```go Go theme={null}
  func verify(rawBody []byte, sigHeader, secret string) bool {
  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write(rawBody)
  	expected := hex.EncodeToString(mac.Sum(nil))
  	return hmac.Equal([]byte(sigHeader), []byte(expected))
  }
  ```
</CodeGroup>

### Test it

Send a test event and rotate your signing secret from the **Payments dashboard**
(**Developers → Webhooks**). Rotating invalidates the old secret — update your verifier
immediately.

## CaaS webhooks

Register your webhook endpoints and choose which events to receive from your **CaaS
dashboard**.

### Delivery & verification (every event)

Every webhook is an HTTP `POST` with a JSON body and these headers:

| Header             | Value                                                                                                                               |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`     | `application/json`                                                                                                                  |
| `X-Rach-Signature` | `sha256=<hex>` — HMAC-SHA256 of the **raw** request body, keyed with the webhook's secret (returned when the webhook is registered) |
| `User-Agent`       | `Rach-CaaS-Webhooks/1.0`                                                                                                            |

```js Signature verification (Node) theme={null}
const crypto = require('crypto');
const expected = 'sha256=' + crypto
  .createHmac('sha256', WEBHOOK_SECRET)
  .update(rawBody)                 // exact bytes received, before JSON.parse
  .digest('hex');
const ok = crypto.timingSafeEqual(
  Buffer.from(expected),
  Buffer.from(req.header('X-Rach-Signature')),
);
```

<Info>
  **Conventions**

  * Delivery is **best-effort** — settlement already happened on-chain; the notification
    never blocks it. Only webhooks subscribed to that event type receive it. Return `2xx`.
  * **Amounts** are decimal strings with 6 dp (e.g. `"5.000000"`).
  * **`tx_hash`** is the real on-chain EVM transaction hash (explorer-lookupable), not the
    internal `userOpHash`.
  * **Timestamps** are RFC3339 UTC.
  * On `*.settled` / `*.failed`, empty fields are **omitted, not null** (`reason` absent on
    success; `amount_*`/`token`/`tx_hash` absent on a pre-broadcast failure). Treat
    missing = unknown.
</Info>

### Transaction events

<Tabs>
  <Tab title="transfer.received">
    Fires when a wallet belonging to your business receives funds (incl. cross-merchant).
    Not sent if the recipient is an unaffiliated phone — they get an SMS instead. Delivered
    to the **recipient** merchant.

    ```json theme={null}
    {
      "event": "transfer.received",
      "transfer_id": "rach_txn_9f2c…",
      "recipient_wallet": "0xFDd4ABaF518d49A6749A3aD6A68f9403bD93cFF0",
      "amount_usdc": "288.000000",
      "token": "USDC",
      "tx_hash": "0xec586fed…",
      "settled_at": "2026-08-14T08:41:12Z"
    }
    ```
  </Tab>

  <Tab title="transfer.settled">
    Your outbound transfer confirmed on-chain. Delivered to the **initiating** merchant.

    ```json theme={null}
    {
      "event": "transfer.settled",
      "transfer_id": "rach_txn_9f2c…",
      "status": "SETTLED",
      "amount_usdc": "288.000000",
      "token": "USDC",
      "tx_hash": "0xec586fed…",
      "updated_at": "2026-08-14T08:41:12Z"
    }
    ```
  </Tab>

  <Tab title="transfer.failed">
    ```json theme={null}
    {
      "event": "transfer.failed",
      "transfer_id": "rach_txn_9f2c…",
      "status": "FAILED",
      "reason": "blockchain execution reverted on-chain (bundler receipt failed)",
      "updated_at": "2026-08-14T08:41:12Z"
    }
    ```
  </Tab>

  <Tab title="swap.settled">
    ```json theme={null}
    {
      "event": "swap.settled",
      "swap_id": "rach_swap_e44fc5b8…",
      "status": "SETTLED",
      "token_in": "USDT",
      "token_out": "USDC",
      "amount_in": "5.000000",
      "tx_hash": "0x78da1625…",
      "updated_at": "2026-08-14T08:41:12Z"
    }
    ```

    <Note>
      `swap.settled.amount_out` is not yet populated (it requires parsing the `tokenOut`
      Transfer log). Use `amount_in`, `tx_hash` and the on-chain tx for reconciliation
      meanwhile.
    </Note>
  </Tab>

  <Tab title="swap.failed">
    ```json theme={null}
    {
      "event": "swap.failed",
      "swap_id": "rach_swap_e44fc5b8…",
      "status": "FAILED",
      "token_in": "USDT",
      "token_out": "USDC",
      "amount_in": "5.000000",
      "reason": "swap reverted on-chain",
      "updated_at": "2026-08-14T08:41:12Z"
    }
    ```
  </Tab>
</Tabs>

### Account / membership events

<CodeGroup>
  ```json user.linked theme={null}
  {
    "event": "user.linked",
    "wallet_address": "0x…",
    "new_merchant_id": "uuid",
    "linked_at": "2026-08-14T08:41:12Z"
  }
  ```

  ```json user.phone_changed theme={null}
  {
    "event": "user.phone_changed",
    "wallet_address": "0x…",
    "changed_by_merchant_id": "uuid",
    "changed_at": "2026-08-14T08:41:12Z"
  }
  ```
</CodeGroup>

<Tip>
  **Field-name note**

  `transfer.received` uses `settled_at`; the status events (`*.settled` / `*.failed`) use
  `updated_at`. Both are RFC3339 UTC. If a webhook is missed, poll `GET /v1/swaps/:id`
  (or the equivalent transfer endpoint) — it returns the same status fields.
</Tip>

Review delivery logs in your **CaaS dashboard**.

## Best practices

<Tip>
  **Verify against the raw body**

  Compute the HMAC over the exact bytes received. Re-serializing parsed JSON changes the
  bytes and breaks the signature.
</Tip>

* **Respond fast (`2xx`) then work async** — Rach retries non-`2xx` deliveries.
* **Be idempotent** — dedupe on `X-Webhook-ID` (Payments, or `deposit_id` for deposits).
* **Credit only when safe** — for deposits, wait for `safe_to_credit: true`.
