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 modal in the dashboard's Settings → API → Webhooks page).

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, so during the brief window between revoke and the new secret being live in your receiver, deliveries will fail. ProductBridge's automatic 3× retry-with-backoff usually papers over this if you complete the rotation within ~5 minutes.

Common pitfalls