Skip to content

Error taxonomy

Every error response has the same shape (see common/errors.py):

{
 "errors": [
 { "code": "auth.invalid_credentials", "description": "Invalid credentials." }
 ]
}

Codes are namespaced by domain (auth., account., ...) and this list grows as new missions add their own — this is the first real taxonomy, seeded by the Accounts & Authentication mission.

Generic (pre-existing scaffold)

Code HTTP status Meaning
http_{status_code} matches status Framework-native HTTPException with no domain-specific code yet.
validation_error 422 Request body/query failed Pydantic validation. The description names each offending field and its reason, one per ; (contact_email: value is not a valid email address: An email address must have an @-sign.), capped at 8 fields with the rest counted. It deliberately omits input — the value that failed is the caller's own, and on a password field echoing it back put the submitted password in the response body. Location prefixes are kept except body, which says nothing (query.limit, but contact_email).
internal_server_error 500 Unhandled exception — logged server-side, no internal detail leaked.

Every email field, everywhere in the API (account registration/login/ password-reset, freight document role email, delegation email — all pydantic.EmailStr, no per-endpoint override) rejects an address whose domain is an RFC 6761 special-use name (.test, .invalid, .localhost) as validation_error, e.g. ops@example.test — confirmed empirically against the running API, not assumed from the library's docs. Ordinary reserved-but-deliverable domains like example.com/example.org are not affected and remain accepted. Use one of those (or a real domain) for test data instead of .test/.invalid/.localhost.

Auth / Accounts (this mission)

