Skip to main content

Errors

All Veridia API errors share one shape. This page is the canonical list of error codes.

Error response shape

{
"error": "invalid_body",
"message": "Request body failed validation",
"requestId": "9f511d92ac11236d-SJC",
"detail": {
"fieldErrors": {
"country": ["expected 2 characters"]
}
}
}
FieldTypeDescription
errorstringMachine-readable code — switch on this, not on the HTTP status
messagestringHuman-readable summary, for logs
requestIdstringUnique per request — include it in support tickets
detailobjectOptional. Field errors, a reason, or retry_after

The same requestId is in the X-Request-Id response header, so you can correlate even when JSON parsing fails.

The codes at a glance

CodeHTTPRetryable?
invalid_body400No — fix the request
missing_api_key401No
invalid_api_key401No
secret_key_required401No — use a secret key
insufficient_credits402No — top up
origin_not_allowed403No
verification_not_found404No
not_found404No — wrong URL
rate_limited429Yes, after Retry-After
internal_error500Yes, with backoff
backend_unavailable503Yes, with backoff

Note the exact spellings. There is no unauthorized, no invalid_key, and no internal — the last one is internal_error.

Error catalog

invalid_body400 Bad Request

The request body failed schema validation. Check detail.fieldErrors.

Common causes:

  • Wrong type, or a string over its length limit (userRef > 128, submittedFullName > 255)
  • Invalid enum value (documentType: "id" instead of "dni")
  • country not exactly two uppercase letters
  • verificationId not matching ^vf_[A-Za-z0-9]{16,24}$

Unknown fields do not cause this error. They are discarded silently and the request succeeds. If a field you sent had no effect, that is why — check the endpoint's field table rather than waiting for an error that will not come.

On /submit, detail.reason may be:

ReasonMeaning
doc_front_key_mismatchkeys.docFront isn't what /init returned
selfie_key_mismatchkeys.selfie isn't what /init returned
doc_back_key_mismatchkeys.docBack isn't what /init returned
doc_front_not_uploadedNo stored object at that key — the client never uploaded
selfie_not_uploadedSame, for the selfie
doc_front_disappearedThe object existed at check time but was gone before OCR (very rare)

On PUT /v1/verify/upload/..., detail.reason may be:

ReasonMeaning
missing_upload_tokenNo X-Veridia-Upload-Token header — you didn't forward slot.headers
invalid_upload_tokenToken doesn't match this verification's intent
key_mismatchThe role in the URL isn't one /init issued
invalid_roleUnrecognised role segment in the URL
not_a_jpegBody doesn't start with the JPEG magic bytes FF D8 FF
image_too_largeBody over 2 MB. Downscale the image before uploading; 2 MB of JPEG is 2-4 MP, more than OCR needs
image_too_smallBody under 100 bytes — usually a truncated or empty upload
upload_source_forbidden_for_biometricX-Veridia-Capture-Source: upload on a selfie or liveness frame
no_active_challengeA liveness frame was sent for a verification that never opted in

Recovery: fix the request. These are client errors; retrying unchanged reproduces them exactly.

missing_api_key401 Unauthorized

No Authorization header, or it isn't a Bearer token.

Recovery: send Authorization: Bearer <key>.

Note that the upload and challenge endpoints do not want this header — they use X-Veridia-Upload-Token instead.

invalid_api_key401 Unauthorized

The token doesn't match a known key. It never existed, was revoked, or doesn't parse.

The parser accepts exactly four prefixes: qv_pub_, qv_pubt_, qv_sec_, qv_sect_, followed by 16-64 characters from [A-Za-z0-9_]. A key of the form qv_pub_test_... does not exist — the test prefix is qv_pubt_.

Recovery: check the prefix and environment first, then the dashboard. Remember that revocation is immediate, with no grace period.

secret_key_required401 Unauthorized

A publishable key was used on GET /v1/verify/:id, the one endpoint that requires a secret key.

The key is fine. It is the wrong family for this call. Publishable keys ship inside your page source, so they are never allowed to read verification results. This code exists precisely so you do not go hunting for a typo in a valid key.

Recovery: create a secret key and call this endpoint from your server. Never place it in front-end code.

insufficient_credits402 Payment Required

The tenant's credit balance is zero. It is raised during authentication, so it can surface on /init before anything else happens — a frequent cause of "the widget just fails" on an exhausted trial account.

Recovery: top up in the dashboard. Worth monitoring as a business alert: it means verifications are being turned away.

origin_not_allowed403 Forbidden

A browser request carried an Origin that is not on the publishable key's allowed-origins list, and that list is non-empty.

Recovery: add the bare hostnameyourapp.com, staging.yourapp.com, localhost. Not https://yourapp.com, and not http://localhost:3000; the check compares hostnames only, so a scheme or port never matches.

Two things worth knowing before you touch this list:

  • An empty list allows every origin. Adding your first entry switches the key from "any origin" to "only these." Add an entry that does not match and you go from everything allowed to everything blocked in one step.
  • The check never applies to non-browser clients, which send no Origin header at all.

See Authentication.

verification_not_found404 Not Found

The verificationId doesn't exist, is malformed, or belongs to another tenant. Another tenant's verification returns 404 rather than 403 so that the response does not confirm the ID exists.

