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

# Onboard a Customer

> Discover onboarding requirements, then submit a customer application

Onboard a business customer along the golden path: discover what a country requires, upload the documents, submit the application, and react to the result.

Conduit is submit-and-listen. Submit one application. Receive a webhook when it is approved or rejected. There is nothing to poll.

For the end-to-end state and recovery map, see [The Onboarding Lifecycle](/guides/onboarding-lifecycle).

<Note>
  **Test this flow in sandbox.** Drive it end-to-end with simulated money and deterministic controls — start with the [sandbox quickstart](/sandbox/quickstart), then [customer KYC simulation](/sandbox/customer-kyc) for this flow, and the [cheat sheet](/sandbox/cheat-sheet) for every magic value and simulate endpoint.
</Note>

## Prerequisites

* An API key for the environment you're integrating against. See [Authentication](/authentication).
* A webhook endpoint subscribed to `application.approved` and `application.rejected`. See [Webhooks](/webhooks).
* The customer's primary country (ISO 3166-1 alpha-2 or alpha-3, e.g. `US` or `USA`).

## Flow

1. Discover the onboarding requirements for the customer's country.
2. Upload each required document and keep the returned `doc_...` ids.
3. Submit the application with the collected fields and document ids.
4. Each person completes a Conduit-hosted identity check — Conduit emails them the link, or you deliver it yourself.
5. Listen for `application.approved` or `application.rejected`.
6. Fetch the new customer.

## Step 1 — Discover requirements

Requirements are country-specific. Call the discovery endpoint to learn which fields and documents to collect. Never hardcode them.

```bash theme={null}
curl 'https://api.conduit.financial/v2/onboarding/requirements?country=US' \
  -H "x-api-key: YOUR_API_KEY"
```

The response has three parts: `fields[]` (customer-level data to collect), `documents[]` (the customer-level document checklist), and `individualRequirements[]` (per-person requirements). A control person is a beneficial owner or controlling person of the business — the individuals you list in `ownership.persons[]`. Each `individualRequirements[]` row is one role and carries how many persons it needs (`minCount`), the scalar `fields[]` each must submit, and the `documents[]` each must supply. Which per-person fields and documents appear depends on the jurisdiction and diligence level — a residential `address` and a proof-of-address document, for example, are listed where enhanced due diligence applies, and the rows are empty where they are not required. Upload each per-person document with `POST /v2/documents` and attach the returned id to that person's `ownership.persons[i].documentIds[]` — never the top-level `documentIds[]`.

`minDocuments` is the authoritative document floor: the number of customer-level documents you must upload before you can submit. When it is `1`, attach at least one document from `documents[]` to the top-level `documentIds[]` — submitting with an empty `documentIds` is rejected with `422 ONBOARDING_NOT_READY`. When it is `0`, documents are optional at submit. The `documents[]` rows are the checklist of acceptable types, not per-row required flags.

The abridged example below includes per-person `fields[]` and `documents[]` rows for illustration — those particular rows appear where enhanced due diligence applies; treat whatever your own discovery response lists as the contract.

```json theme={null}
{
  "schemaVersion": "3",
  "context": "onboarding",
  "country": "USA",
  "fields": [
    {
      "pointer": "/businessInfo/taxId",
      "label": "Tax ID (EIN)",
      "type": "string",
      "required": true,
      "constraints": { "pattern": "^(?:\\d{2}\\-\\d{7})$", "example": "12-3456789" }
    },
    {
      "pointer": "/companyClassification/legalStructure",
      "label": "Legal structure",
      "type": "enum",
      "required": true,
      "allowedValues": [
        "C-Corporation",
        "S-Corporation",
        "Limited Liability Company (multi-member)",
        "Limited Partnership",
        "Sole Proprietorship"
      ]
    },
    {
      "pointer": "/companyClassification/coreIndustry",
      "label": "Core industry",
      "type": "enum",
      "required": true,
      "allowedValues": ["Manufacturing", "Retail/Wholesale", "Healthcare", "Real Estate", "Energy"]
    }
  ],
  "documents": [
    {
      "canonicalType": "business_registration",
      "title": "Articles / Certificate of Incorporation"
    }
  ],
  "minDocuments": 1,
  "individualRequirements": [
    {
      "role": "any",
      "minCount": 1,
      "fields": [
        { "pointer": "/firstName", "type": "string", "required": true },
        { "pointer": "/email", "type": "email", "required": true },
        {
          "pointer": "/taxIdType",
          "type": "enum",
          "required": true,
          "allowedValues": ["SSN", "NATIONAL_ID"]
        },
        { "pointer": "/address/addressLine1", "type": "string", "required": true }
      ],
      "documents": [{ "canonicalType": "PROOF_OF_ADDRESS", "title": "Proof of address" }]
    }
  ]
}
```

