Contacts API
The Contacts API follows the same conventions as Leads API: paginated lists, JSON bodies, JWT auth, tenant-scoped automatically.
List
GET /api/contacts/?account=<account_id>&search=jane
Authorization: Bearer <token>
{
"count": 84,
"next": null,
"previous": null,
"results": [
{
"id": "c012-…",
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@acme.com",
"phone": "+1 555 1234",
"title": "VP Engineering",
"account": { "id": "a001-…", "name": "Acme Corp" },
"linkedin_url": "https://linkedin.com/in/janedoe",
"owner": { "id": "u034-…", "email": "rep@yours.com" },
"custom_fields": { "preferred_channel": "email" },
"created_at": "2026-02-14T09:22:01Z"
}
]
}
Filterable fields: account, owner, search (matches name, email, phone), tags, created_at__gte, cf_<key>.
Create
POST /api/contacts/
Content-Type: application/json
Authorization: Bearer <token>
{
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@acme.com",
"phone": "+1 555 1234",
"title": "VP Engineering",
"account_id": "a001-…",
"custom_fields": { "preferred_channel": "email" }
}
account_id is optional, pass null for standalone contacts.
Update / delete
PATCH /api/contacts/<id>/ # partial
PUT /api/contacts/<id>/ # full
DELETE /api/contacts/<id>/ # hard delete
DELETE removes the row. There is no soft delete, no ?forget=true variant and no PII scrub step;
those were documented here and none of them exists.
Duplicates and merging
POST /api/contacts/duplicates/ {"email": ..., "phone": ..., "first_name": ..., "last_name": ...}
GET /api/contacts/<id>/duplicates/
POST /api/contacts/<keeper id>/merge/ {"merge_id": "<id merged away>"}
The check (a POST that writes nothing, so an email or phone never sits in a URL or an
access log; from django-crm 1.13.0 an API token needs only contacts:read for it) and the record GET answer up to ten contacts you can open, each with only id, name, email,
phone, matched_on and can_delete. A contact you cannot open is never listed or counted.
The merge needs both contacts readable (either one hidden or missing is 404) and the right to
delete the one merged away (an admin or its creator, else 403). The kept contact's values win and
its blank fields are filled from the other; every link moves across; the other contact is deleted,
with a contact.deleted webhook (its assigned_to lists the owners the merged contact had, even those the merge
moved to the kept one) and an audit-log entry. Portal sign-in codes for the deleted contact
are not carried over. Merging a contact into itself, or a malformed merge_id, is 400.
CSV import
Contacts use a two-step import rather than a single upload, so you can see what a file will do before it does it:
POST /api/contacts/import/preview/
POST /api/contacts/import/commit/
Both take multipart/form-data with a file field, capped at 5 MB and 5,000 rows. preview parses
and validates and reports per-row errors as {row, field, message} without writing anything;
commit re-validates and inserts every row or none. Both need an admin or a member with sales
access. The web app and the phone app call the same pair from their Import buttons.
GET /api/contacts/export/ takes the list's query string and returns the same contacts as a CSV.