> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentcard.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Identity verification

> Verify a connected user's identity from inside your own app.

Verify a connected user's identity in three steps: upload the front and back of
their ID, confirm any extra details we ask for, and show the user a short face
scan. You build the screens; Agentcard handles the checks and tells you what to
collect next.

**Order matters: ask for the ID photos first, before any form.** Both upload
responses return an `extracted` object with what the document reader pulled off
the images (name, date of birth, address, document number), so the details form
becomes a *confirm* step you prefill — not a page of empty fields. Asking the
user to type their details before uploading the ID gets you worse data (typos
that then mismatch the document) and a longer flow.

Every call is made from your backend with your platform access token, and names
the user with the `user_id` you received when they connected. Each step below
links to its page in the [API reference](/companies/api/reference) — full
parameters, responses, error codes, and a live playground.

## Instructions for your agent

Paste this into your coding agent to implement identity verification. It assumes
the user is already connected (you have their `user_id` — see
[user authentication](/companies/api/user-authentication)).

```text Instructions for your agent theme={null}
You are adding identity verification for a user who has already connected (you
have their user_id). Implement server-side with your platform token
(Authorization: Bearer <token>); base URL https://api.agentcard.sh.

ORDER: ID photos FIRST, details form SECOND. Never ask the user to type their
personal details before the upload — the upload responses return the data read
off the document ("extracted") so the form is a prefilled confirm step.

1. UPLOAD FRONT
POST /api/v2/kyc/documents/front
  { "user_id": "<id>", "image": "<base64>", "mime_type": "image/jpeg",
    "document_type": "drivers_license" | "state_id" | "passport" (optional),
    "issuing_country": "<ISO code>" (optional) }
-> { status: "awaiting_documents", extracted?, warnings? }
document_type and issuing_country are optional hints: omitted, the document is
auto-detected and defaults to a US driver's license. If you know them (any
non-US document, or a warning told you), send them on BOTH sides.

2. UPLOAD BACK
POST /api/v2/kyc/documents/back
  { "user_id": "<id>", "image": "<base64>", "mime_type": "image/jpeg",
    "document_type"?, "issuing_country"? }
-> { status, required_fields?, iframe_url?, extracted?, warnings? }
"extracted" (both uploads) = what the reader pulled off the images: keys match
the /kyc/information fields (first_name, last_name, date_of_birth, address_*)
plus document_type, issuing_country, document_number. Use it to PREFILL your
form; the user confirms or corrects. document_number is the number printed on
the document — for issuing_country US it is NOT the SSN; never prefill it into
national_id_number for US documents (for most non-US documents it IS the
national ID number).
Branch on status:
  - "needs_information": show a form for ONLY the fields in required_fields,
    prefilled from "extracted" where keys overlap, then
    POST /api/v2/kyc/information { "user_id": "<id>", <those fields> }
      -> { status, iframe_url? }.
    Fields: first_name, last_name, date_of_birth (YYYY-MM-DD), national_id_number,
    phone_number (E.164, e.g. +15551234567), address_line1, address_line2,
    address_city, address_region, address_postal_code,
    address_country (ISO 3166-1 alpha-2).
    400 invalid_fields -> show field_errors and let the user correct.
  - "requires_verification": go to step 3 (iframe_url is present).
  - "rejected": show a failure state.
Upload errors: 422 document_rejected (image unreadable/unusable — ask the user
to retake) and 422 document_expired (ask for a valid document). Any upload
response may include "warnings": [string] with actionable feedback (e.g. the
provider still needs the other side). If a warning says the document reads as a
different document_type or issuing_country than it was uploaded as, resubmit
the SAME images with those fields set — do not ask the user to retake.

3. FACE SCAN
When a response includes iframe_url, show it in an iframe/webview ("Scan your face").
Embed with allow="camera; microphone". iframe_url is short-lived — always render
the most recent one, never store it. If you recorded the user's consent at
connect (POST /api/v2/connect/consent), this page is the verification
provider's own full-screen scan with no extra steps — so make sure consent is
recorded BEFORE verification starts.

4. RESULT
Subscribe to the identity.verification.updated webhook
(data: { user_id, status, required_fields?, iframe_url?, reason? }),
or poll GET /api/v2/kyc?user_id=<id> -> { status, ... }.
"approved" = confirmed; "rejected" = failed.

5. REVIEW BOUNCES (statuses are NOT one-way)
A review can send the user back. After "pending" you may see:
  - "needs_information" again: a detail didn't match the document (e.g. the
    name). Re-collect ONLY required_fields, show "reason" to the user, and
    POST /api/v2/kyc/information again — the check re-runs automatically.
  - "awaiting_documents" again: the images were unusable ("reason" says why).
    Upload BOTH sides again (front then back) — the check re-runs automatically.
  - "requires_verification" again: the user must redo the scan at iframe_url.
So: drive the UI from the CURRENT status on every webhook/poll, never assume a
step is done because it succeeded once.

6. DUPLICATE DOCUMENTS (not retryable)
A "rejected" status (response or webhook) may carry
reason_code: "duplicate_identity": the person is already verified on another
Agentcard account. New documents can never fix it, and you are never told
which account conflicts. Show "reason" and tell the user to link their other
account from the verification page ("Link that account": a one-time code to
that account's email or phone, then the accounts merge). After they link, the
next GET /api/v2/kyc for the same user_id reads "approved" with no action
from you.

STATUS: awaiting_documents | needs_information | requires_verification | pending |
approved | rejected.
"reason" (optional, on needs_information / awaiting_documents /
requires_verification / rejected): end-user-safe explanation — show it verbatim.
ERRORS: { error: { code, message, docs } } — branch on code.

TESTING (test-mode credentials only): test verifications never complete on
their own. Force a verdict with
POST /api/v2/kyc/simulate { "user_id": "<id>", "outcome": "approved" | "rejected"
  | "requires_input", "reason"? }
-> the status flips and identity.verification.updated fires, exactly like a real
review. Use it to exercise steps 4-5 end to end (approve, reject with a reason,
bounce for new documents). 403 sandbox_only on live tokens.
```