Each `fields[].pointer` is an RFC 6901 JSON pointer (e.g. `/businessInfo/taxId`). Render your collection UI from `fields[]`, and validate against each field's `type`, `required`, and `constraints`. `enum` fields carry the closed `allowedValues` set — the values above are abridged for the example; the live response returns the full set, and you must send a value verbatim from it (e.g. `"C-Corporation"`, never an abbreviation like `"C_CORP"`). See [requirements reference](/api-reference/customer-onboarding/discover-onboarding-requirements-for-a-country) for the full schema.

<Note>
  Country codes are normalized server-side. You can send alpha-2 (`US`) or
  alpha-3 (`USA`); the response always echoes alpha-3.
</Note>

## Step 2 — Upload documents

Upload each document the requirements ask for. The endpoint is multipart with an optional `purpose` form field. Ordinary onboarding documents can omit it; identity attestations use `purpose=kyc`. Repeat once per file.

```bash theme={null}
curl https://api.conduit.financial/v2/documents \
  -X POST \
  -H "x-api-key: YOUR_API_KEY" \
  -F "file=@articles-of-incorporation.pdf"
```

```json theme={null}
{ "id": "doc_2xKjF9mQb7vN4hL1pR3w8t" }
```

Keep each returned `id`. Customer-level documents (e.g. incorporation papers) go in the top-level `documentIds[]`; documents that belong to a specific person (e.g. their proof of address) go in that person's `ownership.persons[].documentIds[]` — one per document listed on that person's role in `individualRequirements[].documents[]`.

## Step 3 — Submit the application

Turn each `fields[].pointer` into a nested object (`/businessInfo/taxId` → `businessInfo: { taxId }`), attach the document ids, and POST to `/v2/onboarding`. Send every `required` field — a missing one is rejected with `422 ONBOARDING_NOT_READY` listing it.

For US submissions, `registeredAddress.state` must be an ISO 3166-2 code (e.g. `US-NY`) — discovery advertises the field as an enum of the accepted codes, and submit rejects anything else. For other countries the field is free text; we still recommend an ISO 3166-2 subdivision code (e.g. `MX-CMX` for Mexico City) so the value is unambiguous.

**A person may need their own address.** Where discovery lists it on that person's role (jurisdiction- and diligence-dependent — e.g. under enhanced due diligence), the person owes a **residential address** and a **proof-of-address document**, separate from the business address. Both come from that person's role in `individualRequirements[]`: the residential `address` is among its `fields[]` (submit it under `ownership.persons[i].address`, same shape as `registeredAddress`), and the proof of address is among its `documents[]`. Upload the proof with `POST /v2/documents` and attach its id to that person's `ownership.persons[i].documentIds[]` — never the top-level `documentIds[]`, which is for business-entity documents only. Do **not** upload the person's government ID here; the hosted identity check in Step 4 captures it.

```bash theme={null}
curl https://api.conduit.financial/v2/onboarding \
  -X POST \
  -H "x-api-key: YOUR_API_KEY" \
  -H "content-type: application/json" \
  -H "Idempotency-Key: 8f3a1c2e-1b4d-4e9a-9c7f-2a6b5d8e1f00" \
  -d '{
    "clientReferenceId": "your-ref-001",
    "businessInfo": { "legalName": "Acme Inc", "businessEntityId": "0123456789", "taxId": "12-3456789", "website": "https://acme.example" },
    "registeredAddress": { "country": "US", "addressLine1": "1 Main St", "city": "New York", "state": "US-NY", "postalCode": "10001" },
    "operatingAddress": { "country": "US", "addressLine1": "1 Main St", "city": "New York", "state": "US-NY", "postalCode": "10001" },
    "companyClassification": { "legalStructure": "C-Corporation", "coreIndustry": "Manufacturing", "incorporationDate": "2020-01-15" },
    "ownership": {
      "persons": [
        {
          "roles": ["BENEFICIAL_OWNER", "CONTROLLING_PERSON"],
          "firstName": "Jane",
          "lastName": "Doe",
          "email": "jane@acme.example",
          "phoneNumber": "+14155550100",
          "birthDate": "1985-04-12",
          "nationality": "US",
          "taxIdType": "SSN",
          "taxIdNumber": "123-45-6789",
          "taxIdCountry": "US",
          "ownershipPercent": "100",
          "address": { "country": "US", "addressLine1": "1 Main St", "city": "New York", "state": "US-NY", "postalCode": "10001" },
          "documentIds": ["doc_2aProofOfAddrQb7vN4hL1"]
        }
      ]
    },
    "documentIds": ["doc_2xKjF9mQb7vN4hL1pR3w8t"]
  }'
```

