Developers

Coolie API

Run your prospecting agents from your own tools — a workflow in n8n, Make or Zapier, a script, your CRM — or from an AI assistant: create agents, read the leads they find, look up emails and phones.

Getting started

Create a key in the app, under Settings → API keys. It is shown once: keep it secret, as it acts as you, with your plan, your credits and your agents. Send it in the Authorization header of every request, or in an x-api-key header.

curl https://trycoolie.ai/api/v1/agents \
  -H "Authorization: Bearer $COOLIE_API_KEY"

Base URL https://trycoolie.ai/api/v1. Bodies and answers are JSON.

Limits and costs

  • 60 requests a minute per account. Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a refused one carries Retry-After.
  • 10 agents created through the API a day per account.
  • Everything else is the app's: your plan's agents and daily leads, and the same credits for the same actions. The API costs nothing more, on the trial too.

Errors

An error answers with an HTTP status and a body { "error": code, "message": sentence }: branch on the code, show or log the message.

400invalid_json, invalid_body, invalid_query, invalid_icp_valueThe request is malformed; message says what to fix.
400insight_empty, insight_too_long, too_many_insightsThe insight cannot be added as it stands; message says why.
401missing_api_key, invalid_api_keyNo key; or a value that is no Coolie key, or a key that is unknown or revoked.
402trial_required, payment_failed, payment_pending, no_creditsStart the trial in the app first; pay with another card; wait for the first payment after the trial to go through; or buy credits.
403work_email_required, agent_quota, agent_limit_runningNot allowed for this account or plan: every agent it may have, or run at once, is in use.
404agent_not_found, lead_not_found, webhook_not_found, item_not_found, step_not_found, insight_not_foundNothing with this id in your account.
409agent_runningStop the agent before editing its targeting.
409not_researched, researchingThe lead has no research yet, or its research or writing is under way.
409no_steps, no_email_account, no_linkedin_account, unknown_account, duplicate_stepThe campaign cannot do this as it stands: a step or an account is missing, or not yours; message says which.
429rate_limited, agent_create_limit, daily_cap_reached, write_limitA limit was reached; Retry-After says when to come back.

Account and lists

GET/api/v1/account

Your plan, credits, subscription, agents in use and leads a day, and the API's limits.

{
  "account": {
    "email": "you@company.com",
    "plan": "starter",
    "planName": "Coolie Starter",
    "credits": 30,
    "subscription": { "status": "trialing", "trialEnd": "2026-10-08T00:00:00.000Z", "currentPeriodEnd": null, "cancelAtPeriodEnd": false },
    "agents": { "count": 1, "quota": 1 },
    "leads": { "dailyCap": 20, "monthlyCap": 620 }
  },
  "limits": { "requestsPerMinute": 60, "agentCreationsPerDay": 10 }
}
GET/api/v1/signals

The buying signals an agent can watch, with an available flag.

{ "signals": [ { "id": "hiring", "name": "Hiring", "description": "…", "available": true } ] }
GET/api/v1/icp-options

The values an ICP's locations, industries and company sizes are taken from, the regions a location may name instead of its countries (Europe, North America, Latin America, Asia-Pacific, Middle East, Africa, DACH, Nordics), and job title suggestions. A value outside these lists is refused (400 invalid_icp_value).

GET/api/v1/catalog/technologies

Searches the technologies of the tech-used signal.

Query

q string
Words to look for.
GET/api/v1/catalog/topics

Searches the topics of the buying-intent-topic signal.

Query

q string
Words to look for.
GET/api/v1/catalog/competitors

The products the competitor-churn and competitor-renewal signals know.

Agents

An agent watches buying signals at the companies of its ICP and delivers the right contact there, every day, up to your plan's daily leads. An active agent runs on its own schedule; creating one launches its first search at once.

GET/api/v1/agents

Your agents, newest first, each with its activity: its last search, when it searches next, and a summary.

{ "agents": [ … ] }
POST/api/v1/agents

Creates an agent and launches its first search.

Uses one of your plan's agents. At most 10 agents created through the API a day (429 agent_create_limit).

Body (JSON)

