WebhooksWebhook signatures

Webhook signatures

Verify that webhook deliveries actually came from ProductBridge using HMAC-SHA256.

ProductBridge signs every webhook delivery with HMAC-SHA256. Always verify the signature on incoming requests before trusting the payload — without this check, anyone who learns or guesses your webhook URL could forge events and trigger your downstream automation.

How it works

When ProductBridge dispatches a webhook, it computes:

signature = "sha256=" + HMAC-SHA256(signing_secret, raw_request_body).hex()

…and sends it in the X-ProductBridge-Signature header. The signing secret is the per-webhook value returned exactly once when you registered the webhook (the Signing secret modal shown when you add an endpoint under Settings → Connect → API & Webhooks → Webhook endpoints).

Your receiver verifies by computing the same HMAC with its locally-stored copy of the secret and comparing the two strings in constant time.

The signing secret never crosses the wire — it lives only in two places: ProductBridge's database and your receiver's secrets manager. The header carries the signature (a one-way HMAC output), which is useless to an attacker without the secret.

Headers on every delivery

HeaderDescription
X-ProductBridge-EventThe event type (e.g. vote.created). Mirrors the event field in the JSON body.
X-ProductBridge-Signaturesha256=<hex> where <hex> is the HMAC-SHA256 of the raw request body using the webhook's signing secret.
X-ProductBridge-Webhook-IdUUID of the webhook subscription this delivery is for. Helpful when one receiver serves multiple webhooks.

Verification — what your code must do

Read the raw request body

Capture the unparsed bytes — not a re-serialized JSON object. Most frameworks parse JSON before reaching your handler; you need the original bytes the request arrived with.

Compute the expected signature

expected = "sha256=" + HMAC-SHA256(your_stored_secret, raw_body).hex()

Compare in constant time

Use crypto.timingSafeEqual (Node.js), hmac.compare_digest (Python), hmac.Equal (Go), or hash_equals (PHP) to avoid timing attacks. Never use == for signature comparison.

Reject mismatches with HTTP 401

If the signature doesn't match, return 401 immediately. Don't process the body, don't log it, don't retry — treat it as a forgery attempt.

The signature is computed over the raw request body bytes. Many web frameworks parse JSON before reaching your handler — re-serializing changes whitespace and breaks the signature. Make sure you capture the original bytes:

  • Express / Node.js: use express.raw({ type: 'application/json' }) middleware.
  • FastAPI: call await request.body() before any JSON parsing.
  • Flask: use request.get_data(cache=True).
  • Go (net/http): read r.Body once, store the bytes, then re-parse.

Receiver implementations

// Node.js (Express). The raw-body middleware is essential.
import crypto from "node:crypto";
import express from "express";

const app = express();
app.use(express.raw({ type: "application/json" }));

const SECRET = process.env.PRODUCTBRIDGE_WEBHOOK_SECRET;

function verify(req) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  const got = req.headers["x-productbridge-signature"] || "";
  if (expected.length !== got.length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got));
}

app.post("/webhook", (req, res) => {
  if (!verify(req)) return res.status(401).end();
  const payload = JSON.parse(req.body.toString("utf8"));
  // ... handle payload.event ...
  res.status(200).end();
});

Rotating a signing secret

If a signing secret leaks, revoke the webhook and create a new one. There is no in-place rotation:

Revoke the compromised webhook

In the dashboard, click Revoke on the affected row. Delivery stops immediately; ProductBridge will not POST to that URL again.

Create a fresh webhook with the same URL

Add a new webhook subscription pointing at the same URL. The dashboard will return a new signing secret in the create modal.

Update your receiver's stored secret

Replace the old PRODUCTBRIDGE_WEBHOOK_SECRET value with the new one in your secrets manager / env vars and redeploy.

Plan rotation as a deploy-coordinated step: revoke + recreate + update receiver are not atomic, and ProductBridge does not redeliver failed webhooks. Any events that occur between revoking the old webhook and the new secret being live in your receiver are dropped permanently — keep the window as short as possible, and re-fetch affected resources via the public API afterwards if you need to backfill.

Common pitfalls