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-detectedfires when a deposit is seen on chain but not yet confirmed. Show it as pending, and credit only ontransaction-confirmed.bvnk:payment:crypto:status-changeandbvnk:payment:crypto:refund-initiatedcover crypto payments and their refunds.bvnk:compliance:v1:information-requestarrives when a payment is held for missing Travel Rule information.bvnk:card:card:updatedandbvnk:card:transaction:status-changecover card status and card transactions.- The customer onboarding events moved from
bvnk:customers:*toplatform: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
- In the BVNK Portal, open Integrations > Webhooks and create a webhook.
- Enter the Webhook URL to receive events (the Hookdeck source URL, or your own
/webhooks/bvnkroute). It must be HTTPS on port 443; custom ports are not supported. - Select the events to send, for fiat and crypto operations.
- 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-signatureon 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
eventand aneventId. - 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
eventvalues 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
timestampwith 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.