Skip to content

Leads

Routes in backend/leads/urls.py, views split across leads/views/lead_views.py (list and detail) and leads/views/lead_interactions.py (comment and attachment editing). See Conventions for pagination, filtering and response-shape rules this page assumes rather than repeats, and Errors for the 404-vs-403 principle referenced below.

List leads

GET /api/leads/ (LeadListView.get, backend/leads/views/lead_views.py:246-248) returns two separately paginated sections rather than one list, open leads and closed leads, because the view excludes converted leads from the queryset entirely and then splits what's left on status:

queryset = (
    self.model.objects.filter(org=self.request.profile.org)
    .exclude(status="converted")
    ...
)
...
queryset_open = queryset.exclude(status="closed")
...
queryset_close = queryset.filter(status="closed")

(lead_views.py:90-98,166,187.) A non-admin caller only sees leads assigned to them or created by them (lead_views.py:99-103); an admin or superuser sees every lead in the org. The response follows the hand-rolled envelope Conventions describes for this exact endpoint:

{
  "per_page": 10,
  "page_number": [1],
  "open_leads": {"leads_count": 42, "open_leads": [ { "...": "LeadSerializer" } ], "offset": 10},
  "totals": {"count": 42, "unworked_over_a_week": 5},
  "close_leads": {"leads_count": 8, "close_leads": [ { "...": "..." } ], "offset": null},
  "contacts": [{"id": "...", "first_name": "..."}],
  "status": [["assigned", "Assigned"], ["in process", "In Process"], ["converted", "Converted"], ["recycled", "Recycled"], ["closed", "Closed"]],
  "source": [["call", "Call"], ["email", "Email"], ["existing customer", "Existing Customer"], ["partner", "Partner"], ["public relations", "Public Relations"], ["compaign", "Campaign"], ["other", "Other"]],
  "tags": [{"...": "..."}],
  "users": [{"id": "...", "user__email": "..."}],
  "countries": [["GB", "United Kingdom"], ["AF", "Afghanistan"]],
  "industries": [["ADVERTISING", "ADVERTISING"]]
}

page_number is worth calling out: the view builds it as (int(self.offset / 10) + 1,) (lead_views.py:178) (a one-element Python tuple, trailing comma and all) which the JSON renderer turns into a one-element array like [1], not a bare integer. totals.unworked_over_a_week counts leads in the filtered, org-scoped open queryset whose last_contacted (or, absent that, creation date) is more than 7 days old (UNWORKED_AFTER_DAYS, lead_views.py:58,60-86), not a stored status, just a computed staleness signal.

source accepts only the values in LEAD_SOURCE (backend/common/utils.py:59-67): call, email, existing customer, partner, public relations, compaign, other. There is no website value in this enum: that spelling belongs to a completely different, uppercase vocabulary (Opportunity.lead_source, using SOURCES; see Opportunities). Note also that compaign is not a typo in this documentation. It is the literal stored value for "Campaign" leads. A client that sends source=campaign (the correctly-spelled word) gets zero rows back, not an error, because ?source= is an exact match (lead_views.py:115-116), and a client that tries to create a lead with source: "campaign" gets a validation error, because campaign is not one of the choices Lead.source accepts (leads/models.py:75-77).

Filters beyond source and status, name, salutation, assigned_to, tags, city, email, rating, search, created_at__gte/lte, close_date__gte/lte, cf_<key>, are documented in full, with exact match-vs-contains semantics for each, in Conventions: Filtering and search, which uses this exact endpoint as its example.

Create a lead

Capturing leads from your own website

You do not need this endpoint, or any credential in your site's HTML, to collect leads from a public page. Build a form in Settings and embed the snippet it gives you: see Web forms. That route is anonymous, origin-restricted, rate-limited and captcha-capable, and it deduplicates repeat submissions by email. POST /api/leads/create-from-site/ is the older web-to-lead endpoint, still supported but deprecated, and it requires an authenticated caller.

