ATS integration
Wire an ATS or any external system to matchwire — subscribe to events, read state, write back outcomes.
matchwire integrates with applicant tracking systems through one generic recipe, not per-vendor connectors: subscribe to boundary events over webhooks, read current state through the authenticated MCP/REST surface, and write outcomes back with three MCP verbs. Anything that can receive an HTTPS POST and send an HTTPS request can run this loop — an ATS, a BI pipeline, or a homegrown script.
Two deliberate design points shape the recipe:
- Reads have both an MCP surface (tool reference) and a REST
mirror — but the REST mirror is intentionally limited to
POST /v1/searchandPOST /v1/matches(see the OpenAPI reference in this section). There is no REST CRUD for pipeline state. - Write-back is MCP-only, by design. The three write verbs exist solely as
MCP tools, decided by the credential's scopes and the job's published state. That is not a gap to wait
out: the
/mcpendpoint is stateless Streamable HTTP, so a singlecurlPOST with a bearer credential calls any tool — no MCP SDK, no session handshake.
Event catalog
A webhook endpoint belongs to ONE job (ADR-0187), so a delivery carries a
kind from the job-scoped notification vocabulary below — currently
26 kinds — and every payload names its jobId. Kinds are added over time;
parse tolerantly and route on the kinds you know (see
Choosing events).
| Kind | Subject | Fired when |
|---|---|---|
approval_pending | candidacy | Dormant — kept for already-delivered rows; nothing fires it any more. |
message_received | thread | A new message arrived on a thread. |
mutual_interest | person | Both sides have now expressed interest — the pair became mutual. |
interview_schedule_updated | candidacy (or job) | An interview slot was proposed, confirmed, or cancelled. |
candidacy_stage_changed | candidacy | A candidacy moved to another pipeline stage. |
approval_decided | candidacy | Dormant — kept for already-delivered rows; nothing fires it any more. |
job_published | job | A job posting went live. |
evaluation_requested | job | A staff member was asked to evaluate candidates against a job. |
evaluation_reminder | job | The agent reminded a staff member about a still-unsubmitted evaluation request. |
candidacy_advance_pending | candidacy | A candidacy entered the advance-decision queue and awaits a stage decision. |
interview_slot_responded | candidacy (or job) | The candidate responded to a proposed interview slot. |
counter_request_received | job | Dormant — kept for already-delivered rows; nothing fires it any more. |
counter_request_resolved | job | Dormant — kept for already-delivered rows; nothing fires it any more. |
interview_completed | candidacy | A confirmed interview slot's start time passed — result input is now open on both sides. |
interview_result_recorded | candidacy | The counterpart recorded a positive interview result (negative results ride the stage-change events). |
interview_reminder | candidacy (or job) | A confirmed interview slot's start is approaching (fired once per reminder window per recipient). |
interview_debrief_reminder | candidacy | The candidate's continue decision is still unanswered after a completed interview (at most two re-asks). |
interview_slot_response_nudge | candidacy (or job) | A proposed slot is nearing its start with no candidate response yet (fired once per slot per window). |
interview_slot_confirmation_nudge | candidacy (or job) | A candidate-accepted slot is nearing its start and is still unconfirmed (fired once per slot per window). |
job_matching_paused | job | A job's own matching cap (monthly or total) was reached — new matching for that job paused. |
material_request_received | job | A material request was filed and awaits the candidate's answer. |
material_request_answered | job | A filed material request was already answered by the candidate's standing policy — a receipt, no decision. |
material_request_resolved | job | The candidate shared or declined a material request. |
agreement_answered | thread | The candidate answered "proceed" to an agreement on terms independent of any job posting — the chat's topic is now those terms. The thread resolves no job, so no job endpoint receives it today; in-app and person channels carry it. |
proposal_requested | thread | The candidate asked the company, from the pair's chat, to propose another job. The request closes with the company's next proposal (jobs or terms). A job-topic chat resolves its job; an agreed-terms chat resolves none, so in-app and person channels carry it there. |
interview_cancelled | candidacy | A confirmed interview was cancelled — recorded with who and when, never a reason. The candidate receives it when the company cancels; the job's owners when the candidate does. |
Kinds whose subject says "(or job)" carry the job reference only for a slot
written before the selection record opened at the first proposal; every slot
proposed today hangs off a candidacy, so its subject is the candidacy.
The remaining notification kinds belong to no job — their subject is the
organization, a billing record, or the webhook endpoint itself, or they are
dormant person kinds nothing fires — and are never delivered over
webhooks (they stay in-app; read them with list_notification_deliveries).
A kinds filter naming one of them is refused at registration rather than
silently subscribed:
new_candidate— subjectperson: Dormant — its fire point (an application arriving) retired; kept for already-delivered rows, nothing fires it any more.proposal_received— subjectorganization: A company proposed one or more of its jobs, or terms independent of any job posting, to a candidate, who answers each row in Decisions (interested or pass) — a proposed job on its match card, the terms on the company's one item. Candidate-side, one delivery per proposal. In-app only — never delivered over webhooks.agreement_recorded— subjectagreement: A company recorded that terms were agreed independently of any job posting; the candidate answers in Decisions (proceed or decline). Candidate-side, one item per record; carries nothing of the terms.profile_view— subjectorganization: An organization viewed a candidate's profile (the subject is the viewing organization, never the viewer).recommendation— subjectperson: Reserved (dormant) — recommendation events; the vocabulary member exists but nothing fires it yet.webhook_endpoint_disabled— subjectwebhook_endpoint: An endpoint was auto-disabled after consecutive failures. In-app only — never delivered over webhooks.market_benchmark_update— subjectorganization: The disclosed market-rate digest changed for a subscribed organization.credit_expiry_upcoming— subjectbilling_purchase: Unused mutual-interest credits from a purchase are approaching expiry.credit_balance_low— subjectorganization: An organization's mutual-interest credit balance reached the low-water mark.matching_paused— subjectorganization: New matching paused because the organization has no credits and auto-replenish is off.matching_resumed— subjectorganization: A payment arrival lifted the organization's zero-balance pause.material_disclosure_opened— subjectorganization: A selection milestone auto-opened the candidate's material(s) to an organization.
The recipe
1. Get a credential
An organization admin mints an API credential in the product UI under
Org settings → Connections (/org/connections) — pick the scopes, and
copy the mw_sk_* secret when it is shown (once; it cannot be read back).
This recipe needs notification:read, notification:write, pipeline:read,
and pipeline:write.
2. Calling MCP tools with curl
/mcp is stateless: every call is a self-contained JSON-RPC POST — no
session to establish, no state between calls. The response is a one-event
SSE stream (Content-Type: text/event-stream) whose data: line is the
JSON-RPC response; both Accept values below are required.
curl -s https://your-matchwire-host/mcp \
-X POST \
-H "Authorization: Bearer $MW_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "<tool name>", "arguments": {} }
}'3. Subscribe to events
Register an HTTPS endpoint on the job with create_webhook_endpoint
(jobId is required — an endpoint belongs to one job; register one per job
you integrate), optionally filtered to the kinds you handle. The signing
secret (mw_whsec_*) is returned once in this response — store it now.
curl -s https://your-matchwire-host/mcp \
-X POST \
-H "Authorization: Bearer $MW_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "create_webhook_endpoint",
"arguments": {
"jobId": "<the job's id — the job settings tab's URL carries it>",
"url": "https://ats.example.com/hooks/matchwire",
"kinds": ["candidacy_stage_changed", "interview_completed", "candidacy_advance_pending"]
}
}
}'Signature verification, the delivery contract (retries, auto-disable, at-least-once semantics), and endpoint management are covered on the webhooks page — implement its verification steps before going live.
4. Read state on each event
Delivery payloads are thin — identifiers only, never content. Re-fetch the
current state through your credential using the matching read tool for the
subject.kind: a candidacy via get_pipeline_candidacy, a job via
get_job_posting, a thread via get_employer_thread. For example, on a
candidacy_stage_changed delivery:
curl -s https://your-matchwire-host/mcp \
-X POST \
-H "Authorization: Bearer $MW_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_pipeline_candidacy",
"arguments": { "candidacyId": "<subject.ref from the delivery>" }
}
}'The same disclosure gate that governs the product UI applies here, so a webhook can never leak what your credential could not read.
5. Write outcomes back
Three verbs cover the ATS write-back surface (scope pipeline:write):
transition_candidacy— move a candidacy along the employer edges:"to"is one ofscreening_passed,interviewing,offered,accepted, ordeclined.record_interview_verdict— record the employer's verdict for one completed interview slot:slotId(fromget_pipeline_candidacy) plusverdictofpassedordeclined.convert_candidacy— convert anacceptedcandidacy into a hire record, optionally withstartDate,title, andemploymentType.
curl -s https://your-matchwire-host/mcp \
-X POST \
-H "Authorization: Bearer $MW_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "transition_candidacy",
"arguments": { "candidacyId": "<candidacy id>", "to": "interviewing" }
}
}'Writes are decided by the credential and the job's published state. A
write runs when the credential carries the tool's scope and the job it acts
on is published; an unpublished job refuses with job_not_published, and a
credential without the scope with insufficient_scope. Nothing is filed
for anyone to approve — publish the posting (or re-mint the credential with
the scope) and retry.