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
| Header | Description |
|---|---|
X-ProductBridge-Event | The event type (e.g. vote.created). Mirrors the event field in the JSON body. |
X-ProductBridge-Signature | sha256=<hex> where <hex> is the HMAC-SHA256 of the raw request body using the webhook's signing secret. |
X-ProductBridge-Webhook-Id | UUID 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.Bodyonce, 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();
});
# Python (FastAPI).
import hmac, hashlib, json, os
from fastapi import FastAPI, Request, Response
app = FastAPI()
SECRET = os.environ["PRODUCTBRIDGE_WEBHOOK_SECRET"]
def verify(raw_body: bytes, signature_header: str) -> bool:
expected = "sha256=" + hmac.new(
SECRET.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header or "")
@app.post("/webhook")
async def receive(request: Request):
raw_body = await request.body()
sig = request.headers.get("x-productbridge-signature", "")
if not verify(raw_body, sig):
return Response(status_code=401)
payload = json.loads(raw_body)
# ... handle payload["event"] ...
return Response(status_code=200)
// Go (net/http).
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
var secret = []byte(os.Getenv("PRODUCTBRIDGE_WEBHOOK_SECRET"))
func verify(rawBody []byte, signatureHeader string) bool {
mac := hmac.New(sha256.New, secret)
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signatureHeader))
}
func handler(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
if !verify(raw, r.Header.Get("X-ProductBridge-Signature")) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// ... json.Unmarshal(raw, &payload); handle payload.Event ...
w.WriteHeader(http.StatusOK)
}
<?php
// PHP.
$secret = getenv("PRODUCTBRIDGE_WEBHOOK_SECRET");
$rawBody = file_get_contents("php://input");
$signatureHeader = $_SERVER["HTTP_X_PRODUCTBRIDGE_SIGNATURE"] ?? "";
$expected = "sha256=" . hash_hmac("sha256", $rawBody, $secret);
if (!hash_equals($expected, $signatureHeader)) {
http_response_code(401);
exit;
}
$payload = json_decode($rawBody, true);
// ... handle $payload["event"] ...
http_response_code(200);
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.