# Guide to BVNK Webhooks: Features and Best Practices

BVNK runs payment rails for businesses that move money in stablecoins and fiat: crypto deposit channels, pay-ins and payouts, wallets, cards and the customer onboarding that sits in front of them. Most of that activity is asynchronous. A deposit is detected on chain and confirmed later, a payment is held for a compliance check, a customer's KYB status changes days after sign-up. BVNK's webhooks report each of those changes so your ledger, checkout or onboarding flow can react without polling.

This guide covers BVNK's URN-style event names, the two signing schemes and which one Hookdeck supports, the oddly named "Public Key" secret, and BVNK's long retry schedule.

## What are BVNK webhooks?

You create a BVNK webhook in the Portal with a URL and a set of events. When one happens, BVNK sends a `POST` with `Content-Type: application/json` to that URL, which must be HTTPS on port 443.

BVNK has two generations of webhook. The current hook service sends events named as colon-separated URNs, such as `bvnk:payment:channel:transaction-confirmed`, and signs them with `x-signature`, a base64 HMAC-SHA256 of the payload. Older "Standard" webhooks send `"source": "payment", "event": "statusChanged"` and use a different signature built from the webhook path, the content type and the payload. BVNK's listener page says the older webhooks "will be soon discontinued", but many of its guides still show `statusChanged` payloads, so check which kind you are receiving.

## BVNK webhook features

| Feature | Details |
| --- | --- |
| Configuration | BVNK Portal: **Integrations > Webhooks > Add webhook** |
| URL | HTTPS on port 443 only. Custom ports are not supported |
| Signature | base64 HMAC-SHA256 |
| Signature header | `x-signature` |
| Signed content | The payload exactly as received |
| Secret | Shown in the Portal field labelled **Public Key**. Used as a UTF-8 string |
| Body | JSON |
| Event routing | Body field `event` |
| Response window | `200` within 10 seconds |
| Retries | Growing delays capped at 15 minutes, up to 100 attempts |
| Duplicates | Possible. Deduplicate on `eventId` |
| Source IPs | Not published |

## Events and payloads

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `payment:v2:payin:status-change` | A pay-in changes status | Mark orders paid, update balances |
| `bvnk:payment:channel:created` | A payment channel is created | Track new deposit addresses or channels |
| `bvnk:payment:channel:transaction-confirmed` | A transaction on a channel is confirmed | Credit a customer |
| `bvnk:payment:channel:transaction-on-hold` | A channel transaction is put on hold | Compliance review (enabled on request) |
| `bvnk:payment:crypto:transaction-on-hold` | A crypto transaction is put on hold | Compliance review (enabled on request) |
| `bvnk:ledger:report:ready` | A ledger report is ready | Download the CSV from `data.url` |
| `platform:accounts:customer:created` | A customer is created | Sync customer records |
| `platform:accounts:customer:status-change` | A customer's status changes | KYC/KYB workflow updates |
| `bvnk:customers:status-change` | A customer's status changes (customers API) | KYC/KYB workflow updates |

BVNK's legacy "Standard" webhooks use `"source": "payment", "event": "statusChanged"` instead. They sign differently and fail verification.

Some other events worth knowing:

* `bvnk:payment:channel:transaction-detected` fires when a deposit is seen on chain but not yet confirmed. Show it as pending, and credit only on `transaction-confirmed`.
* `bvnk:payment:crypto:status-change` and `bvnk:payment:crypto:refund-initiated` cover crypto payments and their refunds.
* `bvnk:compliance:v1:information-request` arrives when a payment is held for missing Travel Rule information.
* `bvnk:card:card:updated` and `bvnk:card:transaction:status-change` cover card status and card transactions.
* The customer onboarding events moved from `bvnk:customers:*` to `platform:accounts:customer:*`. BVNK currently sends both, so subscribe to one family or deduplicate across them.

Versions are separate events rather than a header: `payment:v2:...` and `bvnk:compliance:v1:...` carry the version in the URN. BVNK's reference also has v1, v2 and v3 pages for fiat pay-in, payout and transfer events whose URNs it doesn't state, so log the `event` value of a real delivery before you match on it.

### Event Payload Structure

`eventId` is unique per event: use it to deduplicate retries. `timestamp` is when the event was created, and the shape of `data` varies by event.

### Published payload example

