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

# External Evidence KYC

> Route contract for onboarding applicants you captured yourself

Use this flow when your application already performs identity capture and only wants Pods to handle
the ramp provider side and expose one normalized status for money movement.

You keep your own liveness and documentoscopy providers. You send Pods the evidence you already
hold; Pods creates the ramp subaccount, uploads the images, submits the KYC, and tracks it to a
verdict.

<Info>
  **Pods stores no images.** They are held in memory for the duration of your request, forwarded to
  the ramp provider, and discarded. This holds for both transports: an image sent as base64 and an
  image Pods fetched from a URL you provided follow the same path — into memory, to the provider,
  gone. Pods retains the ramp provider's document ids, a SHA-256 digest, the liveness score, and
  timestamps.

  The rest of the submitted payload is kept for **up to 7 days** so a failure on the provider's side
  can be retried without asking the applicant to capture again. **On the `url` transport that
  includes the URL itself, signature and all** — that is what makes the retry able to re-fetch the
  bytes rather than just name them. Size `X-Amz-Expires` knowing Pods holds the link for that
  window. See [Retention](#retention).
</Info>

## What you need before you start

|                |                                                                              |
| -------------- | ---------------------------------------------------------------------------- |
| API key        | Server-to-server, sent as `x-api-key`. Never expose it in a browser.         |
| Liveness       | A liveness check you run yourself, producing a reference image and a result. |
| Documentoscopy | Front and back images of the identity document, plus the data read from it.  |
| Address        | Collected from the user. Brazil only for now.                                |
| Wallet address | The wallet the user's funds will move from. Required at session creation.    |

## The flow

<Steps>
  <Step title="Create the session">
    Send CPF, email and wallet address. You get back a `kycUserId` — use it for every later call and
    for status polling.
  </Step>

  <Step title="Run your capture">
    Liveness and document capture happen entirely on your side, with your providers.
  </Step>

  <Step title="Submit the evidence">
    One call with everything: applicant data, address, the liveness image plus its attestation, and
    the document images plus the data extracted from them.
  </Step>

  <Step title="Poll for the verdict">
    The ramp provider does not decide synchronously. Poll the status endpoint until the session
    reaches a terminal state.
  </Step>
</Steps>

## 1. Create the session

```bash theme={null}
curl -X POST https://api.pods.finance/api/v1/kyc/sessions \
  -H "x-api-key: $PODS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "cpf": "12345678909",
    "email": "maria@example.com",
    "walletAddress": "0x6efa29a060C784075188C43dB94cF203f0D54611"
  }'
```

```json theme={null}
{ "kycUserId": "3f2b1c4a-…", "status": "created", "existing": false }
```

This call is safe to repeat. A CPF that already has a session under your account returns the
existing `kycUserId` rather than creating a second one, with `"existing": true`. If that CPF is
already bound to a different email you get `409 KYC_PROFILE_EMAIL_MISMATCH` — worth surfacing rather
than working around, since one person under two records is almost always a mistake.

## 2. Submit the evidence

One call. Every image is either inline base64 — as most capture providers return it — or an HTTPS
URL that Pods fetches for you. Pick per image; see [Image requirements](#image-requirements).

```bash theme={null}
curl -X POST https://api.pods.finance/api/v1/kyc/sessions/3f2b1c4a-…/submit \
  -H "x-api-key: $PODS_API_KEY" \
  -H "Content-Type: application/json" \
  -d @evidence.json
```

<Tabs>
  <Tab title="Base64">
    ```json theme={null}
    {
      "applicant": {
        "fullName": "Maria Silva",
        "dateOfBirth": "1990-05-14",
        "phone": "+5511999999999"
      },
      "address": {
        "country": "BRA",
        "state": "SP",
        "city": "São Paulo",
        "zipCode": "01310100",
        "streetAddress": "Av. Paulista",
        "number": "1000",
        "complement": "apto 51"
      },
      "liveness": {
        "image": { "base64": "/9j/4AAQSkZJRg…", "mimeType": "image/jpeg" },
        "attestation": {
          "provider": "aws_rekognition_face_liveness",
          "status": "SUCCEEDED",
          "confidence": 95.78811645507812,
          "sessionId": "47fa2a26-bb7a-4ca0-85f1-129ad6a6eb0b",
          "challenge": { "type": "FaceMovementAndLightChallenge", "version": "2.0.0" },
          "completedAt": "2026-08-13T14:58:10Z"
        }
      },
      "documents": {
        "documentType": "RG",
        "front": { "base64": "…", "mimeType": "image/jpeg" },
        "back":  { "base64": "…", "mimeType": "image/jpeg" },
        "attestation": {
          "provider": "bigdatacorp_documentoscopy",
          "onboardingId": "b7d2…",
          "extracted": {
            "fullName": "MARIA SILVA",
            "birthDate": "1990-05-14",
            "cpf": "12345678909",
            "documentType": "RG"
          }
        }
      }
    }
    ```
  </Tab>

  <Tab title="Presigned URL">
    ```json theme={null}
    {
      "applicant": {
        "fullName": "Maria Silva",
        "dateOfBirth": "1990-05-14",
        "phone": "+5511999999999"
      },
      "address": {
        "country": "BRA",
        "state": "SP",
        "city": "São Paulo",
        "zipCode": "01310100",
        "streetAddress": "Av. Paulista",
        "number": "1000",
        "complement": "apto 51"
      },
      "liveness": {
        "image": { "url": "https://s3.us-east-1.amazonaws.com/your-bucket/liveness/abc123/reference.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=…" },
        "attestation": {
          "provider": "aws_rekognition_face_liveness",
          "status": "SUCCEEDED",
          "confidence": 95.78811645507812,
          "sessionId": "47fa2a26-bb7a-4ca0-85f1-129ad6a6eb0b",
          "challenge": { "type": "FaceMovementAndLightChallenge", "version": "2.0.0" },
          "completedAt": "2026-08-13T14:58:10Z"
        }
      },
      "documents": {
        "documentType": "RG",
        "front": { "url": "https://s3.us-east-1.amazonaws.com/your-bucket/documents/abc123?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=…" },
        "back":  { "url": "https://s3.us-east-1.amazonaws.com/your-bucket/documents/def456?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=…" },
        "attestation": {
          "provider": "bigdatacorp_documentoscopy",
          "onboardingId": "b7d2…",
          "extracted": {
            "fullName": "MARIA SILVA",
            "birthDate": "1990-05-14",
            "cpf": "12345678909",
            "documentType": "RG"
          }
        }
      }
    }
    ```
  </Tab>
</Tabs>

```json theme={null}
{
  "kycUserId": "3f2b1c4a-…",
  "status": "provider_pending",
  "timings": { "validate": 8, "upload": 22182, "level1": 376, "totalMs": 22578 }
}
```

<Warning>
  **A failed submit does not mean a failed KYC.** This call does real work before responding — it
  creates the ramp subaccount and uploads all three images — and takes roughly **20 seconds**, almost
  entirely image upload. The platform edge cuts the connection at **30 seconds** and answers with its
  own HTML error page while the submission carries on and succeeds server-side, so a timeout, a `503`
  or a non-JSON body tells you nothing about the outcome.

  On any error or timeout, poll [`/status`](#3-poll-the-status) before concluding anything. Never
  re-`POST /submit` to "make sure" — a retry is safe, but it answers with the current state
  (`provider_pending`) rather than doing the work twice, so it cannot help you and it cannot tell you
  more than `/status` already does.
</Warning>

`timings` reports each phase in milliseconds and is there to make a slow submission diagnosable
without a support ticket. It is informational; do not branch on it.

### Rejections arrive here too

The ramp provider can refuse the submission during this call. When it does, `/submit` answers
**`400`** with an error body, and the session moves to `rejected` or `rejected_retryable`:

```json theme={null}
{
  "error": {
    "code": "AVENIA_KYC_LEVEL_1_API_FAILED",
    "message": "cannot repeat taxId across multiple users"
  }
}
```

Do not read that `message`. Call the status endpoint and read the normalized `reason` instead — see
[Handling rejections](#handling-rejections). The provider returns free-form text that changes
without notice; `reason.code` does not.

### About the liveness attestation

The `attestation` is your assertion about the check you ran. Pods records it and compares
`confidence` against the threshold configured for your account. Pods does **not** call your provider
to verify it — the record is stored as `customer_attested`, which is the honest description of the
arrangement.

Only two fields are load-bearing: `status` must be `SUCCEEDED` or `PASSED`, and `confidence` must
clear your threshold. Everything else is recorded for audit correlation and is not validated.

A `confidence` below the threshold is rejected with `422 LIVENESS_BELOW_THRESHOLD` before any upload
happens, so you save the round trip and the provider call.

<Accordion title="Coming from AWS Face Liveness">
  Amazon Rekognition returns the reference image as **bytes in the API response** —
  `ReferenceImage.Bytes` on `GetFaceLivenessSessionResults` — not as a durable URL. So the transport
  you use depends on whether capture and submission are the same process. `AuditImages` carries the
  other frames in the same shape; `ReferenceImage` is the one to submit.

  **A — no storage needed.** Read the session result and submit in one execution. The result is
  readable only for a short window after the check, so fetch and submit together; there is nothing to
  persist and no bucket to allowlist.

  ```js theme={null}
  import { RekognitionClient, GetFaceLivenessSessionResultsCommand } from '@aws-sdk/client-rekognition'

  const rekognition = new RekognitionClient({ region: 'us-east-1' })

  const result = await rekognition.send(
    new GetFaceLivenessSessionResultsCommand({ SessionId: sessionId })
  )

  if (result.Status !== 'SUCCEEDED' || !result.ReferenceImage?.Bytes) {
    throw new Error(`liveness not usable: ${result.Status}`)
  }

  await fetch(`https://api.pods.finance/api/v1/kyc/sessions/${kycUserId}/submit`, {
    method: 'POST',
    headers: { 'x-api-key': process.env.PODS_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      applicant,
      address,
      documents,
      liveness: {
        image: { base64: Buffer.from(result.ReferenceImage.Bytes).toString('base64') },
        attestation: {
          provider: 'aws_rekognition_face_liveness',
          status: result.Status,
          confidence: result.Confidence,
          sessionId: result.SessionId,
          challenge: result.Challenge && {
            type: result.Challenge.Type,
            version: result.Challenge.Version
          }
        }
      }
    })
  })
  ```

  **B — bucket and pre-sign.** Same bytes, stored first and submitted later. This is the right shape
  when capture and submission are separate processes — a mobile capture that hands off to a backend
  job, or a retry that must not depend on the Rekognition session still being readable.

  ```js theme={null}
  import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

  const s3 = new S3Client({ region: 'us-east-1' })
  const Bucket = 'your-bucket'
  const Key = `liveness/${result.SessionId}/reference.jpg`

  await s3.send(new PutObjectCommand({
    Bucket,
    Key,
    Body: Buffer.from(result.ReferenceImage.Bytes),
    ContentType: 'image/jpeg'
  }))

  // Size the signature to outlive the submit — the call itself takes ~20 seconds.
  const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket, Key }), { expiresIn: 3600 })

  // Then send liveness.image as { url } instead of { base64 }.
  ```

  Do not bother setting `response-content-type` on the signed URL to match the object: Pods reads the
  format from the bytes and ignores the header either way.

  <Note>
    The AWS calls above are illustrative. The field names were read from the published
    `@aws-sdk/client-rekognition` type declarations.
  </Note>
</Accordion>

### About the extracted document data

`documents.attestation.extracted` is the source of truth for the name, birth date and CPF sent to
the ramp provider — because that is what the provider compares the images against. Data typed by the
user tends to produce name and birth-date mismatches downstream. `applicant.fullName` and
`applicant.dateOfBirth` are optional and only fill gaps.

One rule is absolute: if `extracted.cpf` differs from the CPF the session was created with, the
request is refused with `409 IDENTITY_CPF_MISMATCH`. Nothing is uploaded.

### Image requirements

|                            |                                                                                                                                                                                                                                                                                                                          |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Transport                  | Exactly one of `base64` or `url` per image. Sending both, or neither, is a `422`. Images in the same request may use different transports — a client running AWS Face Liveness gets the selfie as bytes from the API and pulls the documents from its own bucket, and that mix is a normal submission, not a workaround. |
| Encoding (`base64`)        | Base64. A `data:` URI prefix is accepted and stripped.                                                                                                                                                                                                                                                                   |
| Scheme (`url`)             | HTTPS only. `http://` is refused without a request being made.                                                                                                                                                                                                                                                           |
| Allowed hosts (`url`)      | Configured per environment, as an exact host list. Ask Pods to allowlist your bucket host if your bucket is not public.                                                                                                                                                                                                  |
| Signature lifetime (`url`) | The pre-signed signature must still be valid when Pods fetches, which is *during* the submit — not when you built the payload.                                                                                                                                                                                           |
| Format                     | JPEG or PNG. Detected from the bytes, not from `mimeType` and not from the response's `Content-Type`.                                                                                                                                                                                                                    |
| Size                       | 8 MB maximum, decoded — or fetched. The transfer is aborted at the limit rather than buffered.                                                                                                                                                                                                                           |
| Back side                  | Required for `RG` and `CNH`. Must be omitted for `PASSPORT`. Either transport satisfies it.                                                                                                                                                                                                                              |