## The flow

<Steps>
  <Step title="Upload the ID">
    Send the front, then the back, as base64-encoded images. Two optional
    fields ride along: `document_type` (`drivers_license`, `state_id`, or
    `passport`) and `issuing_country` (ISO 3166-1 code). Omitted, the document
    is auto-detected and defaults to a US driver's license — so for any non-US
    document, send `issuing_country` (and `document_type` if you know it) on
    both sides.

    The **back** response is the branch point — it tells you what comes next:
    more information, the face scan, or a rejection. If an image can't be
    used, the upload fails with a `422` (`document_rejected` or
    `document_expired`) so you can build a retake flow, and any upload
    response may carry a `warnings` array with actionable feedback.

    Some warnings are addressed to *you*, not the user: when a warning says
    the document reads as a different `document_type` (or a different
    `issuing_country`) than it was uploaded as, resubmit the **same** images
    with those fields set. Don't ask the user to retake the photo — the image
    is fine, only the classification needs correcting.

    Both upload responses also carry an **`extracted`** object — the fields the
    document reader pulled off the images (name, date of birth, address,
    `document_number`, `document_type`, `issuing_country`). That's your prefill
    data for the next step, and it's why the upload comes first.

    → [Upload the front](/companies/api/reference/kyc-upload-front) ·
    [Upload the back](/companies/api/reference/kyc-upload-back)
  </Step>

  <Step title="Confirm extra information (only if asked)">
    A `needs_information` status lists exactly the `required_fields` to
    collect — possible fields are `first_name`, `last_name`, `date_of_birth`,
    `national_id_number`, `phone_number` (E.164), and the `address_*` fields.
    Show a form for only those, **prefilled from `extracted`** where the keys
    overlap, so the user confirms instead of typing. One caveat:
    `document_number` is the number printed on the document — for a US document
    it is *not* the SSN, so never prefill it into `national_id_number` when
    `issuing_country` is `US`. Submit, and handle `invalid_fields` by surfacing
    its `field_errors`.

    → [Submit information](/companies/api/reference/kyc-submit-information)
  </Step>

  <Step title="Scan the face">
    When a response includes an `iframe_url`, show it to the user in an iframe
    or webview. They complete a short face scan there, and the window reports
    back when it's done.

    The face scan uses the device camera, so the embedding iframe must grant
    it:

    ```html theme={null}
    <iframe src="{iframe_url}" allow="camera; microphone"></iframe>
    ```

    Two things to know about `iframe_url`:

    * **It's short-lived.** Always render the most recent one — from the
      latest API response, a fresh status poll, or the latest
      `identity.verification.updated` event. Don't store it for later.
    * **Record consent at connect — before verification starts.** If you
      record the user's authorization with
      [Record consent](/companies/api/reference/connect-consent) (part of the
      connect flow), it also covers the card issuer's terms, and `iframe_url`
      is the verification provider's own scan, full screen, with no extra
      steps and no Agentcard branding — it reads as part of *your* flow.
      Without a recorded consent, the page has to pause on a one-checkbox
      terms confirmation before the scan.
  </Step>

  <Step title="Get the result">
    Listen for the webhook, or poll. `approved` means verified; `rejected`
    means the user did not pass.

    → [Get verification status](/companies/api/reference/kyc-status)
  </Step>
</Steps>

## Learning the outcome

Verification finishes after the face scan. Agentcard sends an
`identity.verification.updated` event to your configured
[webhook endpoints](/companies/webhooks) whenever the status changes:

```json theme={null}
{
  "type": "identity.verification.updated",
  "data": { "user_id": "user_…", "status": "approved" }
}
```

When the new status needs something from you, the event carries it:
`required_fields` (on `needs_information`), `iframe_url` (on
`requires_verification`), and `reason` — a short, end-user-safe explanation of
what the review asked for.