The endpoint returns `202 Accepted` with the new application in `processing`. The customer does not exist yet — the `customerId` field is omitted from the response until the application reaches `approved`.

```json theme={null}
{
  "id": "app_2xKjF9mQb7vN4hL1pR3w8t",
  "clientReferenceId": "your-ref-001",
  "type": "customer_onboarding",
  "status": "processing",
  "submittedAt": "2026-01-15T09:30:00.000Z",
  "createdAt": "2026-01-15T09:30:00.000Z",
  "updatedAt": "2026-01-15T09:30:00.000Z"
}
```

<Note>
  Pass your own `clientReferenceId` to correlate the application with a record in
  your system. It is echoed back on the response and on every webhook for this
  application. Allowed shape everywhere the field appears: 1-255 characters from
  `A-Za-z 0-9 _ - : .` — no spaces.
</Note>

<Warning>
  `Idempotency-Key` is required. Reuse the same key when retrying a submission
  so a network failure can't create a duplicate application. A submission whose
  tax ID or beneficial-owner tax ID matches an existing active
  application is rejected with `409 ONBOARDING_ALREADY_SUBMITTED`.
</Warning>

## Step 4 — Each person verifies their identity

Once the application is in `processing`, every person you listed in `ownership.persons[]` completes a short Conduit-hosted identity check before the application can be approved. You don't build this flow — Conduit hosts it and issues each person a link. What the check involves depends on the diligence level the application requires: a government-ID capture, plus a live selfie where enhanced due diligence applies — the hosted flow adapts on its own, so you never branch on it. This captures the person's government ID (and the selfie, where required) — their proof of address is something you upload yourself in Step 3, not part of this check.

The link reaches each person through two **independent** channels — use either or both:

**Conduit emails it directly (default).** Each person is emailed at the `email` you submitted for them. A per-organization setting controls this and it is on by default; ask your Conduit account manager to turn it off if you'd rather be the only channel.

**You deliver it yourself.** Regardless of that email setting, you can receive each person's link and deliver it through your own channel (your app, SMS, email). Two ways to get it:

* **Pushed** — an `idv_link.created` webhook fires once per person, as soon as that person's verification session is ready, whenever you're subscribed — you don't have to turn the direct email off. The event is queued together with the verification itself, so it is never silently skipped — in the rare case Persona is briefly unreachable it arrives a little later, and the pull endpoint below always works meanwhile. It carries `personReferenceId`, the `url`, and a `shortUrl` suited to SMS/chat. See [Webhooks](/webhooks).
* **Pulled** — fetch a fresh link on demand for any person:

```bash theme={null}
curl https://api.conduit.financial/v2/applications/{applicationId}/persons/{personReferenceId}/idv-link \
  -X POST \
  -H "x-api-key: YOUR_API_KEY"
```

```json theme={null}
{ "url": "https://verify.example.com/session/abc", "shortUrl": "https://vfy.example.com/abc" }
```

`personReferenceId` is Conduit's stable id for each person — you don't submit it. Conduit assigns it and echoes it under `persons[]` on the application, so you can drive the pull endpoint by polling alone, with no webhook:

```bash theme={null}
curl https://api.conduit.financial/v2/applications/{applicationId} \
  -H "x-api-key: YOUR_API_KEY"
```