This is the `bvnk.ledger.report.ready` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/bvnk/latest/bvnk.ledger.report.ready.json). The values are BVNK's own, from its staging environment.

```json
{
  "event": "bvnk:ledger:report:ready",
  "eventId": "0198a8bb-7d56-79ef-92b2-2aed247c21b4",
  "timestamp": "2025-08-14T13:18:36.374620938Z",
  "data": {
    "id": "8d942264-e27e-44d9-a048-5ed66ac5129f",
    "url": "https://bvnk-staging-reports.s3.eu-west-1.amazonaws.com/transaction/8d942264-e27e-44d9-a048-5ed66ac5129f_7b2f87b9-1335-4b1e-bbeb-6a59ba1dc6df.csv?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20250814T131836Z&X-Amz-SignedHeaders=host&X-Amz-Credential=AKIAZP6R7NJUFAGFGMNA%2F20250814%2Feu-west-1%2Fs3%2Faws4_request&X-Amz-Expires=604800&X-Amz-Signature=5c861e552b3e253be28f8e02c818b3c5e00269b9b50b6d4957d5281825d31a67",
    "type": "TRANSACTION",
    "status": "READY"
  }
}

```

This is the body of BVNK's published signature test vector. `data.url` is a pre-signed S3 link to the report CSV with its own expiry, so download it promptly rather than storing the link.

## Setting up BVNK webhooks

1. In the BVNK Portal, open **Integrations** > **Webhooks** and create a webhook.
2. Enter the **Webhook URL to receive events** (the Hookdeck source URL, or your own `/webhooks/bvnk` route). It must be HTTPS on port 443; custom ports are not supported.
3. Select the **events to send**, for fiat and crypto operations.
4. Save. BVNK shows the secret in the **Public Key** field.

Most events are enabled by default. Contact the BVNK Solutions team to enable `bvnk:payment:channel:transaction-on-hold` and `bvnk:payment:crypto:transaction-on-hold`.

Paste the secret shown in the webhook's Public Key field in BVNK. Legacy BVNK webhooks (statusChanged events) sign differently and fail verification.

Set it as `WEBHOOK_SECRET`. It looks like base64, but it is used as its UTF-8 string bytes, exactly as shown (no decoding, no extra whitespace). The field is labelled "Public Key" even though it is the HMAC secret.

The `x-signature` header is the only authentication BVNK puts on a delivery. There is no API key or basic auth.

For sandbox testing on your machine, forward deliveries with the [Hookdeck CLI](/docs/cli). Its public URL is HTTPS on port 443, which satisfies BVNK's URL rule:

```bash
npx hookdeck-cli listen 3000 bvnk --path /webhooks/bvnk

```

## Securing BVNK webhooks