Or poll [`GET /api/v2/kyc`](/companies/api/reference/kyc-status) with the
`user_id`.

## Status values

| Status                  | Meaning                                               |
| ----------------------- | ----------------------------------------------------- |
| `awaiting_documents`    | Waiting for the ID images.                            |
| `needs_information`     | Collect the listed `required_fields` and submit them. |
| `requires_verification` | Show the user the `iframe_url` to scan their face.    |
| `pending`               | Under review. No action needed.                       |
| `approved`              | Verified.                                             |
| `rejected`              | Not verified.                                         |

Non-terminal statuses can also carry a `reason` — an end-user-safe sentence
explaining what the review asked for (for example, *"Enter your full name
exactly as it appears on your identity document."*). Show it to the user
verbatim; it's already scrubbed of anything internal.

## When a review sends the user back

Statuses are **not one-way**. A review that finds a problem it can describe
returns the user to an earlier step instead of rejecting them — the status
tells you which one:

| The review found                                                          | You'll see                                            | What to do                                                                                                                                  |
| ------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| A detail that doesn't match the document (name, date of birth, ID number) | `needs_information` with `required_fields` + `reason` | Re-collect **only** those fields and [submit them](/companies/api/reference/kyc-submit-information) again. The check re-runs automatically. |
| Unusable images (blurry, cropped, expired, a screenshot)                  | `awaiting_documents` with `reason`                    | Upload **both sides** again — front, then back. The check re-runs automatically on the final side.                                          |
| A problem with the face scan                                              | `requires_verification` with a fresh `iframe_url`     | Show the user the new `iframe_url`.                                                                                                         |

Rejections that can't be retried (a terminal review) come back as `rejected`,
with a `reason` when one can be shared. One of them resolves itself:
[duplicate documents](#duplicate-documents), marked by `reason_code`. Drive
your UI from the **current** status on every webhook or poll; never assume a
step is done because it succeeded once.

## Duplicate documents

One rejection is special: the person's documents are **already verified on
another Agentcard account** (usually their own earlier signup under a
different email or phone). It comes back as `rejected` with a machine-readable
class alongside the usual `reason`:

* `reason_code: "duplicate_identity"` on
  [`GET /api/v2/kyc`](/companies/api/reference/kyc-status) responses and on the
  [`identity.verification.updated`](/companies/webhooks#identity-verification-updated)
  webhook.
* `reasonCode: "duplicate_identity"` plus a `resolution` object on the v1
  cardholder endpoint, `GET /api/v1/cardholders/:id/kyc/status`:

```json theme={null}
{
  "status": "rejected",
  "reason": "These documents are already verified on another Agentcard account.",
  "reasonCode": "duplicate_identity",
  "resolution": {
    "type": "account_link",
    "message": "This person already verified on another Agentcard account. Have them open their verification link and choose Link that account to connect it. No new documents are needed."
  }
}
```

Three things to know:

* **It is not retryable.** Re-submitting documents can never fix it, so don't
  send the user back through the upload flow.
* **The user resolves it themselves.** Relay `resolution.message` (or your own
  copy of it). On this rejection the verification page shows a **Link that
  account** flow inline: the user enters the other account's email or phone,
  proves control with a one-time code, and the two accounts merge. The
  identity-verified account survives, and both identifiers sign in afterwards.
* **You are never told which account conflicts.** For privacy, the API only
  says a conflict exists; only the user, by proving control of the other
  identifier, can resolve it.

Once the user links the accounts, the rejection **self-heals**: the next poll
of the same verification reads `approved` (`verified` on the v1 endpoint),
with nothing else for you to do and no support contact needed.

## Testing in test mode

With a test-mode credential, the whole flow runs against a test environment:
no real identity check happens, and no real personal data is needed. One thing
changes — **test verifications never complete on their own**, because there is
no reviewer behind them. Two ways to finish one:

* **From your backend** — force a verdict with
  [Simulate an outcome](/companies/api/reference/kyc-simulate):

  ```bash theme={null}
  curl -X POST https://api.agentcard.sh/api/v2/kyc/simulate \
    -H "Authorization: Bearer $PLATFORM_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{ "user_id": "user_7g8h9i", "outcome": "approved" }'
  ```

  The status flips instantly and `identity.verification.updated` fires —
  exactly the signal a real review produces, delivered to your test-mode
  webhook endpoints. Simulate `rejected` (optionally with a custom `reason`)
  to test your failure state, and `requires_input` to test the
  "send new documents" retry loop from
  [When a review sends the user back](#when-a-review-sends-the-user-back).

* **From the browser** — in test mode, `iframe_url` opens a test-mode page
  with the same two choices: complete instantly with a chosen outcome, or
  preview the document-and-face-scan flow an end user would see. Handy when
  you're testing the embedded iframe step itself.

Re-simulating overwrites the previous verdict, so one test user can walk
approved → rejected → approved without reconnecting. Simulated outcomes only
exist in test mode: on a live token the endpoint returns `403 sandbox_only`,
and live verifications are decided by the identity provider.
