# 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 `rest_framework.views.exception_handler` (unmodified, no custom subclass), so an exception DRF 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 `200` for a successful read or a successful write. Many mutation endpoints return `200` rather 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 "more correct" than the other here, check the endpoint. Beyond that: `400` for a request the server understood but rejected (validation, or a 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 resource that doesn't exist, or that exists but the caller must not be able to distinguish from not existing. See [Not found versus forbidden](#not-found-versus-forbidden); `502` where a view depends 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`). `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). ## 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 error response by hand as `{"error": true, "errors": serializer.errors}`. This exact shape appears well over 200 times across the backend. A `validate_` 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 ``` (`backend/leads/serializer.py:107-133`, `LeadCreateSerializer.validate_email`. The comment above it 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": ["..."]}}`. 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 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 tells you whether the request succeeded. The status code is what does that. ## Authorization errors Authentication and authorization failures go through DRF's permission machinery unmodified, so they always come back as `{"detail": ""}`, 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 permission classes almost every org-scoped view lists: `permission_classes = (IsAuthenticated, 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 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 `