docs platform
Events and webhooks
Account-wide events, signed webhook endpoints, and retries.
Everything that finishes or changes on your account becomes an event: a run completes, a profile fetch job ends, a contact lookup returns, an enrollment starts, a message goes out, a candidate replies, an outreach account pauses, credits run low. Read them from GET /v1/events, or have them pushed to your own HTTPS endpoint.
Events carry ids, statuses, and counts. They never carry profile contents or message text; fetch those with the ids.
{
"id": "evt_9c2e41d0a7b3f5e618",
"object": "event",
"type": "run.completed",
"created_at": "2026-11-03T09:14:22.000Z",
"customer_id": null,
"data": {
"run_id": "run_4b8f0c2a91d3e7a650",
"plan_id": "plan_e1a7c4b9d20f3865aa",
"status": "completed",
"stop_reason": "target_reached",
"candidates": 42,
"spent_credits": 27
}
}
customer_id is set when the event happened inside a customer.
Event types
| Type | When |
|---|---|
run.completed, run.failed, run.cancelled | A guided run reached a final state |
job.completed, job.failed | An async POST /v1/people:fetch job ended |
contacts.completed | A contact lookup has its answers |
enrollment.created, enrollment.completed, enrollment.failed | A candidate entered, finished, or fell out of a sequence |
action.sent, action.failed, action.awaiting_approval | A single outreach step was sent, failed, or waits for your approval |
invite.accepted | A candidate accepted a connection invite |
reply.received | A candidate replied. The sequence for that candidate stops |
account.connected, account.paused, account.disconnected | An outreach account changed state |
outreach.cap_reached | A safety cap stopped sends for the day or week |
credits.low | The balance fell below the workspace threshold (low_balance_credits on GET /v1/workspace, default 250). Sent once per grant. Change it with PATCH /v1/workspace |
credits.expiring | A credit grant expires within 7 days. Sent once per grant |
New types may be added without a new API version. Ignore types you do not know.
Reading events
GET /v1/events returns the last 30 days, newest first, with cursor pagination. Filter with types=run.completed,reply.received, created_after, and customer_id (none for account-level events only). A key bound to a customer sees only that customer's events. GET /v1/events/{event_id} returns one.
Polling events works without a public URL: remember the newest created_at you processed and pass it as created_after.
Webhook endpoints
curl -s -X POST "$RECRUITMCP_BASE/v1/webhook-endpoints" \
-H "Authorization: Bearer $RECRUITMCP_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.example.com/recruitmcp", "events": ["run.completed", "reply.received"]}'
The response includes secret (whsec_…) once. Store it. Later reads only show secret_hint.
- Up to 10 endpoints per account.
eventsdefaults to["*"], every type. - Set
customer_idto receive only one customer's events. Without it, the endpoint receives account and customer events alike. PATCHchanges the URL, the types, the description, orstatus(enabledordisabled). A disabled endpoint receives nothing and pending deliveries to it are dropped.POST /v1/webhook-endpoints/{endpoint_id}/rotate-secretreturns a new secret. The old one stops working at once, including for retries already queued.POST /v1/webhook-endpoints/{endpoint_id}/testsends one signedpingevent right away and returns the status your endpoint answered. It is not retried.
The URL must be public HTTPS on port 443, without credentials in it. Private, loopback, and link-local addresses are refused when you save the endpoint, and the address is resolved and checked again on every delivery. Redirects are not followed.
Verifying a delivery
Each delivery is a POST with the event as the JSON body and these headers:
| Header | Value |
|---|---|
X-RecruitMCP-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
X-RecruitMCP-Event | The event type |
X-RecruitMCP-Event-Id | The event id. Use it to ignore duplicates |
X-RecruitMCP-Delivery | This delivery's id |
v1 is the HMAC-SHA256 of <t>.<raw body> with your endpoint secret. Compute it over the raw bytes, before parsing JSON, compare in constant time, and reject a t more than five minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
The SDKs ship this as verifyWebhookSignature (TypeScript) and verify_webhook_signature (Python).
Retries
Answer with any 2xx within 5 seconds; do slow work after you answer. Anything else, a timeout, or a refused connection is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour, and 6 hours, then given up. Deliveries can arrive more than once and out of order: use the event id to deduplicate, and created_at to order. The event stays readable from GET /v1/events for 30 days whatever happened to the delivery.
What it costs
Events and webhooks are free.
Reference: listEvents, createWebhookEndpoint, testWebhookEndpoint, and the event webhook in the API reference.