Gareth Wilson Gareth Wilson

Guide to Bridge Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationWebhooks API with an event_categories array (category-based)
Signature headerX-Webhook-Signature, formatted t=<timestamp_ms>,v0=<base64_signature>
Signature schemeRSA-SHA256 over SHA256("<timestamp>.<raw_body>")
Verification keyPer-webhook PEM public_key from the create/update/enable API response
FreshnessReject events older than ~10 minutes
StateNew webhooks start disabled; enable them explicitly
RetriesReturn non-2xx (docs use 400) to trigger retries; exact backoff undocumented
SDKNone

Common events

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

EventFires when
customer.updatedA customer record updates
kyc_link.updatedA KYC link status changes
transfer.updatedA transfer changes state
virtual_account.activityA 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.

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

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.

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.

Make Bridge webhooks production-ready

Hookdeck verifies the RSA signature, deduplicates, and durably queues every event

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 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 for free and handle Bridge webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.