Updated
Before you start
- A public https address for your endpoint: Coolie posts only to https addresses on the public internet, never to localhost. While you build, a tunnel (ngrok, Cloudflare Tunnel…) gives your machine one.
- Node.js 18 or later, and Express for the first example (
npm install express). - To create the webhook with the API rather than in Settings, an API key from Settings → API keys, in
COOLIE_API_KEYas in the other guides.
Create the webhook
In Coolie, open Settings → Webhooks, paste your address, tick the events you want and click Add webhook; Reveal shows its secret. Or create it with the API, whose answer carries the secret:
curl https://trycoolie.ai/api/v1/webhooks \
-H "Authorization: Bearer $COOLIE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-server.example/coolie", "events": ["lead.created", "lead.replied"] }'{
"webhook": {
"id": "whk_Hq2xV7pLm0aZ9RkT",
"url": "https://your-server.example/coolie",
"events": [
"lead.created",
"lead.replied"
],
"enabled": true,
"createdAt": "2026-10-19T09:30:00.000Z",
"secret": "whsec_…",
"last": null
}
}An account has at most five webhooks, each with its own secret (whsec_…). The events: lead.created (an agent found a new lead), lead.enriched (Find email or Find phone found one), lead.researched (the Research Agent finished a lead), message.sent (a campaign sent a lead an email, an invitation, a LinkedIn message or a comment on their post) and lead.replied (a lead answered a campaign, or asked it to stop).
Verify the signature in Node.js (Express)
The signature is computed on the bytes Coolie sent, so read the raw body before any JSON parsing: express.raw keeps it as a Buffer. The secret is the whole value, whsec_ included.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.COOLIE_WEBHOOK_SECRET; // whsec_…, from Settings → Webhooks
const handled = new Set(); // event ids already handled: keep them in your database in production
// Coolie-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body" with your secret>
function fromCoolie(rawBody, header, secret) {
const parts = Object.fromEntries(String(header ?? "").split(",").map((part) => part.split("=")));
const expected = Buffer.from(crypto.createHmac("sha256", secret).update(parts.t + "." + rawBody).digest("hex"));
const given = Buffer.from(String(parts.v1 ?? ""));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
const app = express();
// express.raw keeps the body as Coolie sent it: the signature is computed on those bytes.
app.post("/coolie", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!fromCoolie(rawBody, req.get("Coolie-Signature"), SECRET)) return res.sendStatus(401);
res.sendStatus(200); // answer first: Coolie waits ten seconds at most
const event = JSON.parse(rawBody);
if (handled.has(event.id)) return; // a retry of an event already handled
handled.add(event.id);
if (event.type === "lead.created") {
const { company, contact, signal } = event.data.lead;
console.log(signal.name + " at " + company.name + ": " + (contact?.name ?? "no contact yet"));
}
});
app.listen(3000);timingSafeEqual compares without telling how much matched, and the five-minute window refuses an old request replayed. Coolie signs each try afresh, so a retry passes the window.
In a Next.js route handler
In the App Router, request.text() gives the raw body.
// app/api/coolie/route.ts
import crypto from "node:crypto";
export async function POST(request: Request) {
const rawBody = await request.text();
const parts = Object.fromEntries(
(request.headers.get("coolie-signature") ?? "").split(",").map((part) => part.split("=")),
);
const expected = Buffer.from(
crypto.createHmac("sha256", process.env.COOLIE_WEBHOOK_SECRET!).update(parts.t + "." + rawBody).digest("hex"),
);
const given = Buffer.from(String(parts.v1 ?? ""));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!fresh || given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
return new Response("Bad signature", { status: 401 });
}
const event = JSON.parse(rawBody);
// Queue the work on event.type and event.data, and answer at once.
console.log(event.type, event.id);
return new Response(null, { status: 204 });
}In Python (Flask)
request.get_data() gives the raw bytes; hmac.compare_digest compares in constant time.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["COOLIE_WEBHOOK_SECRET"] # whsec_…, from Settings → Webhooks
def from_coolie(raw_body: bytes, header: str, secret: str) -> bool:
"""Coolie-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body" with your secret>"""
parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
timestamp, given = parts.get("t", ""), parts.get("v1", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)
@app.post("/coolie")
def coolie_webhook():
raw_body = request.get_data() # the bytes as Coolie sent them
if not from_coolie(raw_body, request.headers.get("Coolie-Signature", ""), SECRET):
abort(401)
event = json.loads(raw_body)
if event["type"] == "lead.created":
lead = event["data"]["lead"]
print(lead["signal"]["name"], "at", lead["company"]["name"])
return "", 200Send a test event
Click Send test on the webhook in Settings → Webhooks, or call the API. The test event's type is test, and it carries a made-up lead in the real shape, so you can map its fields before the first real event.
curl -X POST https://trycoolie.ai/api/v1/webhooks/whk_Hq2xV7pLm0aZ9RkT/test \
-H "Authorization: Bearer $COOLIE_API_KEY"{
"delivered": true,
"httpStatus": 200,
"error": null
}What Coolie sends
Each event is a POST of JSON: its id (the same on every try, to dedupe on), its type, when it happened, and its data. A lead's events carry the lead in the shape GET /api/v1/leads gives. message.sent adds the message (campaignId, queueItemId, stepId, step, channel, subject, body, sentAt), and lead.replied the reply (campaignId, queueItemId, kind reply or stop, channel email or linkedin, subject, text, receivedAt).
POST /coolie HTTP/1.1
Host: your-server.example
Content-Type: application/json
User-Agent: Coolie-Webhooks/1.0 (+https://trycoolie.ai)
Coolie-Event: lead.created
Coolie-Event-Id: evt_Tq4mZ8cW2xLp7VbN
Coolie-Signature: t=1792141334,v1=6f1c0e…Retries, timing and order
- Each event goes out within about two minutes of happening, and an email found with Find email at once.
- Answer with a 2xx within ten seconds. Anything else, or no answer, is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then left as failed; Settings → Webhooks lists each delivery.
- A 410 Gone answer turns the webhook off.
- Coolie does not follow redirects: give the final address.
- Events may arrive out of order: sort on
createdAtwhen it matters. - Do the slow work after answering, from a queue, so a slow job never turns into a retry.
What a lead.created event looks like
The body of the request, as your endpoint receives it. The values are examples.
{
"id": "evt_Tq4mZ8cW2xLp7VbN",
"type": "lead.created",
"createdAt": "2026-10-19T09:02:14.020Z",
"data": {
"lead": {
"runId": "ui-5b9e2c41",
"prospectId": "p-41b7c2d9e0aa",
"agentId": "agent-3f2a91c0",
"status": "enriched",
"processedAt": "2026-10-19T09:02:13.511Z",
"icpMatch": true,
"aiScore": 8.4,
"feedback": null,
"signal": {
"type": "hiring",
"name": "Hiring",
"headline": "Operations Manager",
"detail": null,
"url": "https://jobs.example.com/12345",
"postedAt": "2026-10-17"
},
"company": {
"name": "Northwind Health",
"domain": "northwind.example",
"website": "https://northwind.example",
"linkedinUrl": "https://www.linkedin.com/company/example",
"industry": "Hospitals and Health Care",
"employees": 320,
"country": "France",
"locations": [
"Lyon, France"
]
},
"contact": {
"name": "Camille Martin",
"title": "COO",
"seniority": "c_suite",
"linkedinUrl": "https://www.linkedin.com/in/example",
"country": "France",
"email": "camille.martin@northwind.example",
"emailConfidence": 0.92,
"phone": null
},
"research": null,
"sequence": null
}
}
}Errors
An error answers with its HTTP status and a body { "error": code, "message": sentence }: branch on the code, show or log the message.
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_url | The address is not https, or not on the public internet. |
| 400 | no_events | events names none of the five events. |
| 409 | too_many_webhooks | The account has five webhooks already: delete one first. |
| 404 | webhook_not_found | No webhook with this id in your account (test, PATCH or DELETE). |
| 401 | missing_api_key | No key in the request: send Authorization: Bearer coolie_sk_… or an x-api-key header. |
| 401 | invalid_api_key | Not a Coolie key, or a key that is unknown or revoked: copy it again from Settings → API keys. |
| 429 | rate_limited | More than 60 requests in the minute for your account: wait the seconds Retry-After gives, then try again. |
Frequently asked questions
Why check the signature?
Anyone who learns your address can post to it. The signature proves that the request came from Coolie with your webhook's secret, and its timestamp stops an old request from being replayed.
Where is the signing secret?
Each webhook has its own, starting with whsec_: Reveal shows it in Settings → Webhooks, and the API gives it in the secret field when it creates the webhook. The whole value, prefix included, is the HMAC key.
Can I receive Coolie webhooks on localhost?
No: Coolie posts only to https addresses on the public internet. Use a tunnel while you build, then your server's address.
What happens when my server is down?
Coolie tries again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. Every try carries the same event id, so a receiver that dedupes on it handles each event once.
Can n8n, Make or Zapier receive these events?
Yes: an n8n Webhook node, a Make custom webhook or a Zapier catch hook all work. The workflow templates check the signature in n8n.
Every endpoint, field and error is in the API reference, and more guides are in the developer hub.
