Server-to-Server Verification
When you already have a capture UI — or you are verifying images that were collected earlier — post them directly. No session, no widget, no customer link.
The call accepts the verification and answers with its identifier. The assessment runs behind the response.
Create a Verification
Endpoint: POST /v1/verifications
Authentication: Bearer token with the verifications scope
Content-Type: multipart/form-data
curl --request POST \
--url https://{FQDN}/v1/verifications \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "Idempotency-Key: $(uuidgen)" \
--form userRef=user-123 \
--form [email protected] \
--form [email protected] \
--form [email protected]
Returns 201:
{
"sessionId": "81e04c30-2620-42f1-a3e0-68aef1f84043",
"status": "pending"
}
That is the whole body. There is no result in it — document extraction, the authenticity review, sanctions screening and the face checks all run after you are answered. Keep sessionId: it is how you read the outcome and how you recognise the webhook when it arrives.
Getting the Outcome
Two channels, and you can use both.
| Channel | How |
|---|---|
| Webhook | With a webhookUrl configured for your account, the outcome is POSTed to it when the verification reaches a terminal state — see Receiving Results |
| Read | GET /v1/verifications/{verificationId} returns the current state, for confirming or recovering an outcome |
A verification is accepted whether or not you have a destination registered — it runs, completes, and its outcome is retrievable either way. A webhook destination is arranged with your Inyo contact rather than set on the API, so this endpoint cannot report whether one exists. Register one: a tight retrieval loop is not the intended use of the read.
Retrieve a Verification
Endpoint: GET /v1/verifications/{verificationId}
Authentication: Bearer token with the verifications scope
curl --request GET \
--url https://{FQDN}/v1/verifications/$VERIFICATION_ID \
--header "Authorization: Bearer $ACCESS_TOKEN"
{
"sessionId": "81e04c30-2620-42f1-a3e0-68aef1f84043",
"userRef": "user-123",
"status": "completed",
"step": "done",
"result": { "…": "the normalized result, updated in place" },
"createdAt": "2026-09-14T14:02:11.481Z",
"updatedAt": "2026-09-14T14:02:58.902Z"
}
| Field | Description |
|---|---|
status | pending, completed, failed, or errored — the lifecycle position, never a verdict. The outcome, where there is one, is result.decision |
result | The full normalized result once the assessment finishes, null before then. Updated in place when an analyst decides |
This path serves your verifications only. A widget session's identifier returns 404 here, and a verification's identifier returns 404 at GET /v1/sessions/{sessionId} — each door reads at its own path, under the scope that door issues.
The verification record carries no deliveryMode. On a widget session that field distinguishes a push from a browser redirect; a verification has neither, and reporting webhook on one would read as a promise that a destination exists when that is your configuration to know, not ours to assert.
Idempotency-Key is required
Every call must carry an Idempotency-Key header. A request without one is rejected with 422.
An assessment is real work — extraction, the authenticity review, screening and the face checks all run against external providers. If a response is lost in transit and you retry blindly, the key is what stops every one of them running a second time and leaving you two verification records for one person.
Use a value your own code can reproduce for the same logical verification: a UUID you generate and store, or an identifier from your system. Do not derive it from something that repeats, such as userRef alone — two genuine verifications of the same person would collide, and the second would return the first one's identifier.
| Situation | Response |
|---|---|
| First call with a key | 201 with a new sessionId |
| Same key, same request | 201 with the same sessionId, whatever state the work has reached. Nothing is started twice |
| Same key, different images or subject | 422 IDEMPOTENCY_KEY_REUSED. Replaying it would hand back somebody else's verification, so the refusal is deliberate |
A replay no longer distinguishes work in progress from work finished, because it no longer needs to: the first response already gave you the identifier, so a retry has nothing left to tell you that you do not have. Read status if you want to know where it got to.
POST /v1/sessionsdoes not take anIdempotency-Key. Its response contains a single-use widget link that cannot be reissued, so a retry there creates a new session; a duplicate costs an unused link rather than a repeated verification.
When a fresh key is the right move
Retrying with the same key is what you want for a lost or failed response — it is free, and it returns the identifier you missed.
Mint a new key in one case: the verification ended errored. That is a platform fault on our side, not a decision about your customer. The old key is spent on it permanently and will keep returning that ending, so a new key is what runs a new verification of the same person.
Outside that, a fresh key starts a second verification of somebody you have already verified.
Form Fields
| Field | Type | Required | Description |
|---|---|---|---|
userRef | text | Yes | Your identifier for the person being verified |
frontImage | file | Yes | Front of the document — the photo page of a passport, the front of a card |
backImage | file | No | Back of the document. For US licenses and state IDs this carries the barcode |
selfieImage | file | No | Omit to run document checks only — see Document-only verification |
dataCheck | text | No | true compares extracted data against prefill |
prefill | text | No | A JSON string with the same schema as session prefill |
Note that prefill here is a JSON string inside a multipart field, not a nested object:
--form 'prefill={"firstName":"Ana","lastName":"Silva","dateOfBirth":"1988-03-04"}' \
--form dataCheck=true
Every field is read before you are answered, so a malformed prefill is still a 422 on the call itself rather than something you discover later.
Send the Back of the Card
For US driver's licenses and state IDs, the back carries a PDF417 barcode encoding the cardholder data. Inyo decodes it on receipt, and because the barcode is self-verifying — the symbology carries its own error correction — it outranks visual-zone OCR for the fields it contains. Decoding is rotation-independent, so an upside-down back still reads.
OCR values stand when the back does not decode, so there is no downside to sending it and a measurable accuracy gain when it does. A back too soft to read publishes document_barcode_valid as not_evaluated with reason no_barcode, which costs nothing; a back carrying a barcode that is not an identity record publishes it as failed. Passports carry a machine-readable zone on the photo page instead, so frontImage alone is sufficient for them.
Document-only Verification
Omit selfieImage to verify the document without biometrics. The document checks run — readability, format, expiry, authenticity, jurisdiction — and no face checks appear in checks[].
One consequence to plan for: a document-only verification carries no biometric evidence and is decided on its document checks alone. If your risk model needs the face compared to the document, send a selfie.
in_review Is Not a Final Answer
Under managed decisioning decision can be in_review, meaning an analyst has yet to decide. status is completed either way — it is the lifecycle position, not a verdict.
Which score bands are sent for review is set for your account, so handle inconclusive and in_review both on this entry point.
The assessment's own outcome and the analyst's later decision reach you the same way: a webhook if you have a destination, and the read either way. There is no longer a first answer that arrives differently from the ones after it. See Manual Review for the full picture.
When the Platform Fails
A verification whose assessment cannot run ends errored. It carries whatever checks completed before the fault, publishes no decision, and reaches no reviewer — there is nothing anyone can act on.
errored is distinct from failed, and the difference is who retries:
| Status | Meaning | What to do |
|---|---|---|
failed | The capture was never usable — on the widget, the customer ran out of attempts | The person tries again |
errored | A fault on our side stopped the assessment | You retry the call, with a new Idempotency-Key |
You never have to read the check rows to tell them apart.
Error Responses
| Status | Cause | How to handle |
|---|---|---|
401 / 403 | Missing or invalid token, or a token without the verifications scope | See Authentication |
404 | No verification of yours carries that id — including a widget session's id | Read a widget session at GET /v1/sessions/{sessionId} instead |
422 | prefill is not valid JSON or violates the prefill schema (including document-number format) | Fix the payload — errors[] names each field and why it was refused |
422 | dataCheck=true with no prefill | Provide a prefill payload to compare against |
422 | errorCode: "IDEMPOTENCY_KEY_REUSED" — the key was already used for a different request | Use a new key |
502 | We could not accept the verification | Transient — retry with the same key. Nothing was decided about your customer |
503 | Too many verifications are already in progress | Transient — retry with the same Idempotency-Key. Nothing was started, so the key is still free and the retry is not a second verification. Wait the number of seconds in the Retry-After header first |
An assessment that fails after you were answered does not produce an error response — you already have a 201. It ends the verification errored, which you see on the read or the webhook.
Widget or Server-to-Server?
Both entry points share the same pipeline and the same result shape, and your thresholds, review routing, accepted jurisdictions, and enrichment options apply identically. The allowed document types are session-level by nature and have no effect here — they constrain what the widget offers a customer. prefill.documentType does have an effect: it is the type document_type_match judges the images against, so declaring one and sending a different document fails that check. Omit it and the check publishes not_evaluated, and the document type is then whatever the images turn out to be with nothing comparing it.
The differences that matter:
| Hosted widget | Server-to-server | |
|---|---|---|
| Capture quality | Guided, with live framing and glare feedback | Yours to control |
| Failed capture | Customer is coached and retries | Ends failed; retrying is your decision |
| Result delivery | Webhook or signed redirect | Webhook when configured, and the read either way |
| Reading the record | GET /v1/sessions/{sessionId} | GET /v1/verifications/{verificationId} |
| Presentation-attack defense | Same checks | Same checks, but with no live capture context to draw on |
| Compliance evidence | Inyo retains the guided capture and optional selfie video | Only the images you send |
The retry difference is about coaching a live customer — it does not change what your configuration means or how the decision is reached.
Next Steps
- Checks & Decisions — the result payload in full
- Manual Review — handling an
in_reviewdecision - Receiving Results — webhook notifications
