Signature Verification
Verify that webhook requests are genuinely from Probo
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.
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.
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, timestamp, signature, signingSecret) { 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 );}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 and store it in a secret manager.
- Return a generic
400or403response. Do not reveal which verification check failed. - Record
eventIdafter verification and process it only once. Timestamp validation limits replay time; idempotency prevents duplicate side effects.
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 |