Webhooks

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

GateWhat it isWhat 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=3a2f1b4c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a

t 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 hex

Two rules:

  1. 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().
  2. 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:

reasonMeaningLook at
malformed_headerNo t=, no v1=, or a non-numeric timestamp.Header parsing. Do not rotate the secret.
invalid_signatureThe MAC does not match.The secret, and raw-body handling.
replay_detectedSignature valid, timestamp outside the window.Your clock (NTP), or widen replayWindowSecs.
malformed_payloadSignature 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 999999

Custom 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:

  1. Create a second webhook with the same source, events, account_ids and request_url, and capture its secret.
  2. Accept both secrets: try the new one, and on invalid_signature the old one.
  3. Delete the first webhook.
  4. 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.

Next steps