Agent skill
BVNK Webhooks Skill
Receive and verify BVNK webhooks. Use when setting up BVNK webhook listeners, debugging x-signature verification (base64 HMAC-SHA256), or handling payment, customer and ledger events like payment:v2:payin:status-change and bvnk:ledger:report:ready.
Install this skill
npx skills add hookdeck/webhook-skills --skill bvnk-webhooks
When to Use This Skill
- How do I receive BVNK webhooks?
- How do I verify the BVNK
x-signatureheader? - How do I handle
payment:v2:payin:status-changeorbvnk:ledger:report:readyevents? - Why is my BVNK webhook signature verification failing (even though the secret looks like base64)?
- Why do legacy BVNK
statusChangedwebhooks fail verification?
Essential Code (USE THIS)
BVNK signs the raw request body and sends the digest in the x-signature header, with no prefix and no timestamp:
- Algorithm: HMAC-SHA256, output as standard base64 (44 characters, ending in
=). - Key: the secret's UTF-8 bytes, as-is. The secret looks like base64 but is not base64-decoded.
- Message: the exact body bytes. Never parse and re-serialize the JSON before verifying.
- Nothing else is signed, so there is no timestamp or replay window to check.
- Check the header's shape (
[A-Za-z0-9+/]{43}=) before comparing, then compare the whole value in constant time. Base64 is compared exactly, so a value that decodes to the same bytes but spells the last character differently is rejected (as Hookdeck does). - Missing, malformed or mismatched signature: respond
401. Respond200within 10 seconds on success.
Hookdeck's setup note for the BVNK source: "Paste the secret shown in the webhook's Public Key field in BVNK. Legacy BVNK webhooks (statusChanged events) sign differently and fail verification."
Verification (Node.js)
const crypto = require('crypto');
// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyBvnk(rawBody, signatureHeader, secret) {
if (!secret || typeof signatureHeader !== 'string') return false;
if (!/^[A-Za-z0-9+/]{43}=$/.test(signatureHeader)) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
const received = Buffer.from(signatureHeader, 'utf8');
const wanted = Buffer.from(expected, 'utf8');
return received.length === wanted.length && crypto.timingSafeEqual(received, wanted);
}
Verification (Python)
import base64, hashlib, hmac, re
def verify_bvnk(raw_body: bytes, signature_header, secret: str) -> bool:
if not secret or not signature_header:
return False
if not re.fullmatch(r"[A-Za-z0-9+/]{43}=", signature_header):
return False
digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest)
return hmac.compare_digest(signature_header.encode("ascii"), expected)
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Legacy webhooks
BVNK's older "Standard" webhooks (body "source": "payment", "event": "statusChanged") use a different scheme: hex HMAC-SHA256 over the webhook URL path, content type and payload. They fail the verification above, and they fail Hookdeck's BVNK source too. Use the newer URN-style events (bvnk:..., payment:v2:...) instead.
Common Event Types
The body is JSON: { event, eventId, timestamp, data }. Dispatch on the event field, a colon-separated URN. No header names the event.
event | Description |
|---|---|
payment:v2:payin:status-change | A pay-in changed status |
bvnk:payment:channel:created | A payment channel was created |
bvnk:payment:channel:transaction-confirmed | A transaction on a channel was confirmed |
bvnk:payment:channel:transaction-on-hold | A channel transaction was put on hold (enabled on request) |
bvnk:payment:crypto:transaction-on-hold | A crypto transaction was put on hold (enabled on request) |
bvnk:ledger:report:ready | A ledger report is ready to download |
platform:accounts:customer:created | A customer was created |
platform:accounts:customer:status-change | A customer's status changed |
bvnk:customers:status-change | A customer's status changed (customers API) |
BVNK documents 28 event types in total across payments, customers and ledger; handle the ones you subscribe to and log the rest. See references/overview.md.
For the full event list and payloads, see BVNK's webhook documentation.
Important Headers
| Header | Description |
|---|---|
x-signature | Base64 HMAC-SHA256 of the raw body, no prefix |
Environment Variables
BVNK_WEBHOOK_SECRET=your_public_key_field_value # BVNK Portal → Integrations → Webhooks ("Public Key" field)
Delivery and Retries
BVNK POSTs to your URL (HTTPS on port 443 only). Return 200 within 10 seconds; otherwise BVNK retries with growing delays capped at 15 minutes, up to 100 attempts. Duplicates are possible, so deduplicate on eventId.
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 bvnk --path /webhooks/bvnk
Reference Materials
- references/overview.md - BVNK webhooks, event types, payload structure
- references/setup.md - Creating a webhook in the BVNK Portal and finding the secret
- references/verification.md - Signature verification details and gotchas