Skip to content

Products

Compliance Officer Service Expert-led compliance, end to end Compliance Portal Share security documents securely Open-source platform Deploy Probo on your own infrastructure

Resources

Probo stories How teams get compliant with Probo Blog Ideas and guidance from the Probo team Guides & tools Practical compliance guides and free tools Love from Customers What customers say about working with Probo Changelog Latest product updates Download Get the Probo Agent

Company

About The people and vision powering Probo Careers Join the team building Probo Brand assets Official logos and visual resources Security Review our security and compliance posture
Overview Understand Probo and its core concepts Product Explore Probo's GRC capabilities Developers Explore GraphQL, CLI, MCP, n8n, and webhooks Deployment Probo Cloud, self-hosting, and configuration

Explore

GitHub Explore our open-source compliance tools

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.

View as Markdown

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.

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:

  • timestamp is the value of the X-Probo-Webhook-Timestamp header (Unix seconds)
  • body is 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.

  1. Extract the headers

    Read X-Probo-Webhook-Timestamp and X-Probo-Webhook-Signature from the request.

  2. Build the signed message

    Concatenate the timestamp, a colon (:), and the raw request body.

  3. Compute the expected signature

    Calculate HMAC-SHA256 using your full signing secret (including the whsec_ prefix) as the key and the signed message as the input. Hex-encode the result.

  4. Compare signatures

    Use a constant-time comparison to check if the computed signature matches the X-Probo-Webhook-Signature header.

  5. 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.

Read the body as bytes before any JSON parser touches it.

RuntimeTypical approach
Go net/httpio.ReadAll(r.Body) before json.Unmarshal
Python Flaskrequest.get_data() — not request.json or request.get_json()
Python FastAPI / Starletteawait request.body() before request.json()
Node httpConcatenate data chunks from the request stream
ExpressMount 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"))
},
);

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.

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
}

Complete HTTP handlers that verify, parse, deduplicate, and acknowledge are in the quickstart.

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.

  • 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 eventId and process it only once. The same value is sent as Idempotency-Key and X-Probo-Webhook-Delivery-Id. Retries reuse it with a new timestamp and signature.
SymptomLikely cause
Every signature failsThe framework parsed or modified the body before verification
Only non-ASCII payloads failThe receiver decoded and re-encoded the body instead of hashing raw bytes
timingSafeEqual throwsThe received signature was not validated as 32-byte hexadecimal first
Valid deliveries are reported as staleThe receiver clock is out of sync or the timestamp was treated as milliseconds
Verification works with one subscription onlyThe endpoint is selecting the wrong subscription secret
Express handlers always failexpress.json() ran on the webhook route instead of express.raw()