Verifying signatures
Every Curviate delivery includes a Curviate-Signature header.
Verify it before processing any event; an unverified webhook can be forged.
You need the signing secret of the webhook whose deliveries you
are verifying. It is returned in plaintext exactly once, in the
201 response of
creating the webhook, and is never
retrievable again. Creating a webhook requires a connected LinkedIn account, so
if you have not connected one yet, start with
Authentication and accounts.
Nothing on this page makes a network call; the code below runs offline against a
secret you already hold.
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"),
);
}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. Rejecting a delivery counts as a failed attempt,
so a wrong secret produces four more retries over roughly 2h36m and then flips
the webhook to degraded. See
Delivery and retries.
Prove your verifier rejects a bad signature
A verifier you have only ever seen succeed is indistinguishable from one that
accepts everything. Run this once against your own implementation. It signs a
body, then feeds the verifier three inputs it must reject: a tampered payload, a
wrong secret, and a malformed v1. All four assertions must hold.
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");timingSafeEqual throws
ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH when its two buffers differ in
length, and Python's hmac.compare_digest raises
TypeError on a non-ASCII string. Both are reachable from
unauthenticated remote input, so a verifier that omits the format check does
not merely return the wrong answer, it takes the handler down. Check the MAC
format first, or wrap the comparison in a try/catch that returns false.
Replay-attack protection
The t field lets you detect and reject replayed deliveries. Curviate stamps a
fresh dispatch timestamp on every attempt; the tolerance window is entirely your
choice as the receiver. 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" });
}The SDK exposes this window as replayWindowSecs and the CLI as
--max-age-secs; both default to 300 seconds.
Verify from the command line
curviate webhook verify checks a signature offline, with no network call. It is
the fastest way to answer "is this delivery genuine, or is my handler wrong?"
curviate webhook verify \
--secret "$CURVIATE_WEBHOOK_SECRET" \
--header "t=1748430900,v1=3a2f1b4c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a" \
--body '{"id":"wdl_01J8Z3K9P0Q1R2S3T4V5W6X7Y8","event":"message.received"}'Exit code 0 means the signature verified. Exit code 2 means it did not.
SDK shortcut
@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: { type, data }
console.log("Verified event:", event.type);
}WebhookSignatureError carries a reason field: invalid_signature,
replay_detected, malformed_header, or malformed_payload.
Deliveries carry the event name in event, and the typed union
narrows on that field. On @curviate/sdk 0.18.x and earlier the
union narrowed on type, a field no delivery has ever contained,
so constructEvent threw on every genuine delivery. Upgrade to
0.19.0 or later; event.type then becomes a compile error that
points at each site needing the one-word change. A reason of
malformed_payload means the signature was valid
and the body is not a Curviate event, so do not rotate the secret.
Full handler: TypeScript (Express)
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";
const app = express();
const HEX_64 = /^[0-9a-f]{64}$/i;
// 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 ?? "";
// Reject a malformed MAC before touching Buffer or timingSafeEqual
if (!HEX_64.test(provided)) {
return res.status(400).json({ error: "Invalid signature" });
}
// 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 {
// Belt and braces: buffer length mismatch means invalid
valid = false;
}
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 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}Rotating your signing secret
There is no rotate endpoint. PATCH /v1/webhooks/{id} deliberately preserves the
secret, so rotation means create a second webhook, then delete the first:
- Create a new webhook pointing at the same URL and capture its
secret. - Accept both secrets in your verifier for the length of the cutover: try the new secret, and on failure try the old one.
- Delete the old webhook.
- Drop the old secret from your verifier.
Deliveries already in flight are signed with whichever secret their webhook owns,
so without the dual-accept window in step 2 those deliveries fail verification,
retry, and can push that webhook to degraded.
Errors you may hit
| Symptom | Cause | Fix |
|---|---|---|
Verifier returns false on every delivery | The body was parsed and re-serialised before the MAC check, so the bytes no longer match what was signed. | Read the raw body first (express.raw, await request.body()). |
WebhookSignatureError, reason invalid_signature | Wrong secret, or the body changed in transit (a proxy re-encoded it). | Confirm the secret belongs to this webhook id, and that no middleware rewrites the body. |
WebhookSignatureError, reason replay_detected | The delivery is older than the replay window, usually clock skew on the receiver. | Sync your clock via NTP, or widen replayWindowSecs. |
WebhookSignatureError, reason malformed_header | The signature header itself did not parse. Not a secret problem. | Do not rotate the secret. Check how you read the header. |
WebhookSignatureError, reason malformed_payload | The signature verified, but the body is not a Curviate event. | Do not rotate the secret. Check what else is POSTing to that URL. |
Handler crashes, ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH | A malformed v1 reached timingSafeEqual. | Add the 64-hex format check shown above. |
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, and so on). Slow handlers time out and trigger a retry.
Next steps
- Webhook authentication: the two-gate model, the exact signed bytes, secret rotation, and a runnable receiver with a negative test.
- Delivery and retries: what happens after you reject a delivery, and what
degradedmeans. - Event reference: every event you can subscribe to.
- Webhooks overview: creating a webhook and capturing its secret.