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

TypeWhen
run.completed, run.failed, run.cancelledA guided run reached a final state
job.completed, job.failedAn async POST /v1/people:fetch job ended
contacts.completedA contact lookup has its answers
enrollment.created, enrollment.completed, enrollment.failedA candidate entered, finished, or fell out of a sequence
action.sent, action.failed, action.awaiting_approvalA single outreach step was sent, failed, or waits for your approval
invite.acceptedA candidate accepted a connection invite
reply.receivedA candidate replied. The sequence for that candidate stops
account.connected, account.paused, account.disconnectedAn outreach account changed state
outreach.cap_reachedA safety cap stopped sends for the day or week
credits.lowThe 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.expiringA 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. events defaults to ["*"], every type.
  • Set customer_id to receive only one customer's events. Without it, the endpoint receives account and customer events alike.
  • PATCH changes the URL, the types, the description, or status (enabled or disabled). A disabled endpoint receives nothing and pending deliveries to it are dropped.
  • POST /v1/webhook-endpoints/{endpoint_id}/rotate-secret returns a new secret. The old one stops working at once, including for retries already queued.
  • POST /v1/webhook-endpoints/{endpoint_id}/test sends one signed ping event 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:

HeaderValue
X-RecruitMCP-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
X-RecruitMCP-EventThe event type
X-RecruitMCP-Event-IdThe event id. Use it to ignore duplicates
X-RecruitMCP-DeliveryThis 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.

API reference