# 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)

```javascript
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)

```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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/bvnk-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/bvnk-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/bvnk-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/bvnk-webhooks/references/overview.md).

> **For the full event list and payloads**, see [BVNK's webhook documentation](https://docs.bvnk.com/bvnk/get-started/create-webhook-listener).

## Important Headers

| Header | Description |
| --- | --- |
| `x-signature` | Base64 HMAC-SHA256 of the raw body, no prefix |

## Environment Variables

```bash
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

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 bvnk --path /webhooks/bvnk

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/bvnk-webhooks/references/overview.md) - BVNK webhooks, event types, payload structure
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/bvnk-webhooks/references/setup.md) - Creating a webhook in the BVNK Portal and finding the secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/bvnk-webhooks/references/verification.md) - Signature verification details and gotchas