Code HTTP status Meaning
auth.invalid_credentials 401 Wrong email/password, revoked/expired API key, or malformed/invalid bearer token. Deliberately generic — see enumeration note below.
auth.account_not_active 403 Account exists and credentials are correct, but its status isn't active (e.g. still pending_activation).
auth.token_expired 400 Activation or password-reset token is invalid, already used, or past its TTL.
auth.rate_limited 429 Sensitive endpoint (register/login/password-reset-request/oauth-token, or any endpoint on the public /v1/signatures/* router — challenge context lookup, OTP request/verify, WebAuthn registration/assertion, sign-on-glass, complete, or attachment upload/confirm) hit its Redis-backed rate limit. Each bucket is independent with its own ceiling. Response includes a Retry-After header.
auth.locked 423 Account temporarily locked after repeated failed login attempts (progressive backoff).
auth.insufficient_scope 403 Bearer token decoded fine but lacks the scope the endpoint requires (e.g. an account:manage token used where accounts:read is needed, or vice versa).
auth.unsupported_grant_type 400 POST /v1/oauth/token called with anything other than grant_type=client_credentials.
account.invalid_external_identifier 409 The external_identifier supplied at registration is already used by another account. Safe to be specific here — it's the caller's own namespace, not an email-enumeration vector.
account.weak_password 400 Password fails the strength policy (minimum length and/or common-password blocklist — see core/common_passwords.py).
account.not_found 404 An account-scoped resource (API key, OAuth client) doesn't exist or doesn't belong to the authenticated account.
account.email_already_exists Reserved, never raised today. Exists in the taxonomy for a future context where confirming an email's existence carries no enumeration risk. POST /v1/accounts/register always returns a generic 202 regardless of whether the email is new, to avoid letting a caller enumerate registered accounts.
account.subaccount_credentials_incomplete 400 A subaccount's email/password were provided one without the other, on create or update (prompt 4). They must be given together or both left unset.
account.not_found_by_email 404 GET /v1/accounts/lookup?email=... found no account with this email. The one deliberate, scoped exception to the anti-enumeration principle below.
account.subaccount_phone_number_already_in_use 409 A subaccount's phone_number (create or update, via the parent-account-managed CRUD endpoints) is already used by another subaccount under the same account. Scoped per-account, not global.
account.subaccount_email_already_in_use 409 A subaccount's email (direct creation/update, or the EMAIL invitation channel) is already used by another subaccount. Global, like subaccounts.email's own DB constraint.
account.invitation_token_invalid 404 The invitation token in GET/POST /v1/subaccounts/invitations/{token}/... doesn't resolve to any row — malformed or copy-pasted wrong. Also raised by the AccountMember invitation endpoints (GET/POST /v1/accounts/members/invitations/{token}/...) — the underlying single-use hashed-token mechanism is identical, so the same three codes are reused rather than duplicated; callers are naturally disambiguated by which endpoint they call. See.
account.invitation_token_expired 400 The invitation token resolved, but is past its TTL (subaccount_invitation_token_ttl_hours / account_member_invitation_token_ttl_hours, both default 168h) — ask the company/an admin to resend it.
account.invitation_token_already_used 400 The invitation was already completed — try logging in instead.
account.invitation_channel_mismatch 400 Either POST /me/subaccounts/invite's email/channel pairing is wrong (SMS forbids email, EMAIL requires it), or a completion/OTP endpoint was called for the wrong channel (e.g. .../complete on an EMAIL-channel invitation).
account.invitation_otp_invalid 401 The EMAIL channel's completion OTP code is invalid, expired, or has been attempted too many times. Distinct from signature.device_verification_failed even though both wrap the same underlying check — different caller, different recovery action.
account.subaccount_already_completed 409 POST .../invitations/{id}/resend was called for a subaccount that has already finished registration (has a password set).
account.invalid_phone_number 400 POST /me/subaccounts/invite's phone_number isn't a valid E.164 number. Scoped to this one request schema only — every other phone_number field in the API remains unvalidated (a known, tracked gap, deliberately not closed by this endpoint).
account.terms_not_accepted 400 POST /v1/accounts/register's terms_accepted is missing or false. A dedicated code rather than a generic 422, so a caller can distinguish "you forgot the checkbox" from a malformed request. See.
account.invalid_vat_number 400 POST /v1/accounts/register's vat_number doesn't match the loose structural check (2-3 letter prefix + 2-20 alphanumeric characters) — format-only, never verified against a real registry (VIES/Zefix). Never a per-country exact format, same reasoning as account.invalid_phone_number. See.
account.invalid_nif 400 POST /v1/accounts/register's or PATCH /v1/accounts/me's nif fails the Spanish check-character algorithm (DNI/NIE/CIF). Strict, unlike every other identifier in this API — the reasoning is in common/spanish_tax_id.py.
account.invalid_address 400 PATCH /v1/accounts/me's legal_address is malformed, partial, or absent while a country is declared. The sub-fields are all-or-nothing, and the whole address is required once the account has a country — an account cannot be left half-identified. Same shared validation, and therefore the same message, as partner.invalid_address.
account.missing_tax_identification 400 PATCH /v1/accounts/me left the account with a declared country but without the tax identity that country requires — today, a Spanish account without a nif. Never about vat_number: on a Spanish account that value is derived from the nif, never submitted.
account.country_not_supported 400 PATCH /v1/accounts/me's country is a valid 2-letter code but not one the product covers (today: ES only). A deliberate refusal rather than a silent accept: the non-Spanish tax rule (a typed VAT number instead of one derived from the nif) is not built, so storing the country would leave the account in a state no rule governs.
account.module_not_deactivatable 409 An attempt to deactivate a module that is a foundation — today TRANSPORT_DOCUMENTS, which the other modules will build on and without which an account could not issue any document. Not a permissions question: an ADMIN cannot do it either, which is why it isn't account_member.admin_required. 409 rather than 400 for the same reason as account_member.cannot_remove_last_admin — the request is well formed, the target state is what's forbidden.
account.premium_feature_required 403 The account's tier on the module a feature belongs to is below what that feature requires — today, reading and adding a partner's trusted phone numbers (GET/POST .../partners/{id}/trusted-phone-numbers), which need PREMIUM on TRANSPORT_DOCUMENTS. DELETE on the same resource is deliberately exempt: those rows are third parties' personal data, and erasing them must never depend on a subscription level. Prefixed account. and not partner. on purpose: the refused condition is a property of the account, so the next gated feature reuses this exact code rather than minting a per-resource twin — the feature itself is named in the description. Reading is refused like writing, and nothing already recorded is deleted: regaining the tier restores the list untouched. 403 and not 402, since no price lives in this system. Raised BEFORE the partner-ownership check, so an account below the tier gets the same answer for its own partner, another account's, and an id that doesn't exist.
partner.invalid_identifier 400 A national identifier in identifiers fails its scheme's format check, or more than one entry is flagged as the primary one. Format checks come from python-stdnum (SIREN/SIRET, Swiss UID, Partita IVA, Codice Fiscale, Spanish NIF); the Dutch KvK number is only length-checked, since it carries no known check digit. Format only — no official registry is ever queried.
partner.identifier_already_exists 409 Another record in the same account already carries this identifier. Never global: two accounts may legitimately hold the same company, each directory being strictly private. Distinct from partner.external_identifier_already_exists, which both collisions used to be reported as.
account_member.cannot_remove_last_admin 409 DELETE/PATCH .../me/members/{id} would leave the account with zero active ADMIN members — revoke/demote is rejected outright, never allowed-with-confirmation. Promote another member first. See.
account_member.email_already_member 409 POST /me/members/invite's email already belongs to a non-revoked member of this same account. Scoped per-account — the same email may be invited to a different account without conflict, since AccountMember.email is unique per (account_id, email), not globally.
account_member.admin_required 403 An OPERATOR called an ADMIN-only endpoint (invite/revoke/change-role). Distinct from auth.insufficient_scope: the bearer token itself is valid (right scope, right principal type) — this is a business-role check, not a token/JWT-scope one.
account.invalid_login_selection 400 POST /v1/accounts/login/select's selection_token is malformed, expired, or doesn't list the given account_id (tampering). Collapses all three into one code, same principle as signature.challenge_expired. See.
account_member.invalid_totp_challenge 400 POST /v1/accounts/login/totp/verify's totp_challenge_token is malformed, expired, or already consumed by a prior successful verification (replay). Same collapsing principle as account.invalid_login_selection. See.
account_member.invalid_totp_code 401 The submitted TOTP/backup code is wrong, or (for a backup code specifically) already used — never distinguished, same anti-enumeration reasoning as auth.invalid_credentials. Returned by both POST /v1/accounts/login/totp/verify and POST /v1/accounts/me/totp/confirm. See.

Freight Documents (prompt 3)

Code HTTP status Meaning
freight_document.invalid_transition 409 POST /v1/freightdocuments/{id}/issue (or any other call into FreightDocumentStateMachine) requested a transition not in the explicit allow-list — e.g. issuing a document that's already ISSUED.
freight_document.external_identifier_already_exists 409 POST /v1/freightdocuments supplied an external_identifier already used by another document — enforced by a DB unique constraint, not just an application check.
freight_document.not_found 404 Superseded for read/issue/role endpoints as of prompt 4 — those now return freight_document.permission_denied (403) uniformly, including for a document that doesn't exist at all, per the anti-enumeration principle below. Kept in the taxonomy as a defensive/internal-invariant code, not expected to be reachable through normal API use today.
freight_document.invalid_role_assignment 400 A role in the creation (or post-creation addition) payload has neither account_id nor email, has an empty/missing name in party_snapshot, explicitly sets role_type: SUBMITTER (that role is derived automatically from the authenticated caller), provides both party_snapshot/partner_id (or, both vehicle_plate/vehicle_id or both trailer_plate/trailer_vehicle_id) for the same role, sets any of vehicle_plate/trailer_plate/vehicle_id/trailer_vehicle_id on a role that isn't CARRIER, or — a goods_lines entry provides both packaging_method/packaging_method_id or neither. Reused deliberately for the goods-line cases: no caller needs a distinct code to distinguish this from the vehicle precedent it mirrors — same fix either way, provide exactly one of the two fields.
freight_document.invalid_driver_phone_number 400 a role's driver_phone_number could not be resolved to a valid phone number: it doesn't parse at all, or it parses into something that isn't a real number (12 -> +4112). Accepted in E.164 form, or as a national number of settings.driver_phone_default_region (default CH) — a national number of any OTHER region is a different failure entirely and does NOT produce this code: it silently normalizes to a valid-looking number of the default region (612345678 -> +41612345678), which is why the integration guide tells partners to send a country code. Deliberately distinct from freight_document.invalid_role_assignment: every case behind that code is a payload-shape mistake fixed once by a developer, whereas this is per-order data a TMS holds in its own driver records and must branch on at runtime. The rejected value is never echoed back (it's somebody's phone number, and error bodies end up in partner logs) — only its character count.
freight_document.vehicle_type_mismatch 400 vehicle_id/trailer_vehicle_id (CMR box 16) resolved to a real, owned Vehicle — but of the wrong kind: vehicle_id referenced a TRAILER, or trailer_vehicle_id referenced anything other than a TRAILER. Deliberately distinct from vehicle.not_found: that one means "doesn't exist or isn't yours", this one means "exists, is yours, wrong field" — a different fix for the caller.
freight_document.goods_line_required 400 goods_lines was empty (or, on PATCH, explicit null) on creation, PUT, or a PATCH that touches the field. A document always needs at least one declared line; empty is never a valid state at any of these three entry points.
freight_document.invalid_package_count 400 A goods_lines entry's package_count or gross_weight_kg is ≤ 0 — enforced at the application layer (this code) and, redundantly, at the DB layer (CHECK constraints on goods_lines); either alone would eventually catch it, but only the application check produces this taxonomy-consistent error response.
freight_document.invalid_address 400 a role's party_snapshot.address is present but isn't the full {street, postal_code, city, country} object: a subfield is missing/empty, or country isn't a 2-letter uppercase code (format-checked only, no whitelist of real ISO 3166-1 alpha-2 codes). address itself stays optional — a role with none at all is still valid. description lists every problem found, not just the first — e.g. omitting both postal_code and country reports both in one response, semicolon-separated.
freight_document.invalid_gln 400 a role's party_snapshot.gln is present but isn't exactly 13 digits (format-only, no check-digit validation — same "light validation" philosophy as account.invalid_vat_number/account.invalid_phone_number). gln itself stays optional. Not raised when the value came from a partner_id instead — see partner.invalid_gln, checked once at the Partner's own create/update time.
freight_document.invalid_sscc 400 a goods_lines entry's sscc is present but isn't exactly 18 digits (format-only, no check-digit validation). Unlike gln, sscc has no directory/partner_id-style alternative — it's generated per shipment/pallet, not a stable property of an entity.
freight_document.snapshot_not_found 404 GET .../snapshots/compare's from/to each name a real milestone, but this document has no snapshot recorded for one of them yet (e.g. asking to compare against DELIVERY_SIGNED before any delivery signature exists). Distinct from invalid_snapshot_comparison below: the request itself is well-formed, the fix is to wait until that milestone actually happens, not to change the request. Reused as-is (not a distinct code) by GET .../pdf?milestone= for the identical situation — a syntactically valid SnapshotMilestone this document hasn't reached yet.
freight_document.invalid_snapshot_comparison 400 GET .../snapshots/compare's from/to don't even name a real SnapshotMilestone value, or from equals to (comparing a milestone to itself) — a malformed request, distinct from snapshot_not_found: fix what you're asking for, not "wait and retry."
freight_document.no_carrier 400 POST .../issue rejected: the document has no CARRIER role at all. Nothing previously required one at issuance.
freight_document.no_collection_signer 400 POST .../issue rejected: no PLACEOFTAKINGOVER role. CONSIGNOR alone can never sign COLLECTION (it never holds SIGN), so a document without this role could be issued but never actually have its collection signed — common with TMS integrations that only know CONSIGNOR/CONSIGNEE, not the CMR-specific site-role split.
freight_document.no_delivery_signer 400 Same reasoning as no_collection_signer, for DELIVERY/PLACEOFDELIVERY/CONSIGNEE.

Roles & Permissions (prompt 4)

Code HTTP status Meaning
freight_document.permission_denied 403 The authenticated principal (account or subaccount) has no role on this freight document granting the permission the endpoint requires — including when the document doesn't exist at all. Deliberately never 404; see the anti-enumeration note below.
freight_document.role_already_exists 409 POST /v1/freightdocuments/{id}/roles tried to add a role type that's a singleton on this document (every type except SUBSEQUENTCARRIER) and one already exists.
freight_document.invalid_role_addition 400 POST /v1/freightdocuments/{id}/roles was called on a document not in ISSUED/TRANSIT status.

Freight Document CRUD (prompt 5)

Code HTTP status Meaning
freight_document.status_immutable_via_put 400 PUT /v1/freightdocuments/{id} body included a status different from the document's current one. Transitions go exclusively through the dedicated endpoints (issue, ...).
freight_document.immutable_field 400 PUT body included a value for hosting_type, external_host_platform, external_document_id, external_sync_status, submitter_account_id, or external_identifier that differs from the document's current one. Omitting these fields, or echoing back the current value, is fine.
freight_document.optimistic_lock_conflict 409 PUT/PATCH's version field didn't match the document's current version — either the application-level check caught a stale read, or the DB-level compare-and-swap (version_id_col) caught a race between the check and the write. Refetch and retry.
freight_document.content_immutable_after_signature 409 a PUT/PATCH carrying content on a document that already has at least one completed signature. A signature attests to the content as it stood when it was made, so changing it afterwards would break that attestation. Deliberately distinct from optimistic_lock_conflict beside it, because the two demand opposite conduct: that one means "refetch and retry", this one means "stop, retrying will never work, this needs a human" — a TMS that treated them alike would loop forever on an impossible write. Keyed on the EXISTENCE of a signature, never on the document's status: HANDOVER and ACCEPTANCE signatures trigger no status transition, so a DRAFT document can carry a completed signature, and a status-based rule would have left exactly that case open. Never names who signed or when — this description reaches partner logs.
freight_document.version_required 400 PUT/PATCH was called without a version field. Deliberately a dedicated code, not a generic validation_errorversion is intentionally optional at the Pydantic level so this specific, common mistake gets a clear message.
freight_document.invalid_filter 400 GET /v1/freightdocuments's filters query param was malformed, named an unknown field, or used an operator not supported for that field.
freight_document.invalid_sort_field 400 GET /v1/freightdocuments's sort query param named a field outside the whitelist (created_at, updated_at, status).

Delegation & External Access (prompt 6)

Code HTTP status Meaning
freight_document.delegation_depth_exceeded 400 No longer emitted. Redelegation is closed — only a role's original holder may delegate it — so every delegation has depth 1 and settings.max_delegation_depth has become unreachable. The code stays in this catalogue, dormant rather than removed, because chain subcontracting may return later behind an explicit authorization from the original holder.
freight_document.delegation_not_permitted 403 The caller doesn't hold DELEGATE on this exact role (directly or via an active delegation), or the role_id/freight_document_id pair in the URL don't match — same generic code either way, no distinguishing signal. Also returned for delegation/access-code revocation when the caller isn't the delegation's own delegator nor anyone upstream in its chain. Two further cases on POST .../delegate, both distinguished by the description rather than by a code of their own: the caller holds the role only through a delegation (redelegation is closed — only the role's original holder may delegate it), and the target account already holds an active delegation of this role.
freight_document.delegation_chain_access_denied 403 GET /v1/freightdocuments/{id}/roles/{role_id}/delegations called by a principal not part of that specific role's delegation chain — including the document's own SUBMITTER/CONSIGNOR if they aren't a chain member.
freight_document.visibility_change_not_permitted 403 Not in prompt 6's original literal list — added by prompt-6-7-udgrade. PATCH .../roles/{role_id}/visibility called by anyone other than this role's own ORIGINAL holder — including an active delegate (however deep) and any out-of-chain principal. Same generic code either way, no distinguishing signal.
freight_document.access_code_invalid 401 POST /v1/access-codes/redeem was called with a code that doesn't match any issued carrierAccessCode, or matches one that's been explicitly revoked.
freight_document.access_code_expired 401 The code matches an issued, non-revoked carrierAccessCode, but expires_at is in the past.
freight_document.access_code_invalid_phone_number 400 POST .../access-codes's optional send_to_phone isn't a valid E.164 number. Never returned for the send itself failing (a real SMS provider error): that's reported as sms_sent: false on the 201 response, not an error.
freight_document.subaccount_assignment_not_permitted 403 PATCH .../roles/{role_id}/assign-subaccount called by anyone other than this role's own ORIGINAL holder — same "original holder only" rule as visibility_change_not_permitted, given its own code for the same reason that one wasn't folded into delegation_not_permitted: this action has nothing to do with delegation on the role side. The delegation-side sibling endpoint (.../delegations/{delegation_id}/assign-subaccount) reuses delegation_not_permitted instead — that one genuinely is a delegation-permission failure (caller isn't this exact delegation's own delegate_account_id).
freight_document.subaccount_not_eligible 400 Either assign-subaccount endpoint's subaccount_id doesn't belong to the account being targeted (the role's account_id on the role-side endpoint, the delegation's delegate_account_id on the delegation-side one) — same anti-enumeration principle as everywhere else, no distinguishing signal between "doesn't exist" and "belongs to someone else". Shared by both endpoints for the criterion behind sharing this code rather than giving each its own.

Directory (prompt 7)

Code HTTP status Meaning
partner.not_found 404 The partner_id in a URL path, or on a Freight Document role, doesn't exist, belongs to another account, or is soft-deleted. Deliberately identical for all three cases — never a distinguishing signal about another account's directory.
partner.external_identifier_already_exists 409 The account already has a Partner with this external_identifier — scoped to (owner_account_id, external_identifier), not global (contrast with account.invalid_external_identifier, which IS global).
partner.invalid_bulk_import 400 POST /v1/accounts/me/partners/bulk's array itself is malformed — currently only raised when it exceeds the per-request entry cap. A single entry's own failure (e.g. a duplicate external_identifier) is reported per-element in the response instead, not via this code.
partner.invalid_address 400 same validation as freight_document.invalid_address (including reporting every missing/invalid subfield at once, not just the first), distinct code because a directory caller fixes it in a different UI: address is present but isn't the full {street, postal_code, city, country} object. Empty ({}, the default) is always valid — a Partner, including one of kind LOCATION, may have no address set yet.
partner.invalid_gln 400 same validation as freight_document.invalid_gln, distinct code because a directory caller fixes it in a different UI: gln is present but isn't exactly 13 digits.
partner.invalid_parent 400 the company/physical-location link would be incoherent: the parent isn't a GENERIC partner of this account (an unknown id, another account's partner and a LOCATION used as a parent all give the same message), the child isn't a LOCATION, or a kind change would leave an attached location as a company or a company holding locations as a location. One code for all of them, with the broken rule in the description — a caller has nothing to branch on, the fix is always "pick a different parent, or detach first". A 400 rather than partner.not_found for the same reason as vehicle.invalid_default_trailer: the caller holds a real partner id and needs to know which rule it broke. See.
partner.trusted_phone_number_not_found 404 the trusted phone number doesn't exist, or belongs to another account's partner. Same anti-enumeration stance as partner.not_found.
partner.invalid_trusted_phone_number 400 the number couldn't be resolved to a valid phone number (common/phone.py, same helper as freight_document.invalid_driver_phone_number). Deliberately stricter than the free-text partners.phone next to it: a trusted number exists only to be compared against a signer's number at signing time, so one that can never match would manufacture a permanent false signal. Still no country whitelist — a number with no country code is only rescued if it is a real national number of DRIVER_PHONE_DEFAULT_REGION. See.
partner.trusted_phone_number_already_exists 409 The partner already has this number. Compared on the normalized E.164 form, so the same real number typed two ways collides.
packaging_method.not_found 404 The packaging method doesn't exist, or belongs to another account. Reachable from the directory CRUD endpoints, and — from packaging_method_id on a goods_lines entry at document creation, PUT, or PATCH.
packaging_method.external_identifier_already_exists 409 Same scoping as partner.external_identifier_already_exists — not in the mission brief's literal error list, added by extension since the same DB constraint is mandated on this table too.
vehicle.not_found 404 The vehicle doesn't exist, belongs to another account, or is soft-deleted.
vehicle.external_identifier_already_exists 409 Same scoping and rationale as packaging_method.external_identifier_already_exists.
vehicle.license_plate_already_exists 409 the account already has a vehicle with this license_plate. Scoped to (owner_account_id, license_plate), never global: two accounts legitimately keep their own entry for the same physical vehicle (a chartered or leased truck). A soft-deleted vehicle releases its plate, so a replacement can reuse it — unlike external_identifier, whose index is not filtered on deleted_at. Matched exactly: plate formats vary by country and are not normalized, so TK-100-AA and tk 100 aa are two different plates here. See.
vehicle.invalid_default_trailer 400 default_trailer_id doesn't reference a different, non-deleted vehicle of type TRAILER belonging to the same account. One code for all four ways to be wrong (wrong type, another account's, soft-deleted, or the vehicle itself) — a caller only ever branches on "pick a different trailer". Purely a prefill source: nothing enforces it at document creation, so a document filed with any other trailer is accepted as sent.
vehicle.invalid_subaccount_link 400 linked_subaccount_id doesn't reference a VEHICLE-type subaccount belonging to the same account — same generic code whether the type is wrong or the subaccount belongs to someone else.
driver.not_found 404 The driver doesn't exist, belongs to another account, or is soft-deleted.
driver.external_identifier_already_exists 409 Same scoping and rationale as packaging_method.external_identifier_already_exists.
driver.invalid_subaccount_link 400 linked_subaccount_id doesn't reference a DRIVER-type subaccount belonging to the same account.

Document drafts

An unfinished creation form, saved so its author can come back to it — not a freight document in status DRAFT. A freight document exists from the moment it is created (its parties are notified, a driver can be enrolled by SMS, every party holding a role can open it); a draft is private to the account that wrote it, notifies nobody and sends nothing.

Nothing about a draft's payload is validated — that is the whole point, and the reason this table is so short. The only rejections are:

Code HTTP status Meaning
document_draft.not_found 404 The draft doesn't exist, or belongs to another account — never a distinguishing signal either way, same stance as partner.not_found. Returned by GET, PUT and DELETE alike.
document_draft.payload_too_large 413 The serialized payload exceeds 256 KiB. Measured on the compact JSON form, so the limit describes the data and not the client's formatting. A fully-filled creation form is a few kilobytes; this stops a client looping on the write, not a real draft.
document_draft.limit_reached 409 The account already holds 200 drafts. A backstop against a client that creates a new draft on every save instead of replacing the one it already has (PUT /v1/freightdocuments/drafts/{id}) — never a quota a real operator meets.

Control Documents (DeCa, Spain)

The Spanish administrative control document has only two codes of its own, and that is the point: everything it validates about parties, addresses, vehicles and goods lines is literally the same code as a freight document's, so it raises the same freight_document.* codes. No caller branches differently on them, and the fix is identical — duplicating the taxonomy per document family would have been churn for integrators, not clarity.

What DOES get its own code is what genuinely differs.

Code HTTP status Meaning
control_document.not_found 404 The control document doesn't exist, or it exists and the caller is neither its issuer nor a party named on one of its roles — never a distinguishing signal either way, same stance as document_draft.not_found. Returned by GET /v1/controldocuments/{id} and its /pdf sibling alike.
control_document.external_identifier_already_exists 409 POST /v1/controldocuments supplied an external_identifier already used by another control document. Deliberately distinct from freight_document.external_identifier_already_exists: the two families are numbered independently, so the same value on a freight document is not a conflict — an integrator receiving the wrong code would look for the collision in the wrong collection.
control_document.public_link_unavailable 404 GET /v1/public/deca/{token}the uniform failure, and its uniformity is the feature. A malformed token, a token matching no document, a link its issuer has switched off, a missing stored file and a file whose SHA-256 no longer matches all produce this exact code, status and description, character for character. Distinguishing any two of them would tell someone probing tokens which ones ever designated a real document — precisely what an enumeration is looking for. A 404 rather than a 403: a 403 would concede there is something here to forbid.
control_document.invalid_correction 400 POST /v1/controldocuments/{id}/corrections — the reason or the new value is empty, the field has nothing on this document to attach to (a plate on a document naming no carrier), or the new value is already what the field reads. One code for the three: no caller needs to tell them apart, the description names the problem, and the fix is the same — correct the request body.
control_document.already_superseded 409 POST /v1/controldocuments/{id}/supersede on a document that already has a successor. A second successor would fork the traceability chain, and nobody would know which document is in force at the roadside. The successor itself may be superseded in turn — the chain lengthens, it does not split.
control_document.pdf_unavailable 404 The authenticated counterpart of the code above, on GET /v1/controldocuments/{id}/pdf: the caller may read this document, but its stored file is missing or its SHA-256 no longer matches what was recorded when it was rendered. A distinct code because the caller must branch on it — "this document is not yours" and "this document is yours, its file cannot be served" call for different messages and different actions. The discretion the public link imposes has no purpose here: the caller has already proved they are a party to the document.

Two things a caller will notice are absent from this table, and both are absent on purpose: there is no invalid-transition code (nothing makes this document transition — it carries no status at all) and no signature code (it carries no signature mechanism, and cannot: signatures are keyed to freight documents at the database level). Amending a control document, with the previous content kept visible, is a separate mechanism that does not exist yet — PUT, PATCH and DELETE answer 405.

The public link is also rate-limited per client IP like every other public entry point, and answers auth.rate_limited (429) past the ceiling. That ceiling applies before anything is decided about the token, so being limited never reveals that a token was recognised.

Attachments & PDF (prompt 8)

Code HTTP status Meaning
attachment.not_found 404 The attachment doesn't exist, belongs to a different document than {id}, is still PENDING_UPLOAD (even after a lazy confirmation attempt), or is sealed and the caller lacks VIEW_SEALED_ATTACHMENT — all four cases return the identical code, see the anti-enumeration note below. Also reused, unchanged, when POST .../signatures/complete or .../sign-on-glass's optional attachment_ids contains one that doesn't resolve to a real attachment on the same document (Partie 3) — same "doesn't exist / isn't yours" non-actionable outcome, not a distinct case.
attachment.size_limit_exceeded 400 The declared size_bytes at creation, or the REAL size confirmed via a HEAD against the stored object, exceeds attachment_max_size_bytes (2 MB default). Confirmation-time is the real, final check.
attachment.document_size_limit_exceeded 400 Adding this attachment's declared (at creation) or real (at confirmation) size to the document's existing AVAILABLE total would exceed attachment_document_max_total_bytes (10 MB default).
attachment.invalid_type_for_status 400 GENERAL/GOODS attempted (creation OR deletion) while the document isn't DRAFT — those two types are DRAFT-only end to end. Outside DRAFT, SUPPLEMENT/COMMENT may still be deleted by their own uploader.
attachment.invalid_mime_type 400 The declared mime_type at creation, or the REAL Content-Type confirmed via HEAD, isn't one of application/pdf, image/jpeg, image/png, text/csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet (xlsx), application/vnd.openxmlformats-officedocument.wordprocessingml.document (docx).
pdf.generation_failed 500 WeasyPrint raised while converting the rendered HTML to PDF bytes.
pdf.invalid_share_token 401 The share token's signature is invalid, it has expired, its purpose claim isn't pdf_share, or its freight_document_id claim doesn't match the {id} in the URL — all four cases return the identical code, deliberately never distinguishing which one failed (this is a deliberate exception to this project's usual distinct-expired-vs-invalid pattern for that pattern and why PDF sharing departs from it here).

Electronic Signature (prompt 9)

Code HTTP status Meaning
signature.challenge_expired 400 The nonce doesn't match any SignatureChallenge, or matches one whose expires_at is in the past — identical code either way, see the anti-enumeration note below.
signature.challenge_already_used 400 The challenge has already been consumed by a completed signature (consumed_at is set) — anti-replay.
signature.invalid_signature 400 The WebAuthn assertion/registration failed verification against the enrolled key — a bad signature, a stale/replayed authenticator counter, or a challenge/origin/rp_id mismatch all return this identical code.
signature.document_hash_mismatch 409 The document's structured content changed between challenge issuance and completion — the freshly computed hash no longer matches the challenge's own document_hash. Criterion 4 enforcement.
signature.key_revoked 401 The signer's WebAuthn key was revoked after assertion options were issued but before completion — distinct from no_enrolled_key (this subaccount DID have a key; it was revoked mid-ceremony).
signature.no_enrolled_key 404 No active (non-revoked) SignatureKey exists for this phone number — either never enrolled, or its only key was revoked and no new one enrolled yet.
email.delivery_unavailable 503 The invitation email could not be handed to the email provider. Retryable. The sub-account and its invitation token are already created by the time this can be raised, so the correct follow-up is POST /v1/accounts/me/subaccounts/{id}/invitations/resend, never creating the driver again — a second create hits the email/phone uniqueness constraint and reads as an unrelated failure. Deliberately mirrors sms.delivery_unavailable, including never asserting non-delivery: a provider that raised may still have accepted the message.
subaccount.contact_immutable 409 This sub-account's email/phone_number can no longer be changed, because the person holds a password the caller has never seen — either they completed an invitation, or they ran the sub-account password reset themselves. Changing the email there, then triggering a reset, would be a full account takeover; the phone is what the signature flow resolves a signer by. Only the display name (and the driver fields) stay editable. A sub-account whose credentials the operator chose directly is NOT affected.
subaccount.deactivated 409 This sub-account has been deactivated (revoked_at set) and can no longer log in, sign, or be resolved as a driver. Distinct from freight_document.invalid_driver_phone_number (the number is malformed) and from signature.no_enrolled_key (the number was never enrolled): here the person exists and is known, but was retired — the caller must use a different phone number, since no reactivation endpoint exists yet. Raised by find_subaccount_by_phone, so it reaches every signature-resolution path and driver_phone_number at document creation. The public, unauthenticated invitation and password-reset endpoints deliberately do NOT return it — they keep their existing generic errors rather than becoming an oracle.
signature.device_verification_failed 401 Phone OTP verification failed — a wrong code, an expired/never-requested one, or too many attempts, all return this identical code (see the anti-enumeration note below).
signature.not_authorized_for_transfer 403 Not in the brief's literal list — added by extension. The signer's subaccount/account doesn't match SIGNER_ROLE_FOR_TRANSFER_TYPE for this transfer type (including a phone number already tied to an unrelated account).
signature.not_found 404 Not in the brief's literal list — added by extension, same rationale as attachment.not_found/partner.not_found. GET .../signatures/{signature_id}/verify's {signature_id} doesn't resolve to a real row on the given document.
signature.timestamp_unavailable 503 Not in the brief's literal list — added by extension. The RFC 3161 timestamp authority couldn't be reached; without this code the failure would surface as an unhandled 500.
signature.collection_acceptance_required 409 DELIVERY cannot be signed until a COLLECTION_ACCEPTANCE signature already exists on this document. Returned both at challenge-creation time (fail-fast) and, redundantly, at completion time (closes a real concurrency window between the two) — same code either way.
signature.unloading_not_completed 409 DELIVERY cannot be signed until an UNLOADING_COMPLETED event has been logged for this document. Same fail-fast-at-challenge + redundant-recheck-at-completion shape as signature.collection_acceptance_required above.

Webhooks (prompt 10)

Code HTTP status Meaning
webhook.subscription_limit_exceeded 409 This document already has 10 active webhook_subscriptions (the maximum) — revoke one before creating another. Counted across every account subscribed to the document, not per-account.
webhook.invalid_callback_url 400 callback_url isn't https://, or its host resolves to a private/internal address (loopback, RFC 1918, link-local including the cloud-metadata 169.254.169.254) — checked at creation AND re-checked immediately before every real delivery attempt, since DNS can rebind between the two.
webhook.not_found 404 DELETE .../subscriptions/{id} or GET .../subscriptions/{id}/delivery-attempts (prompt 17 gap-fill) for a subscription that doesn't exist, or exists but was created by a different account — identical code either way, same anti-enumeration principle as everywhere else in this taxonomy.
webhook.security_config_invalid n/a — never an HTTP response security_type=MTLS selected but no platform client certificate is configured in this environment. Deliberately not validated at subscription creation (per the brief, a real certificate need not exist yet in this mission) — only surfaces when a delivery actually attempts to use it, recorded as that attempt's response_snippet with status EXHAUSTED (a permanent misconfiguration, not a transient failure, so no retries).
webhook.subscription_revoked 409 POST /v1/accounts/me/webhook-delivery-attempts/{id}/retry or POST /v1/accounts/me/webhook-subscriptions/{id}/test targeted a subscription its own owner has since revoked. Deliberately distinct from webhook.not_found: the subscription exists and does belong to the caller, so the fix is different (create a new subscription — don't retry this one), and reporting "not found" would be misleading. Both actions refuse rather than send traffic to an endpoint deliberately switched off.

Notifications (prompt 11)

Code HTTP status Meaning
notification.invalid_push_subscription 400 POST /v1/notifications/push-subscribe's subscription payload couldn't be stored, or DELETE /v1/subaccounts/me/push-subscriptions/{id} targeted an id that doesn't exist or belongs to a different subaccount — identical code either way, same anti-enumeration principle as webhook.not_found.
notification.preference_not_found 404 PUT .../notification-preferences named an event_type that isn't a valid preference for this owner type — e.g. a PUSH-channel event on the account (EMAIL-only) endpoint, or vice versa.
notification.mandatory_event_not_overridable 400 PUT .../notification-preferences tried to set enabled: false for ACCOUNT_ACTIVATION_REQUESTED/PASSWORD_RESET_REQUESTED — checked against MANDATORY_NOTIFICATION_EVENT_TYPES, an explicit constant, not an inline check.

Events & Comments (prompt 12)

Code HTTP status Meaning
event.invalid_geolocation 400 POST .../events's geolocation.lat/geolocation.lng is out of range (lat outside -90/90, lng outside -180/180) or not a number. Omitting geolocation entirely is always accepted — only an out-of-range value already present is rejected.
event.permission_denied 403 The caller doesn't hold RECORD_EVENT on this document — including when the document doesn't exist at all, same anti-enumeration posture as freight_document.permission_denied , under its own code rather than the generic one.
comment.permission_denied 403 The caller doesn't hold COMMENT on this document (granted to every RoleType by default since prompt 4 — reachable only for a principal with no role on the document at all, or the document not existing).

event.not_found/comment.not_found, listed in the original brief, are deliberately not implemented: neither events nor comments has a per-resource GET/DELETE endpoint (only POST and a list GET) for either code to ever be reachable, and inventing a distinguishable "not found" signal for a freight-document-scoped sub-resource would actually violate this project's own anti-enumeration principle rather than extend it.

Anti-enumeration note

Per the mission brief, no endpoint may reveal whether a given email is registered, nor whether a given freight document exists to a caller who isn't authorized to see it:

  • POST /v1/accounts/register always returns the same 202 response, whether the email is new or already registered (the existing-email case is a silent no-op — no second account, no second email).
  • POST /v1/accounts/login returns auth.invalid_credentials for both "no such account" and "wrong password" — same status code, same message. A dummy password hash is verified even when no account is found, so the response time doesn't leak which case it was.
  • POST /v1/accounts/password/reset-request always returns the same generic 200 message.
  • POST /v1/accounts/subaccounts/login (prompt 4) applies the same pattern: an unknown email, a subaccount with no password set (a traceability-only record), and a wrong password all return the identical auth.invalid_credentials.
  • GET /v1/freightdocuments/{id}, GET /v1/freightdocuments/ext/{external_identifier}, POST /v1/freightdocuments/{id}/issue, and POST /v1/freightdocuments/{id}/roles (prompt 4) all return freight_document.permission_denied (403), never 404, whether the document/identifier doesn't exist or exists but the caller has no role on it.
  • The delegation/access-code endpoints (prompt 6) apply the same principle to the {id}/{role_id} pair in their URLs: a role_id that doesn't exist, one that belongs to a different document than {id}, and one that exists on the right document but the caller has no DELEGATE access to, all return the identical freight_document.delegation_not_permitted (or delegation_chain_access_denied for the chain-read endpoint).
  • The directory (prompt 7) applies the same principle to every GET/PATCH/DELETE on a Partner/Vehicle/Driver/Packaging Method: an ID that doesn't exist and one that exists but belongs to a different account both return the identical *.not_found — never a signal that a resource exists in someone else's private directory. partner_id on a Freight Document role follows the same rule (partner.not_found either way).
  • Attachments (prompt 8) extend the sealing principle one step further: a sealed attachment isn't merely download-blocked for a principal without VIEW_SEALED_ATTACHMENT — it's absent from GET .../attachments entirely, and GET .../attachments/{id}/download for it returns the identical attachment.not_found a truly nonexistent ID would — no way to distinguish "wrong ID" from "sealed, not for you" from "not part of this document." The PDF share-link endpoint applies the same principle to its own token: a bad signature, an expired token, and a token whose freight_document_id doesn't match the URL's {id} all return the identical pdf.invalid_share_token.
  • Electronic signature (prompt 9) applies the same principle twice: a nonexistent challenge nonce and a real-but-expired one both return the identical signature.challenge_expired, and a wrong OTP code, an expired/never-requested one, and too many attempts all return the identical signature.device_verification_failed.
  • Webhooks (prompt 10) apply the same principle to DELETE .../subscriptions/{id}: a subscription that doesn't exist and one that exists but was created by a different account both return the identical webhook.not_found.
  • Notifications (prompt 11) apply the same principle to DELETE /v1/subaccounts/me/push-subscriptions/{id}: an id that doesn't exist and one that exists but belongs to a different subaccount both return the identical notification.invalid_push_subscription.
  • Events & Comments (prompt 12) apply the same principle via event.permission_denied/comment.permission_denied: a document that doesn't exist and one that exists but the caller has no RECORD_EVENT/ COMMENT on both return the identical code — and, per the same principle, no event.not_found/comment.not_found exists at all, since neither resource has a per-id endpoint that could otherwise leak a distinguishable "not found" signal.

The one deliberate exception: GET /v1/accounts/lookup

account.not_found_by_email (404) is the single endpoint in this API that answers "does an account with this email exist" directly — the frontend's "Compte existant" role-identification mode needs a live yes/no to search by email instead of asking a human to type a raw account_id UUID. Confirmed explicitly with the user as a deliberate, scoped exception, not an oversight of the principle above:

  • Requires authentication (get_current_account_via_management_token) — never reachable by an anonymous caller, unlike every endpoint listed above.
  • Answers for exactly one email per call, never a list or a search-by-prefix — no bulk enumeration surface.
  • The result is minimal (id, company_name) — no status, no other account metadata.

The risk this accepts: any authenticated account can probe whether a specific email it already knows has an account. Judged acceptable because finding out costs the caller nothing they couldn't already get by other means (e.g. inviting that email as a delegate and observing whether the notification email reads as "already registered" vs. "not yet"), and the alternative (resolving only at role-creation time, erroring after the fact) was rejected in favor of live search feedback before submission.