# Webhooks

Banxa sends webhook notifications to your callback endpoint whenever a ramp's status changes or a customer's account is blocked. This eliminates the need to poll for status updates and allows you to track the complete transaction lifecycle 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. Ramp Webhooks

Ramp webhooks are triggered on all ramp status transitions. Banxa sends an HTTP `POST` request with a JSON body.

**Payload example:**


```json
{
  "order_id": "fd04c5780062121628e05324003eef30",
  "status": "FULFILLED",
  "status_date": "2023-06-05 19:53:08",
  "created_at": "2023-06-02 14:44:00",
  "updated_at": "2023-06-09 13:53:08",
  "internal_reason": "Order has been completed.",
  "external_reason": "Your payment was successfully received. Your order is now being processed.",
  "order_type": "ONRAMP",
  "crypto_coin": "ETH",
  "crypto_blockchain": "ETH",
  "crypto_amount": "0.228632",
  "fiat_currency": "USD",
  "fiat_amount": "100",
  "asset_price": "0.018656",
  "payment": "100",
  "processing_fee": "1.95",
  "network_fee": "2.33",
  "usd_exchange_rate": "1.36",
  "transaction_hash": "0x9401a7173d7bd2ad73e8b798fdc30c83fb0529e6edbad163c549a5ad136407be"
}
```

| Field | Description |
|  --- | --- |
| `order_id` | The unique Banxa identifier for the ramp. |
| `status` | The new ramp status. See the status table below. |
| `status_date` | Timestamp of the most recent status transition. |
| `created_at` | Timestamp of when the ramp was initially created. |
| `updated_at` | Timestamp of the last update to the ramp record. |
| `internal_reason` | Internal system description of the status — for your records. |
| `external_reason` | User-facing message you can surface to your customer. |
| `order_type` | Transaction direction: `ONRAMP` (fiat to crypto) or `OFFRAMP` (crypto to fiat). |
| `crypto_coin` | Ticker symbol of the cryptocurrency (e.g., `ETH`, `BTC`). |
| `crypto_blockchain` | Blockchain network used for the transaction. |
| `crypto_amount` | Amount of cryptocurrency involved. |
| `fiat_currency` | ISO code of the fiat currency (e.g., `USD`, `AUD`). |
| `fiat_amount` | Fiat amount of the transaction. |
| `asset_price` | Price of one unit of the cryptocurrency in the source fiat currency. |
| `payment` | Payment amount. |
| `processing_fee` | Banxa processing fee in the source fiat currency. |
| `network_fee` | Blockchain network (gas) fee in the source fiat currency. |
| `usd_exchange_rate` | Exchange rate: 1 unit of source fiat to USD at order creation time. |
| `transaction_hash` | On-chain transaction identifier (TXID) for the crypto transfer, if available. |


**Ramp status values:**

| Status | Description |
|  --- | --- |
| `IN_PROGRESS` | Ramp is in progress. |
| `PAYMENT_READY` | Payment is ready to be processed. |
| `PAYMENT_ACCEPTED` | Payment has been accepted. |
| `PAYMENT_RECEIVED` | Payment has been received by Banxa. |
| `PAYMENT_DECLINED` | Payment was declined. |
| `PAYMENT_CANCELLED` | Payment was cancelled. |
| `COIN_DEPOSIT_READY` | Crypto deposit address is ready (off-ramp). |
| `COIN_DEPOSIT_CONFIRMED` | Crypto deposit has been confirmed (off-ramp). |
| `COIN_TRANSFERRED` | Crypto has been transferred to the customer's wallet (on-ramp). |
| `FIAT_TRANSFERRED` | Fiat has been transferred to the customer's bank account (off-ramp). |
| `FULFILLED` | Ramp has been completed successfully. |
| `REFUNDED` | Ramp has been refunded. |
| `EXPIRED` | Ramp has expired. |
| `EXTRA_VERIFICATION` | Customer requires additional verification before the ramp can proceed. |
| `ACCOUNT_BLOCKED` | Customer account is blocked. A separate identity webhook will also be sent. |


### 2. Identity Webhooks

Identity webhooks are triggered when a customer's account is blocked due to compliance or risk policies.

**Payload example:**


```json
{
  "identity_reference": "partner-customer-123",
  "status": "ACCOUNT_BLOCKED",
  "status_date": "2023-06-05 19:53:08",
  "internal_reason": "Customer account is blocked.",
  "external_reason": "Your order could not be processed."
}
```

| Field | Description |
|  --- | --- |
| `identity_reference` | Your stable identifier for the customer, as provided during ramp creation. |
| `status` | The identity status. Currently: `ACCOUNT_BLOCKED`. |
| `status_date` | Timestamp of the status update. |
| `internal_reason` | Internal system description — for your records. |
| `external_reason` | User-facing message you can surface to your customer. |


### 3. KYC Webhooks

KYC webhooks are triggered whenever a customer's identity verification state changes. This allows you to track verification progress and prompt customers to take action when required.

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

**Payload example:**


```json
{
  "identityReference": "customer-12345",
  "account": {
    "exists": true,
    "blocked": false,
    "createdAt": "2023-06-05T19:53:08.320Z"
  },
  "kyc": {
    "status": "VERIFIED"
  }
}
```

| Field | Description |
|  --- | --- |
| `identityReference` | Your stable identifier for the customer, as provided during ramp creation. |
| `account.exists` | Whether the customer profile exists in the Banxa system. |
| `account.blocked` | Whether the customer account has been blocked. |
| `account.createdAt` | ISO 8601 timestamp of account creation. `null` if the identity does not yet exist. |
| `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 Lite tier data, 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. Always use the eligibility endpoint to determine whether a customer can proceed with a ramp.


## Retry behaviour

If your endpoint does not respond with `200 OK`, Banxa will automatically retry delivery with the same payload.

Retries follow a **Fibonacci sequence**: 1s, 2s, 3s, 5s, 8s, 13s, and so on — for a maximum of **2 hours**.

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

Implement idempotent webhook handling. In rare cases your endpoint may receive the same event more than once. Use `order_id` and `status` as a deduplication key.

## 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 to Banxa, 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** — for example `/webhooks/banxa` — not a Banxa API path. Everything else follows the same algorithm described in [Authentication](/products/native-api/docs/getting-started/authentication).


Each webhook includes an `Authorization` header:


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

Banxa constructs the signature from:


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

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