## 3. Poll the status

```bash theme={null}
curl "https://api.pods.finance/api/v1/kyc/status?kycUserId=3f2b1c4a-…" \
  -H "x-api-key: $PODS_API_KEY"
```

```json theme={null}
{
  "kycUserId": "3f2b1c4a-…",
  "provider": "external_evidence",
  "status": "provider_pending",
  "brlaEnabled": false,
  "reason": null,
  "documentType": "RG",
  "documentEvidenceSource": "customer_attested",
  "livenessEvidenceSource": "customer_attested",
  "updatedAt": "2026-08-13T15:01:22Z"
}
```

Poll every 10 seconds until `status` is terminal.

| Status               | Meaning                                                          |
| -------------------- | ---------------------------------------------------------------- |
| `created`            | Session exists, no evidence submitted yet.                       |
| `provider_pending`   | Submitted; awaiting the ramp provider's decision.                |
| `approved`           | Verified. `brlaEnabled` is now true and the user can move money. |
| `rejected`           | Terminal. Do not resubmit the same evidence.                     |
| `rejected_retryable` | Failed, but a new attempt may succeed. See `reason.retryFrom`.   |

The response carries the full profile; the fields above are the ones this flow adds. The rest is
shared with the other Pods KYC flows and documented in the API reference.

