Errors and retries
Every non-2xx response uses the same envelope:
{
"error": {
"type": "validation_error",
"message": "burst mode requires at least 3 frames",
"request_id": "01a07991-79d5-76da-a6bc-1020aa49ba28",
"param": "image_<n>",
"doc_url": "https://docs.drukverify.com/errors/validation_error"
}
}
type is the machine-readable error code. request_id uniquely
identifies the failing request — include it when contacting support.
Error catalogue
| Type | HTTP status | Meaning |
|---|---|---|
missing_authorization | 401 | No Authorization header. |
invalid_key | 401 | The dv_sk_* key is malformed or unknown. Terminal. |
revoked_key | 401 | The key was revoked in the dashboard. Terminal. |
expired_token | 401 | The dv_tok_* session token is past its TTL. Mint a new one and retry. |
invalid_signature | 401 | The session token's signature does not verify. Re-mint. |
unauthorized | 401 | The credential is valid but not allowed here (for example a token used where a key is required). |
forbidden | 403 | The credential's role does not permit the action. |
tenant_suspended / tenant_archived | 403 | The tenant is not active. Contact ops. |
feature_disabled / feature_not_assigned / feature_paused | 403 | The feature behind this endpoint is not enabled for your plan, or has been paused. |
validation_error | 400 | A request field is malformed or missing; param names it. Also returned for uploads over 10 MB (upload rejected: …). |
frame_replayed | 400 | A liveness frame was already scored, or appears twice in one request. See Liveness checks. |
challenge_token_invalid | 400 | The liveness challenge_token is unknown, expired or already used. |
not_found / registration_not_found | 404 | No object with that id in your tenant and mode. |
idempotency_conflict | 409 | The Idempotency-Key was reused with a different request body. |
idempotency_in_progress | 409 | The same idempotent request is still being processed. Retry shortly. |
model_version_mismatch | 409 | A face embedding from an older model version cannot be compared. Re-register. |
verification_closed | 409 | The verification session is completed, or its attempt limit is exhausted. |
rate_limited | 429 | Per-key rate limit hit. Sleep retry_after_ms, then retry. |
quota_hard_cap_exceeded | 429 | The tenant's monthly hard cap for this feature is reached. |
missing_version_header / unknown_version | 400 | The API version header is missing or not a known version. |
internal_error | 500 | Platform fault. Safe to retry with backoff. |
service_unavailable | 503 | A downstream service is unreachable. Safe to retry with backoff. |
A response can also carry version_deprecated as a warning on a
successful (200) call when the requested API version is on its way out.
SDK error handling
Both SDKs wrap the envelope in a typed error class.
Go:
result, err := client.Liveness.Check(ctx, req)
if err != nil {
var ekycErr *ekyc.Error
if errors.As(err, &ekycErr) {
switch ekycErr.Code {
case ekyc.ErrCodeRateLimited:
time.Sleep(ekycErr.RetryAfter)
case ekyc.ErrCodeTokenExpired:
// Don't retry; SDK already refreshed and retried once.
// If you see this, the new token is also expired.
}
log.Printf("ekyc error %s (request_id=%s): %s",
ekycErr.Code, ekycErr.RequestID, ekycErr.Message)
}
}
Flutter:
try {
final r = await sdk.liveness.check(/* ... */);
} on EkycError catch (e) {
if (e.code == EkycErrorCode.rateLimited && e.retryAfter != null) {
await Future.delayed(e.retryAfter!);
}
print('ekyc error ${e.code} (requestId=${e.requestId}): ${e.message}');
}
Retry policy
The SDK retries network errors and 5xx automatically (3 attempts, exponential backoff). It does NOT retry 4xx — those are your bug, not a transient blip. Specifically:
- 5xx: SDK retries with the same
Idempotency-Key, freshX-Request-Idper attempt. - 401 with
token_expired/invalid_token(Flutter only): one refresh + one retry. Subsequent failures surface to caller. - 401 with
invalid_credentials(Go): terminal. Fix your config. - 429: NOT auto-retried. Surface to caller with
RetryAfterpopulated.