Developer guide

How to receive Coolie webhooks in Node.js and verify their signature

Add your https address in Coolie under Settings → Webhooks, or with POST /api/v1/webhooks, and keep the secret it gives you. Each event arrives as a JSON POST with a Coolie-Signature header, t=<unix seconds>,v1=<hex>: compute the HMAC-SHA256 of the timestamp, a dot and the raw body with that secret, compare it with v1 in constant time, refuse an old timestamp, answer with a 2xx within ten seconds, and skip an event id you have already handled.

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_KEY as 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
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"] }'
Answer (201)
{
  "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.

JavaScript (Express)
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.

TypeScript (Next.js)
// 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.

Python (Flask)
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 "", 200

Send 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
curl -X POST https://trycoolie.ai/api/v1/webhooks/whk_Hq2xV7pLm0aZ9RkT/test \
  -H "Authorization: Bearer $COOLIE_API_KEY"
Answer
{
  "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).

Headers
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 createdAt when 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.

JSON
{
  "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.

StatusCodeWhat to do
400invalid_urlThe address is not https, or not on the public internet.
400no_eventsevents names none of the five events.
409too_many_webhooksThe account has five webhooks already: delete one first.
404webhook_not_foundNo webhook with this id in your account (test, PATCH or DELETE).
401missing_api_keyNo key in the request: send Authorization: Bearer coolie_sk_… or an x-api-key header.
401invalid_api_keyNot a Coolie key, or a key that is unknown or revoked: copy it again from Settings → API keys.
429rate_limitedMore 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.

Your next conversation is already happening somewhere.

Set up an agent in five minutes. The first leads arrive with their reason to write.