## Handling rejections

When a session is rejected, `reason` is populated:

```json theme={null}
{
  "kycUserId": "3f2b1c4a-…",
  "status": "rejected_retryable",
  "brlaEnabled": false,
  "reason": {
    "code": "LIVENESS_FACE_MISMATCH",
    "message": "The selfie does not match the submitted document.",
    "retryable": true,
    "retryFrom": "liveness"
  }
}
```

These are Pods codes, not the upstream provider's. They stay stable if Pods changes ramp provider,
so you can program against them safely — which `rejectReason`, `providerResult` and
`providerMessage` on the same response cannot promise, since they carry raw provider values.

| Code                            | Retryable | Redo        | What happened                                               |
| ------------------------------- | --------- | ----------- | ----------------------------------------------------------- |
| `LIVENESS_FACE_MISMATCH`        | yes       | `liveness`  | The selfie and the document are not the same person.        |
| `LIVENESS_QUALITY_INSUFFICIENT` | yes       | `liveness`  | The face in the selfie was not clear enough.                |
| `LIVENESS_FRAUD_SUSPECTED`      | **no**    | —           | The liveness evidence was flagged as fraudulent.            |
| `IMAGE_QUALITY_INSUFFICIENT`    | yes       | `documents` | Blurry, too dark, too bright, cropped.                      |
| `DOCUMENT_INVALID`              | yes       | `documents` | Not a valid document, or irregularities detected.           |
| `IDENTITY_DATA_MISMATCH`        | yes       | `applicant` | Name or birth date do not match official records.           |
| `IDENTITY_CPF_MISMATCH`         | **no**    | —           | The document belongs to a different CPF than the session.   |
| `TAX_ID_INVALID`                | **no**    | —           | CPF not found or irregular at Receita Federal.              |
| `TAX_ID_ALREADY_VERIFIED`       | **no**    | —           | This person is already verified under another record.       |
| `IDENTITY_DECEASED`             | **no**    | —           | Death record found.                                         |
| `COMPLIANCE_BLOCKED`            | **no**    | —           | Sanctions, PEP, adverse media or similar.                   |
| `UNDER_18`                      | **no**    | —           | The applicant is a minor.                                   |
| `PROVIDER_ERROR`                | yes       | —           | The provider could not complete the request. Retry shortly. |

