Every error the API returns is a JSON object with an error field: a human-readable message. A client parses one shape.
Authentication failures, permission failures, not-found, rate limiting, and every otherwise-unhandled failure also carry a code: a short, stable, machine-readable token from a fixed vocabulary (unauthorized, forbidden, not_found, conflict, validation_failed, unprocessable, rate_limited, internal_error) that a client can branch on without matching the message text, which is free to change:
Coverage of code is not yet on every business-rule-specific rejection — an endpoint that names exactly which rule was broken ("image reference must not contain credentials", above) may still answer with only error. Never branch client logic on the absence of code; treat it as present-when-useful, not as a discriminator between response shapes.

Status codes

401 tells you nothing about why

Missing, malformed, unknown, wrong secret, revoked and expired credentials all produce the same 401 with the same body. Distinguishing them would tell an attacker which part of a forged credential to fix next, which is a hint worth more to them than it is to you.

403 from a scope check carries two extra fields

required_scope is what the endpoint needed. principal is user or api_key, which distinguishes “this key was issued too narrowly” — fix by issuing a new key — from “this person’s role does not permit it” — fix by changing a role. Two different problems with two different owners.

404 covers two different situations, on purpose

A resource that does not exist and a resource belonging to another organisation return byte-identical responses. This is the one place where the honest-looking answer is the wrong one. A 403 for “exists, but not yours” would confirm that the identifier names something real. That turns a UUID guess into reconnaissance: an attacker who can tell a real identifier from a fabricated one learns which resources exist and, by extension, something about who else is a customer. The same rule holds inside a bulk response, where a finding belonging to another organisation is reported as not_found rather than as a permission error. A bulk endpoint that distinguished them would be a fast way to enumerate which identifiers are real, 500 at a time. GET /assets/{id}/schedule follows it too: an asset with no schedule returns 404, not 200 with a null. An asset with no schedule and an asset that is not yours must look the same.

Rate limiting

The limiter exists to protect capacity, not to meter usage. Nothing here prices a plan or produces an invoice. It counts per credential, never per organisation — an API key by its own identifier, a browser session by user identifier. Counting per organisation would make every credential inside a company share one bucket, and the first thing that happens is a misbehaving CI job locking its colleagues out of the dashboard. Counting per IP address is no better: a customer’s whole CI fleet can sit behind one NAT address while an attacker with a /64 is effectively unlimited, and an IP is not something TRUSTIVAN has authenticated. The default is 600 requests per minute, configurable per deployment. Generous on purpose: a pipeline polling a scan every two seconds for ten minutes makes three hundred requests. A limiter that interrupts legitimate work teaches customers to retry harder, which is the opposite of what it is for.

Headers

Every response carries the current state, so a well-behaved client can slow down before it is refused:
On refusal:
Retry-After is rounded up to at least one second — a value of 0 would tell a client to retry immediately, into the same refusal. The body says what to do and nothing else. No algorithm name, no cache state, no shard identifier. An error body is read by whoever is probing the service, and infrastructure detail in it is a map for them.

Authentication endpoints are limited separately

POST /auth/signup, /auth/login, /auth/refresh and /auth/exchange are limited per source address at 10 requests per minute. They have to be. The per-credential limiter runs after authentication has produced a credential to count, and on a login attempt there is not one yet. Without a separate control, password brute force, credential stuffing and user enumeration would be bounded only by network bandwidth.

The failure mode, stated plainly

The limiter keeps its counters in Redis, and it fails open. When Redis is unreachable, requests are allowed. That is a deliberate trade, not an oversight. Every request this limiter sees has already presented a valid credential. Failing closed would turn a Redis blip into a total API outage for every customer — a far larger incident than the one being prevented. A control whose failure mode is worse than the threat is not a control worth having. The degradation is logged rather than swallowed: a limiter that has been failing open for a week without anyone noticing is a limiter that is not there. A deployment that has decided otherwise can configure it to fail closed. A deployment without Redis has no per-credential rate limiting at all. It runs unlimited rather than refusing to start. If you are operating TRUSTIVAN yourself and you have not deployed Redis, that limit does not exist for you, whatever this page’s default says.

Limits

Two of these deserve a note. The bulk ceiling is on work, not on convenience. Without one, a single request makes the server hold a transaction over an unbounded number of rows, which is a denial of service the caller does not even have to intend. The concurrency limit never drops work. Requesting a sixth scan while five are running on Pro returns normally and the scan sits in queued until capacity frees. A client polls the same way it always does; the only difference is that the terminal state arrives later. This is the one limit that does not produce an error, because an error would push retry logic into every caller for a condition that resolves itself.

Errors specific to a request body

Some endpoints reject input at the boundary with a message that names the rule that was broken. POST /scans treats the image reference as untrusted input. Private and link-local registries, embedded credentials, URL schemes and path traversal segments are all refused:
PATCH /findings/{id} refuses status: "resolved". Resolution means a scan that would have found the vulnerability did not — evidence, not opinion. The way to close something you decided not to fix is to suppress it; the way to close something you fixed is to rescan. See Finding lifecycle. PUT /assets/{id}/schedule refuses a digest-pinned asset. There is no tag to re-resolve, so a “rescan” would either re-read identical bytes forever or silently scan :latest — a different image from the one you are watching.

Bulk responses are not errors

POST /findings/bulk returns 200 whenever the request itself was valid, even when every individual item was rejected.
Branch on the counts, not on the status line. 207 Multi-Status is a WebDAV code most HTTP clients treat as an error, and a caller that got one would have to decide whether “partial success” meant retry. Per-item outcomes are applied, already_applied, not_found, invalid_transition and conflict. already_applied is not an error — a retried request, a re-run pipeline and an operator repeating themselves all produce it. A request-level problem is still a request-level failure. A bulk suppression with no reason is a single 400, not 500 identical per-item rejections dressed up as partial success — because nothing in that request could ever have succeeded.

Verdicts are never errors either

POST /scans/{id}/verdict returns 201 when it records a decision and 200 when it replays one. A failing verdict is a successful request. A pipeline that treated a policy failure as an HTTP error could not distinguish “the build should stop” from “TRUSTIVAN is down”, and those demand opposite reactions. Branch on blocking in the body. The one genuine error case is 409: no policy is enabled for this organisation, so there is nothing to evaluate against.

Operational probes

GET /health is a liveness probe and deliberately checks nothing. A liveness probe that consulted the database would restart every instance during a failover, turning a recoverable dependency outage into a total one. GET /ready checks dependencies and returns 503 while any is unavailable, so an orchestrator stops routing to a process that cannot work. Neither takes a credential, and neither lives under /api/v1.