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):
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
statusis"converted":LeadCreateSerializer.__init__flipsself.fields["email"].required = Trueexactly in that case, having otherwise madefirst_name,last_nameandsalutationoptional regardless of what theLeadmodel 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.deletedwebhook, and aRECORD_MERGEDrow naming both ids and both names is written to the audit log. There is no undo. From django-crm 1.13.0 that webhook'sassigned_tolists 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).