To retry, call `/submit` again on the **same** `kycUserId` with corrected evidence for the block
named in `retryFrom`. Do not create a new session — deduplication is per CPF, so it will return the
same one anyway.

<Note>
  `TAX_ID_ALREADY_VERIFIED` means the person exists at the ramp provider under a different record.
  A tax ID can only be verified once across the whole platform, so this is terminal for a new
  record; reach out to Pods to have the existing identity attached to your account.
</Note>

## Rejections before anything is uploaded

These are validation failures, returned in under a second. They never reach the ramp provider and
never create a subaccount.

| Status | Code                                |                                                                              |
| ------ | ----------------------------------- | ---------------------------------------------------------------------------- |
| `422`  | `LIVENESS_NOT_SUCCEEDED`            | The attestation status is not a pass.                                        |
| `422`  | `LIVENESS_CONFIDENCE_MISSING`       | A threshold is configured but no score was sent.                             |
| `422`  | `LIVENESS_BELOW_THRESHOLD`          | The score is below your configured minimum.                                  |
| `422`  | `INVALID_EVIDENCE_IMAGE`            | The image is unusable: not valid base64, or over 8 MB.                       |
| `422`  | `UNSUPPORTED_EVIDENCE_IMAGE_FORMAT` | The bytes are neither JPEG nor PNG.                                          |
| `422`  | `EVIDENCE_URL_NOT_ALLOWED`          | Not HTTPS, the host is not allowlisted, or it resolves to a private address. |
| `422`  | `EVIDENCE_URL_EXPIRED`              | The pre-signed signature had already expired. Re-sign and submit again.      |
| `422`  | `EVIDENCE_URL_FORBIDDEN`            | The storage provider refused the request (`403`).                            |
| `422`  | `EVIDENCE_URL_NOT_FOUND`            | The object does not exist (`404`).                                           |
| `422`  | `EVIDENCE_URL_UNREACHABLE`          | The storage provider could not be reached.                                   |
| `422`  | `EVIDENCE_URL_TIMEOUT`              | Fetching the images exceeded 8 seconds.                                      |
| `422`  | `DOCUMENT_BACK_REQUIRED`            | `RG` and `CNH` need a back image.                                            |
| `422`  | `DOCUMENT_BACK_NOT_ALLOWED`         | A passport is single sided.                                                  |
| `422`  | `INVALID_BIRTH_DATE`                | The extracted birth date could not be parsed.                                |
| `409`  | `IDENTITY_CPF_MISMATCH`             | The document CPF is not the session CPF.                                     |
| `409`  | `UNDER_18`                          | The applicant is a minor.                                                    |
| `409`  | `INVALID_KYC_PROVIDER`              | This session was not opened for external evidence.                           |

