---
description: >-
  Verify documents you captured yourself with POST /v1/verifications — multipart document and selfie upload returning a synchronous normalized result, including the non-terminal in_review case.
---

# Server-to-Server Verification

When you already have a capture UI — or you are verifying images that were collected earlier — post them directly and get the result back in the response. No session, no widget, no customer link.

***

### Create a Verification

**Endpoint:** `POST /v1/verifications`\
**Authentication:** Bearer token with the `verifications` scope\
**Content-Type:** `multipart/form-data`

```bash
curl --request POST \
  --url https://{FQDN}/v1/verifications \
  --header "Authorization: Bearer $ACCESS_TOKEN" \
  --header "Idempotency-Key: $(uuidgen)" \
  --form userRef=user-123 \
  --form frontImage=@front.jpg \
  --form backImage=@back.jpg \
  --form selfieImage=@selfie.jpg
```

Returns `201` with the same [normalized result](checks-and-decisions.md) the widget produces.

#### `Idempotency-Key` is required

**Every call must carry an `Idempotency-Key` header.** A request without one is rejected with `422`.

This endpoint runs document extraction, the authenticity review, sanctions screening and the face checks **synchronously**, so a single call can take several seconds. Long enough that a client timeout is an ordinary event rather than an exotic one — and a blind retry would run all of that a second time, and bill you for it.

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 result.

| Situation | Response |
| --------- | -------- |
| First call with a key | `201` with the result |
| Same key, first call **finished** | `201` with that verification's **current** result — not a frozen copy, so a [later review decision](manual-review.md) is reflected |
| Same key, first call **still running** | `409` carrying the `sessionId`, so you can poll [`GET /v1/sessions/{session_id}`](sessions.md#retrieve-a-session) for the outcome |
| Same key, **different** tenant | Unrelated — keys are scoped to your tenant |

The `409` matters more than it looks. If your request timed out you never received a `sessionId`, so you have no way to ask about the work already in progress. The conflict response hands it back:

```json
{
  "detail": {
    "code": "IDEMPOTENCY_KEY_IN_FLIGHT",
    "message": "A request with this Idempotency-Key is still being processed. Poll GET /v1/sessions/{sessionId} for the outcome.",
    "sessionId": "81e04c30-2620-42f1-a3e0-68aef1f84043"
  }
}
```

So the correct behaviour on a timeout is: **retry the same call with the same key.** You will get either the result or a `409` telling you where to look. Never retry with a fresh key — that starts a second billed verification of the same person.

> `POST /v1/sessions` does **not** take an `Idempotency-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.

#### 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](#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](sessions.md#prefill) |

Note that `prefill` here is a JSON string inside a multipart field, not a nested object:

```bash
  --form 'prefill={"firstName":"Ana","lastName":"Silva","dateOfBirth":"1988-03-04"}' \
  --form dataCheck=true
```

***

### 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.

A missing, unreadable, or non-barcode back is a silent no-op: OCR values stand and no check changes. There is no downside to sending it, and a measurable accuracy gain when it decodes. 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 score**. If you have a review threshold configured, there is nothing for it to compare against, and an unmeasured verification is treated as *unmeasured, not confident* — so it routes to `in_review` rather than auto-approving. Send a selfie, or leave the review threshold unset, if you need document-only verifications decided synchronously.

***

### `in_review` Is Not a Final Answer

`POST /v1/verifications` returns the result synchronously, but a `201` response does not guarantee a terminal decision. `status` can be `in_review`, meaning an analyst has yet to decide.

This happens when you have configured a review threshold, or when held rejections are enabled and the checks rejected the document. With neither configured, every verification comes back decided.

When it does happen:

| Channel | Behavior |
| ------- | -------- |
| Webhook | With a `webhookUrl` configured, the decided result is `POST`ed to it like any other result — see [Result notifications](results.md#result-notifications) |
| Polling | `GET /v1/sessions/{sessionId}` returns the live `status` and `result`, updated in place, with `result.manualReview` naming who decided and why |

Use `sessionId` from the response body as the identifier for both. **No webhook is sent for the original synchronous answer** — the `201` already delivered it. See [Manual Review](manual-review.md) for the full picture.

***

### Error Responses

| Status | Cause | How to handle |
| ------ | ----- | ------------- |
| `401` / `403` | Missing or invalid token, or a token without the `verifications` scope | See [Authentication](authentication.md#error-responses) |
| `422` | `prefill` is not valid JSON or violates the prefill schema (including document-number format) | Fix the payload — the `detail` names the problem |
| `422` | `dataCheck=true` with no `prefill` | Provide a prefill payload to compare against |
| `502` | The verification service was unavailable | **Transient — retry.** This is an infrastructure failure, not a decline. Do not treat it as a negative outcome for the customer |

***

### 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. Two settings are session-level by nature and have no effect here: the allowed document types and `prefill.documentType` locking both constrain what the widget offers a customer, so on this endpoint the document type is simply whatever the images turn out to be.

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 | Returns a result; retrying is your decision |
| Result delivery | Webhook or signed redirect | The response body, plus later changes by webhook |
| 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](checks-and-decisions.md) — the result payload in full
* [Manual Review](manual-review.md) — handling `in_review`
* [Receiving Results](results.md) — webhook notifications for later changes
