Webhooks

Verifying signatures

Every Curviate delivery includes a Curviate-Signature header. Verify it before processing any event; an unverified webhook can be forged.

Before you start

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"),
  );
}
Sign with the webhook's own secret

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
FieldDescription
tUnix timestamp (seconds) at the moment Curviate dispatched the delivery.
v1

HMAC-SHA256 of `${t}.${rawBody}` using your webhook secret, hex-encoded. Always 64 lowercase hex characters.

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>
Security

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");
The malformed case is the one that bites

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.

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

  1. Create a new webhook pointing at the same URL and capture its secret.
  2. Accept both secrets in your verifier for the length of the cutover: try the new secret, and on failure try the old one.
  3. Delete the old webhook.
  4. 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

SymptomCauseFix
Verifier returns false on every deliveryThe 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_signatureWrong 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_detectedThe delivery is older than the replay window, usually clock skew on the receiver.Sync your clock via NTP, or widen replayWindowSecs.
WebhookSignatureError, reason malformed_headerThe signature header itself did not parse. Not a secret problem.Do not rotate the secret. Check how you read the header.
WebhookSignatureError, reason malformed_payloadThe 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_LENGTHA malformed v1 reached timingSafeEqual.Add the 64-hex format check shown above.
Respond quickly

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

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