# Guide to Bridge Webhooks: Features and Best Practices

Bridge webhooks notify your systems about stablecoin and payments activity: a customer or KYC status updates, a transfer changes, a virtual account sees activity. If you're building on Bridge, webhooks are how you react to these events without polling.

This guide covers how Bridge webhooks work, the events you'll handle, how to verify the RSA signature (this is asymmetric crypto with a per-webhook public key), and the best practices for production.

## What are Bridge webhooks?

Bridge webhooks are HTTP POSTs delivered to an endpoint you create via the webhooks API. Each event is signed with an RSA-SHA256 signature (not HMAC): the `X-Webhook-Signature` header carries `t=<timestamp_ms>,v0=<base64_signature>`, verified with a per-webhook PEM public key returned by the API.

Subscriptions are category-based (`event_categories`), and new webhooks start disabled until you enable them.

## Bridge webhook features

| Feature | Details |
| --- | --- |
| Configuration | Webhooks API with an `event_categories` array (category-based) |
| Signature header | `X-Webhook-Signature`, formatted `t=<timestamp_ms>,v0=<base64_signature>` |
| Signature scheme | RSA-SHA256 over `SHA256("<timestamp>.<raw_body>")` |
| Verification key | Per-webhook PEM `public_key` from the create/update/enable API response |
| Freshness | Reject events older than ~10 minutes |
| State | New webhooks start disabled; enable them explicitly |
| Retries | Return non-2xx (docs use 400) to trigger retries; exact backoff undocumented |
| SDK | None |

## Common events

Bridge event names use `<category>.<action>`:

| Event | Fires when |
| --- | --- |
| `customer.updated` | A customer record updates |
| `kyc_link.updated` | A KYC link status changes |
| `transfer.updated` | A transfer changes state |
| `virtual_account.activity` | A virtual account sees activity |

You subscribe by category (for example `["customer", "kyc_link", "transfer"]`), not per event. Consult Bridge's webhook reference for the full category and action list.

## Setting up Bridge webhooks

Create an endpoint via the webhooks API with an `event_categories` array. New webhooks start disabled, so enable the webhook after creating it. The create/update/enable response includes the `public_key` (PEM) you use to verify that webhook's deliveries, so capture it, it's per-webhook, not a single global key.

## Securing Bridge webhooks

The `X-Webhook-Signature` header is `t=<timestamp_ms>,v0=<base64_signature>`. To verify: build `"<timestamp>.<raw_body>"`, SHA256-hash it, then RSA-SHA256-verify that digest with the webhook's PEM public key. (Passing the SHA256 digest into an RSA-SHA256 verify, which hashes again, is intentional, follow Bridge's reference code exactly.) Also reject events whose timestamp is older than ~10 minutes.

```javascript
const crypto = require("crypto");

const PUBLIC_KEY = process.env.BRIDGE_WEBHOOK_PUBLIC_KEY; // per-webhook PEM

function verify(rawBody, signatureHeader) {
  const parts = Object.fromEntries(
    (signatureHeader || "").split(",").map((kv) => kv.split("="))
  );
  const timestamp = parts.t;
  const signature = parts.v0;

  // Reject stale events (~10 min; timestamp is milliseconds)
  if (Math.abs(Date.now() - Number(timestamp)) > 10 * 60 * 1000) return false;

  const signedPayload = `${timestamp}.${rawBody.toString()}`;
  const digest = crypto.createHash("sha256").update(signedPayload).digest();

  const verifier = crypto.createVerify("RSA-SHA256");
  verifier.update(digest); // verify over the SHA256 digest (per Bridge's reference impl)
  return verifier.verify(PUBLIC_KEY, signature, "base64");
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.headers["x-webhook-signature"])) {
    return res.sendStatus(400); // non-2xx triggers a retry
  }

  res.sendStatus(200); // acknowledge fast
  processQueue.add(JSON.parse(req.body)); // process asynchronously
});

```

The same in Python (using `cryptography`):

```python
import hashlib
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes

def verify(raw_body: bytes, timestamp: str, signature_bytes: bytes, public_key) -> bool:
    signed_payload = f"{timestamp}.{raw_body.decode()}"
    digest = hashlib.sha256(signed_payload.encode()).digest()
    try:
        public_key.verify(signature_bytes, digest, padding.PKCS1v15(), hashes.SHA256())
        return True
    except Exception:
        return False

```

## Bridge webhook limitations and pain points

### RSA with a per-webhook public key, and a hashing quirk

The Problem: Verification is asymmetric (RSA-SHA256), the key is per-webhook (not global), and the reference implementation SHA256-hashes the payload and then RSA-SHA256-verifies that digest, which looks like double hashing but is deliberate. Reaching for HMAC, using a global key, or "simplifying" the hashing all fail.

Why It Happens: Bridge signs with RSA and its reference code pre-hashes before RSA verification.

Workarounds:

* Follow Bridge's reference code exactly: `SHA256("<timestamp>.<body>")`, then RSA-SHA256 verify that digest with the per-webhook PEM key.

How Hookdeck Can Help: Hookdeck verifies the RSA signature at the edge, so your application receives pre-verified events without embedding asymmetric crypto or the hashing quirk in your handler.

### New webhooks start disabled

The Problem: A newly created webhook is disabled and delivers nothing until you enable it. It's easy to create one, register the endpoint, and wonder why no events arrive.

Why It Happens: Bridge requires an explicit enable step.

Workarounds:

* Enable the webhook after creation, and capture the `public_key` from the enable/create response.

How Hookdeck Can Help: Hookdeck's observability shows whether events are arriving, so a disabled webhook surfaces immediately rather than as silence.

### Category-based subscriptions

The Problem: You subscribe by category, not per event, so a category delivers all its actions. A handler expecting only one event type receives the whole category.

Why It Happens: Bridge scopes subscriptions to categories.

Workarounds:

* Branch on `<category>.<action>` and ignore actions you don't handle.

How Hookdeck Can Help: Hookdeck's filters route by event name, so each handler sees only the actions it cares about.

### Freshness and duplicates

The Problem: Events older than ~10 minutes should be rejected, and retries (triggered by non-2xx) mean duplicates.

Why It Happens: The timestamp bounds replay; at-least-once delivery brings duplicates.

Workarounds:

* Reject stale timestamps and dedupe on an event id; make side effects idempotent.

How Hookdeck Can Help: Hookdeck verifies freshness and deduplicates at the edge. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Best practices

### Verify RSA-SHA256 over the SHA256 digest with the per-webhook key

Build `"<timestamp>.<body>"`, SHA256 it, RSA-SHA256-verify the digest with the webhook's PEM public key, and follow Bridge's reference code exactly.

### Enable the webhook and store its public key

Enable after creating, and capture the per-webhook `public_key` from the API response.

### Reject stale events and dedupe

Reject timestamps older than ~10 minutes and dedupe so retries don't double-process.

### Acknowledge fast, process asynchronously

Return 2xx once verified and defer work to a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Bridge webhooks are signed with RSA-SHA256 using a per-webhook PEM public key over `SHA256("<timestamp>.<body>")`, with a ~10-minute freshness window and endpoints that start disabled. Follow Bridge's reference verification exactly (including the deliberate pre-hash), enable the webhook, store its public key, and dedupe.

[Hookdeck](https://hookdeck.com) verifies the RSA signature, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Bridge webhooks reliably in minutes.