POST /api/leads/ (LeadListView.post, lead_views.py:273-409) accepts multipart/form-data (for an optional lead_attachment file, read straight off request.FILES, lead_views.py:331) or JSON. The body is validated by LeadCreateSerializer (leads/serializer.py:88-224); see Fields below for exactly which keys it accepts. On success (200, not 201. See Errors: Status codes):

{"error": false, "message": "Lead Created Successfully"}

Sending "status": "converted" on create runs the lead straight through leads.services.convert_lead_to_account and returns a different shape instead:

{
  "error": false,
  "message": "Lead Converted Successfully",
  "account_id": "<uuid>",
  "contact_id": "<uuid or null>",
  "opportunity_id": "<uuid or null>"
}

A validation failure returns 400 with {"error": true, "errors": {...}} (lead_views.py:406-409), the shape Errors documents. Two validators worth knowing about before you integrate:

  • Email uniqueness is case-insensitive per org, and only checked when an email is actually supplied (validate_email, leads/serializer.py:107-134): {"error": true, "errors": {"email": ["Another lead in this organisation already uses that email address."]}}.
  • Email becomes required, but only when status is "converted": LeadCreateSerializer.__init__ flips self.fields["email"].required = True exactly in that case, having otherwise made first_name, last_name and salutation optional regardless of what the Lead model itself requires (leads/serializer.py:100-104). Every other field on this serializer is optional; see Fields.

tags, contacts, teams and assigned_to are not part of LeadCreateSerializer. The view reads each straight off the request body after the serializer succeeds, resolved against org=request.profile.org once parsed. They are not handled uniformly, and the gap matters because this same endpoint accepts multipart/form-data (above). See Fields for the exact per-verb, per-field table and the concrete failure a multipart request hits.

Retrieve, update, delete

GET /api/leads/{id}/ (LeadDetailView.get, lead_views.py:568-571) looks the lead up scoped to the caller's org (get_object, :416-417. A lead in another org is a 404, same as one that never existed, matching the cross-org rule Errors states generally). It then calls assert_lead_access (:419-443): an admin or superuser sees any lead; anyone else must be the lead's creator or one of its assignees, or the request is refused with a 403, not a 404, even though LeadListView would have hidden that same lead from that same user entirely. Errors: Not found versus forbidden doesn't name leads at all, its own text says the within-org choice between 403 and 404 "depends on what the resource is," naming invoices (403) and personal macros (404) as its two worked examples, so treat this paragraph, not that page, as the source for what leads actually does: there is no comment or test in leads/ recording this as a deliberate disclosure decision the way there is for invoices or macros; it's simply the behavior as written. The response nests the record under lead_obj, matching the shape Conventions documents for this exact endpoint, alongside attachments, comments, users_mention, assigned_data, users, users_excluding_team, source, status, teams, countries and custom_field_definitions (lead_views.py:513-540).

PUT /api/leads/{id}/ (:674-823) and PATCH /api/leads/{id}/ (:847-1064) both enforce the same creator-or-assignee-or-admin rule before touching anything, and both can convert the lead when status (or, for PATCH only, is_converted) is set. leads/workflow.py is explicit that this guard covers less than "any already-closed-off status": it deliberately separates two ideas that used to be conflated under one name (leads/workflow.py:11-30). IRREVERSIBLE_STATUSES is {"converted"} alone (:34), converting an already-converted lead a second time is refused with 400 rather than silently creating a second Opportunity against the same Account. CLOSING_STATUSES is the separate, deliberately-reversible set {"closed"} (:37; "reopening a closed lead is ordinary work," per the module docstring at :24), and recycled is in neither set. Converting a closed or recycled lead succeeds and runs the full conversion, same as converting an assigned or in process one. There is no guard against it. PATCH's conversion branch returns before LeadCreateSerializer (and its validate_status, which enforces the same IRREVERSIBLE_STATUSES-only rule) ever runs, so it repeats the check inline instead (lead_views.py:875-894). Every converting write (POST, PUT or PATCH) runs in one transaction with the lead row locked (_atomic_when_converting): a failure at any step leaves no account, contact or deal and the lead unconverted, and a second conversion arriving at the same moment waits for the first, then reads the lead as converted and is refused. The emails to assignees are sent only once it commits. Both verbs re-attach tags/contacts/teams/ assigned_to from the request body when present, but, like POST, not identically across the four fields; see Fields for the exact per-verb table. Success returns {"error": false, "message": "Lead updated Successfully"} (or the conversion shape above); failure returns {"error": true, "errors": {...}}.

