2026-08-02 14:38:12 +00:00
# Errors
How to tell a failed request from a successful one, and how to read the body of a failed one.
`REST_FRAMEWORK["EXCEPTION_HANDLER"]` in `backend/crm/settings.py` is set to DRF's own
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
`rest_framework.views.exception_handler` (unmodified, no custom subclass), so an exception DRF
2026-08-02 14:38:12 +00:00
itself raises (a bad permission check, a missing object, an unhandled `ValidationError` ) comes back
in DRF's own default shape. A view that catches its own errors and builds a `Response` by hand,
which most views in this codebase do, can and often does return something else. Both are documented
below with real examples, because a client has to handle both.
## Status codes
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
`200` for a successful read or a successful write. Many mutation endpoints return `200` rather
2026-08-02 14:38:12 +00:00
than `201` even on creation, wrapping the result in the `{"error": false, ...}` shape described in
[Conventions ](conventions.md#response-shape ) rather than relying on the status code alone (see
`POST /api/leads/` , `backend/leads/views/lead_views.py:402-404` ). Some do use `201` : personal access
token creation, for instance, returns `201` (`backend/common/views/pat_views.py:69`). Neither is
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
"more correct" than the other here, check the endpoint.
2026-08-02 14:38:12 +00:00
Beyond that: `400` for a request the server understood but rejected (validation, or a
2026-10-23 09:52:09 +00:00
request-shaped error like a missing required field); `401` only where a view says so itself (an
expired or malformed refresh token on the token refresh endpoint); `403` for a
request with no credentials or invalid ones, and for one that authenticated fine but isn't allowed
to do this; `404` for a
2026-08-02 14:38:12 +00:00
resource that doesn't exist, or that exists but the caller must not be able to distinguish from not
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
existing. See [Not found versus forbidden ](#not-found-versus-forbidden ); `502` where a view depends
2026-08-02 14:38:12 +00:00
on an external service and that service is unreachable, for example the Google token exchange in
[Google OAuth ](authentication.md#google-oauth-web ) (`backend/common/views/auth_views.py:110-114`).
2026-10-02 06:53:22 +00:00
`429` comes only from the anonymous endpoints that name their own throttles: web form submit, the
public help center, the calendar feed, and the magic-link and portal sign-in requests.
`REST_FRAMEWORK` sets no `DEFAULT_THROTTLE_CLASSES` , so no authenticated endpoint returns it. The
sign-in requests also cap tokens per address (five an hour), and that limit answers with their
normal `200` rather than a `429` , so the response can't be used to distinguish "rate limited" from
"sent" from "invalid address". See [Magic links ](authentication.md#magic-links ).
2026-08-02 14:38:12 +00:00
## Validation errors
The dominant pattern in this codebase is not DRF's automatic one. Most views call
`serializer.is_valid()` without `raise_exception=True` , check the boolean themselves, and build the
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
error response by hand as `{"error": true, "errors": serializer.errors}` . This exact shape appears
2026-08-02 14:38:12 +00:00
well over 200 times across the backend. A `validate_<field>` failure lands under that field's key,
DRF's normal behavior:
```python
def validate_email(self, value):
...
if duplicates.exists():
raise serializers.ValidationError(
"Another lead in this organisation already uses that email address."
)
return value
```
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
(`backend/leads/serializer.py:107-133`, `LeadCreateSerializer.validate_email` . The comment above it
2026-08-02 14:38:12 +00:00
explains this exists because a duplicate email used to reach the database as an unhandled
`IntegrityError` and come back as an opaque `500` .) A `POST /api/leads/` with a duplicate email
returns:
```json
{"error": true, "errors": {"email": ["Another lead in this organisation already uses that email address."]}}
```
A `validate()` failure that isn't tied to one field lands under DRF's default
`non_field_errors` key (`NON_FIELD_ERRORS_KEY` is not overridden anywhere in this codebase), still
nested the same way: `{"error": true, "errors": {"non_field_errors": ["..."]}}` .
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
A smaller, newer set of views instead call `serializer.is_valid(raise_exception=True)` , for
example `MacroListCreateView.post` (`backend/macros/views.py:123`), and let DRF's exception handler
2026-08-02 14:38:12 +00:00
build the response itself, with no `error` /`errors` wrapper at all:
```json
{"email": ["Another lead in this organisation already uses that email address."]}
```
Both shapes exist in the API today. Check the specific endpoint's page (or the view, if it isn't
documented yet) rather than assuming one; do not assume the presence or absence of an `error` key
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
tells you whether the request succeeded. The status code is what does that.
2026-08-02 14:38:12 +00:00
## Authorization errors
Authentication and authorization failures go through DRF's permission machinery unmodified, so they
always come back as `{"detail": "<message>"}` , whether the message is DRF's own default or one this
codebase set:
```json
{"detail": "Authentication credentials were not provided."}
```
```json
{"detail": "Organization context is required. Please login again."}
```
The second is `HasOrgContext.message` (`backend/common/permissions.py:11-41`), one of the two
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
permission classes almost every org-scoped view lists: `permission_classes = (IsAuthenticated,
2026-08-02 14:38:12 +00:00
HasOrgContext)`, and often a role check on top such as `IsOrgAdmin`
(`backend/common/permissions.py:44-59`, `"You must be an organization administrator to perform this
action."`). Whether a failure comes back as `401` or `403` is not decided by which permission class
2026-10-23 09:52:09 +00:00
failed. DRF's `APIView.permission_denied()` raises `NotAuthenticated` whenever no authenticator on
the request actually succeeded, and `PermissionDenied` (`403`, carrying the failing permission
class's `message` ) whenever one did but the permission check itself failed. DRF answers
`NotAuthenticated` with `401` only when the view's first authentication class defines
`authenticate_header` , and none of the staff classes (`PATAuthentication`, `JWTAuthentication` ,
`APIKeyAuthentication` ) does, so it comes back as `403` . In practice: no credentials, or invalid
ones → `403` ; a valid, authenticated session that isn't allowed to do this → `403` .
## Switched-off modules
An org admin can switch parts of the CRM off (see [Modules ](modules.md )). A signed-in request to
a route of an off module is a `404` for every method, with a body that says why:
```json
{"detail": "Tickets is switched off for this organization.", "code": "module_off", "module": "tickets"}
```
`code` is always `module_off` and `module` is the module's key, so a client can tell this `404`
from a missing record. Public links, the customer portal and inbound email do not use this body:
they answer what they give an unknown link.
A delete that would destroy, or be blocked by, records of an off module is refused with `409`
and the same two fields:
```json
{"error": true, "errors": "This account has 3 tickets. Tickets is switched off, so switch it on to review them before deleting.", "code": "module_off", "module": "tickets"}
```
Writes that name an off module's record, such as a webhook event, a saved view or a web form
target, are `400` on that field with the sentence `<Label> is switched off for this organization.`
2026-08-02 14:38:12 +00:00
## Not found versus forbidden
The rule that holds everywhere an object is looked up by id: a record outside the caller's org
returns `404` , the same as a record that never existed at all, never `403` . Every such lookup
filters on `org=request.profile.org` (or the RLS-protected queryset equivalent) before matching the
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
id, so a request for another org's record simply finds nothing;
2026-08-02 14:38:12 +00:00
`get_object_or_404(Macro, pk=pk, org=request.profile.org)` (`backend/macros/views.py:148`, repeated
at `:168` for the plain `GET` ) is one of dozens of examples of the same shape.
`invoices/permissions.py` states the reasoning explicitly in its own module docstring:
```
1. Does this record exist *in the caller's org* ? -> 404 if not, so that a
caller in another org cannot probe for record IDs.
2. May this caller act on it? -> 403 if not.
```
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
(`backend/invoices/permissions.py:5-9`.) A `403` response inherently confirms the record exists.
It's not possible to be forbidden from acting on nothing, so reserving `403` for "exists, but you
2026-08-02 14:38:12 +00:00
may not do this" and `404` for everything else is what stops an id becoming an oracle a caller in
another org can probe with.
Within a single org, whether a permission failure still returns `403` or drops all the way to `404`
depends on what the resource is. Invoices and estimates keep step 2 above as a real `403` : an org
member who is neither the record's creator, an assignee, nor an admin is refused with
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
`{"error": true, "message": "Permission denied"}` (`backend/invoices/permissions.py:80-84`). A
2026-08-02 14:38:12 +00:00
colleague can tell the invoice exists, just not read or change it. A personal (as opposed to
org-scope) macro goes further and hides itself completely from everyone except its owner and org
admins, `404` even for a same-org caller, precisely to avoid the invoice case's disclosure:
```python
if macro.scope == Macro.SCOPE_PERSONAL and macro.owner_id != request.profile.id:
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
# A personal macro you don't own is invisible to you. The list and
2026-08-02 14:38:12 +00:00
# GET hide it (404). The write verbs must not confirm it exists via
# a 403 either, or the id space leaks which rows are somebody else's
# personal macros. Mirror the GET: 404.
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
```
(`backend/macros/views.py:156-164`.) Both are deliberate, documented choices for the resource in
Refactor comments for clarity and consistency across multiple screens and services
- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style.
- Adjusted punctuation and phrasing for better flow and understanding.
2026-08-03 04:06:48 +00:00
question, not an inconsistency to paper over, when you add an authorization check to a new
2026-08-02 14:38:12 +00:00
endpoint, decide which of the two your resource needs (does an org-mate being aware this record
exists leak anything?) rather than defaulting to whichever is easier to write.