Draft. The API is still being built, so endpoints and fields on this page can change before it is stable.
Events
| Event | Sent when |
|---|---|
person.created | A new person is added to the workspace. |
signal.created | We capture a new signal, such as a reply, a follow or a funding round. |
draft.approved | Someone in the workspace approves a message draft. |
Add an endpoint
Add your HTTPS URL in your workspace's API settings and choose its events. You get a signing secret that starts with whsec_. Store it as an environment variable; you need it to verify every request.
Payload
Each event is a POST with a JSON body. data holds the created record.
{
"id": "5d2b8f1a-7c3e-4e0a-9b61-0c4f2a9e7d13",
"type": "signal.created",
"created_at": "2026-10-25T09:12:44.120Z",
"workspace_id": "8e4f…",
"data": { "kind": "reply", "platform": "x", "origin": "own", "occurred_at": "2026-10-25T09:10:03.000Z" }
}Verify the signature
Every request carries a signature header:
Webhook-Signature: t=1761383564,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd- Read the raw request body, before any JSON parsing. Parsing and re-serializing changes the bytes and breaks the signature.
- Split the header into
t(a Unix timestamp in seconds) andv1(the signature). - Compute an HMAC-SHA256 of
t, a period and the raw body, with your signing secret as the key. - Compare it with
v1in constant time, and reject requests whosetis more than five minutes from your clock. That stops someone from replaying an old request.
import { createHmac, timingSafeEqual } from "node:crypto";
// Returns true when the request was signed with your webhook secret
// and the timestamp is less than five minutes old.
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
String(signatureHeader ?? "").split(",").map((part) => part.trim().split("=", 2)),
);
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
const received = Buffer.from(parts.v1, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}import express from "express";
import { verifyWebhook } from "./verify-webhook.js";
const app = express();
// Keep the raw body: the signature is computed over the exact bytes we sent.
app.post("/webhooks/customers", express.text({ type: "application/json" }), (req, res) => {
if (!verifyWebhook(req.body, req.get("webhook-signature"), process.env.WEBHOOK_SECRET)) {
return res.status(400).send("Invalid signature");
}
res.sendStatus(204); // answer first, then do the slow work
const event = JSON.parse(req.body);
if (event.type === "signal.created") {
// add event.data to your CRM, queue, or spreadsheet
}
});
app.listen(3000);// app/api/webhooks/customers/route.js
import { verifyWebhook } from "@/lib/verify-webhook";
export async function POST(request) {
const rawBody = await request.text();
if (!verifyWebhook(rawBody, request.headers.get("webhook-signature"), process.env.WEBHOOK_SECRET)) {
return new Response("Invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody);
// handle event.type here
return new Response(null, { status: 204 });
}Respond quickly
Answer with any 2xx status within 5 seconds, then do slow work, such as calling your CRM, afterwards or in a queue.
Delivery
While webhooks are in draft, each event is sent once, without retries. If your endpoint was down, fetch what you missed from the read API with since. Events can arrive out of order, and once retries are added the same event may arrive twice: use the event id to skip duplicates.