Parseo

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 delivery
  • v1 — HMAC-SHA256 signature (hex-encoded)

To verify the signature:

  1. Extract t and v1 from the header.
  2. Construct the signed payload: "{t}.{rawRequestBody}" (the literal timestamp string, a dot, then the raw bytes of the request body — before any JSON parsing).
  3. Compute HMAC-SHA256(signingSecret, signedPayload) and hex-encode the result.
  4. Compare your computed signature with v1 using a constant-time comparison function.
  5. 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:

  1. Generates a new signing secret.
  2. Keeps the old secret active for a grace period (so in-flight deliveries signed with the old secret remain valid).
  3. During the grace period, the X-Parseo-Signature header may contain two v1= 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).

On this page