The Tonic Desk API is a JSON REST API for reading and writing your CRM data from scripts, integrations, and AI tools.
Machine-readable spec: OpenAPI 3.1 (openapi.json) — also served live at https://app.tonicdesk.com/api/v1/openapi.json. If you're pointing an AI tool at this API, give it that file.
Connecting an AI assistant? See Connect AI to Tonic Desk (MCP) for a copy-paste setup.
Authentication
Create an API key in Settings → API Keys inside Tonic Desk (you need the settings permission on your account; keys are only ever created from inside the logged-in app). The key is shown once — store it in a secrets manager, never in code or a repo.
Send it on every request, either way works:
Authorization: Bearer crm_YOUR_KEY
X-API-Key: crm_YOUR_KEY
Keys are scoped to your organisation: the API can only ever see your own org's data. Keys can be revoked instantly from the same settings screen, and revoking a user's account revokes their keys with it.
Scopes
Every key carries a scope list, chosen at creation:
- Read-only (recommended, the default) — every
read:scope; all GET endpoints work, nothing can be modified. - Full access — read plus
write:contacts,write:companies,write:deals,write:activities,write:tasks.
A call outside the key's scopes returns 403 with "code": "insufficient_scope" and the scope it needed. Objects added to the API after a key was created aren't in that key's scope list — issue a fresh key to pick them up.
Base URL
https://app.tonicdesk.com/api/v1
Endpoints
| Resource | Methods | Notes |
|---|---|---|
/contacts · /contacts/:id | GET, POST, PUT/PATCH, DELETE | ?search= name/email; single GET includes custom_fields |
/contacts/:id/timeline | GET | Full activity history for one contact |
/companies · /companies/:id | GET, POST, PUT/PATCH, DELETE | ?search= name; create is dedup-guarded (returns the existing company + already_existed: true instead of a duplicate) |
/companies/:id/timeline | GET | |
/deals · /deals/:id | GET, POST, PUT/PATCH, DELETE | ?pipeline_id=; amounts, stages, close dates |
/deals/:id/move-stage | POST | {"stage_id": …} |
/deals/:id/close-won · /close-lost | POST | Optional {"reason": "…"} |
/deals/:id/reopen | POST | |
/deals/:id/timeline | GET | |
/activities · /activities/:id | GET, POST | Calls, emails, meetings, notes; ?type= |
/notes | GET, POST | Sugar over activities with type note; filter by contact_id/company_id/deal_id |
/tasks · /tasks/:id | GET, POST, PUT/PATCH, DELETE | ?status= |
/pipelines | GET | Pipelines with ordered stages |
/tags · /users · /custom-fields · /segments | GET | Directory objects (read-only) |
/segments/:id/contacts | GET | Members of a segment |
/search | GET | ?q= across contacts, companies, deals; contacts also match by linked-company name |
Full request/response shapes, parameters, and per-endpoint scopes are in the OpenAPI spec.
Pagination
All list endpoints take ?page= and ?per_page= (default 25, max 100) and return:
{
"data": [ ... ],
"meta": { "page": 1, "per_page": 25, "total": 142, "pages": 6, "has_next": true, "has_prev": false }
}
Incremental sync
Every list endpoint accepts ?updated_since=<ISO 8601 datetime>. Results are then ordered (updated_at, id) ascending so pages stay stable while you walk them — poll with the timestamp of the last record you processed instead of re-downloading everything.
Updates are partial
PUT and PATCH are equivalent: only the fields you send change.
Errors
{ "error": "Human-readable message", "code": "machine_readable_code" }
| Status | code | Meaning |
|---|---|---|
| 401 | missing_api_key / invalid_api_key | No key, or key invalid/revoked/expired |
| 403 | insufficient_scope | Key lacks the required scope (included in the response) |
| 400 | invalid_request | Bad parameter (message says which) |
| 400 | invalid_reference | A contact_id/company_id/deal_id/owner you referenced doesn't exist in your org |
| 404 | — | Record not found (or soft-deleted) |
| 429 | rate_limited | Slow down; the Retry-After header says how long |
Rate limits
60 requests per minute per API key. 429 responses carry a Retry-After header (seconds). If you need sustained higher throughput, use updated_since to pull incrementally rather than re-listing.
Quick test
curl -H "Authorization: Bearer crm_YOUR_KEY" \
"https://app.tonicdesk.com/api/v1/pipelines"