On /submit and uploads, it also means the verification intent expired. The intent lives for one hour after /init; after that, restart the flow.

On GET /v1/verify/:id there is no such expiry. Results stay readable indefinitely — ownership is checked against the tenant recorded with the verification itself, not against the short-lived intent. You do not need to mirror results locally to keep them reachable.

not_found404 Not Found

The route doesn't exist.

Recovery: check the URL. The classic is /v1/verifications or /v1/verifications/:id — neither exists. The results endpoint is GET /v1/verify/:id.

rate_limited429 Too Many Requests

You hit one of two limits. detail.retry_after and the Retry-After header give the wait in seconds.

LayerLimit
Per client IP, all routes, checked before authentication100 / 60 s
Per tenant — /v1/verify/init60 / 60 s
Per tenant — /v1/verify/submit30 / 60 s
Per tenant — /v1/verify/:id600 / 60 s

The per-IP layer is the one that surprises people: it runs before your key is read, it covers the upload and challenge endpoints (which have no tenant limit at all), and it is what mobile users behind shared CGNAT addresses run into while your tenant counters look idle.

Recovery: wait for Retry-After, then retry. If it recurs, read Rate limits — particularly the request-count-per-verification table.

internal_error500 Internal Server Error

Something broke on our side.

Recovery: retry with exponential backoff. If it persists, open a ticket with the requestId — for this error especially, it is the only way to trace what happened.

backend_unavailable503 Service Unavailable

The verification pipeline is unreachable, or the circuit breaker opened after repeated failures. detail.retry_after may be present.

Recovery: retry with backoff. This is the most likely transient error in normal operation — make sure your retry policy actually includes it.

Handling errors well

JavaScript / Node.js

const RETRYABLE = new Set(['rate_limited', 'internal_error', 'backend_unavailable']);

async function callVeridia(url, options, attempt = 0) {
const response = await fetch(url, options);
if (response.ok) return response.json();

const error = await response.json().catch(() => ({ error: 'unparseable' }));

// Switch on the error code, not the HTTP status.
switch (error.error) {
case 'invalid_body':
logger.error('Veridia validation failed', {
fieldErrors: error.detail?.fieldErrors,
reason: error.detail?.reason,
requestId: error.requestId,
});
throw new ValidationError(error);

case 'secret_key_required':
// The key is valid — it's the wrong family. Do not rotate it.
throw new ConfigError('Use a secret key for GET /v1/verify/:id');

case 'insufficient_credits':
await notifyCreditsExhausted();
throw new BusinessError(error);

case 'verification_not_found':
throw new NotFoundError(error);

case 'rate_limited':
case 'internal_error':
case 'backend_unavailable': {
if (attempt >= 3) throw new ExternalServiceError(error);
const retryAfter =
Number(response.headers.get('Retry-After')) ||
error.detail?.retry_after ||
2 ** attempt;
logger.warn('Veridia transient error, retrying', {
code: error.error,
requestId: error.requestId,
retryAfter,
});
await new Promise(r => setTimeout(r, retryAfter * 1000));
return callVeridia(url, options, attempt + 1);
}

default:
// New codes can appear on /v1 without a version bump — fail loudly
// but log enough to diagnose.
logger.error('Unhandled Veridia error', {
code: error.error,
requestId: error.requestId,
status: response.status,
});
throw new Error(`Unhandled Veridia error: ${error.error}`);
}
}

Note that RETRYABLE and the switch agree, and that the default branch logs the requestId. Both matter: the codes you do not yet handle are the ones you will most need to diagnose.

Python

import time
import requests

RETRYABLE = {"rate_limited", "internal_error", "backend_unavailable"}

def call_veridia(method, url, *, max_attempts=4, **kwargs):
for attempt in range(max_attempts):
response = requests.request(method, url, **kwargs)
if response.ok:
return response.json()

error = response.json()
code = error.get("error")

if code == "invalid_body":
raise ValidationError(error)

if code == "secret_key_required":
# The key is valid, just the wrong family. Rotating it won't help.
raise ConfigError("GET /v1/verify/:id requires a secret key")

if code == "insufficient_credits":
notify_credits_exhausted()
raise BusinessError(error)

if code == "verification_not_found":
raise NotFoundError(error)

if code in RETRYABLE and attempt < max_attempts - 1:
retry_after = int(
response.headers.get("Retry-After")
or error.get("detail", {}).get("retry_after")
or 2 ** attempt
)
logger.warning(
"veridia_transient_error",
extra={"code": code, "request_id": error.get("requestId")},
)
time.sleep(retry_after)
continue

logger.error(
"veridia_error",
extra={"code": code, "request_id": error.get("requestId")},
)
raise ExternalServiceError(error)

Best practices

  • Switch on error.error, not on the HTTP status. Two different codes share 401 and two share 404; the code is the part that tells you what to do.
  • Retry exactly three codes: rate_limited, internal_error, backend_unavailable. Retrying anything else reproduces the same failure and burns rate-limit budget doing it.
  • Always log the requestId, including in your fallback branch.
  • Never surface these codes to end users. "Something went wrong, please try again" for them; the code, requestId and detail for your logs.
  • Alert on insufficient_credits. It is a business event, not a bug.
  • Expect new codes. New ones can appear on /v1 without a version bump. Handle the default case rather than assuming exhaustiveness.

What's next