name string · required
The agent's name.
icp object · required
Who to contact and at which companies: jobTitles (at least one, free text), industries, locations, companySizes (values from GET /icp-options; a location may also be a region it lists, such as "Europe" or "DACH", which stands for its countries), keywordsInclude, keywordsExclude, mode ("broad" or "specific").
signalEventIds string[] · required
3 to 5 signal ids from GET /signals whose available is true.
signalConfigs object
Settings per signal, keyed by its id: hiring { jobTitles, jobDescriptionKeywords }, tech-used { technologies, slugs }, buying-intent-topic { topics, slugs }, competitor-churn { competitors }… At most 5 values a list.
findEmail boolean · required
Look up each lead's email.
findPhone boolean · required
Look up each lead's phone.
sequenceConfig object
How the outreach messages are written. A default otherwise.

Errors: trial_required, agent_quota, invalid_body, invalid_icp_value, signal_coming_soon, signal_not_live, too_few_searches, agent_create_limit

curl https://trycoolie.ai/api/v1/agents \
  -H "Authorization: Bearer $COOLIE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Hospitals in France",
    "icp": {
      "jobTitles": ["COO"], "industries": ["Hospitals and Health Care"],
      "locations": ["France"], "companySizes": ["201-500 employees"],
      "keywordsInclude": [], "keywordsExclude": [], "mode": "broad"
    },
    "signalEventIds": ["hiring", "funding", "tech-used"],
    "findEmail": true,
    "findPhone": false
  }'
{ "agentId": "agent-3f2a91c0", "runId": "run-8c1d…" }
GET/api/v1/agents/{agentId}

One agent, with its activity: leads so far and today, its last three searches and what each signal found, when it searches next (null while paused), and a summary to show as it is.

{
  "agent": {
    "id": "agent-3f2a91c0",
    "name": "Hospitals in France",
    "status": "active",
    "createdAt": "2026-10-01T09:12:44.120Z",
    "icp": {
      "jobTitles": ["COO", "Head of Operations"],
      "industries": ["Hospitals and Health Care"],
      "locations": ["France"],
      "companySizes": ["201-500 employees", "501-1000 employees"],
      "keywordsInclude": [],
      "keywordsExclude": [],
      "mode": "broad"
    },
    "signalEventIds": ["hiring", "funding", "tech-used"],
    "signalConfigs": { "hiring": { "jobTitles": ["Operations Manager"] } },
    "findEmail": true,
    "findPhone": false,
    "sequenceConfig": null,
    "autoWritePerDay": 0,
    "activity": {
      "leads": { "total": 12, "today": 3 },
      "lastSearches": [
        {
          "at": "2026-10-01T12:00:03.000Z",
          "running": false,
          "newLeads": 3,
          "title": "3 new leads",
          "signals": [{ "signal": "hiring", "name": "Hiring", "text": "8 results, 4 new companies: 3 leads." }],
          "note": null
        }
      ],
      "nextSearchAt": "2026-10-01T15:00:00.000Z",
      "summary": "Its last search (5 minutes ago) brought 3 new leads. 12 leads so far, 3 today. Next search in 2 h 55 min."
    }
  }
}
PATCH/api/v1/agents/{agentId}

Changes the fields given, and status ("active" or "paused").

A running agent's targeting is locked (409 agent_running): stop it first.

Body (JSON)

name string
The agent's name.
icp object
Who to contact and at which companies: jobTitles (at least one, free text), industries, locations, companySizes (values from GET /icp-options; a location may also be a region it lists, such as "Europe" or "DACH", which stands for its countries), keywordsInclude, keywordsExclude, mode ("broad" or "specific").
signalEventIds string[]
3 to 5 signal ids from GET /signals whose available is true.
signalConfigs object
Settings per signal, keyed by its id: hiring { jobTitles, jobDescriptionKeywords }, tech-used { technologies, slugs }, buying-intent-topic { topics, slugs }, competitor-churn { competitors }… At most 5 values a list.
findEmail boolean
Look up each lead's email.
findPhone boolean
Look up each lead's phone.
sequenceConfig object
How the outreach messages are written. A default otherwise.
status string
"active" or "paused".

Errors: agent_not_found, agent_running, agent_limit_running, invalid_body, invalid_icp_value

POST/api/v1/agents/{agentId}/start

Switches the agent on: it runs again on its schedule.

Errors: agent_limit_running

POST/api/v1/agents/{agentId}/pause

Pauses the agent: no new search until it is started.

POST/api/v1/agents/{agentId}/stop

