Error shape
Errors come back as JSON with the HTTP status repeated in the body. statusMessage is a short machine-ish description; data is present on validation failures and carries the field-level detail.
{ "statusCode": 400, "statusMessage": "Invalid request body", "message": "Invalid request body", "data": { "issues": [ { "path": ["purpose"], "message": "Invalid enum value" } ] } } Branch on the HTTP status code, not on the text of statusMessage. The wording can change without notice.
Status codes
| Code | Name | What it means |
|---|---|---|
| 400 | Bad request | The body failed validation. The data field carries the field-level detail. Not retryable, so fix the request. |
| 401 | Unauthorized | The key is missing, unrecognised, or expired. Not retryable. Check the Authorization header and whether the key has passed its expiry. |
| 403 | Forbidden | The key is valid but lacks the scope for this endpoint, or the request came from an IP outside the key's allowlist. Not retryable. |
| 404 | Not found | No such resource, or it belongs to another organization. IDs are scoped per organization, so a valid ID under the wrong key is a 404, not a 403. |
| 429 | Too many requests | You hit a rate limit. Retryable, but back off before trying again. |
| 5xx | Server error | Something failed on our side. Retryable with backoff. Check the status page if it persists. |
401 vs 403
These mean different things and are worth handling separately, because the fix is different:
401means we do not accept this key at all. Wrong secret, deleted key, or past its expiry.403means the key is fine, but it isn't allowed to do this. Missing scope, or a blocked source IP.
A 403 will never fix itself by retrying. Add the scope in the dashboard, or add the caller's egress IP to the key's allowlist. See Authentication.
Retrying safely
Retry 429 and 5xx. Never retry 400, 401, 403 or 404. The same request will fail the same way and you will just burn your rate limit.
Use exponential backoff with jitter, and cap the attempts. A tight retry loop against a struggling service makes it worse.
const RETRYABLE = new Set([429, 500, 502, 503, 504]); async function withRetry(fn, attempts = 4) { for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (err) { const status = err.statusCode ?? err.response?.status; if (!RETRYABLE.has(status) || i === attempts - 1) throw err; // Exponential backoff with jitter: ~1s, 2s, 4s.const delay = 2 ** i * 1000 + Math.random() * 250; await new Promise(r => setTimeout(r, delay)); } } }Don't create duplicate verifications
POST /verifications creates a new session every time it is called. If a request times out you cannot tell whether the session was created, and a blind retry gives you two: two sessions, two verification:created webhooks, and a user who may complete the wrong one.
Protect against this on your side:
- Generate your
external_idbefore the call and persist it, so a retry reuses the same value. - On a timeout, call
GET /verificationsand look for a session already carrying thatexternal_idbefore creating another. - Store the returned
verification_idin the same transaction that marks your record as "verification started".
external_id is yours to define and is echoed back on every webhook, which makes it the natural key for reconciliation.
Timeouts
Set a client timeout. The SDKs default to a sensible one, raw fetch does not. Document extraction and face matching do real work and are slower than session creation, so allow more headroom on those than on a plain POST /verifications.
A timeout is not a failure signal. The work may have completed. Treat it as "unknown" and reconcile rather than assuming either outcome.
What to log
Log the status code, statusMessage, and your own external_id. Never log the request body of a verification or extraction call, because it contains identity documents and face images. Never log your API secret, including in a URL or an error dump that echoes headers.
When it keeps failing
Check Service Status first. If everything is green and calls are still failing, email contact@idproval.com with the status code, the approximate timestamp, and the external_id or verification_id. That is enough for us to find the request.