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/search and POST /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 /mcp endpoint is stateless Streamable HTTP, so a single curl POST 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).

KindSubjectFired when
approval_pendingcandidacyDormant — kept for already-delivered rows; nothing fires it any more.
message_receivedthreadA new message arrived on a thread.
mutual_interestpersonBoth sides have now expressed interest — the pair became mutual.
interview_schedule_updatedcandidacy (or job)An interview slot was proposed, confirmed, or cancelled.
candidacy_stage_changedcandidacyA candidacy moved to another pipeline stage.
approval_decidedcandidacyDormant — kept for already-delivered rows; nothing fires it any more.
job_publishedjobA job posting went live.
evaluation_requestedjobA staff member was asked to evaluate candidates against a job.
evaluation_reminderjobThe agent reminded a staff member about a still-unsubmitted evaluation request.
candidacy_advance_pendingcandidacyA candidacy entered the advance-decision queue and awaits a stage decision.
interview_slot_respondedcandidacy (or job)The candidate responded to a proposed interview slot.
counter_request_receivedjobDormant — kept for already-delivered rows; nothing fires it any more.
counter_request_resolvedjobDormant — kept for already-delivered rows; nothing fires it any more.
interview_completedcandidacyA confirmed interview slot's start time passed — result input is now open on both sides.
interview_result_recordedcandidacyThe counterpart recorded a positive interview result (negative results ride the stage-change events).
interview_remindercandidacy (or job)A confirmed interview slot's start is approaching (fired once per reminder window per recipient).
interview_debrief_remindercandidacyThe candidate's continue decision is still unanswered after a completed interview (at most two re-asks).
interview_slot_response_nudgecandidacy (or job)A proposed slot is nearing its start with no candidate response yet (fired once per slot per window).
interview_slot_confirmation_nudgecandidacy (or job)A candidate-accepted slot is nearing its start and is still unconfirmed (fired once per slot per window).
job_matching_pausedjobA job's own matching cap (monthly or total) was reached — new matching for that job paused.
material_request_receivedjobA material request was filed and awaits the candidate's answer.
material_request_answeredjobA filed material request was already answered by the candidate's standing policy — a receipt, no decision.
material_request_resolvedjobThe candidate shared or declined a material request.
agreement_answeredthreadThe 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_requestedthreadThe 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_cancelledcandidacyA 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 — subject person: Dormant — its fire point (an application arriving) retired; kept for already-delivered rows, nothing fires it any more.
  • proposal_received — subject organization: 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 — subject agreement: 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 — subject organization: An organization viewed a candidate's profile (the subject is the viewing organization, never the viewer).
  • recommendation — subject person: Reserved (dormant) — recommendation events; the vocabulary member exists but nothing fires it yet.
  • webhook_endpoint_disabled — subject webhook_endpoint: An endpoint was auto-disabled after consecutive failures. In-app only — never delivered over webhooks.
  • market_benchmark_update — subject organization: The disclosed market-rate digest changed for a subscribed organization.
  • credit_expiry_upcoming — subject billing_purchase: Unused mutual-interest credits from a purchase are approaching expiry.
  • credit_balance_low — subject organization: An organization's mutual-interest credit balance reached the low-water mark.
  • matching_paused — subject organization: New matching paused because the organization has no credits and auto-replenish is off.
  • matching_resumed — subject organization: A payment arrival lifted the organization's zero-balance pause.
  • material_disclosure_opened — subject organization: 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 of screening_passed, interviewing, offered, accepted, or declined.
  • record_interview_verdict — record the employer's verdict for one completed interview slot: slotId (from get_pipeline_candidacy) plus verdict of passed or declined.
  • convert_candidacy — convert an accepted candidacy into a hire record, optionally with startDate, title, and employmentType.
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.

On this page