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

# Webhooks

> Unified Transaction webhook events, request format, signature verification, delivery and retry behavior.

A webhook is sent for each status marked "Webhook: Yes" in [Statuses](https://guides.saber.money/api-reference/unified-transaction/transaction-object.md#statuses), to the callback URL configured for your client account. No webhook is sent for `CREATED` or `APPROVAL_REQUIRED`.

See also [Webhook Configuration](https://guides.saber.money/client/dashboard/webhooks.md).

## Request

| Item | Value |
| - | - |
| Method | `POST` |
| `Content-Type` | `application/json` |
| Body | `{"event": "<event>", "data": <transaction object>}` |

## Events

| Event | Status in `data` |
| - | - |
| `unified-transaction-pool-sell-sender-kyc-verified` | `SENDER_KYC_VERIFIED` |
| `unified-transaction-pool-sell-beneficiary-kyc-verified` | `BENEFICIARY_KYC_VERIFIED` |
| `unified-transaction-pool-sell-beneficiary-instrument-verified` | `BENEFICIARY_INSTRUMENT_VERIFIED` |
| `unified-transaction-pool-sell-completed` | `COMPLETED` |
| `unified-transaction-pool-sell-failed` | `FAILED` |

`data` has the same shape as the `GET` response. See [Transaction Object](https://guides.saber.money/api-reference/unified-transaction/transaction-object.md).

Example body (captured `FAILED` transaction):

```json theme={null}
{
  "event": "unified-transaction-pool-sell-failed",
  "data": {
    "id": "00000000-0000-4000-8000-000000000021",
    "client_reference_id": "order-12345",
    "transaction_type": "POOL_SELL",
    "status": "FAILED",
    "reason_code": "BENEFICIARY_INSTRUMENT_REJECTED",
    "reason_desc": "beneficiary payment instrument was rejected on verification",
    "from_currency": "USDT",
    "to_currency": "INR",
    "from_amount": "1",
    "to_amount": null,
    "exchange_rate": null,
    "payment_method": "bank_transfer",
    "purpose_code": "P1301",
    "quote_id": null,
    "bank_transaction_id": null,
    "sender_id": "00000000-0000-4000-8000-000000000023",
    "beneficiary_id": "00000000-0000-4000-8000-000000000024",
    "beneficiary_instrument_id": "00000000-0000-4000-8000-000000000025",
    "beneficiary_instrument_type": "BANK_ACCOUNT",
    "beneficiary_relationship": "FAMILY",
    "created_at": 1793020135000,
    "updated_at": 1793020143000
  }
}
```

## Signature

| Header | Value |
| - | - |
| `X-Api-Key` | Client id of your webhook credential |
| `X-Client-Id` | Same as `X-Api-Key` |
| `X-Timestamp` | Unix time in seconds when the attempt was signed |
| `X-Signature` | Upper-case hex HMAC-SHA256 of `X-Api-Key` + `X-Timestamp` + `X-User-Id`, keyed with your webhook secret |
| `X-Secret-Key` | Same as `X-Signature` |
| `X-User-Id` | Empty |

> [!WARNING]
>
> The body is not signed. Treat the body as a notification and read the authoritative state with `GET`.

When several webhook credentials are active, the oldest one signs.

Verification (Python):

```python theme={null}
import hashlib
import hmac
import time

def verify(headers, webhook_client_id, webhook_secret, max_skew_seconds=300):
    client_id = headers.get("X-Api-Key", "")
    timestamp = headers.get("X-Timestamp", "")
    user_id = headers.get("X-User-Id", "")
    signature = headers.get("X-Signature", "")
    if not hmac.compare_digest(client_id, webhook_client_id):
        return False
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > max_skew_seconds:
        return False
    expected = hmac.new(
        webhook_secret.encode(),
        (client_id + timestamp + user_id).encode(),
        hashlib.sha256,
    ).hexdigest().upper()
    return hmac.compare_digest(expected, signature.upper())
```

Each attempt is signed with a fresh timestamp. A window of 300 s is sufficient.

## Delivery

| Item | Value |
| - | - |
| Success | Any HTTP status below 400 |
| Timeout | 15 s |
| Retries | Up to 5, about 10 s, 20 s, 40 s, 80 s and 160 s after each failed attempt, ±25% |
| After the last retry | Dropped |
| Guarantee | At least once. Not ordered. |
| Event id | None |

Deduplicate on (`data.id`, `data.status`). Order by `data.updated_at`. Ignore a webhook whose `updated_at` is older than the state you hold. Poll `GET` or list when a webhook is missing.


## Related topics

- [Webhooks](https://guides.saber.money/api-reference/wallet-operations/webhooks.md)
- [Webhook Configuration](https://guides.saber.money/client/dashboard/webhooks.md)
- [Order lifecycle and Webhooks](https://guides.saber.money/offramp/operations/lifecycle.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.