Stops the search in flight (what it found stays) and pauses the agent, so it can be edited.

{ "stopped": 1, "abandoned": 4, "status": "paused" }
DELETE/api/v1/agents/{agentId}

Deletes the agent with its runs and leads. This cannot be undone.

{ "deleted": "agent-3f2a91c0" }

Leads

A lead is named by its runId and prospectId. Lists are newest first, a page at a time: pass nextCursor back as cursor for the next page, or since to get only what came in after a moment.

GET/api/v1/leads

Your leads, every agent's.

Query

agentId string
One agent's leads only.
since ISO 8601
Leads found after this instant only.
limit 1–100
Leads per page, 25 by default.
cursor string
The nextCursor of the page before.
{
  "leads": [
    {
      "runId": "run-8c1d…",
      "prospectId": "p-41b7…",
      "agentId": "agent-3f2a91c0",
      "status": "enriched",
      "processedAt": "2026-10-01T10: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-09-29"
      },
      "company": {
        "name": "Example Health",
        "domain": "examplehealth.fr",
        "website": "https://examplehealth.fr",
        "linkedinUrl": "https://www.linkedin.com/company/example-health",
        "industry": "Hospitals and Health Care",
        "employees": 320,
        "country": "France",
        "locations": ["Lyon, France"]
      },
      "contact": {
        "name": "Camille Martin",
        "title": "COO",
        "seniority": null,
        "linkedinUrl": "https://www.linkedin.com/in/camille-martin",
        "country": "France",
        "email": "camille.martin@examplehealth.fr",
        "emailConfidence": 0.92,
        "phone": null
      },
      "research": null,
      "sequence": null
    }
  ],
  "nextCursor": "WyIyMDI2LTEwLTAxVDEwOjAyOjEzLjUxMVoiLCJwLTQxYjcuLi4iXQ"
}
GET/api/v1/agents/{agentId}/leads

One agent's leads; the same query as /leads.

GET/api/v1/agents/{agentId}/export

The agent's leads as the CSV file the app downloads.

Query

leads string
runId:prospectId,… to export only those.
POST/api/v1/agents/{agentId}/leads/contact

Finds one lead's email or phone, as the app's Find email and Find phone.

Charged only when something is found (0.1 credit an email, 1 a phone); free when it is already known.

Body (JSON)

runId string · required
The lead's runId, from the leads list.
prospectId string · required
The lead's prospectId, from the leads list.
field string · required
"email" or "phone".

Errors: lead_not_found, no_contact, no_credits, sources_unavailable

{ "found": true, "value": "camille.martin@examplehealth.fr", "charged": 0.1 }
POST/api/v1/agents/{agentId}/leads/actions

Researches leads and writes their sequences (the Research Agent), or deletes them from the list.

Research runs in the background, a minute or two a lead: the answer is 202 with how many were queued, and the lead's research and sequence appear in /leads when done. 1 credit a lead researched, charged when its research is saved; the balance must cover the batch (402 no_credits). Asking again for leads still queued resumes them, at no extra charge.

Body (JSON)

action string · required
"research" or "delete".
leads object[] · required
Up to 50 { runId, prospectId }.

Errors: no_credits, agent_not_found

{ "queued": 3, "skipped": 0 }
POST/api/v1/agents/{agentId}/leads/feedback

A lead's thumbs: the AI score learns from them.

Body (JSON)

runId string · required
The lead's runId, from the leads list.
prospectId string · required
The lead's prospectId, from the leads list.
feedback string | null · required
"up", "down", or null to take it back.
POST/api/v1/agents/{agentId}/leads/insights

Adds your own insight to a researched lead, as the research panel's Add custom insight.

Kept with the lead's research, after the agent's insights, with custom true: 5,000 characters at most, five a lead. It changes none of the lead's messages until POST /leads/sequence writes them from it. Free. The answer is 201, with its id and the lead's research.

Body (JSON)

runId string · required
The lead's runId, from the leads list.
prospectId string · required
The lead's prospectId, from the leads list.
text string · required
The insight, in your words.

Errors: lead_not_found, not_researched, researching, insight_empty, insight_too_long, too_many_insights

