Skip to content

Errors

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.

Response shape

{
  "error": {
    "code": 404,
    "message": "No observation found for ORN: orn:scs:observation:...",
    "status": "NOT_FOUND",
    "reason": "RESOURCE_NOT_FOUND"
  }
}
FieldTypeNotes
codeintegerThe HTTP status code.
messagestringA description of the failure, in English, written for developers. Not safe to show to end users.
statusstringA symbolic name for the category of failure — NOT_FOUND for a 404, INVALID_ARGUMENT for most 400s.
reasonstringThe 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.

Reason vocabulary

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.

400 Bad Request

status: INVALID_ARGUMENT, or FAILED_PRECONDITION for UNSUPPORTED_API_VERSION.

reasonWhen it fires
MISSING_REQUIRED_FIELDA required request field was absent or empty. Also fired when a required bounding filter is absent on list endpoints that require one.
INVALID_FIELD_VALUEA 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_FORMATA field's value did not match the expected structural format (e.g. ORN, timestamp, domain name).
FIELD_LENGTH_EXCEEDEDA field's item count, byte size, or character count exceeded the documented limit.
MISSING_IDENTIFIERNo resource identifier was supplied where one is required.
CONFLICTING_IDENTIFIERSTwo mutually exclusive identifier fields were both provided.
VIEW_MISMATCHThe explicit view parameter conflicts with the view encoded in a supplied ORN.
UNSUPPORTED_GROUP_BY_FOR_VIEWA group-by field is not valid for the caller's current view.
UNSUPPORTED_API_VERSIONThe requested API version is not served by this instance.
EXTERNAL_REMEDIATION_REQUIRES_FLAGExternal remediation was requested (is_external_remediation) without also flagging the observations (flag_observations).
REASON_UNSPECIFIEDNo specific reason. Read message for detail and treat it as a generic bad request.

401 Unauthenticated

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.

:::

reasonWhen it fires
MISSING_AUTH_METADATAThe token is valid but does not carry the organization-domain claim (allowedDomains) the service requires.
MISSING_AUTH_TOKENAn expected credential was not present.
INVALID_AUTH_TOKENA credential was present but failed validation (malformed, expired, signature mismatch).
INVALID_TOKEN_PAYLOADThe token parsed but its payload failed schema validation.
TOKEN_EXTRACTION_FAILEDThe caller's identity could not be determined from the token.
REASON_UNSPECIFIEDNo specific reason. Re-authenticate.

403 Permission Denied

status: PERMISSION_DENIED.

reasonWhen it fires
ORN_DOMAIN_NOT_ALLOWEDA 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_ENTITLEDThe caller's organization is not entitled to the requested view (e.g. VRM access revoked, or a missing OAuth2 scope).
REASON_UNSPECIFIEDNo specific reason. The caller does not have permission.

404 Not Found

status: NOT_FOUND.

reasonWhen it fires
RESOURCE_NOT_FOUNDA resource lookup by identifier returned no result within the caller's authorization scope.
REASON_UNSPECIFIEDNo specific reason.

409 Conflict

status: ALREADY_EXISTS, FAILED_PRECONDITION, or ABORTED.

reasonWhen it fires
RESOURCE_ALREADY_EXISTSA 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_TRANSITIONThe requested status transition is not allowed from the resource's current status.
REASON_UNSPECIFIEDNo 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.

500 Internal Server Error

status: INTERNAL.

reasonWhen it fires
INTERNAL_ERRORAn unexpected failure on our side.
PV1_DOMAIN_NOT_CONFIRMEDThe 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_UNSPECIFIEDAn 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.

Handling errors in code

Branching on reason

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
  }
}

Using the TypeScript SDK

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
}

Retry guidance

StatusRetry?Action
400NoFix the request. Read message for detail. Do not retry without changing the payload.
401NoRe-authenticate. Refresh or re-obtain the bearer token.
403NoThe caller does not have access. Do not retry.
404NoThe resource does not exist or is not visible to this caller.
409DependsRESOURCE_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.
429YesHonour the Retry-After header. See Usage Limits.
500DependsINTERNAL_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.
503YesService temporarily unavailable. Retry as above.

Reporting a bug

When opening a support ticket, include:

  1. HTTP status and error.reason.
  2. The full error.message from the response.
  3. Approximate timestamp (UTC).
  4. Minimal request example (method, path, headers other than Authorization).
  5. What you expected vs. what you observed.