Skip to content

Conventions

This page describes patterns that hold across most of the API: how the base URL is put together, how a client authenticates, how list endpoints paginate and filter, and what a response body looks like. It is deliberately drawn from the code that is actually there (a real pagination class, a real list view, the real REST_FRAMEWORK settings) rather than from what a typical DRF API "should" look like. Several of these conventions are less uniform than that would suggest, and the differences matter to anyone integrating against them.

Base URL

Every endpoint in this section, and every endpoint in Endpoint index, is mounted under /api/ at the Django root (backend/crm/urls.py: path("api/", include("common.app_urls", ...))), which in turn includes backend/common/urls.py, the file most of this section's page references point at, at the same, empty prefix. Individual apps are mounted under their own segment from there, for example path("leads/", include("leads.urls", ...)), so a lead endpoint is /api/leads/….

Locally this is http://localhost:8000/api/ (the default in the Docker quick start's .env.docker, and what PUBLIC_DJANGO_API_URL points the frontend at. See Docker quick start). In a self-hosted deployment it is whatever host you put DOMAIN_NAME / ALLOWED_HOSTS on, still under /api/.

Authentication header

Three credential types authenticate a request, tried by common.middleware.get_company.GetProfileAndOrg in this order and, independently, by the DRF authentication classes in DEFAULT_AUTHENTICATION_CLASSES (backend/crm/settings.py, common.pat_auth.PATAuthentication, rest_framework_simplejwt.authentication.JWTAuthentication, common.external_auth.APIKeyAuthentication):

Authorization: Bearer <jwt-access-token>          # interactive sign-in (see authentication.md)
Authorization: Bearer bcrm_pat_...                 # personal access token
Token: bcrm_pat_...                                 # personal access token, alternate header
Token: <org-api-key>                                # organization API key

A personal access token is recognized by its bcrm_pat_ prefix and can be presented either as a bearer token or in the Token header (backend/common/pat_auth.py, _extract_raw); an organization API key is only ever read from Token (backend/common/external_auth.py, APIKeyAuthentication.authenticate). See Authentication for how a JWT is obtained and Tokens and API keys for the other two. Which identity a request authenticates as, and which org it is scoped to, is never something the request supplies directly. It is derived from whichever of these three the server can validate.

The two non-interactive credentials are bounded in ways a signed-in session is not. A personal access token is limited to its scopes (<resource>:<action>, enforced in middleware before the view runs; an empty list means unrestricted). The organization API key is read-only. Neither may reach /api/profile/tokens/, /api/org/tokens/ or /api/org/api-key/ at all: credential management requires an interactive sign-in. See Tokens and API keys.