Compute an HMAC-SHA256 of the raw payload with the secret's UTF-8 bytes, encode it as standard base64, and compare it with `x-signature` in constant time. BVNK's own documentation states the secret is "used as string directly, not Base64 decoded". See the [verification documentation](https://docs.bvnk.com/bvnk/get-started/create-webhook-listener).

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

`decodeBase64` refuses any value that doesn't round-trip, because `Buffer.from(sig, 'base64')` silently drops invalid characters. The handler below verifies, skips an `eventId` it has seen, treats a legacy body as a configuration problem, and stores the event before answering.

```javascript
const express = require('express');

function createBvnkApp({ alreadyProcessed, record }) {
  const app = express();
  app.post('/webhooks/bvnk', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    let event;
    try {
      event = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!event || typeof event !== 'object' || Array.isArray(event)) return res.sendStatus(400);
    if (!event.eventId) return res.sendStatus(400); // not a hook-service event

    try {
      if (await alreadyProcessed(event.eventId)) return res.sendStatus(200);
      await record(event.event, event.eventId, event.data);
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

BVNK signs only the payload. The body's `timestamp` is inside what is signed, but BVNK sets no tolerance and sends no separate timestamp header.

Hookdeck's BVNK source type verifies the hook-service scheme: a base64 HMAC-SHA256 of the raw body in `x-signature`, keyed with the secret as a UTF-8 string. It does not verify the legacy "Standard" signature. Hookdeck answers BVNK with `200` in every case, records a failed check as failed verification and does not forward that request. It does not compare the body's `timestamp` with the current time.

> **Make BVNK webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `x-signature` on every BVNK delivery, keeps each payment and onboarding event, and lets you replay any of them into your ledger.

## BVNK webhook limitations and pain points

### A secret called "Public Key"

**The Problem:** Every signature fails even though you copied the secret from the right webhook.

**Why It Happens:** The Portal labels the HMAC secret **Public Key**, and the value looks like base64. Developers either go looking for a separate secret, or decode the value before using it. BVNK uses the string's UTF-8 bytes directly, so decoding gives a different key.

**Workarounds:**

* Copy the Public Key value exactly, with no added whitespace.
* Use it as a plain string key, as in the code above.
* Test with BVNK's published test vector before going live.

**How Hookdeck Can Help:** Paste the value into the Hookdeck BVNK source and it is used the right way, so the decoding mistake can't happen.

### Two signature schemes in the wild

**The Problem:** Some deliveries verify and others never do.

**Why It Happens:** Legacy "Standard" webhooks (`statusChanged`) sign a hex HMAC-SHA256 over the webhook path, content type and payload. The hook service signs a base64 HMAC over the payload alone. An account can still have the older kind configured.

**Workarounds:**

* Check the body: hook-service events have a colon-separated `event` and an `eventId`.
* Move legacy webhooks to the hook service rather than verifying both schemes.

**How Hookdeck Can Help:** Hookdeck's request log shows which deliveries failed verification, which points straight at any legacy webhook still configured.

### Event names that are moving

**The Problem:** Onboarding events are processed twice, or not at all after a rename.

**Why It Happens:** BVNK is moving the customer events from `bvnk:customers:*` to `platform:accounts:customer:*` and sends both during the transition. Some fiat and transfer events have versioned reference pages without a documented URN.

**Workarounds:**

* Subscribe to the `platform:accounts:customer:*` family only, if the Portal lets you.
* Map both names to one internal event and deduplicate on the customer and status.
* Log unknown `event` values rather than rejecting them.

**How Hookdeck Can Help:** Hookdeck filters can drop the deprecated `bvnk:customers:*` events before they reach your service.

### Retries can arrive long after the event

**The Problem:** An old status change overwrites a newer one.

**Why It Happens:** BVNK retries up to 100 times with delays that grow to 15 minutes, so a delivery that failed for an hour can arrive after later events for the same payment.

**Workarounds:**

* Compare each event's `timestamp` with the last one you applied for that object.
* BVNK advises not updating a transaction after you've received its final webhook, so treat final states as terminal.

**How Hookdeck Can Help:** Hookdeck accepts each delivery at once, so BVNK's own retries stop, and you control [how fast](/docs/destinations#set-a-max-delivery-rate) events reach your service. [Delivery order isn't guaranteed](/docs/use-cases/ai-agents#handle-out-of-order-events), so keep the `timestamp` check in your handler.

## Best practices

### Keep the raw bytes

Take the body as bytes (`express.raw({ type: '*/*' })` in Express, `await request.body()` in FastAPI). Re-serialised JSON changes the bytes and the signature fails.

### Validate base64 before decoding

A signature with stray low bits in its last character decodes to the same bytes but isn't the canonical value, and Hookdeck rejects it, so the linked skill examples validate the 44-character form. Never call `Buffer.from(sig, 'base64')` on an unvalidated value.

### Deduplicate on `eventId`

BVNK says duplicates are possible. Store `eventId` with the change it causes and skip repeats. Our [webhook idempotency guide](/webhooks/guides/implement-webhook-idempotency) walks through it.

### Credit on confirmation, not detection

For crypto channels, `transaction-detected` means BVNK has seen a transaction that is not yet confirmed. Credit a customer on `transaction-confirmed`, and handle `transaction-on-hold` as a separate state.

### Acknowledge within 10 seconds

Return `200` once the event is stored and do the work afterwards. This is the [asynchronous processing](/webhooks/guides/why-implement-asynchronous-processing-webhooks) pattern.

## Conclusion

Most BVNK verification bugs come from the secret: it is labelled "Public Key", it looks like base64, and it must be used as a plain string. Beyond that, expect duplicates and late retries, credit deposits on confirmation, and make sure every webhook in the Portal is on the current hook service.

[Hookdeck Event Gateway](https://hookdeck.com) verifies each BVNK signature and gives you a searchable, replayable record of every payment event.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle BVNK webhooks reliably in minutes.