```json theme={null}
{
  "id": "app_2xKjF9mQb7vN4hL1pR3w8t",
  "type": "customer_onboarding",
  "status": "processing",
  "persons": [
    { "referenceId": "app_2xKjF9mQb7vN4hL1pR3w8t:a4K", "name": "Jane Doe" }
  ]
}
```

<Warning>
  The link is a one-time credential — deliver it straight to the person and
  never log it. Verification sessions are created asynchronously, so the pull
  endpoint returns `404` until a person's session exists (react to
  `idv_link.created`, or retry) and `409` once that person's verification has
  already settled.
</Warning>

<Note>
  The direct email and the `idv_link.created` webhook are independent: the email
  setting only controls whether Conduit emails each person directly, and the
  webhook fires (as above) for verifications you're subscribed to
  either way. Turn the
  email off only if you want your own channel to be the sole one.
</Note>

## Step 5 — Listen for the result

When review completes, Conduit delivers one of two webhooks. Both include your `clientReferenceId`.

`application.approved` — the customer is now active. `customerId` is populated.

```json theme={null}
{
  "applicationType": "customer_onboarding",
  "applicationId": "app_2xKjF9mQb7vN4hL1pR3w8t",
  "customerId": "cus_2xKjF9mQb7vN4hL1pR3w8t",
  "clientReferenceId": "your-ref-001"
}
```

`application.rejected` — no customer is created. `failureCode` (machine-readable) and `failureMessage` (human-readable) are present when a specific reason is available. `customerId` is absent when the rejection occurs before customer creation, which is the common case for a `customer_onboarding` rejection.

```json theme={null}
{
  "applicationType": "customer_onboarding",
  "applicationId": "app_2xKjF9mQb7vN4hL1pR3w8t",
  "failureCode": "rejected_by_ops",
  "failureMessage": "Application does not meet compliance requirements",
  "clientReferenceId": "your-ref-001"
}
```

## Step 6 — Fetch the customer

On approval, retrieve the customer with the `customerId` from the webhook.

```bash theme={null}
curl https://api.conduit.financial/v2/customers/{customerId} \
  -H "x-api-key: YOUR_API_KEY"
```

## Handling a rejection

A rejected application is terminal. For a `customer_onboarding` rejection, no customer is created and `customerId` is absent from the webhook. The `application.rejected` webhook carries `failureCode` (machine-readable) and `failureMessage` (human-readable) when a specific reason is available. Both fields are also readable from `GET /v2/applications/{applicationId}` after the fact, so if you miss the webhook you can fetch them from the resource.

You don't have to rely on the webhook alone. Confirm an application's status at any time — this reads the same rejection reason, so it also works if a webhook was missed:

```bash theme={null}
curl https://api.conduit.financial/v2/applications/{applicationId} \
  -H "x-api-key: YOUR_API_KEY"
```

```json theme={null}
{
  "id": "app_2xKjF9mQb7vN4hL1pR3w8t",
  "clientReferenceId": "your-ref-001",
  "type": "customer_onboarding",
  "status": "rejected",
  "failureCode": "rejected_by_ops",
  "failureMessage": "Application does not meet compliance requirements",
  "submittedAt": "2026-01-15T09:30:00.000Z",
  "createdAt": "2026-01-15T09:30:00.000Z",
  "updatedAt": "2026-01-15T09:42:00.000Z"
}
```

The response reports `status`, `failureCode`, and `failureMessage` — the same information the webhook delivers — and the list endpoint (`GET /v2/applications`) carries the pair on each rejected row, so a polling integrator can read the reason directly. `customerId` stays absent because a `customer_onboarding` rejection does not create a customer.

### Correct and resubmit

A rejection does not block the business. A rejected application no longer counts as active. Once you've corrected the data, submit a fresh application for the same tax ID and beneficial owners. Submit it as a new `POST /v2/onboarding` with:

* a **new `Idempotency-Key`** — reusing the rejected submission's key replays its original response instead of creating a new application.
* the **same `clientReferenceId`** — keeps every attempt correlated to one record in your system.

<Note>
  To abandon an application that is still in review, before any decision, call
  `POST /v2/applications/{applicationId}/cancel`. Approved and rejected
  applications are terminal and cannot be cancelled.
</Note>

## What happens next

The customer is active but has no features yet. Add a Virtual Account so they can receive funds — see [Add Virtual Accounts](/guides/add-virtual-accounts).
