Webhooks

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.

Before you start

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.

GateWhat it isActive whenWhat it proves
Gate 1: URL token

A high-entropy secret you embed in the request_url you register, as a query parameter or a path segment. Curviate POSTs to the exact URL you registered, query string included.

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 Curviate-Signature header, an HMAC-SHA256 digest over the delivery body, keyed with your webhook secret.

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.

Gate 2 is not optional

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" }]
  }'
Header values are encrypted at rest

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.

Unsigned headers can be forged

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

Unix timestamp in seconds at the moment Curviate dispatched this attempt. A retry carries a fresh t.

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 hex

Two details decide whether your verifier works:

  1. 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().
  2. 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, and data.occurred_at are 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.mjs
curl -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.json

What the client sees:

=== genuine ===
{"received":true}
HTTP 200
=== tampered ===
{"error":"invalid_signature"}
HTTP 400
=== wrong url token ===
 
HTTP 404

And 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 mismatch

One word changed in a 556-byte body flips the digest completely. There is no partial match and no near miss.

Rejecting counts as a failed delivery

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.
The discriminant is the event field

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:

reasonMeaningWhat to look at
malformed_header

The header itself is unusable: no t=, no v1=, or a non-numeric timestamp.

How you read the header. Do not rotate the secret.
invalid_signatureThe header parsed, the MAC does not match.

The secret, and whether you passed the raw bytes rather than a re-serialised object.

replay_detectedSignature valid, timestamp outside the replay window.

Your clock (NTP), or widen replayWindowSecs.

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.

Upgrade past CLI 0.19.0 before you debug

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:

  1. Create a second webhook with the same source, events, account_ids, and request_url, and capture its secret.
  2. Accept both secrets in your verifier: try the new one, and on invalid_signature try the old one.
  3. Delete the first webhook.
  4. Drop the old secret from your verifier.
Expect duplicates during the overlap

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

SymptomCauseFix
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: express.raw, await req.text(), await request.body().

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 Content-Length, and read the header case-insensitively.

Handler crashes with ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTHA malformed v1 reached the comparison.Add the 64-hex format check before building any buffer.
Signature valid, everything else fine, still rejected as a replayReceiver 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 degraded.

Read health from GET /v1/webhooks, fix the handler, then recreate the webhook.

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

COMPANY · LEGAL

Privacy Policy

Redmer Holding GmbHLast updated August 4, 2026

Who we are

Curviate is operated by Redmer Holding GmbH ("Curviate", "we", "us"), a German GmbH registered at Amtsgericht Bonn, HRB 29957, registered address Hostertstraße 16, 53332 Bornheim, Germany. Full company details are on our Imprint. We haven't appointed a statutory Data Protection Officer, since our processing doesn't reach the scale or sensitivity that requires one. Privacy questions go to privacy@curviate.com.

The two roles we play

When you create an account and use Curviate, we process your own data (identity, billing, API keys, connector authorizations). For that data, we are the controller.

When you use Curviate to act on your own connected LinkedIn account, viewing profiles, sending messages, managing engagement, that content and those contacts belong to that account and its people. You are the controller of that data; we are the processor, acting only on your instructions, under a Data Processing Agreement available on request (see below). If one of your contacts has a question about being reached through Curviate, you're who they should contact first; email privacy@curviate.com if you need help routing it.

What we collect, and why

DataWhy
Account identity (name, email, sign-in method)Create and secure your account
Your LinkedIn credentialsOperate the actions you request
LinkedIn content returned by an API callFulfil that specific request, nothing more
API keys and connector (OAuth) authorizationsAuthenticate your API, CLI, MCP, or SDK requests
Billing detailsCharge you correctly and meet our tax obligations
Usage and security logsKeep the service reliable and abuse-free
Support messagesRespond to you
Website analytics, only if you opt inUnderstand how the site is used

We rely on our contract with you, our legitimate interest in running and securing the service, our legal obligations (tax law, for example), and, for analytics, your consent. We never sell your data or use it to train models.

Where it's processed, and who else touches it

Our infrastructure runs in the EU. Hosting: Railway. Database and auth: Supabase, Ireland. Email: Resend. Payments: Stripe. Network security: a DDoS-protection provider sits in front of our app and never sees or stores request content. LinkedIn connectivity: a third-party infrastructure provider that lets us execute LinkedIn actions on your behalf. Error tracking: Sentry, Frankfurt. Product analytics: PostHog, Frankfurt. Uptime monitoring: Better Stack.

We give the current, named list of every provider above to any customer who asks: security@curviate.com.

Data processing agreement