The detail response also carries can_delete: whether this caller's DELETE would be accepted, so a client offers the action only then.

DELETE /api/leads/{id}/ (:1080-1095) allows an admin, a superuser, or the lead's own creator (request.profile.user == self.object.created_by), anyone else gets 403, and additionally requires the lead's org to match the caller's, even though get_object already guarantees that by construction.

Comments and attachments

Adding a comment or an attachment to a lead is not a separate endpoint: it's the same POST /api/leads/{id}/ route the create endpoint uses, just with an existing pk (LeadDetailView.post, lead_views.py:589-651). The caller must be an admin/superuser, the lead's creator, or one of its assignees (:599-610). Send comment (text) and/or lead_attachment (a multipart file), either or both, and the response echoes the same lead_obj/attachments/comments shape as GET.

Editing or removing an existing comment goes through a dedicated route: PUT / PATCH / DELETE /api/leads/comment/{id}/ (LeadCommentView, backend/leads/views/lead_interactions.py:60-183), restricted to an admin, a superuser, or the comment's own author (request.profile == obj.commented_by; commented_by is a Profile foreign key, so this comparison is correct, unlike some created_by comparisons elsewhere in this codebase. See Architecture: Permissions and roles).

DELETE /api/leads/attachment/{id}/ (LeadAttachmentView.delete, lead_interactions.py:203-221) has a real gap, documented in Architecture: Permissions and roles: the lookup is self.model.objects.get(pk=pk) with no org= filter at all (lead_interactions.py:204), so the id space for attachment deletion is not scoped to the caller's org by the ORM query, only PostgreSQL RLS, if correctly configured for the deployment, stands between this endpoint and a cross-tenant delete. The ownership check on the same endpoint (request.profile.user == self.object.created_by, :208) is correct: it compares a User to a User, so once an attachment is found, only its uploader, an admin, or a superuser can delete it; the defect is specifically the missing org scope in the lookup, not the permission check.

Duplicates and merging

POST /api/leads/duplicates/ takes the fields a create form has typed so far as a JSON body (email, phone, first_name, last_name, company_name) and answers {"duplicates": [...]}: at most 10 active leads, newest first. GET /api/leads/{id}/duplicates/ answers the same list for a saved lead, leaving the lead itself out, plus can_delete for that lead. Each hit carries only id, name, email, phone, matched_on (the rules it matched) and can_delete (whether the caller could merge it away), never the record. Both search only the leads the caller may open, so a hidden one is neither listed nor counted, and a hidden or missing {id} is the same 404. A value longer than the model allows is a 400. The create-form check is a POST that writes nothing, so that an email address and a phone number never land in a URL and the access logs that record it. From django-crm 1.13.0 a token needs only the module's read scope (leads:read) for it, because it writes nothing; before that it needed leads:write. It is throttled at 120 requests a minute per user, and the clients debounce well under that.

Matching: email, case-insensitive; first and last name together; the company name exactly, but only when no person name is given, because two people at one company are two leads. Converted leads are never matched. Phone numbers match on their last ten digits (or every digit of a shorter number, seven at least), whatever the separators.

POST /api/leads/{id}/merge/ with {"merge_id": "<uuid>"} merges the lead named in the body into the one in the URL. The caller must be able to open both (either one hidden or missing is the same 404), must be allowed to edit the one kept (for leads, anyone who can open it), and must hold the delete rule on the one merged away (admin, superuser or its creator), else 403. Merging a lead into itself, or a missing or malformed merge_id, is a 400. A converted lead, on either side, is a 400: it already became an account, a contact and a deal. Both rows are locked for the merge, which runs in one transaction:

  • Every link to the merged lead moves to the kept one: tasks, web form submissions and linked contacts, comments, attachments and activity. A link the kept lead already has is not doubled.
  • The kept lead's values win. Its blank fields, including custom fields key by key, take the other's values; owners (assigned_to, teams) are taken only when the kept lead has none; tags and linked contacts are the union of both. The kept lead keeps its own status, pipeline stage and board position.
  • The merged lead is then deleted, which sends the usual lead.deleted webhook, and a RECORD_MERGED row naming both ids and both names is written to the audit log. There is no undo. From django-crm 1.13.0 that webhook's assigned_to lists the owners the merged lead had before the merge, even when they moved to the kept one; before, it was built after the move and could list nobody.

