# Webhooks

Banxa sends webhook notifications to your webhook endpoint URL whenever an order's status changes or a user's KYC/Account state updates. This eliminates the need to poll our APIs and allows you to track the complete user journey in real-time.

## Setup

Configure your webhook endpoint URL in the [Partner Dashboard](https://dashboard.banxa.com/). You can set separate URLs for sandbox and production.

The webhook URL must:

- Be publicly accessible over HTTPS.
- Return a `200` HTTP response to acknowledge receipt.


## Available Webhooks

### 1. Order Webhooks

Order webhooks are triggered on all order status transitions. We will send an HTTP `POST` request with a JSON body.

**Payload Example:**


```json
{
  "order_id": "d9efc5d228cb7edfc4b6bb82f7b39f94",
  "status": "complete",
  "status_date": "2026-01-1604:04:21",
  "created_at": "2026-01-1604:04:20",
  "updated_at": "2026-01-1604:04:20",
  "external_id": null,
  "order_type": "BUY",
  "crypto_coin": "USDT",
  "crypto_blockchain": "ETH",
  "crypto_amount": "67.1000000000000000",
  "fiat_currency": "AUD",
  "fiat_amount": "100",
  "asset_price": "1.490312965722801",
  "payment_method": "payid-bank-transfer",
  "processing_fee": "0",
  "network_fee": "0",
  "usd_exchange_rate": "1.4923330",
  "transaction_hash": "0",
  "metadata": []
}
```

| Field | Description |
|  --- | --- |
| `order_id` | The unique Banxa identifier for the order. |
| `status` | The new order status. See [Order Statuses](/products/hosted-checkout/docs/transaction-lifecycle/order-statuses) for all possible values. |
| `status_date` | ISO 8601 timestamp of the most recent status transition. |
| `created_at` | ISO 8601 timestamp of when the order was initially created. |
| `updated_at` | ISO 8601 timestamp of the last time the order record was modified. |
| `external_id` | The unique ID provided by the partner during order creation (if applicable). |
| `order_type` | The transaction direction: `BUY` (Fiat to Crypto) or `SELL` (Crypto to Fiat). |
| `crypto_coin` | The ticker symbol of the cryptocurrency (e.g., `USDT`, `BTC`). |
| `crypto_blockchain` | The specific network/blockchain used for the transaction (e.g., `ETH`, `SOL`). |
| `crypto_amount` | The total amount of cryptocurrency involved in the transaction. |
| `fiat_currency` | The 3-letter ISO code of the fiat currency used (e.g., `AUD`, `CAD`). |
| `fiat_amount` | The total fiat amount of the transaction. |
| `asset_price` | The price of one unit of the cryptocurrency in the source fiat currency. |
| `payment_method` | The specific payment rail used for the transaction (e.g., `payid-bank-transfer`). |
| `processing_fee` | The service/gateway fee charged by Banxa in the source fiat currency. |
| `network_fee` | The blockchain network (gas) fee in the source fiat currency. |
| `usd_exchange_rate` | The exchange rate used to convert 1 unit of source fiat to USD at the time of order creation. |
| `transaction_hash` | The on-chain identifier (TXID) for the crypto transfer, if available. |
| `metadata` | A collection of custom key-value pairs passed by the partner for tracking purposes. |


After receiving a webhook, use the `order_id` to fetch full order details from the [order lookup endpoint](/products/hosted-checkout/docs/transaction-lifecycle/order-lookup) if you need additional data.

### 2. Identity & KYC Webhooks

#### 2.1 KYC Webhooks

KYC webhooks are triggered whenever a user's identity verification state changes.

Please reach out to your Banxa contact if you would like to have this webhook enabled.

**Payload Example:**


```json
{
  "external_customer_id": "demomerchant-61466523855",
  "account": {
    "exists": true,
    "blocked": false,
    "createdAt": "2026-03-30T05:08:11Z"
  },
  "kyc": {
    "status": "UNDER_REVIEW"
  }
}
```

| Field | Description |
|  --- | --- |
| `external_customer_id` | Your system's unique identifier for the user. |
| `account.exists` | Boolean indicating if the user profile is successfully created in our system. |
| `account.blocked` | Boolean indicating if the user has been banned, suspended, or hit a compliance block. |
| `account.createdAt` | ISO 8601 timestamp of account creation. |
| `kyc.status` | The verification outcome of the customer's submitted identity documents (selfie + document). See status table below. |


**`kyc.status` values:**

| Status | Description |
|  --- | --- |
| `PENDING` | No identity documents have been submitted yet. |
| `UNDER_REVIEW` | Documents are being reviewed. |
| `ACTION_REQUIRED` | Additional action is required from the customer. |
| `VERIFIED` | Document and liveness verification passed. |
| `REJECTED` | Verification was unsuccessful. |


> **Important:** `kyc.status` reflects document and liveness verification only — it does not account for supplementary fields (e.g. purpose of transaction, occupation) or overall transaction eligibility. A `VERIFIED` status means the identity documents passed review; it does not mean the customer is eligible to transact. Use the order creation flow to determine whether a customer can proceed.


#### 2.2 Enhanced Due Diligence (EDD) Webhook

This identity-level webhook is triggered when an order requires manual intervention or additional documentation (Enhanced Due Diligence). It allows you to monitor when a user has been flagged for specific verification steps beyond standard KYC.

Please reach out to your Banxa contact if you would like to have this webhook enabled.

**Payload Example:**:


```json
{
  "identity_reference": "demomerchant-61466233701",
  "status": "extraVerification",
  "status_date": "2026-02-13 04:39:38",
  "internal_reason": "Customer requires extra verification",
  "external_reason": "Your order is pending additional verification. We'll notify you once it's complete"
}
```

| Field | Description |
|  --- | --- |
| `identity_reference` | The unique identifier for the user (matches `external_customer_id` or `account_reference`). |
| `status` | The verification status (typically `extraVerification`). |
| `status_date` | ISO 8601 timestamp of when the additional verification was triggered. |
| `internal_reason` | The internal system categorization for the verification request. |
| `external_reason` | The user-facing message explaining why the transaction is pending. |


**Triggers:**
This webhook is sent when any of the following identity-level exceptions are required:

* **Enhanced Due Diligence (EDD):** High-level manual review.
* **Verification Phone Call (VPC):** Requirement for a manual call with the user.
* **Questionnaire:** Scam check or suitability assessment.
* **Proof of Address (POA):** Request for residency documentation.
* **Source of Funds (SOF):** Request for documentation regarding the origin of funds.


#### 2.3 Account Blocked Webhook

This identity-level webhook is triggered when a customer has been restricted from creating or completing orders due to compliance or risk policies. It provides the necessary context to inform your internal teams or the end user.

**Payload Example:**


```json
{
  "identity_reference": "partner-customer-123",
  "status": "cancelled",
  "status_date": "2026-03-05 19:53:08",
  "internal_reason": "Account is blocked.",
  "external_reason": "Your account has been restricted from further transactions."
}
```

| Field | Description |
|  --- | --- |
| `identity_reference` | A unique customer identifier provided by the partner |
| `status` | The specific account status. Enum: `cancelled`. |
| `status_date` | ISO 8601 timestamp of the status update. |
| `internal_reason` | Internal system message detailing the specific reason for the block. |
| `external_reason` | The designated message to be displayed to the end customer via your UI. |


## Retry behaviour

The webhook retry mechanism ensures reliable delivery of event notifications. If your endpoint does not respond with `200 OK`, Banxa will automatically retry delivery. The payload is unchanged across all retry attempts.

Retries follow a **Fibonacci sequence**: 1s, 2s, 3s, 5s, 8s, 13s, and so on. This continues for a **maximum of 2 hours**, with up to **18 retries** over that period.

Respond with `200 OK` immediately and process the event asynchronously to avoid unnecessary retries.

We recommend implementing idempotent webhook handling. In rare cases your endpoint may receive the same event more than once. Using `order_id` and `status` as a deduplication key will prevent duplicate processing.

## Securing Webhooks

Banxa signs every webhook it sends using HMAC-SHA256. You verify this signature to confirm the request genuinely came from Banxa.

> **Webhook verification is the reverse of request signing.** When you sign outbound API requests, you use a Banxa API path in the canonical string. When you verify an incoming webhook, you use the URI path of **your own webhook endpoint** — not a Banxa API path. Everything else is the same algorithm.


Each webhook includes an `Authorization` header:


```
Authorization: Bearer {API_KEY}:{SIGNATURE}:{NONCE}
```

Banxa constructs the signature from:


```
POST\nYOUR_WEBHOOK_PATH\nNONCE\nPAYLOAD
```

Where `YOUR_WEBHOOK_PATH` is the path of the endpoint Banxa is calling — for example, `/webhooks/banxa` — not any Banxa API path.

### Verification flow

1. Extract the `Authorization` header from the incoming request
2. Strip the `Bearer ` prefix and split on `:` to get `receivedKey`, `receivedSignature`, `nonce`
3. Recompute the expected signature using the same algorithm, your secret, and `YOUR_WEBHOOK_PATH`
4. Use a **timing-safe comparison** to check that `receivedSignature` matches — never use `==`


### Code examples


```python Python
import hmac

MY_WEBHOOK_PATH = '/webhooks/banxa'
KEY = '[YOUR_API_KEY]'
SECRET = '[YOUR_API_SECRET]'

def verify_webhook(auth_header, request_body):
    _, token = auth_header.split('Bearer ', 1)
    received_key, received_signature, nonce = token.split(':')

    data = f"POST\n{MY_WEBHOOK_PATH}\n{nonce}\n{request_body}"
    expected = hmac.new(SECRET.encode('utf-8'), data.encode('utf-8'), 'sha256').hexdigest()

    return hmac.compare_digest(received_signature, expected)
```


```javascript Node.js
const crypto = require('crypto');

const MY_WEBHOOK_PATH = '/webhooks/banxa';
const KEY = '[YOUR_API_KEY]';
const SECRET = '[YOUR_API_SECRET]';

function verifyWebhook(authHeader, requestBody) {
    const token = authHeader.replace('Bearer ', '');
    const [receivedKey, receivedSignature, nonce] = token.split(':');

    const data = `POST\n${MY_WEBHOOK_PATH}\n${nonce}\n${requestBody}`;
    const expected = crypto.createHmac('sha256', SECRET).update(data).digest('hex');

    return crypto.timingSafeEqual(Buffer.from(receivedSignature), Buffer.from(expected));
}
```


```php PHP
$MY_WEBHOOK_PATH = '/webhooks/banxa';
$KEY = '[YOUR_API_KEY]';
$SECRET = '[YOUR_API_SECRET]';

function verifyWebhook($authHeader, $requestBody, $myWebhookPath, $key, $secret) {
    $token = str_replace('Bearer ', '', $authHeader);
    [$receivedKey, $receivedSignature, $nonce] = explode(':', $token);

    $data = implode("\n", ['POST', $myWebhookPath, $nonce, $requestBody]);
    $expected = hash_hmac('sha256', $data, $secret);

    return hash_equals($expected, $receivedSignature);
}
```


```java Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Formatter;

private static final String MY_WEBHOOK_PATH = "/webhooks/banxa";
private static final String SECRET = "[YOUR_API_SECRET]";

public boolean verifyWebhook(String authHeader, String requestBody) throws Exception {
    String token = authHeader.replace("Bearer ", "");
    String[] parts = token.split(":");
    String nonce = parts[2];
    String receivedSignature = parts[1];

    String data = "POST\n" + MY_WEBHOOK_PATH + "\n" + nonce + "\n" + requestBody;
    SecretKeySpec signingKey = new SecretKeySpec(SECRET.getBytes(), "HmacSHA256");
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(signingKey);
    Formatter formatter = new Formatter();
    for (byte b : mac.doFinal(data.getBytes())) formatter.format("%02x", b);
    String expected = formatter.toString();

    return MessageDigest.isEqual(receivedSignature.getBytes(), expected.getBytes());
}
```


```swift Swift
import CryptoKit

let MY_WEBHOOK_PATH = "/webhooks/banxa"
let SECRET = "[YOUR_API_SECRET]"

func verifyWebhook(authHeader: String, requestBody: String) -> Bool {
    let token = authHeader.replacingOccurrences(of: "Bearer ", with: "")
    let parts = token.split(separator: ":")
    guard parts.count == 3 else { return false }
    let nonce = String(parts[2])
    let receivedSignature = String(parts[1])

    let data = "POST\n\(MY_WEBHOOK_PATH)\n\(nonce)\n\(requestBody)"
    let secretKey = SymmetricKey(data: SECRET.data(using: .utf8)!)
    let expected = HMAC<SHA256>.authenticationCode(for: data.data(using: .utf8)!, using: secretKey)
        .map { String(format: "%02hhx", $0) }.joined()

    return receivedSignature == expected
}
```


```ruby Ruby
require 'openssl'

MY_WEBHOOK_PATH = '/webhooks/banxa'
SECRET = '[YOUR_API_SECRET]'

def verify_webhook(auth_header, request_body)
    token = auth_header.sub('Bearer ', '')
    received_key, received_signature, nonce = token.split(':')

    data = "POST\n#{MY_WEBHOOK_PATH}\n#{nonce}\n#{request_body}"
    expected = OpenSSL::HMAC.hexdigest('sha256', SECRET, data)

    ActiveSupport::SecurityUtils.secure_compare(received_signature, expected)
end
```