Signature Verification
Verify Probo webhook signatures with HMAC-SHA256 over the raw body and timestamp, with Go, Python, and TypeScript examples and framework-specific raw-body notes.
Every Probo webhook includes an HMAC signature. Verify it before parsing the body or performing side effects. Signature verification proves that the payload and timestamp were produced with your subscription’s signing secret. A timestamp freshness check limits replay attacks.
How it works
Section titled “How it works”Probo signs each webhook payload using HMAC-SHA256 with the signing secret from your webhook subscription. The signature is sent in the X-Probo-Webhook-Signature header.
The signed message is the concatenation of the timestamp and the raw request body, separated by a colon:
{timestamp}:{body}Where:
timestampis the value of theX-Probo-Webhook-Timestampheader (Unix seconds)bodyis the raw JSON request body
Use the full signing secret string (including the whsec_ prefix) as the HMAC key. Do not strip the prefix or hex-decode the secret.
Probo generates that secret when you create the subscription. It does not change if you later update the endpoint URL or selected events. Store it in a secret manager, scope it to the receiving service, and never log it. See Signing secret for rotation.
Verification steps
Section titled “Verification steps”-
Extract the headers
Read
X-Probo-Webhook-TimestampandX-Probo-Webhook-Signaturefrom the request. -
Build the signed message
Concatenate the timestamp, a colon (
:), and the raw request body. -
Compute the expected signature
Calculate
HMAC-SHA256using your full signing secret (including thewhsec_prefix) as the key and the signed message as the input. Hex-encode the result. -
Compare signatures
Use a constant-time comparison to check if the computed signature matches the
X-Probo-Webhook-Signatureheader. -
Check timestamp freshness
After the signature matches, reject the request if its timestamp is more than 5 minutes in the past or future. The signed timestamp prevents an attacker from substituting a fresh value.
Cloud and default self-hosted senders stamp the timestamp at the start of each attempt, so a delayed retry still passes a 5-minute check. Self-hosted operators can change sender lease recovery with PROBOD_WEBHOOK_STALE_AFTER; that setting does not change this receiver check. Keep a 5-minute window unless you operate both sides and have a reason to change it.
Preserve the raw body
Section titled “Preserve the raw body”Read the body as bytes before any JSON parser touches it.
| Runtime | Typical approach |
|---|---|
Go net/http | io.ReadAll(r.Body) before json.Unmarshal |
| Python Flask | request.get_data() — not request.json or request.get_json() |
| Python FastAPI / Starlette | await request.body() before request.json() |
Node http | Concatenate data chunks from the request stream |
| Express | Mount express.raw({ type: "application/json" }) on the webhook route so req.body is a Buffer. Do not use express.json() on that path |
Express example:
import express from "express";
const app = express();app.post( "/webhooks/probo", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body; // Buffer // verify, then JSON.parse(rawBody.toString("utf8")) },);Choose the signing secret
Section titled “Choose the signing secret”Verify with the secret that belongs to the subscription that sent the request. subscriptionId is only in the JSON body, so you cannot use it to select a secret until after verification succeeds.
Give each subscription its own URL path (or its own endpoint) and load the matching secret from your secret manager before you compute the HMAC. Do not try every stored secret on a shared URL.
Examples
Section titled “Examples”package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "strconv" "time")
func verifyWebhook(r *http.Request, signingSecret string) ([]byte, error) { body, err := io.ReadAll(r.Body) if err != nil { return nil, err }
timestamp := r.Header.Get("X-Probo-Webhook-Timestamp") signature := r.Header.Get("X-Probo-Webhook-Signature") if timestamp == "" || signature == "" { return nil, fmt.Errorf("missing signature headers") }
mac := hmac.New(sha256.New, []byte(signingSecret)) mac.Write([]byte(timestamp)) mac.Write([]byte(":")) mac.Write(body)
received, err := hex.DecodeString(signature) if err != nil || !hmac.Equal(mac.Sum(nil), received) { return nil, fmt.Errorf("invalid signature") }
signedAt, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return nil, fmt.Errorf("invalid timestamp") } delta := time.Now().Unix() - signedAt if delta > 300 || delta < -300 { return nil, fmt.Errorf("stale timestamp") }
return body, nil}import hashlibimport hmacimport reimport time
def verify_webhook( body: bytes, timestamp: str | None, signature: str | None, signing_secret: str,) -> bool: if ( timestamp is None or signature is None or re.fullmatch(r"[0-9]+", timestamp) is None ): return False
expected = hmac.new( signing_secret.encode(), timestamp.encode("ascii") + b":" + body, hashlib.sha256, ).digest()
if re.fullmatch(r"[0-9a-fA-F]{64}", signature) is None: return False if not hmac.compare_digest(expected, bytes.fromhex(signature)): return False
signed_at = int(timestamp) return abs(time.time() - signed_at) <= 300import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook( rawBody: Buffer, timestamp: string | undefined, signature: string | undefined, signingSecret: string,): boolean { if ( !Buffer.isBuffer(rawBody) || typeof timestamp !== "string" || typeof signature !== "string" || !/^[0-9]+$/.test(timestamp) || !/^[0-9a-fA-F]{64}$/.test(signature) ) { return false; }
const expected = createHmac("sha256", signingSecret) .update(timestamp) .update(":") .update(rawBody) .digest(); const received = Buffer.from(signature, "hex");
if ( expected.length !== received.length || !timingSafeEqual(expected, received) ) { return false; }
const signedAt = Number(timestamp); return ( Number.isFinite(signedAt) && Math.abs(Date.now() / 1000 - signedAt) <= 300 );}Complete HTTP handlers that verify, parse, deduplicate, and acknowledge are in the quickstart.
Failure responses
Section titled “Failure responses”Return a generic 400 or 403 when verification fails. Do not reveal whether the timestamp, signature, or secret was the problem.
Probo treats most 4xx responses as terminal failures and does not retry them. A rejected signature should fail closed. Do not return 5xx or 429 for an invalid signature — those codes look like transient errors and trigger retries. See Delivery and recovery for retryable status codes.
Security recommendations
Section titled “Security recommendations”- Verify the signature before parsing JSON, authorizing the organization, or queuing work.
- Reject missing, malformed, stale, and future-dated timestamps. The examples use a 5-minute tolerance.
- Compare decoded signature bytes in constant time. Check their length first where the comparison API requires equal-length inputs.
- Keep a separate secret for each subscription. Load it by endpoint path, not by parsing the body first.
- After verification, record
eventIdand process it only once. The same value is sent asIdempotency-KeyandX-Probo-Webhook-Delivery-Id. Retries reuse it with a new timestamp and signature.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
| Every signature fails | The framework parsed or modified the body before verification |
| Only non-ASCII payloads fail | The receiver decoded and re-encoded the body instead of hashing raw bytes |
timingSafeEqual throws | The received signature was not validated as 32-byte hexadecimal first |
| Valid deliveries are reported as stale | The receiver clock is out of sync or the timestamp was treated as milliseconds |
| Verification works with one subscription only | The endpoint is selecting the wrong subscription secret |
| Express handlers always fail | express.json() ran on the webhook route instead of express.raw() |