Verifying signatures
Every Curviate delivery includes a Curviate-Signature header.
Verify it before processing any event — an unverified webhook can be forged.
import { createHmac, timingSafeEqual } from "crypto";
/**
* 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
*/
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. Replay-attack guard — reject deliveries older than 5 minutes
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
// 3. Recompute the expected MAC
// signed_payload = `${timestamp}.${rawBody}`
const signedPayload = `${timestamp}.${rawBody}`;
const expectedMac = createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest("hex");
// 4. Constant-time comparison (prevents timing attacks)
return timingSafeEqual(
Buffer.from(expectedMac, "hex"),
Buffer.from(providedMac, "hex"),
);
}Every delivery is signed with that webhook's own secret — the
plaintext value returned exactly once in the create (201) response,
not a shared or account-level key. Store it as your signing key at create time; it
is never retrievable again (reads show only secret_prefix). If you
delete and recreate a webhook, verify against the new secret.
The Curviate-Signature header
Every HTTP POST delivery from Curviate includes a Curviate-Signature header in
the following format:
Curviate-Signature: t=1748430900,v1=3a2f1b4c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a| Field | Description |
|---|---|
t | Unix timestamp (seconds) at the moment Curviate dispatched the delivery. |
v1 | HMAC-SHA256 of |
How the MAC is computed
Curviate constructs the signed payload by concatenating the timestamp, a literal dot, and the raw JSON body — then signs it with your secret:
const timestamp = Math.floor(Date.now() / 1000); // Unix seconds
const signedPayload = `${timestamp}.${JSON.stringify(body)}`;
const mac = HMAC-SHA256(secret, signedPayload).hex();
// Header sent: Curviate-Signature: t=<timestamp>,v1=<mac>Always verify the signature before processing a webhook.
Reject any request that fails verification with a 400 response.
Never parse the JSON body before running the MAC check — the raw bytes must
match what Curviate signed.
Replay-attack protection
The t field lets you detect and reject replayed deliveries. Curviate embeds the
dispatch timestamp and refuses to re-deliver events older than the delivery window.
On your side, reject requests where the timestamp is more than 5 minutes from
the current time:
const timestamp = parseInt(parts.t, 10);
const ageSeconds = Math.abs(Date.now() / 1000 - timestamp);
if (ageSeconds > 300) {
// Reject — too old or clock skew exceeds tolerance
return res.status(400).json({ error: "Timestamp out of tolerance" });
}SDK shortcut
The @curviate/sdk exports constructEvent which performs signature verification,
replay-window enforcement, and payload parsing in one call — throwing
WebhookSignatureError on any failure:
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
console.log("Verified event:", event.event, "| delivery:", event.id);
}Use constructEvent as a drop-in replacement for the manual verification loop
whenever you are already using the SDK.
Full handler — TypeScript (Express)
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";
const app = express();
// IMPORTANT: use express.raw — you must read the body before JSON parsing
app.post(
"/webhooks/curviate",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["curviate-signature"] as string ?? "";
const secret = process.env.CURVIATE_WEBHOOK_SECRET!;
const rawBody = req.body.toString("utf8");
// Parse header
const parts = Object.fromEntries(sig.split(",").map((p) => p.split("=")));
const timestamp = parseInt(parts.t ?? "", 10);
const provided = parts.v1 ?? "";
// Replay guard
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) {
return res.status(400).json({ error: "Invalid timestamp" });
}
// Verify MAC
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest("hex");
let valid = false;
try {
valid = timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(provided, "hex"),
);
} catch {
// Buffer length mismatch → invalid
}
if (!valid) return res.status(400).json({ error: "Invalid signature" });
const event = JSON.parse(rawBody);
console.log("Verified event:", event.event, "| delivery:", event.id);
// Acknowledge immediately — do heavy processing in the background
res.status(200).json({ received: true });
},
);Full handler — Python (FastAPI)
import hmac
import hashlib
import os
import time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
CURVIATE_WEBHOOK_SECRET = os.environ["CURVIATE_WEBHOOK_SECRET"].encode()
@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)
ts = int(parts.get("t", 0))
provided = parts.get("v1", "")
# Replay guard
if not ts or 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 — process asynchronously
return {"received": True}Curviate considers a delivery successful on any 2xx response
within 10 seconds. Return 200 immediately after
signature verification and process the event asynchronously (queue, background
worker, etc.). Slow handlers will time out and trigger a retry.