The response is {"error": false, "message": "...", "id": "<kept id>"}.

Fields

LeadCreateSerializer.Meta.fields (leads/serializer.py:178-213) is what POST /api/leads/, PUT /api/leads/{id}/ and PATCH /api/leads/{id}/ accept. Every field the Lead model itself declares is optional (blank=True, null=True, or has a default) except first_name and last_name, which have null=True but not blank=True at the model level and would default to required under DRF's normal derivation: the serializer's __init__ explicitly overrides both to required=False (leads/serializer.py:102-103, leads/models.py:49-50). The practical result: a lead can be created with no name at all, as long as status isn't "converted" (which requires email instead. See Create a lead).

Field Type Required Notes
title string optional Free text, e.g. "Enterprise Deal"
salutation string optional
first_name string optional Forced optional by the serializer; see above
last_name string optional Forced optional by the serializer; see above
email string required only when status: "converted" Must be unique per org, case-insensitive
phone string optional
job_title string optional
website string optional Free text, not validated as a URL
linkedin_url string optional Validated as a URL
status one of LEAD_STATUS optional assigned, in process, converted, recycled, closed
source one of LEAD_SOURCE optional call, email, existing customer, partner, public relations, compaign, other, no website
industry one of INDCHOICES optional e.g. SOFTWARE
rating HOT | WARM | COLD optional
opportunity_amount decimal optional Non-negative (DB constraint)
currency one of CURRENCY_CODES optional Defaults from the org's default_currency if omitted and opportunity_amount is set
probability integer, 0-100 optional
close_date date optional
address_line, city, state, postcode string optional
country one of COUNTRIES optional
last_contacted, next_follow_up date optional
description text optional
company_name string optional
is_active boolean optional Defaults true

Not part of the serializer, but accepted in the same request body and resolved by the view on POST/PUT/PATCH (each a list of ids, org-scoped): tags, contacts, teams, assigned_to; plus lead_attachment (a multipart file). These four id-list fields are not parsed the same way on every verb, and the difference is a real trap for a client that also uploads a file (multipart fields arrive as strings, never as JSON arrays):

Field POST /api/leads/ PUT /api/leads/{id}/ PATCH /api/leads/{id}/
tags plain id__in=data.get("tags"), no string handling (lead_views.py:318-323) same gap (:715-721) json.loads'd if a string, {"id": …} unwrapped (:981-995)
contacts same gap (:325-329) json.loads'd if a string (:732-745) json.loads'd if a string (:997-1011)
teams json.loads'd if a string (:340-350) same (:747-758) same (:1013-1027)
assigned_to json.loads'd if a string (:352-364) same (:760-773) same (:1029-1043)

Sending tags or contacts as a JSON-encoded string in the same request as a multipart/form-data lead_attachment upload (on POST, or on PUT for tags specifically) hands Django's id__in lookup a raw string. Django iterates it character by character; every character fails UUIDField parsing, and the request dies as an unhandled 500 rather than a clean 400, the same failure mode already documented for a different field at contacts/views.py:105-108. Sending those two fields as a real JSON body (no file) works fine, because DRF parses a JSON request body into an actual list before the view ever reads it.

GET /api/leads/{id}/ and GET /api/leads/ additionally return, but never accept as input: id, created_by, created_at, updated_at, is_sample (server-set only. See leads/serializer.py:80-85), lead_attachment and lead_comments (nested read-only serializations of this lead's attachments and comments: leads/serializer.py:64-65), stage and kanban_order (managed by the Kanban endpoints, not this page), and custom_fields (validated separately against the org's CustomFieldDefinition rows).