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-signature header?
  • How do I handle payment:v2:payin:status-change or bvnk:ledger:report:ready events?
  • Why is my BVNK webhook signature verification failing (even though the secret looks like base64)?
  • Why do legacy BVNK statusChanged webhooks 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. Respond 200 within 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.

eventDescription
payment:v2:payin:status-changeA pay-in changed status
bvnk:payment:channel:createdA payment channel was created
bvnk:payment:channel:transaction-confirmedA transaction on a channel was confirmed
bvnk:payment:channel:transaction-on-holdA channel transaction was put on hold (enabled on request)
bvnk:payment:crypto:transaction-on-holdA crypto transaction was put on hold (enabled on request)
bvnk:ledger:report:readyA ledger report is ready to download
platform:accounts:customer:createdA customer was created
platform:accounts:customer:status-changeA customer's status changed
bvnk:customers:status-changeA 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

HeaderDescription
x-signatureBase64 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 11, 2026

View on GitHub →