Webhook Signing
Verify that webhook deliveries originated from Parseo using HMAC-SHA256 signatures.
How signing works
Every webhook delivery includes a X-Parseo-Signature header:
X-Parseo-Signature: t=1713094496789,v1=abc123def456...t— Unix timestamp in milliseconds at the time of deliveryv1— HMAC-SHA256 signature (hex-encoded)
To verify the signature:
- Extract
tandv1from the header. - Construct the signed payload:
"{t}.{rawRequestBody}"(the literal timestamp string, a dot, then the raw bytes of the request body — before any JSON parsing). - Compute
HMAC-SHA256(signingSecret, signedPayload)and hex-encode the result. - Compare your computed signature with
v1using a constant-time comparison function. - Reject the request if the timestamp is more than 5 minutes older or newer than your current clock.
Always use the raw request body bytes, not a re-serialized version of the parsed JSON. Whitespace differences will cause verification to fail.
Code examples
const crypto = require('crypto');
app.post('/webhooks/parseo', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['parseo-signature'];
const secret = process.env.PARSEO_WEBHOOK_SECRET; // e.g. "whsec_..."
if (!verifySignature(sig, req.body, secret)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.body);
// handle event...
res.json({ received: true });
});
function verifySignature(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(',').map(p => p.split('='))
);
const timestamp = parts.t;
const signature = parts.v1;
// Reject stale events (> 5 minutes)
if (Math.abs(Date.now() - Number(timestamp)) > 300_000) return false;
const signingKey = Buffer.from(secret.replace('whsec_', ''), 'base64url');
const payload = `${timestamp}.${rawBody}`;
const expected = crypto.createHmac('sha256', signingKey)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
PARSEO_WEBHOOK_SECRET = "whsec_..."
@app.post("/webhooks/parseo")
async def handle_webhook(request: Request):
sig_header = request.headers.get("parseo-signature", "")
raw_body = await request.body()
if not verify_signature(sig_header, raw_body, PARSEO_WEBHOOK_SECRET):
raise HTTPException(status_code=400, detail="Invalid signature")
event = await request.json()
# handle event...
return {"received": True}
def verify_signature(header: str, raw_body: bytes, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = parts.get("t", "")
signature = parts.get("v1", "")
if abs(time.time() * 1000 - int(timestamp)) > 300_000:
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
func verifyParseoSignature(r *http.Request, rawBody []byte, secret string) bool {
header := r.Header.Get("X-Parseo-Signature")
parts := make(map[string]string)
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
ts, _ := strconv.ParseInt(parts["t"], 10, 64)
if math.Abs(float64(time.Now().UnixMilli()-ts)) > 300_000 {
return false
}
payload := strconv.FormatInt(ts, 10) + "." + string(rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}Secret rotation
When you call POST /webhooks/:id/regenerate-secret, Parseo:
- Generates a new signing secret.
- Keeps the old secret active for a grace period (so in-flight deliveries signed with the old secret remain valid).
- During the grace period, the
X-Parseo-Signatureheader may contain twov1=values: one signed with the new secret, one with the old.
To handle rotation gracefully, verify against both values:
function verifyWithRotation(header, rawBody, currentSecret, previousSecret) {
// Try current secret first; fall back to previous during grace period
return (
verifySignature(header, rawBody, currentSecret) ||
(previousSecret && verifySignature(header, rawBody, previousSecret))
);
}Replay protection
Reject any event with a timestamp more than 5 minutes from your current clock. This prevents an attacker who captured a signed payload from replaying it later. Your server must be clock-synchronized (NTP).