## Idempotency

There is no idempotency header. Deduplication is structural: one session per CPF per account. A
repeated `POST /sessions` returns the existing session. A repeated `/submit` while an upload is in
flight, or after one completed, returns the current state instead of uploading the documents again.

## Retention

|                                                                                                                               | Kept                                                                                                                                  |
| ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Document and selfie images                                                                                                    | **Never.** In memory for the duration of your request, then discarded. Only a SHA-256 digest and the provider's document ids survive. |
| The rest of the submitted payload — address, applicant data, both attestations, the extracted document data including the CPF | **Up to 7 days**, then deleted automatically                                                                                          |
| Session record: CPF hash, last four digits, status, provider ids                                                              | For the life of the verification                                                                                                      |

The 7-day window exists for one reason: when the ramp provider fails on their side, Pods can retry
the submission instead of sending your applicant back through capture. Expiry is automatic and
needs no call from you.

<Note>
  Expiry is a floor, not an exact instant — records are swept periodically, so a payload may live
  slightly past the 7-day mark. Nothing in the retry buffer is ever returned by an endpoint.
</Note>

## What Pods does not do

* Pods does not call your liveness or documentoscopy provider.
* Pods does not verify your attestation against your provider.
* Pods does not store images, ever.
* Pods does not keep the address or the full CPF beyond the retention window above.
* Pods does not return the liveness score. Only pass or fail — showing a biometric score to an end
  user is discouraged by the providers themselves.