{
  "insightId": "c1",
  "research": {
    "companyContext": "…",
    "roleTension": "…",
    "confidence": "high",
    "insights": [
      {
        "id": "s1",
        "custom": false,
        "type": "Hiring Activity",
        "insight": "Example Health is hiring: Operations Manager",
        "date": "2026-09-29",
        "relevancy": "The buying signal that brought this lead in.",
        "score": null,
        "sources": ["https://jobs.example.com/12345"],
        "hiddenProblem": null
      },
      {
        "id": "c1",
        "custom": true,
        "type": "Custom Insight",
        "insight": "Met Camille at a trade show in September: their two sites still plan shifts by hand.",
        "date": "2026-10-13",
        "relevancy": "Written by you.",
        "score": null,
        "sources": [],
        "hiddenProblem": null
      }
    ],
    "persona": null
  }
}
DELETE/api/v1/agents/{agentId}/leads/insights

Removes one of your own insights from a lead. The agent's insights cannot be removed.

Body (JSON)

runId string · required
The lead's runId, from the leads list.
prospectId string · required
The lead's prospectId, from the leads list.
insightId string · required
Its id, from POST /leads/insights or the lead's research (custom true).

Errors: lead_not_found, insight_not_found, researching

{ "research": { … } }
POST/api/v1/agents/{agentId}/leads/sequence

Writes a researched lead's messages again from the insights you choose, as the research panel's button.

insightIds are ids from the lead's research, your own included; without any (or none the lead holds), the agent's five best. The new messages replace the lead's, and the steps of its campaign that personalize and have not gone out yet use them. Free; up to a minute, the request waits for it.

Body (JSON)

runId string · required
The lead's runId, from the leads list.
prospectId string · required
The lead's prospectId, from the leads list.
insightIds string[]
Up to 50 insight ids.

Errors: lead_not_found, not_researched, researching, write_failed

{ "sequence": [ { "channel": "email", "step": 1, "subject": "…", "body": "…" } ] }

Campaigns

Each agent has one outreach campaign: its steps in order (an email, a LinkedIn invitation or message, each with its delay, its AI writing settings and its template), its sending rules (working hours and days, daily caps) and the accounts it sends from. Its queue holds the leads it writes to, each with its own messages. Launching it sends real emails and LinkedIn messages from your own accounts, within its working hours. Templates speak to the lead through merge fields: {{first_name}}, {{last_name}}, {{company}}, {{title}}, {{sender_name}}, {{sender_company}}.

GET/api/v1/agents/{agentId}/campaign

The campaign: its steps, sending rules, accounts, status (draft, active or paused) and each step's numbers.

Made, a draft, the first time it is asked for. Its emailAccountId and linkedinAccountId are ids from its accounts.

Errors: agent_not_found

PATCH/api/v1/agents/{agentId}/campaign

Saves the campaign's language, steps, autopilot, sending rules and accounts.

steps is the whole sequence, in order: send back what GET gave, changed. A step left out is deleted, and a new step needs its own id (st- then 6 to 16 hex digits).

Body (JSON)

