Developers
Coolie API
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.
| 400 | invalid_json, invalid_body, invalid_query, invalid_icp_value | The request is malformed; message says what to fix. |
| 400 | insight_empty, insight_too_long, too_many_insights | The insight cannot be added as it stands; message says why. |
| 401 | missing_api_key, invalid_api_key | No key; or a value that is no Coolie key, or a key that is unknown or revoked. |
| 402 | trial_required, payment_failed, payment_pending, no_credits | Start the trial in the app first; pay with another card; wait for the first payment after the trial to go through; or buy credits. |
| 403 | work_email_required, agent_quota, agent_limit_running | Not allowed for this account or plan: every agent it may have, or run at once, is in use. |
| 404 | agent_not_found, lead_not_found, webhook_not_found, item_not_found, step_not_found, insight_not_found | Nothing with this id in your account. |
| 409 | agent_running | Stop the agent before editing its targeting. |
| 409 | not_researched, researching | The lead has no research yet, or its research or writing is under way. |
| 409 | no_steps, no_email_account, no_linkedin_account, unknown_account, duplicate_step | The campaign cannot do this as it stands: a step or an account is missing, or not yours; message says which. |
| 429 | rate_limited, agent_create_limit, daily_cap_reached, write_limit | A limit was reached; Retry-After says when to come back. |
Account and lists
/api/v1/accountYour 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 }
}/api/v1/signalsThe buying signals an agent can watch, with an available flag.
{ "signals": [ { "id": "hiring", "name": "Hiring", "description": "…", "available": true } ] }/api/v1/icp-optionsThe 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).
/api/v1/catalog/technologiesSearches the technologies of the tech-used signal.
Query
- q string
- Words to look for.
/api/v1/catalog/topicsSearches the topics of the buying-intent-topic signal.
Query
- q string
- Words to look for.
/api/v1/catalog/competitorsThe 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.
/api/v1/agentsYour agents, newest first, each with its activity: its last search, when it searches next, and a summary.
{ "agents": [ … ] }/api/v1/agentsCreates 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…" }/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."
}
}
}/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
/api/v1/agents/{agentId}/startSwitches the agent on: it runs again on its schedule.
Errors: agent_limit_running
/api/v1/agents/{agentId}/pausePauses the agent: no new search until it is started.
/api/v1/agents/{agentId}/stopStops the search in flight (what it found stays) and pauses the agent, so it can be edited.
{ "stopped": 1, "abandoned": 4, "status": "paused" }/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.
/api/v1/leadsYour 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"
}/api/v1/agents/{agentId}/leadsOne agent's leads; the same query as /leads.
/api/v1/agents/{agentId}/exportThe agent's leads as the CSV file the app downloads.
Query
- leads string
- runId:prospectId,… to export only those.
/api/v1/agents/{agentId}/leads/contactFinds 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 }/api/v1/agents/{agentId}/leads/actionsResearches 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 }/api/v1/agents/{agentId}/leads/feedbackA 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.
/api/v1/agents/{agentId}/leads/insightsAdds 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
}
}/api/v1/agents/{agentId}/leads/insightsRemoves 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": { … } }/api/v1/agents/{agentId}/leads/sequenceWrites 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}}.
/api/v1/agents/{agentId}/campaignThe 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
/api/v1/agents/{agentId}/campaignSaves 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
/api/v1/agents/{agentId}/campaign/launchStarts 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
/api/v1/agents/{agentId}/campaign/pauseStops sending until it is launched again; the queue keeps its places.
/api/v1/agents/{agentId}/campaign/writeWrites 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}}, …" } }/api/v1/agents/{agentId}/queueThe 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.
/api/v1/agents/{agentId}/queueAdds 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 }/api/v1/agents/{agentId}/queue/actionsPauses, 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 }/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
/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 }/api/v1/agents/{agentId}/queue/{itemId}/regenerateWrites 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
/api/v1/settingsYour profile and the sender identity every message is signed with.
/api/v1/settingsSaves 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.
/api/v1/webhooksYour webhooks, with their secrets, and the latest deliveries.
/api/v1/webhooksAdds 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 } }/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
/api/v1/webhooks/{webhookId}Removes a webhook and its events.
Errors: webhook_not_found
/api/v1/webhooks/{webhookId}/testSends 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.
