Webhook authentication
Your webhook endpoint is a public URL. Anything on the internet can POST to it, and a forged delivery that your handler trusts becomes a message sent, a lead created, or a record overwritten. This page is the single place that teaches how to tell a genuine Curviate delivery from a forgery, and how to prove your check actually discriminates.
You need three things: an API key, at least one connected LinkedIn account
(account_ids is required on create, so there is no account-free
path), and the signing secret of the webhook you are
verifying. The secret is returned in plaintext exactly once, in the
201 response of
creating the webhook, and is never
retrievable again. If you have not connected an account yet, start with
Authentication and accounts.
The code on this page needs Node 18 or newer and has no dependencies.
The two gates
A Curviate delivery passes through two independent checks on your side. They answer different questions, and only one of them is authentication.
| Gate | What it is | Active when | What it proves |
|---|---|---|---|
| Gate 1: URL token | A high-entropy secret you embed in the | Only if you choose to use one. It is your convention, not a Curviate field. | That the caller knows a URL you have not published. Nothing about the body. |
| Gate 2: HMAC signature | The | Always. Every delivery Curviate sends carries this header, on every attempt. | That the body is byte-for-byte what Curviate signed, and that the sender holds your secret. |
Gate 1 is a doormat, gate 2 is the lock. Gate 1 is worth having because it lets you drop scanner traffic before you read a body or compute a digest, and because a leaked-and-rotated URL is cheaper to fix than a leaked secret. It is never sufficient on its own: the token travels in the URL, so it lands in proxy logs, access logs, and error trackers, and it says nothing about whether the body was modified in flight.
Run gate 2 on every delivery, before your handler acts on anything in the body. A receiver that checks only the URL token accepts any payload from anyone who has ever seen the URL, including your own logs.
Custom delivery headers
The headers field on webhook create and update takes a list of { key, value }
pairs, and Curviate adds every one of them to each delivery POST for that
webhook. It is useful for routing and tagging: telling your receiver which
environment a delivery belongs to, or which internal service should handle it.
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" }]
}'A custom header value is treated as a credential, like the signing secret on
the same webhook: it is encrypted at rest and no read returns it. Reading a
webhook gives you the header name plus a masked value as
value_prefix. A value of 24 characters or more shows its first 8
characters and an ellipsis; anything shorter shows a fixed
********, because a preview of a short value would be the whole
value. The header name is what tells you which credential is configured. Only
the delivery itself carries the full value.
A custom header is never covered by the signature, so nothing carried in one proves anything about the delivery or its body.
Some names are refused at registration, because a custom header must not be able
to impersonate one that carries meaning, and because a name the HTTP client
itself owns would not arrive with the value you set. They include
Content-Type, User-Agent, Host, anything beginning with Curviate- or
Sec-, and the hop-by-hop headers; the error names the header and the reason.
Values must be printable ASCII with no line breaks. A webhook may carry up to 10
custom headers, each value up to 1024 characters, 4096 bytes in total.
What Curviate signs
Curviate signs the delivery body, and only the delivery body. Not the URL, not
the method, not any other header. Here is a delivery as it arrived at a
receiver, with two unsigned extras riding along on top of the documented three.
Sensitive values in the extras are shown as REDACTED.
POST /hooks/curviate?token=0210239dddfbecae75142fc6c9b46fc9 HTTP/1.1
Content-Type: application/json
User-Agent: Curviate-Webhook/1.0
Curviate-Signature: t=1785766573,v1=da3c5b3d9b5aad8d0345ccb5c2e6a184310bdc97ef12052158c5555f6ac5d4d4
Sentry-Trace: 4842bc8728d04c77b6d3996a70686fcf-a271c0579c3245e2
Baggage: sentry-environment=development,sentry-release=local,sentry-public_key=REDACTED,sentry-trace_id=4842bc8728d04c77b6d3996a70686fcf,sentry-org_id=REDACTED,sentry-sample_rand=0.7284398526492726
{"id":"wdl_01KZ3ZPXR4XVD1WCGHNHT0M61B","webhook_id":"wh_01KZ3ZH1EJCY6PC7KWC7BTBYPW","event":"message.received","data":{"account_id":"acc_01KXR3N1NCR435P7FG3230VS6A","event":"message.received","occurred_at":"2026-08-03T14:18:00.000Z","text":"Thanks for the intro, happy to chat Thursday.","sender":{"name":"Sophie Keller","profile_url":"https://www.linkedin.com/in/sophie-keller-example","provider_id":"ACoAAFakeSophie"},"chat_id":"2-YWJjZGVmZ2hpamtsbW5vcA==","message_id":"msg_01KZ3ZMFNAL7WM0DDPJ29PG47","attachments":[]},"delivered_at":"2026-08-03T14:16:13.830Z"}Three headers are the documented contract: Content-Type, User-Agent, and
Curviate-Signature. Any custom header you configured on the webhook arrives
too, exactly as you set it. Everything else is noise you did not ask for and
must not build on. Tracing instrumentation on a sender, an egress proxy, a
service mesh, a CDN or your own HTTP stack can each add headers of their own,
exactly like the Sentry-Trace and Baggage pair above, on top of
transport-level headers not shown here (Host, Connection, Accept-Encoding,
Sec-Fetch-Mode and similar). None of those extras are part of the documented
contract, and nothing outside the body is covered by the signature.
The signature covers the body and nothing else, so every header, including the ones you configured yourself, can be added, removed or rewritten in flight without invalidating it. A configured header tells you what you asked Curviate to send, never who sent it. Treat every other extra header as untrusted input: log it if it helps you, and never route, authorise or deduplicate on it.
The header format
Curviate-Signature: t=<unix-seconds>,v1=<hex>| Field | Value |
|---|---|
t | Unix timestamp in seconds at the moment Curviate
dispatched this attempt. A retry carries a fresh |
v1 | The MAC, hex-encoded. Always exactly 64 lowercase hex characters (HMAC-SHA256). |
The exact signed bytes
The signed payload is the value of t, then a single ASCII period, then the raw
request body exactly as received:
signed_payload = "1785766573" + "." + <raw request body bytes>
v1 = HMAC_SHA256(secret, signed_payload) as lowercase hexTwo details decide whether your verifier works:
- Use the raw body, before any JSON parsing. The signature covers bytes,
not a JSON value. Parsing the body and re-serialising it changes key order,
whitespace, and unicode escaping, and the digest stops matching. This is the
single most common cause of a verifier that fails on every delivery. In
Express, that means
express.raw({ type: "application/json" })on the webhook route and nothing else; in Next.js route handlers,await req.text(); in Hono,await c.req.text(). - Use the secret as text, exactly as returned. The secret happens to be 64 hex characters, which tempts people to hex-decode it into 32 bytes. Do not. The HMAC key is the UTF-8 bytes of the string itself.
What Curviate does not forward
Every delivery you receive is built and signed by Curviate. Nothing from upstream passes through untouched:
- No upstream envelope, headers, or authentication material. There is no second signature to check and no upstream header worth trusting. The only authentication on the delivery is the one described on this page.
data.account_id,data.event, anddata.occurred_atare set by Curviate, after the event payload is assembled, so a colliding key in the upstream payload cannot override them. They are the fields you can rely on.- Internal upstream identifiers are stripped before the body is built, so
the ids you see are Curviate ids (
acc_,wh_,wdl_) or LinkedIn-native ones you can use directly.
LinkedIn content does pass through: a messaging delivery carries the real
message text and sender fields, and a connection delivery carries the new
contact's profile fields. That content is in the signed bytes, which is why the
raw-body rule matters for real payloads and not just for tidy examples. See
Messaging events for the per-event shapes.
The verifier
Zero dependencies, Node 18 or newer. Save as verify.mjs.
// verify.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
/** A Curviate v1 MAC is always 64 lowercase hex characters (SHA-256). */
const HEX_64 = /^[0-9a-f]{64}$/;
/**
* Verify a Curviate-Signature header against the raw request body.
*
* @param {string|Buffer} rawBody The exact bytes received, before any JSON parsing.
* @param {string} header Value of the Curviate-Signature header.
* @param {string} secret The webhook signing secret, exactly as returned by the create call.
* @param {number} maxAgeSecs Replay window. 300 matches the SDK and CLI defaults.
* @returns {{ok: true, event: object} | {ok: false, reason: string}}
*/
export function verifyCurviateSignature(rawBody, header, secret, maxAgeSecs = 300) {
// 1. Parse "t=<unix-seconds>,v1=<hex>". Anything else is unusable.
let t, v1;
for (const part of String(header).split(",")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === "t") t = value;
else if (key === "v1") v1 = value;
}
if (t === undefined || v1 === undefined) return { ok: false, reason: "malformed_header" };
const timestamp = Number(t);
if (!Number.isFinite(timestamp)) return { ok: false, reason: "malformed_header" };
// 2. Reject a malformed MAC BEFORE building a buffer from it. This is not
// cosmetic: Buffer.from(x, "hex") silently truncates invalid input, and
// timingSafeEqual throws when the two buffers differ in length. Skip this
// and an unauthenticated caller can crash your handler with "v1=00".
if (!HEX_64.test(v1)) return { ok: false, reason: "invalid_signature" };
// 3. Recompute the MAC over the exact signed bytes: `${t}.${rawBody}`.
// rawBody is the body as received. The secret is used as-is, as text; it is
// already hex on the wire, but it is never hex-decoded before use.
const body = Buffer.isBuffer(rawBody) ? rawBody.toString("utf8") : rawBody;
const expected = createHmac("sha256", secret).update(`${t}.${body}`, "utf8").digest("hex");
// 4. Constant-time compare. Both buffers are 32 bytes by construction.
const match = timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v1, "hex"));
if (!match) return { ok: false, reason: "invalid_signature" };
// 5. Only once the MAC matched is the timestamp worth trusting. Reject stale
// or far-future deliveries; the window is yours to choose.
if (Math.abs(Date.now() / 1000 - timestamp) > maxAgeSecs) {
return { ok: false, reason: "replay_detected" };
}
// 6. Parse last. A parse failure here is a body problem, not a secret problem.
try {
return { ok: true, event: JSON.parse(body) };
} catch {
return { ok: false, reason: "malformed_payload" };
}
}Why the comparison is written that way
Compare digests in constant time. expected === v1 leaks how many leading
characters matched through its own runtime, which is enough to reconstruct a
valid MAC one character at a time. timingSafeEqual compares the full width
regardless of where the first difference is.
Check the MAC format before you build a buffer. timingSafeEqual throws
ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH when its arguments differ in length, and
Buffer.from("zz", "hex") returns an empty buffer rather than an error. Both are
reachable from unauthenticated remote input, so a verifier without the format
check does not merely return the wrong answer, it takes the handler down with
two characters. Python's hmac.compare_digest has the same edge: it raises
TypeError on a non-ASCII string.
Check the signature before the timestamp. The timestamp is attacker-supplied
until the MAC proves otherwise, so rejecting on age first tells an attacker which
of their two guesses was wrong. It also produces a clearer diagnosis: a stale
delivery reports replay_detected only when it was genuinely from Curviate.
A complete receiver
Save as receiver.mjs next to verify.mjs and run it. It implements both gates.
// receiver.mjs - run with: node receiver.mjs
import { createServer } from "node:http";
import { timingSafeEqual } from "node:crypto";
import { writeFileSync } from "node:fs";
import { verifyCurviateSignature } from "./verify.mjs";
const SECRET = process.env.CURVIATE_WEBHOOK_SECRET;
const URL_TOKEN = process.env.CURVIATE_WEBHOOK_URL_TOKEN; // gate 1, optional
const PORT = Number(process.env.PORT ?? 8787);
if (!SECRET) {
console.error("Set CURVIATE_WEBHOOK_SECRET to the secret from the create call.");
process.exit(1);
}
/** Gate 1: compare the URL token in constant time, so it cannot be guessed byte by byte. */
function urlTokenMatches(url) {
if (!URL_TOKEN) return true; // gate 1 not in use
const given = new URL(url, "http://localhost").searchParams.get("token") ?? "";
const a = Buffer.from(given);
const b = Buffer.from(URL_TOKEN);
return a.length === b.length && timingSafeEqual(a, b);
}
createServer((req, res) => {
if (req.method !== "POST") {
res.writeHead(405).end();
return;
}
// Gate 1 runs before the body is read, so unauthenticated scanners cost nothing.
if (!urlTokenMatches(req.url)) {
console.log("gate 1 rejected: url token mismatch");
res.writeHead(404).end();
return;
}
// Collect the RAW bytes. Do not use a JSON body parser on this route: parsing
// and re-serialising changes the bytes, and the signature covers the bytes.
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const rawBody = Buffer.concat(chunks);
const header = req.headers["curviate-signature"] ?? "";
// Gate 2.
const result = verifyCurviateSignature(rawBody, header, SECRET);
if (!result.ok) {
console.log(`gate 2 rejected: ${result.reason}`);
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: result.reason }));
return;
}
const event = result.event;
console.log(`verified ${event.event} | delivery ${event.id} | account ${event.data.account_id}`);
// Capture the exact bytes and header of a genuine delivery, so the tamper
// test in "What a verification failure looks like" has something to
// replay. Overwritten on every verified call.
writeFileSync("body.json", rawBody);
console.log("captured to body.json - copy the line below to replay it:");
console.log(`export SIG='${header}'`);
// Acknowledge within 10 seconds, then do the real work asynchronously.
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ received: true }));
});
}).listen(PORT, () => console.log(`listening on http://localhost:${PORT}/hooks/curviate`));Start it, expose it over HTTPS with a tunnel of your choice, and register that URL with the token appended:
export CURVIATE_WEBHOOK_URL_TOKEN=$(openssl rand -hex 16)
export CURVIATE_WEBHOOK_SECRET=<the secret from your create response>
node receiver.mjscurl -X POST https://api.curviate.com/v1/webhooks \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Authenticated receiver\",
\"source\": \"messaging\",
\"request_url\": \"https://hooks.example.com/hooks/curviate?token=$CURVIATE_WEBHOOK_URL_TOKEN\",
\"account_ids\": [\"acc_YOUR_ACCOUNT_ID\"],
\"events\": [\"message.received\"]
}"When the first real delivery lands, the receiver prints:
listening on http://localhost:8787/hooks/curviate
verified message.received | delivery wdl_01KZ3ZKRBCDR66NFZFCWAKPPXA | account acc_01KXR3N1NCR435P7FG3230VS6A
captured to body.json - copy the line below to replay it:
export SIG='t=1785766469,v1=e9a053c608228f7b6dc0e43b36e0a2afaed4e7cfb7fe95eac6959feeccb36cb4'Keep that terminal open: body.json now sits next to receiver.mjs, and the
export SIG=... line is exactly what the next section needs.
Prove your verifier discriminates
A verifier you have only ever seen succeed is indistinguishable from one that
returns true unconditionally. The success path proves nothing on its own, so
run the failures too. Save as prove.mjs; it makes no network calls.
// prove.mjs - run with: node prove.mjs (no network, no dependencies)
import { createHmac } from "node:crypto";
import { verifyCurviateSignature } from "./verify.mjs";
const secret = "9dfe5cc9992c5d07f846840f50f9c0569cdf5fb6aa57da00cb0b198ca8133472";
// A delivery body in the exact shape Curviate sends.
const body = JSON.stringify({
id: "wdl_01KZ3R0QNP0MTKX77PST2C8YTB",
webhook_id: "wh_01KZ3R0Q5F0ED7TKA6DK1SP3SJ",
event: "message.received",
data: {
account_id: "acc_01K1S9V0X2Y3Z4A5B6C7D8E9F0",
event: "message.received",
occurred_at: "2026-08-03T09:14:02.000Z",
text: "Thanks for the intro, happy to chat Thursday.",
},
delivered_at: "2026-08-03T12:01:46.679Z",
});
// Sign it the way Curviate does: HMAC-SHA256 over `${t}.${body}`.
const t = Math.floor(Date.now() / 1000);
const mac = createHmac("sha256", secret).update(`${t}.${body}`, "utf8").digest("hex");
const header = `t=${t},v1=${mac}`;
const cases = [
["genuine delivery", body, header, secret],
["tampered body (one character)", body.replace("Thursday", "Tuesday!"), header, secret],
["signed with the wrong secret", body, `t=${t},v1=${createHmac("sha256", "wrong-secret").update(`${t}.${body}`, "utf8").digest("hex")}`, secret],
["malformed MAC", body, `t=${t},v1=00`, secret],
["header with no v1", body, `t=${t}`, secret],
["stale timestamp", body, `t=${t - 3600},v1=${createHmac("sha256", secret).update(`${t - 3600}.${body}`, "utf8").digest("hex")}`, secret],
];
let failures = 0;
for (const [label, b, h, s] of cases) {
const result = verifyCurviateSignature(b, h, s);
const shouldAccept = label === "genuine delivery";
const correct = result.ok === shouldAccept;
if (!correct) failures++;
console.log(
`${correct ? "PASS" : "FAIL"} ${label.padEnd(30)} -> ${result.ok ? "accepted" : `rejected (${result.reason})`}`,
);
}
console.log(
failures === 0
? "\nThe verifier discriminates: it accepts the genuine delivery and rejects every forgery."
: `\n${failures} case(s) behaved incorrectly. Do not ship this verifier.`,
);
process.exit(failures === 0 ? 0 : 1);Actual output:
PASS genuine delivery -> accepted
PASS tampered body (one character) -> rejected (invalid_signature)
PASS signed with the wrong secret -> rejected (invalid_signature)
PASS malformed MAC -> rejected (invalid_signature)
PASS header with no v1 -> rejected (malformed_header)
PASS stale timestamp -> rejected (replay_detected)
The verifier discriminates: it accepts the genuine delivery and rejects every forgery.Exit code 0. If any line reads FAIL, the verifier accepts forgeries; do not
deploy it.
What a verification failure looks like
The receiver above already did the capture for you: body.json sits next to
it, and it printed an export SIG=... line. Run that export (or paste the
line the receiver printed for your own delivery), then replay the same body
against your running receiver with one character changed. This is the check
worth doing before you go live, because it is the only one that tells you the
gates are wired to something.
# From the receiver's own output (or paste yours).
export SIG='t=1785766469,v1=e9a053c608228f7b6dc0e43b36e0a2afaed4e7cfb7fe95eac6959feeccb36cb4'
# Genuine: correct URL token, untouched body, original header.
echo "=== genuine ==="
curl -s -w "\nHTTP %{http_code}\n" \
-X POST "http://localhost:8787/hooks/curviate?token=$CURVIATE_WEBHOOK_URL_TOKEN" \
-H "Content-Type: application/json" \
-H "Curviate-Signature: $SIG" \
--data-binary @body.json
# Tampered: same header, one word changed in the body.
sed 's/Thursday/Tuesday!/' body.json > body-tampered.json
echo "=== tampered ==="
curl -s -w "\nHTTP %{http_code}\n" \
-X POST "http://localhost:8787/hooks/curviate?token=$CURVIATE_WEBHOOK_URL_TOKEN" \
-H "Content-Type: application/json" \
-H "Curviate-Signature: $SIG" \
--data-binary @body-tampered.json
# Wrong URL token: rejected at gate 1, before the body is read.
echo "=== wrong url token ==="
curl -s -w "\nHTTP %{http_code}\n" \
-X POST "http://localhost:8787/hooks/curviate?token=deadbeef" \
-H "Content-Type: application/json" \
-H "Curviate-Signature: $SIG" \
--data-binary @body.jsonWhat the client sees:
=== genuine ===
{"received":true}
HTTP 200
=== tampered ===
{"error":"invalid_signature"}
HTTP 400
=== wrong url token ===
HTTP 404And what the receiver logs:
verified message.received | delivery wdl_01KZ3ZKRBCDR66NFZFCWAKPPXA | account acc_01KXR3N1NCR435P7FG3230VS6A
captured to body.json - copy the line below to replay it:
export SIG='t=1785766469,v1=e9a053c608228f7b6dc0e43b36e0a2afaed4e7cfb7fe95eac6959feeccb36cb4'
gate 2 rejected: invalid_signature
gate 1 rejected: url token mismatchOne word changed in a 556-byte body flips the digest completely. There is no partial match and no near miss.
Curviate treats any response that is not 2xx within 10 seconds
as a failed attempt. There are 5 attempts in total, spaced 30 seconds, 5
minutes, 30 minutes and 2 hours apart; after the fifth the webhook flips to
health: degraded. So a verifier that rejects everything (the
classic symptom of a JSON body parser on the route) does not fail quietly:
it disables your integration about two and a half hours later. See
Delivery and retries.
Using the SDK instead
@curviate/sdk ships constructEvent, which does the header parse, the
constant-time HMAC check, the replay-window check, and the JSON parse in one
call. It runs on Web Crypto, so it works in Node 18 or newer, Cloudflare Workers,
and Vercel Edge, and it is always async.
import { constructEvent, WebhookSignatureError } from "@curviate/sdk";
app.post("/hooks/curviate", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await constructEvent(
req.body, // the raw Buffer, not a parsed object
req.headers["curviate-signature"],
process.env.CURVIATE_WEBHOOK_SECRET,
);
} catch (err) {
if (err instanceof WebhookSignatureError) {
console.log("rejected:", err.reason);
return res.sendStatus(400);
}
throw err;
}
if (event.event === "message.received") {
console.log(event.data.text);
}
res.sendStatus(200);
});Run against the same genuine and tampered bodies as above, on @curviate/sdk
0.19.0:
=== genuine delivery ===
event.event = message.received
event.id = wdl_01KZ3ZKRBCDR66NFZFCWAKPPXA
event.data.account_id = acc_01KXR3N1NCR435P7FG3230VS6A
=== tampered body ===
WebhookSignatureError: reason=invalid_signature
message: Webhook signature does not match. Verify your signing secret.Branch on event.event, not event.type. 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, which no delivery has ever contained;
upgrading to 0.19.0 or later is the fix, and event.type becomes
a compile error that points at each site needing the one-word change.
WebhookSignatureError carries a reason you can branch on:
| reason | Meaning | What to look at |
|---|---|---|
malformed_header | The header itself is unusable: no | How you read the header. Do not rotate the secret. |
invalid_signature | The header parsed, the MAC does not match. | The secret, and whether you passed the raw bytes rather than a re-serialised object. |
replay_detected | Signature valid, timestamp outside the replay window. | Your clock (NTP), or widen |
malformed_payload | Signature valid, but the body is not a Curviate event. Your secret and header are both proven correct at this point. | What else is POSTing to that URL. Do not rotate the secret. |
Check a captured delivery from the command line
When a delivery has already failed in production and you need to know whether the
problem is the signature or your handler, curviate webhook verify answers it
offline, with no network call and no server involved. Everything below was run
on @curviate/cli 0.20.0.
On @curviate/cli 0.19.0, curviate webhook verify
rejects every genuine delivery with
malformed_header: Webhook payload missing "type" field. The MAC
has already verified at that point; the failure is the CLI looking for a
type field that no delivery has ever contained. If you debug a
real incident on 0.19.0 you will conclude your signing is broken when it is
not. Upgrade to 0.20.0 or later, where the same command exits 0.
# t and v1 are re-signed at run time against the real secret, so that the
# delivery falls inside the default replay window. The body is the captured one.
curviate webhook verify \
--secret "$CURVIATE_WEBHOOK_SECRET" \
--header "t=1785771545,v1=bfbced24fa257056a2a4cc9f93de394b4aef6374fc65aeeac82cf4115322a963" \
--body body.json--body takes inline JSON, a path to a file, or - to read stdin. On a genuine
delivery it prints the parsed event and exits 0:
{"id":"wdl_01KZ3ZKRBCDR66NFZFCWAKPPXA","webhook_id":"wh_01KZ3ZH1EJCY6PC7KWC7BTBYPW","event":"message.received","data":{"account_id":"acc_01KXR3N1NCR435P7FG3230VS6A","event":"message.received","occurred_at":"2026-08-03T14:16:00.000Z","text":"Thanks for the intro, happy to chat Thursday.","sender":{"name":"Sophie Keller","profile_url":"https://www.linkedin.com/in/sophie-keller-example","provider_id":"ACoAAFakeSophie"},"chat_id":"2-YWJjZGVmZ2hpamtsbW5vcA==","message_id":"msg_01KZ3ZJQP9T7WM0DDPJ29PG47","attachments":[]},"delivered_at":"2026-08-03T14:14:29.997Z"}On the tampered body it prints the structured failure on stdout, a summary on
stderr, and exits 2:
{"error":{"name":"WebhookSignatureError","reason":"invalid_signature","message":"Webhook signature does not match. Verify your signing secret."}}error: webhook verification failed, invalid_signature: Webhook signature does not match. Verify your signing secret.Exit 0 means verified, 2 means it did not verify or the input was unusable.
A trailing newline on a captured body file is stripped before the check, because
the signed bytes never end in one.
Old but genuine deliveries need --max-age-secs
By default curviate webhook verify enforces the same 300 second replay window
constructEvent does, so forensics on a delivery older than that fails with
replay_detected, not because anything is wrong with the signature but
because the check assumes it is looking at a live delivery. A delivery that is
hours or days old is not a replay attack here, it is exactly what you came to
inspect, so widen the window:
# The same genuine delivery, re-signed with a timestamp from a day ago
# (t and v1 both computed for real against the real secret, not edited by hand).
curviate webhook verify \
--secret "$CURVIATE_WEBHOOK_SECRET" \
--header "t=1785685136,v1=ca677858aa064d751af02df7dbb9af57601a57f667d87463bf53009715a94319" \
--body body.json{"error":{"name":"WebhookSignatureError","reason":"replay_detected","message":"Webhook event is outside the replay window (86409s ago/ahead, window is 300s)."}}error: webhook verification failed, replay_detected: Webhook event is outside the replay window (86409s ago/ahead, window is 300s).Add --max-age-secs with a window wide enough to cover however old the
delivery you are inspecting is:
curviate webhook verify \
--secret "$CURVIATE_WEBHOOK_SECRET" \
--header "t=1785685136,v1=ca677858aa064d751af02df7dbb9af57601a57f667d87463bf53009715a94319" \
--body body.json \
--max-age-secs 999999{"id":"wdl_01KZ3ZKRBCDR66NFZFCWAKPPXA","webhook_id":"wh_01KZ3ZH1EJCY6PC7KWC7BTBYPW","event":"message.received","data":{"account_id":"acc_01KXR3N1NCR435P7FG3230VS6A","event":"message.received","occurred_at":"2026-08-03T14:16:00.000Z","text":"Thanks for the intro, happy to chat Thursday.","sender":{"name":"Sophie Keller","profile_url":"https://www.linkedin.com/in/sophie-keller-example","provider_id":"ACoAAFakeSophie"},"chat_id":"2-YWJjZGVmZ2hpamtsbW5vcA==","message_id":"msg_01KZ3ZJQP9T7WM0DDPJ29PG47","attachments":[]},"delivered_at":"2026-08-03T14:14:29.997Z"}Exit 0. Widening the window does not weaken the check: the signature still
has to match exactly over the exact bytes, --max-age-secs only changes how
old a genuinely-signed delivery is allowed to be.
Rotating the secrets
The two gates rotate very differently.
The URL token: one call, no cutover
PATCH /v1/webhooks/{id} replaces request_url and deliberately leaves the
signing secret untouched, so rotating a leaked URL token is a single call with no
dual-accept window.
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"}'Accept both tokens at gate 1 until every in-flight retry has drained, then drop the old one.
The signing secret: create, overlap, delete
There is no rotate endpoint, and PATCH preserves the secret by design, so
rotating means replacing the webhook:
- Create a second webhook with the same
source,events,account_ids, andrequest_url, and capture itssecret. - Accept both secrets in your verifier: try the new one, and on
invalid_signaturetry the old one. - Delete the first webhook.
- Drop the old secret from your verifier.
While both webhooks exist, every matching event is delivered twice, once per
webhook, each signed with its own secret. Deduplicate on the combination of
event, data.account_id and
data.occurred_at, which is stable across both webhooks and
across retries. Do not deduplicate on the top-level
id: it identifies a delivery attempt and is freshly minted on
every retry.
Skipping the dual-accept window in step 2 is what turns a rotation into an
outage: deliveries already in flight are signed with whichever secret their
webhook owns, so they fail verification, retry, and push that webhook to
degraded.
Symptoms and causes
| Symptom | Cause | Fix |
|---|---|---|
| Every delivery fails verification, including brand new ones | A JSON body parser ran before the check, so the bytes were re-serialised and no longer match what was signed. | Read the raw body on that route only: |
| Verification works locally, fails behind a proxy or gateway | Something in front of your app re-encoded the body, or stripped the header. Header names are case-insensitive, but some frameworks lowercase them and some do not. | Log the byte length you received against |
Handler crashes with ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH | A malformed v1 reached the comparison. | Add the 64-hex format check before building any buffer. |
| Signature valid, everything else fine, still rejected as a replay | Receiver clock skew, or a retry arriving after a long outage. | Sync via NTP. Retries can arrive up to about 2h36m after the event, so do not widen the window to cover them; reject and let the retry schedule do its job. |
| Deliveries stopped arriving entirely | Five consecutive failures flipped the webhook to | Read |
Requests arrive with no Curviate-Signature header at all | They are not from Curviate. Every delivery carries the header on every attempt. | Reject them. This is gate 2 doing exactly its job. |
Next steps
- Verifying signatures: the same check written out for Express and FastAPI, plus the replay-window details.
- Delivery and retries: what happens after you
reject a delivery, and how to recover from
degraded. - Webhooks overview: creating a webhook and capturing its secret.
- Event reference: every event you can subscribe to, and its payload.