Gareth Wilson Gareth Wilson

Guide to BVNK Webhooks: Features and Best Practices

Published · Updated


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

FeatureDetails
ConfigurationBVNK Portal: Integrations > Webhooks > Add webhook
URLHTTPS on port 443 only. Custom ports are not supported
Signaturebase64 HMAC-SHA256
Signature headerx-signature
Signed contentThe payload exactly as received
SecretShown in the Portal field labelled Public Key. Used as a UTF-8 string
BodyJSON
Event routingBody field event
Response window200 within 10 seconds
RetriesGrowing delays capped at 15 minutes, up to 100 attempts
DuplicatesPossible. Deduplicate on eventId
Source IPsNot published

Events and payloads

EventTriggered WhenCommon Use Cases
payment:v2:payin:status-changeA pay-in changes statusMark orders paid, update balances
bvnk:payment:channel:createdA payment channel is createdTrack new deposit addresses or channels
bvnk:payment:channel:transaction-confirmedA transaction on a channel is confirmedCredit a customer
bvnk:payment:channel:transaction-on-holdA channel transaction is put on holdCompliance review (enabled on request)
bvnk:payment:crypto:transaction-on-holdA crypto transaction is put on holdCompliance review (enabled on request)
bvnk:ledger:report:readyA ledger report is readyDownload the CSV from data.url
platform:accounts:customer:createdA customer is createdSync customer records
platform:accounts:customer:status-changeA customer's status changesKYC/KYB workflow updates
bvnk:customers:status-changeA 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. The values are BVNK's own, from its staging environment.

{
  "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. Its public URL is HTTPS on port 443, which satisfies BVNK's URL rule:

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.

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.

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 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 events reach your service. Delivery order isn't guaranteed, 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 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 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 verifies each BVNK signature and gives you a searchable, replayable record of every payment event.

Get started with Hookdeck for free and handle BVNK webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.