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_error — version 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/registeralways 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/loginreturnsauth.invalid_credentialsfor 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-requestalways 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 identicalauth.invalid_credentials.GET /v1/freightdocuments/{id},GET /v1/freightdocuments/ext/{external_identifier},POST /v1/freightdocuments/{id}/issue, andPOST /v1/freightdocuments/{id}/roles(prompt 4) all returnfreight_document.permission_denied(403), never404, 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: arole_idthat doesn't exist, one that belongs to a different document than{id}, and one that exists on the right document but the caller has noDELEGATEaccess to, all return the identicalfreight_document.delegation_not_permitted(ordelegation_chain_access_deniedfor the chain-read endpoint). - The directory (prompt 7) applies the same principle to every
GET/PATCH/DELETEon 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_idon a Freight Document role follows the same rule (partner.not_foundeither way). - Attachments (prompt 8) extend the sealing principle one step further: a
sealedattachment isn't merely download-blocked for a principal withoutVIEW_SEALED_ATTACHMENT— it's absent fromGET .../attachmentsentirely, andGET .../attachments/{id}/downloadfor it returns the identicalattachment.not_founda 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 whosefreight_document_iddoesn't match the URL's{id}all return the identicalpdf.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 identicalsignature.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 identicalwebhook.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 identicalnotification.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 noRECORD_EVENT/COMMENTon both return the identical code — and, per the same principle, noevent.not_found/comment.not_foundexists 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.