Skip to content

Leads API

The Leads API is representative of every entity endpoint in BottleCRM, same shape, same conventions. Once you know this one, Contacts / Accounts / Opportunities / Tasks all behave identically.

Base URL

https://<your-host>/api/leads/

All requests require a valid bearer token (see Authentication).

List leads

GET /api/leads/
Authorization: Bearer <token>

Response:

{
  "count": 142,
  "next": "https://…/api/leads/?page=2",
  "previous": null,
  "results": [
    {
      "id": "ab12-cd34-…",
      "title": "Acme renewal interest",
      "first_name": "Jane",
      "last_name": "Doe",
      "email": "jane@acme.com",
      "status": "qualified",
      "source": "website_form",
      "assigned_to": { "id": "…", "email": "rep@yours.com" },
      "custom_fields": { "industry": "saas" },
      "created_at": "2026-04-01T10:14:33Z"
    }
  ]
}

Filtering

GET /api/leads/?status=qualified&source=website_form
GET /api/leads/?search=acme
GET /api/leads/?assigned_to=<profile_id>
GET /api/leads/?cf_industry=saas               # custom field filter
GET /api/leads/?ordering=-created_at

Filterable fields: status, source, assigned_to, tags, created_at__gte, created_at__lte, plus any custom field marked filterable.

Create a lead

POST /api/leads/
Content-Type: application/json
Authorization: Bearer <token>

{
  "title": "Acme renewal interest",
  "first_name": "Jane",
  "last_name": "Doe",
  "email": "jane@acme.com",
  "phone": "+1 555 1234",
  "status": "new",
  "source": "website_form",
  "custom_fields": { "industry": "saas", "bant_score": 72 }
}

201 Created returns the full record. Server-derived fields (org, created_by, id, created_at) must not be in the request body; they are populated by the view.

Retrieve / update / delete

GET    /api/leads/<id>/
PATCH  /api/leads/<id>/      # partial update, only fields in the body change
PUT    /api/leads/<id>/      # full update
DELETE /api/leads/<id>/      # hard delete

The detail GET response also includes custom_field_definitions so the frontend can render the right form fields without a second round-trip.

Convert a lead

There is no POST /api/leads/<id>/convert/. Conversion is a status change on the lead itself:

PUT /api/leads/<id>/
{ "status": "converted", ... }

Setting status to converted runs the conversion server-side: it creates the Account, the Contact and, where there is an amount, the Opportunity, links them to the lead, and returns account_id, contact_id and opportunity_id in the response so you can follow them without a second call.

Duplicates and merging

POST /api/leads/duplicates/                {"email": ..., "phone": ..., "first_name": ..., "last_name": ..., "company_name": ...}
GET  /api/leads/<id>/duplicates/
POST /api/leads/<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 leads:read for it) and the record GET answer up to ten open leads you can see, each with only id, name, email, phone, matched_on and can_delete. A company name only counts when no person name is given, since two people at one company are two leads.

The merge needs both leads readable (either one hidden or missing is 404) and the right to delete the one merged away (an admin or its creator, else 403). A converted lead on either side is 400. The kept lead's values, status and board position win and its blank fields are filled from the other; tasks, web form submissions, linked contacts, notes and files move across; the other lead is deleted, with a lead.deleted webhook (its assigned_to lists the owners the merged lead had, even those the merge moved to the kept one) and an audit-log entry.

CSV import and export

POST /api/leads/import/preview/
POST /api/leads/import/commit/
Content-Type: multipart/form-data

file=<leads.csv>

preview validates every row and writes nothing; commit re-validates and creates every row or none. Both need an admin or a member with sales access. The older POST /api/leads/upload/ (file under leads_file) is deprecated and now runs the same validation synchronously.

GET /api/leads/export/ takes the list's query string and returns the same leads as a CSV.

See CSV import and export for headers, limits and the response shapes.

Errors

Status Meaning
400 Validation failed. Body: { "errors": { "field": "reason" } }
401 Missing or invalid token
403 You can open the lead, but your role does not allow this action
404 Lead does not exist, belongs to another org, or is one you are not allowed to open
429 Rate limit exceeded

See Errors for the full structure.