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"]
}
}
}
| Field | Type | Description |
|---|---|---|
error | string | Machine-readable code — switch on this, not on the HTTP status |
message | string | Human-readable summary, for logs |
requestId | string | Unique per request — include it in support tickets |
detail | object | Optional. 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
| Code | HTTP | Retryable? |
|---|---|---|
invalid_body | 400 | No — fix the request |
missing_api_key | 401 | No |
invalid_api_key | 401 | No |
secret_key_required | 401 | No — use a secret key |
insufficient_credits | 402 | No — top up |
origin_not_allowed | 403 | No |
verification_not_found | 404 | No |
not_found | 404 | No — wrong URL |
rate_limited | 429 | Yes, after Retry-After |
internal_error | 500 | Yes, with backoff |
backend_unavailable | 503 | Yes, 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_body — 400 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") countrynot exactly two uppercase lettersverificationIdnot 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:
| Reason | Meaning |
|---|---|
doc_front_key_mismatch | keys.docFront isn't what /init returned |
selfie_key_mismatch | keys.selfie isn't what /init returned |
doc_back_key_mismatch | keys.docBack isn't what /init returned |
doc_front_not_uploaded | No stored object at that key — the client never uploaded |
selfie_not_uploaded | Same, for the selfie |
doc_front_disappeared | The object existed at check time but was gone before OCR (very rare) |
On PUT /v1/verify/upload/..., detail.reason may be:
| Reason | Meaning |
|---|---|
missing_upload_token | No X-Veridia-Upload-Token header — you didn't forward slot.headers |
invalid_upload_token | Token doesn't match this verification's intent |
key_mismatch | The role in the URL isn't one /init issued |
invalid_role | Unrecognised role segment in the URL |
not_a_jpeg | Body doesn't start with the JPEG magic bytes FF D8 FF |
image_too_large | Body over 2 MB. Downscale the image before uploading; 2 MB of JPEG is 2-4 MP, more than OCR needs |
image_too_small | Body under 100 bytes — usually a truncated or empty upload |
upload_source_forbidden_for_biometric | X-Veridia-Capture-Source: upload on a selfie or liveness frame |
no_active_challenge | A 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_key — 401 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_key — 401 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_required — 401 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_credits — 402 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_allowed — 403 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 hostname — yourapp.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
Originheader at all.
See Authentication.
verification_not_found — 404 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_found — 404 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_limited — 429 Too Many Requests
You hit one of two limits. detail.retry_after and the Retry-After header give the wait in seconds.
| Layer | Limit |
|---|---|
| Per client IP, all routes, checked before authentication | 100 / 60 s |
Per tenant — /v1/verify/init | 60 / 60 s |
Per tenant — /v1/verify/submit | 30 / 60 s |
Per tenant — /v1/verify/:id | 600 / 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_error — 500 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_unavailable — 503 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 share401and two share404; 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,
requestIdanddetailfor your logs. - Alert on
insufficient_credits. It is a business event, not a bug. - Expect new codes. New ones can appear on
/v1without a version bump. Handle the default case rather than assuming exhaustiveness.
What's next
- Authentication — key families and the origin list
- Rate limits — both limits, and the CGNAT caveat
- API Reference — back to the overview