language string
english, french, spanish, german, italian, portuguese or dutch.
steps object[]
The sequence, each step as GET gives it: channel, role, delayDays, tone, framework, cta, length, personalize, template { subject, body }, ownPrompt (your own instructions for the AI, in place of the role; null for none)… channel is email, li_invite, li_message or li_inmail, or a LinkedIn step without a message: li_visit (a profile visit they see), li_like (a like on their latest post), li_comment (a comment on their latest post, written from it when personalize is on, else the template), li_withdraw (takes back the campaign's invitation if it still waits). 12 steps at most, 8 of them emails, invitations and messages.
autopilot boolean
The agent's new leads join the queue on their own.
rules object
workingHoursStart and workingHoursEnd (HH:MM), timezone, workingDays (1 to 7), maxEmailsPerDay, maxInvitesPerDay, maxMessagesPerDay, maxInmailsPerDay, maxVisitsPerDay, maxLikesPerDay, maxCommentsPerDay and maxWithdrawalsPerDay (each what an account sends a day, exactly; the last five optional, defaults 10, 50, 20, 10 and 20), pauseOnReply, pauseOnOoo, randomDelay, and with randomDelay minDelayMinutes and maxDelayMinutes (1 to 600, both or neither: the gap between two sends of an account; null, the day's sends spread over the working hours).
emailAccountId string | null
The mailbox it sends from: an id from its accounts.
linkedinAccountId string | null
The LinkedIn account it sends from: an id from its accounts.

Errors: unknown_account, duplicate_step, agent_not_found

POST/api/v1/agents/{agentId}/campaign/launch

Starts sending, from your own accounts, within its working hours.

With autopilot on, the agent's leads join the queue at once, then as they come.

Errors: no_steps, no_email_account, no_linkedin_account

POST/api/v1/agents/{agentId}/campaign/pause

Stops sending until it is launched again; the queue keeps its places.

POST/api/v1/agents/{agentId}/campaign/write

Writes one step's template with AI, from the step's settings and your sender identity. Nothing is saved.

Body (JSON)

step object · required
The step, as in the campaign's steps.
language string · required
The campaign's language.

Errors: step_has_no_message, write_limit, write_unavailable, write_failed

{ "template": { "subject": "…", "body": "Hi {{first_name}}, …" } }
GET/api/v1/agents/{agentId}/queue

The leads in the campaign, 50 a page, the next send first.

Each row: the lead, its next step and when, its status, whether its messages are personalized (from its research) or the step templates, and flags, what keeps a step from going out. notQueued counts the agent's leads not in the queue yet.

Query

status string
One status only; all by default.
q string
Searches the leads' names and companies.
offset number
Rows to skip.
POST/api/v1/agents/{agentId}/queue

Adds the agent's leads to the queue; those already in it are left alone.

Body (JSON)

all true
Every lead of the agent.
prospectIds string[]
Or these leads only, up to 500.
{ "added": 12 }
POST/api/v1/agents/{agentId}/queue/actions

Pauses, resumes or removes queued leads, or switches their messages to personalized or to the templates.

personalize orders the leads' research: 1 credit a lead not researched yet, which the balance must cover (402 no_credits). The answer is 202 while research runs.

Body (JSON)

action string · required
pause, resume, remove, personalize or template.
ids string[] · required
Up to 200 ids of queue rows.

Errors: no_credits

{ "updated": 2, "researching": 0 }
GET/api/v1/agents/{agentId}/queue/{itemId}

One lead's messages, step by step, as they would go out, and what happened so far.

Each step's source says where its message comes from: edited (yours), personalized (its research) or template.

Errors: item_not_found

PATCH/api/v1/agents/{agentId}/queue/{itemId}

Your own message for the lead's steps not sent yet; null takes an edit back.

Body (JSON)

edits object · required
{ [stepId]: { subject, body } | null }

Errors: item_not_found

{ "ok": true }
POST/api/v1/agents/{agentId}/queue/{itemId}/regenerate

Writes one step's message for this lead again with AI. Nothing is saved: PATCH the lead to keep it.

Body (JSON)

stepId string · required
The step, from the lead's messages.

Errors: item_not_found, step_not_found, step_has_no_message, write_limit

{ "subject": "…", "body": "…" }

Settings

GET/api/v1/settings

Your profile and the sender identity every message is signed with.

PATCH/api/v1/settings

Saves the profile and/or the sender identity (sender null clears it).

Body (JSON)

profile object
{ fullName, linkedinUrl, companyLinkedinUrl }
sender object | null
{ company, website, pitch, sender_name, sender_role, calendly_url, proof_points }

Webhooks

Coolie POSTs an event to your https address as it happens: 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), lead.replied (a lead answered a campaign, or asked it to stop). Settings → Webhooks in the app does the same as these routes. How to read the events: Receiving webhooks, below.

GET/api/v1/webhooks

Your webhooks, with their secrets, and the latest deliveries.

POST/api/v1/webhooks

Adds a webhook: the events chosen go to its address from now on. The answer carries its secret.

Body (JSON)

url string · required
An https address on the public internet.
events string[] · required
Any of "lead.created", "lead.enriched", "lead.researched", "message.sent", "lead.replied".

Errors: invalid_url, no_events, too_many_webhooks

{ "url": "https://hooks.zapier.com/hooks/catch/123/abc/", "events": ["lead.created"] }
{ "webhook": { "id": "whk_…", "url": "https://hooks.zapier.com/hooks/catch/123/abc/", "events": ["lead.created"], "enabled": true, "secret": "whsec_…", "last": null } }
PATCH/api/v1/webhooks/{webhookId}

Turns a webhook off, or on again.

Body (JSON)

enabled boolean · required
false stops the events; true sends them again.

Errors: webhook_not_found

DELETE/api/v1/webhooks/{webhookId}

Removes a webhook and its events.

Errors: webhook_not_found

POST/api/v1/webhooks/{webhookId}/test

Sends a test event now, a made-up lead in the real shape, and says what your address answered.

Errors: webhook_not_found

{ "delivered": true, "httpStatus": 200, "error": null }

MCP server

Connect Coolie to Claude, Cursor or any client of the Model Context Protocol, and ask in plain words: set up an agent for a market, show today's leads, find this person's email, pause an agent. The server takes the same key and has the same limits as the API; each message counts as one request.

URL
https://trycoolie.ai/api/mcp
Transport
Streamable HTTP
Header
Authorization: Bearer YOUR_API_KEYorx-api-key: YOUR_API_KEY

Claude (web, desktop and mobile)

In Claude, open Customize → Connectors → Add custom connector (on a Team or Enterprise plan, an Owner adds it in Organization settings → Connectors). Enter the URL above and keep Sign in now, then click Connect: each person logs in to Coolie and allows the connection, which signs in with OAuth 2.1 (PKCE; Claude's published client identity, or dynamic registration) and appears in Settings → API keys, where it can be revoked. With an API key instead, choose No sign-in and add a request header x-api-key with the key as its value.

Claude Code

claude mcp add --transport http coolie https://trycoolie.ai/api/mcp \
  --header "Authorization: Bearer YOUR_API_KEY"

Claude Desktop, Cursor and others

Settings → MCP in the app gives the configuration to paste for each, with this address.

Tools

get_account, list_signals, get_icp_options, search_catalog, list_agents, get_agent, create_agent, update_agent, start_agent, pause_agent, stop_agent, delete_agent, list_leads, find_contact, research_leads, delete_leads, rate_lead, add_lead_insight, remove_lead_insight, write_lead_sequence, get_campaign, update_campaign, launch_campaign, pause_campaign, write_step, list_queue, add_to_queue, update_queue, get_queue_item, edit_queue_messages, regenerate_message, get_settings, update_settings.

Receiving webhooks

Each event is a POST of JSON to your address: its id (the same on every try, to dedupe on), its type, when it happened, and the lead, in the shape GET /leads gives. A Zapier or Make "catch hook", an n8n webhook node or your own server will do: add its address in Settings → Webhooks, or with POST /webhooks.

POST https://your-server.example/coolie
Content-Type: application/json
Coolie-Event: lead.created
Coolie-Event-Id: evt_7Hq2…
Coolie-Signature: t=1790935200,v1=5f0c…

{
  "id": "evt_7Hq2…",
  "type": "lead.created",
  "createdAt": "2026-10-02T09:00:00.000Z",
  "data": {
    "lead": {
      "runId": "…", "prospectId": "…", "agentId": "…",
      "status": "enriched", "aiScore": 8,
      "signal": { "type": "hiring", "name": "Hiring", "headline": "Head of Sales Operations", … },
      "company": { "name": "Example Robotics", "domain": "example.com", … },
      "contact": { "name": "Jordan Lee", "title": "VP of Sales", "email": null, … },
      "research": null, "sequence": null
    }
  }
}

A campaign's events carry the lead and what happened: message.sent its message (campaignId, queueItemId, stepId, step, channel, subject, body, sentAt; an invitation without a note has neither, a comment on their post its text as body; a profile visit, a like and a withdrawal send none), lead.replied their reply (campaignId, queueItemId, kind "reply" or "stop", channel "email" or "linkedin", subject, text, receivedAt). An out-of-office or a bounce is not a reply.

Coolie-Event
lead.created, lead.enriched, lead.researched, message.sent, lead.replied, or test
Coolie-Event-Id
The event's id
Coolie-Signature
t=<unix seconds>,v1=<HMAC-SHA256 of "t.body" with your secret, in hex>

Checking the signature (Node.js)

import crypto from "node:crypto";

// rawBody: the request's body as received, before any JSON parsing.
function fromCoolie(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}

Answer with a 2xx within ten seconds. Anything else is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then left as failed; Settings → Webhooks shows each delivery. A 410 Gone turns the webhook off. Events may arrive out of order: sort on createdAt when it matters.

API — Coolie