Beta. These endpoints are switched on per organization. While they are off for yours, each one answers as an unknown route (
404). Ask Finn support to turn them on.
Overview
The CRM API does what a person does in the Finn CRM: search and filter records, read one record in full, edit fields, keep follow-ups, fill forms, handle documents and import a list. It is the API behind the MCP server's CRM tools.
It acts as one person. Call it with that person's token: a personal MCP key (finn_mcp_...) or an AI app's OAuth access token (finn_oat_...). Each request sees only the records that person can see. An org API key gets 403 user_token_required; org keys use the Records endpoints (/contacts) instead.
Base URL: https://api.hirefinn.ai/api/v1. Auth: Authorization: Bearer finn_mcp_.... If the person belongs to more than one organization, send Finn-Org with the organization's ID. See authentication and scopes.
Responses are the CRM's own objects, with camelCase keys. Errors use the shape on api-errors. A CRM check that refuses a request keeps its own error.code, for example invalid_spec or contact_exists.
Setup and schema
| Method | Path | Scope | What it does |
|---|---|---|---|
GET | /crm/setup | crm:read | Stages, close reasons, fields, record kinds and workspace settings. |
GET | /crm/filter_schema | crm:read | The fields and operators a query spec may use. |
Read both once and cache them. The stage and field keys in every other call come from here.
Finding records
| Method | Path | Scope | What it does |
|---|---|---|---|
POST | /crm/records/search | crm:read | Free-text search. Body: q, optional limit. |
POST | /crm/records/query | crm:read | Records that match a filter spec, sorted by sort and dir. |
POST | /crm/records/query/count | crm:read | How many records match a spec. |
POST | /crm/records/query/stage_counts | crm:read | Per-stage counts for a spec. |
GET | /crm/duplicates | crm:read | Groups of records that look like the same person or company. |
POST | /crm/ask | crm:read | A question in plain words (text), answered from the records this person can see. |
POST /crm/records/query pages with a cursor. Put limit and cursor in the query string. Pass next_cursor back as cursor with the same body. It is null on the last page. total_count is the number of matching records. See pagination.
One record
| Method | Path | Scope | What it does |
|---|---|---|---|
GET | /crm/records/{id} | crm:read | The record with its fields, stage and links. |
GET | /crm/records/{id}/timeline | crm:read | Notes, calls, messages, stage moves and other activity, newest first. |
GET | /crm/records/{id}/stage_gate | crm:read | The checks the record needs before it can move to stage to, and which are done. |
POST | /crm/records | crm:write | Create a record. phone is required. A number the organization already has gets 409. |
PATCH | /crm/records/{id} | crm:write | Edit name, custom fields, tags and links. clearFields empties fields. |
POST | /crm/records/{id}/notes | crm:write | Add a comment to the timeline. mentions are user IDs to notify. |
PUT | /crm/records/{id}/stage_checks/{stage}/{check} | crm:write | Tick ("state": "done") or clear (null) a stage gate check. Checks can't be waived here. |
POST | /crm/records/{id}/ref_no | crm:write | Give the record its next reference number, if it has none. No body. |
Sensitive field values are hidden unless the token also has crm:sensitive:read. To refuse an edit when someone else changed a field first, send the values you expect in expectCustomFields.
PATCH does not move the stage. Use POST /crm/records/{id}/stage_moves, below.
Files and documents
| Method | Path | Scope | What it does |
|---|---|---|---|
GET | /crm/records/{id}/files | crm:read | Files on the record: names, types and dates. No download links. |
POST | /crm/records/{id}/files | crm:docs:write | Attach files (multipart field file), optionally as document type doc_type. Not held. |
GET | /crm/records/{id}/documents | crm:read | The record's document checklist and each document's status. |
POST | /crm/records/{id}/documents/{type}/status | crm:write | Move a checklist document to status to, with an optional reason, note and file. |
POST | /crm/records/{id}/doc_drafts | crm:write | Draft a document for the record from a template (template_id). |
Forms
| Method | Path | Scope | What it does |
|---|---|---|---|
GET | /crm/forms | crm:read | The organization's forms. |
GET | /crm/records/{id}/forms_checklist | crm:read | The forms this record needs, and which are done. |
GET | /crm/records/{id}/submissions | crm:read + crm:sensitive:read | Submitted forms with their answers. |
GET | /crm/records/{id}/form_drafts | crm:read + crm:sensitive:read | Unsubmitted drafts with their answers. |
PUT | /crm/records/{id}/forms/{form_id}/draft | crm:write + crm:sensitive:read | Save answers to the draft. version refuses the save if the draft changed meanwhile. |
Follow-ups and work
| Method | Path | Scope | What it does |
|---|---|---|---|
GET | /crm/follow_ups | crm:read | Follow-ups, soonest due first. Filters: view, status, due, assignee, contact, tz. Cursor pages. |
GET | /crm/follow_ups/stats | crm:read | Open, overdue and due-today counts. |
POST | /crm/records/{id}/follow_ups | crm:write | Create a follow-up. title is required. |
PATCH | /crm/follow_ups/{id} | crm:write | Complete, snooze, reschedule or reassign. expectDueAt refuses the change if the due date moved. |
GET | /crm/my_work | crm:read | This person's queue: their records and follow-ups that need action. |
Views and reports
| Method | Path | Scope | What it does |
|---|---|---|---|
GET | /crm/views | crm:read | Saved views (filters and columns) this person can use. |
POST | /crm/views | crm:write | Save a personal view. |
GET | /crm/summary | crm:read | Totals across the pipeline. |
GET | /crm/stage_counts | crm:read | How many records are in each stage. |
GET | /crm/speed | crm:read | Response times for new leads, for a period. |
Writes held for approval
These writes reach outside the CRM or change a lot at once. Finn holds each one for the person to approve. The answer is 202 with "status": "pending_approval" and an approval_id. Nothing runs until the person approves. Once approved, it runs exactly as sent. See approvals.
| Method | Path | Scope | What it does |
|---|---|---|---|
POST | /crm/records/{id}/stage_moves | crm:stage | Move the record to stage. Send closeReason and substatus when it closes. Every stage gate still applies. |
POST | /crm/records/{id}/forms/{form_id}/submissions | crm:stage | Submit a form as staff, with JSON answers. No files. |
POST | /crm/records/{id}/forms/{form_id}/links | crm:share | A link someone outside the organization fills the form with. expires_in_days: 1 to 30. |
POST | /crm/records/{id}/upload_links | crm:share | A link someone outside the organization uploads documents with. expires_in_days: 1 to 30. |
POST | /crm/imports | crm:import | Import up to 500 rows. A dry run (dry_run, the default) runs at once and writes nothing. A real run is held. |
POST | /crm/imports/columns | crm:import | Suggest which CRM field each CSV header fills. Runs at once. |
Holds need the organization's API approvals switched on. With them off, a held write gets 403 approval_unavailable.
Example: move a record to another stage
Check the gate first, so you know the move can pass:
curl "https://api.hirefinn.ai/api/v1/crm/records/5b0e8d2a-7c1f-4e3b-9a6d-2f4c8b1e0a73/stage_gate?to=qualified" \
-H "Authorization: Bearer $FINN_MCP_KEY"
Then ask for the move:
curl -X POST https://api.hirefinn.ai/api/v1/crm/records/5b0e8d2a-7c1f-4e3b-9a6d-2f4c8b1e0a73/stage_moves \
-H "Authorization: Bearer $FINN_MCP_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: move-5b0e8d2a-qualified" \
-d '{ "stage": "qualified" }'
The move is held for the person to approve. The answer (some fields left out):
{
"status": "pending_approval",
"approval_id": "3d1f9b2c-6a4e-4f0b-8c7d-1e2f3a4b5c6d",
"approve_url": "https://www.hirefinn.ai/approvals/holds/3d1f9b2c-6a4e-4f0b-8c7d-1e2f3a4b5c6d",
"expires_at": "2026-11-08T09:00:00.000Z",
"action": "contact.stage_move"
}
Show approve_url to the person, or poll GET /approvals/{id} until it is decided. A retry with the same Idempotency-Key returns the same hold.
Rate limits
Reads and writes have separate per-key limits. The current numbers are on rate limits.