Errors

Error response format and error codes for Loom API v1.

Errors

Loom API v1 returns a consistent JSON error body on every failure. This page documents the response format, the HTTP status codes, and the error codes the API can return.

Error Response Format

All errors share one flat JSON structure:

{
  "error": "INVALID_REQUEST_BODY",
  "message": "Human-readable description of what went wrong",
  "details": { "field": "additional context" },
  "requestId": "550e8400-e29b-41d4-a716-446655440000"
}

Response fields

  • error — machine-readable error code (a string; see the tables below).
  • message — human-readable description. Optional.
  • details — additional context. Optional; present only for some errors.
  • requestId — UUID identifying the request. Also returned in the x-request-id response header. Include it when contacting support.

There is no nested error.code object — the value of error is the code string.

HTTP Status Codes

StatusMeaning
400 Bad RequestInvalid request body or parameters
401 UnauthorizedAuthentication failed
402 Payment RequiredSubscription inactive
403 ForbiddenAuthenticated but not permitted
404 Not FoundRoute or resource not found
409 ConflictConflicting state (e.g. already provisioned)
429 Too Many RequestsRate limit or quota exceeded
500 Internal Server ErrorUnexpected server error
503 Service UnavailableA dependency (cache) is unavailable

Error Codes

Authentication

CodeStatusMeaning
UNAUTHENTICATED_TENANT401The x-tenant-api-key header is missing, invalid, or revoked.
FORBIDDEN403The key is valid but not permitted for this resource.
{
  "error": "UNAUTHENTICATED_TENANT",
  "message": "The provided API key is invalid or expired",
  "requestId": "550e8400-e29b-41d4-a716-446655440000"
}

Solutions: confirm you are sending the x-tenant-api-key header (not Authorization: Bearer), and that the key is active in the dashboard.

Validation

CodeStatusMeaning
INVALID_REQUEST_BODY400The request body is malformed JSON or contains unknown fields.
INVALID_PARAMETERS400A query or path parameter is missing or invalid.
ROUTE_NOT_FOUND404No route matches the request.

POST /verify/start accepts only the optional userAgent and ip string fields. Any other field is rejected with INVALID_REQUEST_BODY.

Verification

CodeStatusMeaning
VERIFICATION_NOT_FOUND404No verification matches the given verificationId.
VERIFICATION_ALREADY_COMPLETED400The verification has already reached a terminal status.
VERIFICATION_MUST_USE_WEBHOOK400POST /verify/complete is unavailable for iDenfy-backed verifications; results arrive via the webhook callback.

Rate limiting & quota

CodeStatusMeaning
RATE_LIMIT_EXCEEDED429Too many requests in the current window.
QUOTA_EXCEEDED429The plan's monthly quota has been reached.
HARD_LIMIT_EXCEEDED429An absolute usage ceiling has been reached.

429 responses set a Retry-After header when a retry delay is known, and may include details.retryAfterSeconds. See Rate Limits.

{
  "error": "RATE_LIMIT_EXCEEDED",
  "message": "Rate limit exceeded",
  "details": { "retryAfterSeconds": 30 },
  "requestId": "550e8400-e29b-41d4-a716-446655440000"
}

Billing & tenant

CodeStatusMeaning
BILLING_INACTIVE402The subscription is inactive; resolve billing in the dashboard.
TENANT_INACTIVE401The tenant account is disabled.
NOT_PROVISIONED / TENANT_NOT_PROVISIONED409 / 403The tenant has not been provisioned.

Server

CodeStatusMeaning
INTERNAL_ERROR500Unexpected server error. Retry; if it persists, contact support with the requestId.
DATABASE_ERROR500A database operation failed.
REDIS_ERROR503The cache service is unavailable.

Token validation is not an error

POST /tokens/validate always returns HTTP 200. An invalid, expired, or revoked token is reported in the response body — not as an error envelope:

{ "valid": false, "over18": false, "reason": "TOKEN_EXPIRED" }

reason is one of OK, TOKEN_EXPIRED, TOKEN_REVOKED, TOKEN_NOT_FOUND, UNDERAGE_OR_UNKNOWN. See Verification.

Handling Errors

Retry logic

Retry 5xx and 429 responses; never retry other 4xx responses — they will not succeed on retry.

async function requestWithRetry(url, options, maxRetries = 3) {
  let lastError
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, options)
    if (response.ok) return response

    const body = await response.json().catch(() => ({}))

    // 4xx other than 429 is a client error — do not retry
    if (response.status >= 400 && response.status < 500 && response.status !== 429) {
      throw new Error(`${body.error}: ${body.message}`)
    }

    lastError = new Error(`${body.error}: ${body.message}`)
    if (attempt < maxRetries) {
      const retryAfter =
        Number(response.headers.get('Retry-After')) || 2 ** attempt
      await new Promise((r) => setTimeout(r, retryAfter * 1000))
    }
  }
  throw lastError
}

Log the requestId

const response = await fetch('https://api.loomapi.com/verify/start', {
  method: 'POST',
  headers: {
    'x-tenant-api-key': process.env.LOOM_TENANT_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({}),
})

if (!response.ok) {
  const body = await response.json()
  console.error('Loom API error', {
    code: body.error,          // e.g. "INVALID_REQUEST_BODY"
    message: body.message,
    requestId: body.requestId, // also in the x-request-id response header
    status: response.status,
  })
}

Always quote the requestId when contacting support@loomapi.com — it lets us locate your request in the logs.

More