> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zbx.boomfi.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify Webhook Signatures

> Verify RSA signatures on outbound webhook deliveries using your org public key.

ZBX signs outbound webhook POST bodies with your organisation’s RSA private key. The corresponding public key is available in the merchant dashboard. Verifying the signature proves authenticity and integrity; checking the timestamp reduces replay risk.

## Headers

Every signed delivery includes:

| Header               | Meaning                                                              |
| -------------------- | -------------------------------------------------------------------- |
| `X-BoomFi-Timestamp` | Unix time (seconds) when the request was signed                      |
| `X-BoomFi-Signature` | Base64-encoded RSA PKCS#1 v1.5 signature over SHA-256 of the message |

These header names are platform-level (the same string across white-label deployments).

## Message format

```text theme={null}
message = timestamp + "." + raw_request_body
```

* Use the **raw body bytes** as received (the exact JSON posted). Do not re-serialise the parsed object.
* The body already includes the `event` field (for example `Payment.Updated`) at send time.

## Algorithms

1. SHA-256 hash the message
2. Verify with RSA PKCS#1 v1.5 against your org **PEM public key**
3. Decode signature as standard Base64

Reference implementation matches Merchants API event signing (`VerifySignature` / `SignMessage` using PKCS#1 private/public keys).

## Steps

1. Read `X-BoomFi-Timestamp` and `X-BoomFi-Signature`
2. Reject if the timestamp is outside your freshness window (for example ±5 minutes)
3. Build `message = timestamp + "." + rawBody`
4. Verify RSA signature with the public key from [Configure Webhooks](/webhooks/setup)
5. Parse JSON only after verification succeeds
6. Confirm `org_id` (or `org.id`) matches your organisation

## Code examples

### TypeScript (Node.js)

```typescript theme={null}
import { createVerify, createPublicKey } from "crypto";

function verifyWebhookSignature(opts: {
  rawBody: string | Buffer;
  signatureBase64: string;
  timestamp: string;
  publicKeyPem: string;
}): boolean {
  const message = `${opts.timestamp}.${opts.rawBody.toString()}`;
  const verifier = createVerify("RSA-SHA256");
  verifier.update(message);
  verifier.end();
  return verifier.verify(
    createPublicKey(opts.publicKeyPem),
    opts.signatureBase64,
    "base64",
  );
}

// Express-style: use express.raw() or capture raw body before JSON middleware
export function webhookHandler(req: {
  body: Buffer;
  headers: Record<string, string | string[] | undefined>;
}, res: { status: (n: number) => { send: (s: string) => void } }) {
  const signature = String(req.headers["x-boomfi-signature"] ?? "");
  const timestamp = String(req.headers["x-boomfi-timestamp"] ?? "");
  const publicKeyPem = process.env.WEBHOOK_PUBLIC_KEY!.replace(/\\n/g, "\n");

  const ts = Number(timestamp);
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) {
    return res.status(401).send("stale timestamp");
  }

  const ok = verifyWebhookSignature({
    rawBody: req.body,
    signatureBase64: signature,
    timestamp,
    publicKeyPem,
  });
  if (!ok) return res.status(401).send("invalid signature");

  const payload = JSON.parse(req.body.toString("utf8"));
  // Assert org_id matches your organisation; handle payload.event idempotently
  return res.status(200).send("ok");
}
```

### Python

```python theme={null}
import base64
import hashlib
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature


def verify_signature(public_key_pem: str, message: bytes, signature_base64: str) -> None:
    public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
    signature = base64.b64decode(signature_base64)
    public_key.verify(
        signature,
        message,
        padding.PKCS1v15(),
        hashes.SHA256(),
    )


def verify_webhook(raw_body: bytes, timestamp: str, signature_b64: str, public_key_pem: str) -> None:
    message = f"{timestamp}.".encode("utf-8") + raw_body
    try:
        verify_signature(public_key_pem, message, signature_b64)
    except InvalidSignature as exc:
        raise ValueError("invalid signature") from exc
```

### Go

```go theme={null}
package webhooks

import (
	"crypto"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/pem"
	"errors"
	"fmt"
)

func VerifySignature(publicKeyPEM string, message []byte, signatureBase64 string) error {
	block, _ := pem.Decode([]byte(publicKeyPEM))
	if block == nil || block.Type != "PUBLIC KEY" {
		return errors.New("failed to decode PEM block containing public key")
	}
	publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		return fmt.Errorf("failed to parse public key: %w", err)
	}
	rsaPublicKey, ok := publicKey.(*rsa.PublicKey)
	if !ok {
		return errors.New("not an RSA public key")
	}
	signature, err := base64.StdEncoding.DecodeString(signatureBase64)
	if err != nil {
		return fmt.Errorf("failed to decode signature: %w", err)
	}
	h := sha256.New()
	h.Write(message)
	return rsa.VerifyPKCS1v15(rsaPublicKey, crypto.SHA256, h.Sum(nil), signature)
}

func VerifyWebhook(rawBody []byte, timestamp, signatureB64, publicKeyPEM string) error {
	message := append([]byte(timestamp+"."), rawBody...)
	return VerifySignature(publicKeyPEM, message, signatureB64)
}
```

## Rotate keys

Rotate from Business settings or `PATCH https://mapi.zbx.boomfi.xyz/v1/orgs/webhook-secret`. Deploy the new public key to all verifiers before or during rotation, depending on your dual-key window.

## Related

* [Event Types](/webhooks/event-types)
* [Webhook Best Practices](/webhooks/best-practices)
