When a request fails, the Titan Public API returns a JSON body containing a single top-level error object. Its shape is the same for every endpoint and every status code, so one error handler covers your whole integration.
The field to act on is error.reason — a constant such as RESOURCE_NOT_FOUND that names the specific thing that went wrong. The HTTP status code tells you the broad category of failure; reason tells you which failure within that category, so a 400 caused by a missing field is distinguishable from a 400 caused by a malformed identifier. Write your error handling as a switch on error.reason, and use error.message for logs and debugging rather than for control flow.
{
"error": {
"code": 404,
"message": "No observation found for ORN: orn:scs:observation:...",
"status": "NOT_FOUND",
"reason": "RESOURCE_NOT_FOUND"
}
}| Field | Type | Notes |
|---|---|---|
code | integer | The HTTP status code. |
message | string | A description of the failure, in English, written for developers. Not safe to show to end users. |
status | string | A symbolic name for the category of failure — NOT_FOUND for a 404, INVALID_ARGUMENT for most 400s. |
reason | string | The specific failure, as an UPPER_SNAKE_CASE constant. This is the field to branch on. REASON_UNSPECIFIED means the failure was not narrowed down any further. |
These four fields are the whole contract. Ignore any other field you see in a response body — nothing else is part of the public error envelope, and anything extra may disappear without notice.
Each status code has its own set of reason values, listed below.
Handle the reasons your integration cares about and fall back to a generic message for the rest. New reasons are added over time, and one you do not recognise is always safe to treat as a generic failure of its status code.
status: INVALID_ARGUMENT, or FAILED_PRECONDITION for UNSUPPORTED_API_VERSION.
reason | When it fires |
|---|---|
MISSING_REQUIRED_FIELD | A required request field was absent or empty. Also fired when a required bounding filter is absent on list endpoints that require one. |
INVALID_FIELD_VALUE | A field was present but its value was rejected by validation (unrecognised enum, wrong type, out-of-range number). Also fired when a required bounding filter is present but out of range, such as a date window that is too wide. |
INVALID_FIELD_FORMAT | A field's value did not match the expected structural format (e.g. ORN, timestamp, domain name). |
FIELD_LENGTH_EXCEEDED | A field's item count, byte size, or character count exceeded the documented limit. |
MISSING_IDENTIFIER | No resource identifier was supplied where one is required. |
CONFLICTING_IDENTIFIERS | Two mutually exclusive identifier fields were both provided. |
VIEW_MISMATCH | The explicit view parameter conflicts with the view encoded in a supplied ORN. |
UNSUPPORTED_GROUP_BY_FOR_VIEW | A group-by field is not valid for the caller's current view. |
UNSUPPORTED_API_VERSION | The requested API version is not served by this instance. |
EXTERNAL_REMEDIATION_REQUIRES_FLAG | External remediation was requested (is_external_remediation) without also flagging the observations (flag_observations). |
REASON_UNSPECIFIED | No specific reason. Read message for detail and treat it as a generic bad request. |
status: UNAUTHENTICATED.
:::info
Some authentication failures are rejected before the request reaches the service; those responses carry no reason field, so a switch on error.reason falls through to your default case. Treat any 401 without a reason as "get a new token and retry once".
When the service itself rejects the credential, the response carries one of the reasons below.
:::
reason | When it fires |
|---|---|
MISSING_AUTH_METADATA | The token is valid but does not carry the organization-domain claim (allowedDomains) the service requires. |
MISSING_AUTH_TOKEN | An expected credential was not present. |
INVALID_AUTH_TOKEN | A credential was present but failed validation (malformed, expired, signature mismatch). |
INVALID_TOKEN_PAYLOAD | The token parsed but its payload failed schema validation. |
TOKEN_EXTRACTION_FAILED | The caller's identity could not be determined from the token. |
REASON_UNSPECIFIED | No specific reason. Re-authenticate. |
status: PERMISSION_DENIED.
reason | When it fires |
|---|---|
ORN_DOMAIN_NOT_ALLOWED | A domain named in the request is not one the caller may act on: the domain embedded in a supplied ORN, or a domain field such as org_domain or vendor_domain. |
VIEW_NOT_ENTITLED | The caller's organization is not entitled to the requested view (e.g. VRM access revoked, or a missing OAuth2 scope). |
REASON_UNSPECIFIED | No specific reason. The caller does not have permission. |
status: NOT_FOUND.
reason | When it fires |
|---|---|
RESOURCE_NOT_FOUND | A resource lookup by identifier returned no result within the caller's authorization scope. |
REASON_UNSPECIFIED | No specific reason. |
status: ALREADY_EXISTS, FAILED_PRECONDITION, or ABORTED.
reason | When it fires |
|---|---|
RESOURCE_ALREADY_EXISTS | A create/upsert was rejected because a resource with the supplied identifier already exists, or an Idempotency-Key was reused with a different request payload. |
FORBIDDEN_STATUS_TRANSITION | The requested status transition is not allowed from the resource's current status. |
REASON_UNSPECIFIED | No specific reason. |
A 409 with status ABORTED and no reason means a request with the same Idempotency-Key is still being processed. Retry it later with the same key.
status: INTERNAL.
reason | When it fires |
|---|---|
INTERNAL_ERROR | An unexpected failure on our side. |
PV1_DOMAIN_NOT_CONFIRMED | The domain could not be confirmed as added — most often because its DNS does not currently resolve. This is a condition of the request, not a transient failure; see Retry guidance. |
REASON_UNSPECIFIED | An unexpected failure on our side, with no further detail. |
INTERNAL_ERROR and REASON_UNSPECIFIED are safe to retry; PV1_DOMAIN_NOT_CONFIRMED is not — see Retry guidance.
if (response.status >= 400) {
const body = await response.json();
const reason = body?.error?.reason;
switch (reason) {
case 'RESOURCE_NOT_FOUND':
// show "not found" UI
break;
case 'VIEW_NOT_ENTITLED':
// re-fetch with non-VRM ORN, or prompt re-auth
break;
case 'MISSING_REQUIRED_FIELD':
// surface validation hint to the user
break;
default:
// treat as a generic failure for this status
}
}If you generate hooks via @securityscorecard/react-query, use the isErrorStatus guard to narrow the error type:
import { isErrorStatus } from '@securityscorecard/react-query';
const { error } = useGetObservationByOrn(orn);
if (isErrorStatus(error, 403)) {
// error.response.data is PermissionDeniedError
const reason = error.response.data.error?.reason;
// reason is narrowed to 'ORN_DOMAIN_NOT_ALLOWED' | 'VIEW_NOT_ENTITLED' | 'REASON_UNSPECIFIED' | undefined
}| Status | Retry? | Action |
|---|---|---|
| 400 | No | Fix the request. Read message for detail. Do not retry without changing the payload. |
| 401 | No | Re-authenticate. Refresh or re-obtain the bearer token. |
| 403 | No | The caller does not have access. Do not retry. |
| 404 | No | The resource does not exist or is not visible to this caller. |
| 409 | Depends | RESOURCE_ALREADY_EXISTS: resolve the conflict first, or use a new Idempotency-Key for a different payload. FORBIDDEN_STATUS_TRANSITION: do not retry. No reason (status ABORTED): a request with the same key is still in flight; retry later with the same key. |
| 429 | Yes | Honour the Retry-After header. See Usage Limits. |
| 500 | Depends | INTERNAL_ERROR / REASON_UNSPECIFIED: retry with exponential backoff (base 1 s, factor 2, jitter, cap 30 s). PV1_DOMAIN_NOT_CONFIRMED: do not retry — verify the domain's DNS resolves, then resubmit. |
| 503 | Yes | Service temporarily unavailable. Retry as above. |
When opening a support ticket, include:
- HTTP status and
error.reason. - The full
error.messagefrom the response. - Approximate timestamp (UTC).
- Minimal request example (method, path, headers other than
Authorization). - What you expected vs. what you observed.