A data processing agreement under Article 28 of the GDPR is available to business customers on request. Email security@curviate.com and we will send you the current version.

Outside the EU

All customer LinkedIn data, account data, and telemetry are processed and stored exclusively in EU regions of our sub-processors. A few providers we rely on (Stripe and Sentry, for example) are headquartered outside the EU/EEA; where that applies, it's covered by their own GDPR safeguards, typically the EU Standard Contractual Clauses.

How long we keep it

DataRetention
Account and workspace dataWhile your account is active
Closed accountDeleted immediately and irreversibly; see Deleting your account below
LinkedIn credentialsUntil you disconnect that account
LinkedIn contentNot stored; any transient cache clears within 1 hour, never indexed, never used for training
API keysUntil you revoke or rotate them
Connector (OAuth) authorizationsAccess token ~1 hour; refresh token up to ~12 months, or until you revoke it, whichever comes first
Billing recordsAs required by German tax law, currently up to 10 years
LogsA short operational window; metadata only, never message content

The 12-month figure above is a server-side credential for a connected AI agent or app. It is not a cookie and doesn't touch your browser session; see Cookies below for that. You can see and revoke every connector from Authorized applications in your dashboard at any time.

Cookies

We keep cookies to a minimum, and ask before anything beyond the essentials runs.

Strictly necessary, no consent needed:

NamePurposeExpiry
cc_cookieRemembers your cookie choice12 months
curviate-themeRemembers light/dark mode (local storage, not a cookie)Persistent
sb-*-auth-tokenKeeps you signed inWhile active; cleared on sign-out

Analytics, only if you accept:

NamePurposeExpiry
_gaGoogle Analytics: distinguishes visitors2 years
_gidGoogle Analytics: distinguishes visitors24 hours
_ga_<container id>Google Analytics: persists session state2 years

No advertising cookies, ever. Accept and reject are equally easy, and you can change your mind any time via Cookie Preferences in the footer; we won't ask again for 12 months unless something material changes. Our LinkedIn connect flow and OAuth authorization screen never set anything beyond the essentials, so no banner appears there.

Connecting an AI agent or app

Curviate is built for AI agents and automated clients as much as for people. If you connect an app like Claude, or your own code, via an API key or an OAuth connector, it can act on your workspace within the access you gave it. What it does with anything it receives back, including what it sends to its own AI model, is between you and that provider; review its practices before connecting it. Review and revoke any connection any time from your dashboard.

Deleting your account

You can delete your account yourself, from Settings in your dashboard. It takes effect immediately and it cannot be undone. There is no grace period and nothing to restore afterwards, so export anything you want to keep before you start.

Deleting removes your sign-in identity, which frees your email address for reuse straight away, along with your profile, your workspace membership and settings, your API keys, and your seats. For any connected LinkedIn account, we instruct our infrastructure provider to delete it, and your access ends immediately. Records of the connection itself can remain in our systems; email privacy@curviate.com if you need those removed as well. LinkedIn content was never stored in the first place, so there is none of it to delete.

A few things are kept on purpose. We would rather name them than claim a clean sweep:

  • Billing records, for as long as German tax law requires. They hold plan, seat count, amount, and payment references; no name, no email, no LinkedIn data.
  • A record that the deletion happened, so we can show you or a regulator that we did it.
  • A security log of which requests were made, kept for 90 days and then removed automatically. It records that a request happened, never what was in it.
  • A one-way fingerprint, if you used a free trial, that lets us recognise a repeat trial. It holds no readable identifier and cannot be read back into your name, your email, or your LinkedIn profile.

Internal workspace identifiers can also remain in operational records such as queue entries and rate-limit counters. Those carry no name, no email, and no content. If you want to know exactly what is left for your own account, ask us at privacy@curviate.com.

Your rights

You can access, correct, delete, restrict, or object to your data, port it elsewhere, and withdraw consent at any time: email privacy@curviate.com. A copy of your data in a machine-readable format is available on request. We don't make automated decisions about you that have a legal or similarly significant effect. You can also complain to a supervisory authority; ours is the Landesbeauftragte für Datenschutz und Informationsfreiheit Nordrhein-Westfalen (LDI NRW), www.ldi.nrw.de, though you're free to complain to the one in your own country instead.

Keeping it secure

Credentials are encrypted and never logged, returned, or shared. LinkedIn actions run through native, humanized flows; full detail is on our Security & Compliance page. If a breach puts your rights at risk, we'll notify the authorities and you, as GDPR requires. Curviate isn't directed at, or offered to, anyone under 16.

Changes

We'll update this page when our practices change, and reset the cookie prompt if the change is material.

Contact