One thing the generated OpenAPI schema does not reflect accurately: a large minority of operations (112 of the schema's 350, about a third) declare an org header parameter (organization_params_in_header, defined in backend/common/swagger_params.py and copied into the swagger_params.py of six other apps: leads, cases, opportunity, contacts, accounts and tasks). Nothing in the request-handling code reads a header by that name. Org context comes only from the JWT's org_id claim, the PAT's owning profile, or the API key's associated org, as above. Sending an org header has no effect; do not rely on it.

Pagination

List endpoints paginate with rest_framework.pagination.LimitOffsetPagination (DEFAULT_PAGINATION_CLASS in backend/crm/settings.py), using the query parameters limit and offset. The default page size is PAGE_SIZE = 10 from the same settings block; there is no max_limit set on the pagination class, so a caller can request a larger page and get it.

That default pagination class is not applied automatically, though. There is no generics.ListAPIView anywhere in this codebase. Every list endpoint is a hand-written APIView that mixes LimitOffsetPagination in directly and calls self.paginate_queryset(...) itself, for example:

class LeadListView(APIView, LimitOffsetPagination):
    ...
    def get_context_data(self, **kwargs):
        ...
        results_leads_open = self.paginate_queryset(
            queryset_open.distinct(), self.request, view=self
        )

(backend/leads/views/lead_views.py:51, :167.) The same shape repeats in common/views/user_views.py, common/views/team_views.py, common/views/document_views.py, common/views/tags_views.py, cases/views.py, contacts/views.py, accounts/views.py, opportunity/views/opportunity_views.py, tasks/views/task_views.py and invoices/api_views.py, among others.

Because pagination is applied by hand, the response envelope is a per-endpoint choice, not a guarantee. The common case in this codebase is that a view builds its own shape rather than calling DRF's get_paginated_response(): GET /api/leads/ reports pagination per section, since the list is split into open and closed leads:

context["open_leads"] = {
    "leads_count": self.count,   # LimitOffsetPagination's own running total
    "open_leads": open_leads,     # this page's serialized results
    "offset": offset,             # offset to request for the next page, or None on the last page
}

(backend/leads/views/lead_views.py:180-184.) But get_paginated_response(), and with it, DRF's standard count/next/previous/results shape, does appear: GET /api/cases/solutions/ (SolutionListView, backend/cases/solution_views.py:56, routed at backend/cases/urls.py:59) calls it directly and adds one extra key on top:

response = self.get_paginated_response(serializer.data)
response.data["totals"] = counts
return response

(backend/cases/solution_views.py:140-142.) So both shapes are real. Treat the exact pagination field names, and whether the standard DRF envelope or a hand-rolled one is used, as per-endpoint: limit/offset as query parameters are consistent, but where the resulting count, next-offset and results end up in the response body is not; check the endpoint you're calling.

There is no shared filter backend; DEFAULT_FILTER_BACKENDS is not set in REST_FRAMEWORK, and no view uses django_filters. Each list view reads request.query_params directly inside its own get/get_context_data and applies whatever filters it defines. GET /api/leads/ (backend/leads/views/lead_views.py:105-160) is representative of how rich these can get:

Query parameter Matches
name first_name or last_name, case-insensitive contains
salutation case-insensitive contains
source exact
assigned_to repeatable; assigned_to__id__in
status exact
tags repeatable; tags__id__in
city case-insensitive contains
email case-insensitive contains
rating exact
search first_name, last_name, company_name or email, case-insensitive contains, OR'd together
created_at__gte, created_at__lte date range
close_date__gte, close_date__lte date range
cf_<key> custom field <key> equals the given value (custom_fields__contains)

For example, GET /api/leads/?status=assigned&source=partner&search=acme; status and source are exact matches against LEAD_STATUS and LEAD_SOURCE (backend/common/utils.py:50-67: statuses are assigned, in process, converted, recycled, closed; sources are call, email, existing customer, partner, public relations, compaign, other), so a filter value outside those exact strings matches nothing rather than erroring. Other list endpoints define their own, generally smaller, filter sets in the same style. There is no single reference for "every filter the API supports"; read the view (or watch for a dedicated section on that resource's page, where one exists) rather than assuming this table applies elsewhere.

Response shape

There is no single response envelope used across the whole API. Three shapes recur, and which one a given endpoint uses is part of that endpoint's own contract:

Mutations wrapped in an error flag. The large majority of create/update/delete endpoints return {"error": bool, ...}, checked explicitly rather than relying on the HTTP status code alone, for example a successful lead create (backend/leads/views/lead_views.py:402-404):

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

and a failed one (backend/leads/views/lead_views.py:406-408):

{"error": true, "errors": {"email": ["Another lead in this organisation already uses that email address."]}}

The same {"error": false, "tokens": [...]} shape is used by the personal-access-token list in Tokens and API keys.

Bare serializer data. Some newer endpoints skip the error wrapper entirely and return serializer.data (or the created/updated object's serialization) directly, relying on serializer.is_valid(raise_exception=True) and the HTTP status code to signal failure. For example MacroListCreateView.post returns Response(MacroSerializer(macro).data, status=201) (backend/macros/views.py:136).

Single-object detail responses nest the record, not return it bare. GET /api/leads/{id}/ does not return a Lead object at the top level; it returns the record under lead_obj alongside related data fetched in the same call:

{
  "lead_obj": {"id": "...", "first_name": "...", "...": "..."},
  "attachments": [...],
  "comments": [...],
  "users_mention": [...],
  "assigned_data": [...]
}

(backend/leads/views/lead_views.py:412-517.) Don't assume a detail endpoint returns its resource at the top level of the response; check the endpoint.

Content types

REST_FRAMEWORK sets no DEFAULT_PARSER_CLASSES, so DRF's own defaults apply: a request body may be application/json, application/x-www-form-urlencoded, or multipart/form-data (JSONParser, FormParser, MultiPartParser, in that order). multipart/form-data is what every endpoint that accepts a file upload alongside other fields needs. For example POST /api/leads/ reads an optional lead_attachment file straight off request.FILES in the same call that creates the lead (backend/leads/views/lead_views.py:330), so a lead with an initial attachment is created with one multipart request rather than a create-then-upload round trip.

REST_FRAMEWORK sets no DEFAULT_RENDERER_CLASSES either, so both of DRF's defaults are live: JSONRenderer for application/json (what every example on this page assumes, and what every client integration should send Accept: application/json for) and BrowsableAPIRenderer for text/html: request the same endpoint with a browser's default Accept header and DRF's interactive HTML form is what comes back instead of JSON.