Skip to content
Last updated

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. 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:

{
  "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"
}
FieldDescription
order_idThe unique Banxa identifier for the ramp.
statusThe new ramp status. See the status table below.
status_dateTimestamp of the most recent status transition.
created_atTimestamp of when the ramp was initially created.
updated_atTimestamp of the last update to the ramp record.
internal_reasonInternal system description of the status — for your records.
external_reasonUser-facing message you can surface to your customer.
order_typeTransaction direction: ONRAMP (fiat to crypto) or OFFRAMP (crypto to fiat).
crypto_coinTicker symbol of the cryptocurrency (e.g., ETH, BTC).
crypto_blockchainBlockchain network used for the transaction.
crypto_amountAmount of cryptocurrency involved.
fiat_currencyISO code of the fiat currency (e.g., USD, AUD).
fiat_amountFiat amount of the transaction.
asset_pricePrice of one unit of the cryptocurrency in the source fiat currency.
paymentPayment amount.
processing_feeBanxa processing fee in the source fiat currency.
network_feeBlockchain network (gas) fee in the source fiat currency.
usd_exchange_rateExchange rate: 1 unit of source fiat to USD at order creation time.
transaction_hashOn-chain transaction identifier (TXID) for the crypto transfer, if available.

Ramp status values:

StatusDescription
IN_PROGRESSRamp is in progress.
PAYMENT_READYPayment is ready to be processed.
PAYMENT_ACCEPTEDPayment has been accepted.
PAYMENT_RECEIVEDPayment has been received by Banxa.
PAYMENT_DECLINEDPayment was declined.
PAYMENT_CANCELLEDPayment was cancelled.
COIN_DEPOSIT_READYCrypto deposit address is ready (off-ramp).
COIN_DEPOSIT_CONFIRMEDCrypto deposit has been confirmed (off-ramp).
COIN_TRANSFERREDCrypto has been transferred to the customer's wallet (on-ramp).
FIAT_TRANSFERREDFiat has been transferred to the customer's bank account (off-ramp).
FULFILLEDRamp has been completed successfully.
REFUNDEDRamp has been refunded.
EXPIREDRamp has expired.
EXTRA_VERIFICATIONCustomer requires additional verification before the ramp can proceed.
ACCOUNT_BLOCKEDCustomer 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:

{
  "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."
}
FieldDescription
identity_referenceYour stable identifier for the customer, as provided during ramp creation.
statusThe identity status. Currently: ACCOUNT_BLOCKED.
status_dateTimestamp of the status update.
internal_reasonInternal system description — for your records.
external_reasonUser-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:

{
  "identityReference": "customer-12345",
  "account": {
    "exists": true,
    "blocked": false,
    "createdAt": "2023-06-05T19:53:08.320Z"
  },
  "kyc": {
    "status": "VERIFIED"
  }
}
FieldDescription
identityReferenceYour stable identifier for the customer, as provided during ramp creation.
account.existsWhether the customer profile exists in the Banxa system.
account.blockedWhether the customer account has been blocked.
account.createdAtISO 8601 timestamp of account creation. null if the identity does not yet exist.
kyc.statusThe verification outcome of the customer's submitted identity documents (selfie + document). See status table below.

kyc.status values:

StatusDescription
PENDINGNo identity documents have been submitted yet.
UNDER_REVIEWDocuments are being reviewed.
ACTION_REQUIREDAdditional action is required from the customer.
VERIFIEDDocument and liveness verification passed.
REJECTEDVerification 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.

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

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)
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
$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);
}
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());
}
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
}
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