Verifying signatures
Your endpoint is public, so anything can POST to it. Verify the Curviate-Signature header on every delivery before acting.
You need the signing secret of the webhook you verify. It is returned in plaintext once, in the 201 response of creating the webhook, and never again (reads show only secret_prefix). Each webhook has its own secret, not an account-level key.
Two gates
| Gate | What it is | What it proves |
|---|---|---|
| 1. URL token (optional) | A high-entropy secret in your registered request_url (query parameter or path segment). Curviate POSTs to the exact URL, query string included. | Only that the caller knows the URL, nothing about the body. |
| 2. HMAC signature (always) | Curviate-Signature on every attempt: HMAC-SHA256 over the body, keyed with the webhook secret. | That the body is byte for byte what Curviate signed, and the sender holds your secret. |
Gate 1 drops scanner traffic cheaply, but the token lands in logs. Run gate 2 on every delivery before acting on the body.
What is signed
Curviate-Signature: t=1748430900,v1=3a2f1b4c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5at is the attempt's dispatch time in Unix seconds (a retry gets a fresh t). v1 is always 64 lowercase hex characters:
signed_payload = t + "." + <raw request body bytes>
v1 = HMAC_SHA256(secret, signed_payload) as lowercase hexTwo rules:
- Use the raw body, before any JSON parsing. Re-serialising changes key order and whitespace, breaking the digest. Express:
express.raw({ type: "application/json" }); Next.js route handlers:await req.text(); Hono:await c.req.text(). - Use the secret as text, exactly as returned. It is 64 hex characters; do not hex-decode it. The HMAC key is the UTF-8 bytes of the string.
The signature covers the body only, no URL or header. Content-Type, User-Agent and Curviate-Signature are the documented headers; anything a proxy or tracer adds is untrusted: never route, authorise or deduplicate on it.
The verifier
import { createHmac, timingSafeEqual } from "crypto";
/** A Curviate v1 MAC is always 64 lowercase hex characters (SHA-256). */
const HEX_64 = /^[0-9a-f]{64}$/i;
/**
* Verify a Curviate-Signature header.
*
* @param rawBody Raw request body as a UTF-8 string (do NOT parse JSON first)
* @param signatureHeader Value of the Curviate-Signature header (e.g. "t=1748430900,v1=abc123...")
* @param secret Your webhook signing secret (CURVIATE_WEBHOOK_SECRET)
* @returns true if the signature is valid and the timestamp is within 5 minutes.
* Never throws: every malformed or hostile input returns false.
*/
export function verifyCurviateSignature(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
// 1. Parse the header: t=<unix_seconds>,v1=<hex>
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=")),
);
const timestamp = parseInt(parts.t ?? "", 10);
const providedMac = parts.v1 ?? "";
if (!timestamp || !providedMac) return false;
// 2. Reject a malformed MAC BEFORE building any buffer.
// This check is not cosmetic. Buffer.from(x, "hex") silently truncates
// invalid input, and timingSafeEqual throws when the two buffers differ
// in length. Without this guard an unauthenticated caller crashes your
// handler by sending "v1=00".
if (!HEX_64.test(providedMac)) return false;
// 3. Replay-attack guard: reject deliveries older than 5 minutes
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
// 4. Recompute the expected MAC
// signed_payload = `${timestamp}.${rawBody}`
const signedPayload = `${timestamp}.${rawBody}`;
const expectedMac = createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest("hex");
// 5. Constant-time comparison (prevents timing attacks).
// Both buffers are now guaranteed to be 32 bytes.
return timingSafeEqual(
Buffer.from(expectedMac, "hex"),
Buffer.from(providedMac, "hex"),
);
}Compare in constant time, and check the MAC format before building a buffer: timingSafeEqual throws on a length mismatch, and Python's hmac.compare_digest raises TypeError on non-ASCII input.
Reject a delivery more than 5 minutes from your clock (SDK replayWindowSecs, CLI --max-age-secs, both default 300 seconds).
Prove it rejects a bad signature
A verifier only ever seen succeeding may accept everything. Run this once (no network call):
import { createHmac } from "crypto";
import { verifyCurviateSignature } from "./verify";
import assert from "assert";
const secret = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2";
// A delivery body in the exact shape Curviate sends.
const body = JSON.stringify({
id: "wdl_01J8Z3K9P0Q1R2S3T4V5W6X7Y8",
webhook_id: "wh_01J8Z3K9P0Q1R2S3T4V5W6X7Y8",
event: "message.received",
data: {
account_id: "acc_01J8Z3K9P0Q1R2S3T4V5W6X7Y8",
event: "message.received",
occurred_at: "2026-08-02T10:00:00.000Z",
text: "hello",
},
delivered_at: "2026-08-02T10:00:00.000Z",
});
const t = Math.floor(Date.now() / 1000);
const mac = createHmac("sha256", secret).update(`${t}.${body}`, "utf8").digest("hex");
const header = `t=${t},v1=${mac}`;
// 1. Genuine delivery: accepted.
assert.strictEqual(verifyCurviateSignature(body, header, secret), true);
// 2. One byte changed in the body, same signature: rejected.
const tampered = body.replace("hello", "hell0");
assert.strictEqual(verifyCurviateSignature(tampered, header, secret), false);
// 3. Signed with the wrong secret: rejected.
const forged = createHmac("sha256", "not-your-secret")
.update(`${t}.${body}`, "utf8")
.digest("hex");
assert.strictEqual(verifyCurviateSignature(body, `t=${t},v1=${forged}`, secret), false);
// 4. Malformed MAC: rejected, NOT thrown. A verifier that throws here lets an
// unauthenticated caller crash your handler with two characters.
assert.strictEqual(verifyCurviateSignature(body, `t=${t},v1=00`, secret), false);
assert.strictEqual(verifyCurviateSignature(body, `t=${t},v1=${"z".repeat(64)}`, secret), false);
assert.strictEqual(verifyCurviateSignature(body, `t=${t},v1=${mac.slice(0, 63)}`, secret), false);
console.log("verifier OK: accepts genuine, rejects tampered, forged and malformed");Python (FastAPI):
import hmac
import hashlib
import os
import re
import time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
CURVIATE_WEBHOOK_SECRET = os.environ["CURVIATE_WEBHOOK_SECRET"].encode()
HEX_64 = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE)
@app.post("/webhooks/curviate")
async def handle_webhook(request: Request):
# Read raw bytes before anything else
body = await request.body()
sig = request.headers.get("curviate-signature", "")
# Parse header: t=<ts>,v1=<mac>
parts = dict(p.split("=", 1) for p in sig.split(",") if "=" in p)
provided = parts.get("v1", "")
# Reject a malformed MAC first. compare_digest raises TypeError on a
# non-ASCII string, so this check keeps hostile input from crashing you.
if not HEX_64.match(provided):
raise HTTPException(status_code=400, detail="Invalid signature")
# A non-numeric t would raise ValueError, so parse it defensively
try:
ts = int(parts.get("t", ""))
except ValueError:
raise HTTPException(status_code=400, detail="Invalid timestamp")
# Replay guard
if abs(time.time() - ts) > 300:
raise HTTPException(status_code=400, detail="Invalid timestamp")
# Verify MAC
signed_payload = f"{ts}.{body.decode('utf-8')}".encode()
expected = hmac.new(CURVIATE_WEBHOOK_SECRET, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, provided):
raise HTTPException(status_code=400, detail="Invalid signature")
event = await request.json()
print(f"Verified event: {event['event']} | delivery: {event['id']}")
# Acknowledge, then process asynchronously
return {"received": True}SDK
@curviate/sdk exports constructEvent: header parse, constant-time HMAC check, replay window and JSON parse in one async call. Runs on Web Crypto (Node 18 or newer, Workers, Edge).
import { constructEvent } from "@curviate/sdk";
async function handleWebhook(rawBody: string, req: Request) {
const event = await constructEvent(
rawBody,
req.headers["curviate-signature"],
process.env.CURVIATE_WEBHOOK_SECRET!,
);
// event is typed as CurviateEvent, discriminated on event.event
console.log("Verified event:", event.event);
}Branch on event.event, not event.type (@curviate/sdk 0.19.0 or later). WebhookSignatureError carries a reason:
| reason | Meaning | Look at |
|---|---|---|
malformed_header | No t=, no v1=, or a non-numeric timestamp. | Header parsing. Do not rotate the secret. |
invalid_signature | The MAC does not match. | The secret, and raw-body handling. |
replay_detected | Signature valid, timestamp outside the window. | Your clock (NTP), or widen replayWindowSecs. |
malformed_payload | Signature valid, body is not a Curviate event. | What else POSTs to that URL. Do not rotate the secret. |
Check a captured delivery from the command line
curviate webhook verify checks a delivery offline. --body takes inline JSON, a file path, or - for stdin. Exit 0 means verified, 2 not. For a delivery older than 5 minutes, widen --max-age-secs; the signature must still match.
curviate webhook verify \
--secret "$CURVIATE_WEBHOOK_SECRET" \
--header "t=1748430900,v1=3a2f1b4c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a" \
--body body.json \
--max-age-secs 999999Custom delivery headers
The headers field on webhook create and update adds { key, value } pairs to every delivery:
curl -sS -X POST https://api.curviate.com/v1/webhooks \
-H "Authorization: Bearer $CURVIATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "messaging",
"request_url": "https://hooks.example.com/hooks/curviate",
"account_ids": ["'"$CURVIATE_ACCOUNT_ID"'"],
"headers": [{ "key": "X-Environment", "value": "production" }]
}'Values are encrypted at rest; reads return the name and an 8-character value_fingerprint (equal fingerprints mean equal values). Custom headers are unsigned and prove nothing about the sender. Names such as Content-Type, User-Agent, Host, and anything starting Curviate- or Sec- are refused. Up to 10 headers, each value up to 1024 printable ASCII characters, 4096 bytes in total.
Rotating the secrets
URL token: PATCH /v1/webhooks/{id} replaces request_url and keeps the signing secret. Accept both tokens until in-flight retries drain.
curl -X PATCH "https://api.curviate.com/v1/webhooks/wh_01J8Z3K9P0Q1R2S3T4V5W6X7Y8" \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"request_url": "https://hooks.example.com/hooks/curviate?token=NEW_TOKEN"}'Signing secret: there is no rotate endpoint, and PATCH preserves the secret, so replace the webhook:
- Create a second webhook with the same
source,events,account_idsandrequest_url, and capture itssecret. - Accept both secrets: try the new one, and on
invalid_signaturethe old one. - Delete the first webhook.
- Drop the old secret.
While both exist, each event arrives twice. Deduplicate on event, data.account_id and data.occurred_at, never the top-level id, which is new on every attempt.
Rejecting counts as a failed delivery
No 2xx within 10 seconds is a failed attempt. After 5 attempts (30 seconds, 5 minutes, 30 minutes and 2 hours apart) the webhook flips to health: degraded. A verifier that rejects everything (usually a JSON body parser running first) disables your integration about two and a half hours later. Return 200 right after verification and process asynchronously.