> ## Documentation Index
> Fetch the complete documentation index at: https://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_034A0gCCW6w2ubcipmRoY8" }
```

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_034A0gCCW6w2ubcipmRoY8"]
  }'
```

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_034A0gCCVlvyhfAZssklTs",
  "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 created verification session, as soon as that session is ready, whenever you're subscribed. That is normally one event per person, and a person who is re-verified receives another one — 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 the verification provider is briefly unreachable it arrives a little later. The pull endpoint below reaches the same provider, so during that window it can return `502 KYC_UPSTREAM_UNAVAILABLE`; retry it once the provider recovers. It carries `personReferenceId`, the `url`, and a deprecated `shortUrl`. 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/verify?inquiry-id=inq_example&session-token=example",
  "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_034A0gCCVlvyhfAZssklTs",
  "type": "customer_onboarding",
  "status": "processing",
  "persons": [
    { "referenceId": "app_034A0gCCVlvyhfAZssklTs:a4K", "name": "Jane Doe" }
  ]
}
```

<Warning>
  Send `url`. It is resumable, not single-use: the person can refresh it,
  reopen it, and finish on a second device, and it stays valid as long as
  their verification session does. That makes it bearer material — deliver it
  straight to the person and never log it.

  `shortUrl` is **deprecated and will be removed**. Despite the name it is not
  a shortening of `url` — it is a separate one-time link that opens once and
  stops working about five minutes after the person opens it, so anyone who
  steps away mid-check comes back to a dead page. It is short enough for SMS,
  which is exactly why it was used there; prefer `url` even when that means
  shortening it yourself, and use a shortener you control, since the link is
  bearer material.

  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_034A0gCCVlvyhfAZssklTs",
  "customerId": "cus_034A0gCCVsxdV2PjHLx9k1",
  "clientReferenceId": "your-ref-001"
}
```

`application.rejected` — no customer is created. `resubmittable` says whether a corrected application will be considered. `failureCode` (machine-readable) accompanies every rejection. `failureMessage` (human-readable) is the specific correction to make on a resubmittable rejection and a fixed contact-support line with no per-field detail on a final one (see [Handling a rejection](#handling-a-rejection)); it is omitted when no reason was recorded. `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_034A0gCCVlvyhfAZssklTs",
  "failureCode": "rejected_by_ops",
  "failureMessage": "The uploaded proof of address is expired. Upload a current document and resubmit.",
  "resubmittable": true,
  "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 rejection is a decision on the submission, not on the customer. The application record itself is terminal, but the customer can still be onboarded through a fresh submission unless the rejection was final.

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

`resubmittable` is the field to branch on:

| `resubmittable` | `failureCode`       | `failureMessage`                                                                       | What to do                                                               |
| --------------- | ------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `true`          | `rejected_by_ops`   | The specific correction to make                                                        | Correct the submitted data and submit a fresh application.               |
| `false`         | `compliance_denied` | A fixed line: `"Your application requires additional review. Please contact support."` | The decision is final. Do not resubmit — direct the customer to support. |

Branch on `resubmittable`, not on the code. Any failure code we add later reads as `false` until it is deliberately made resubmittable, so the boolean stays correct as the code list grows.

`failureMessage` follows the same split. On a resubmittable rejection it is the specific reason to surface to the applicant, so they can correct and resubmit. On a final one it is always the same fixed contact-support line, with no per-field or per-document detail — treat it as a cue to route the customer to support, not as text to parse.

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_034A0gCCVlvyhfAZssklTs",
  "clientReferenceId": "your-ref-001",
  "type": "customer_onboarding",
  "status": "rejected",
  "failureCode": "rejected_by_ops",
  "failureMessage": "The uploaded proof of address is expired. Upload a current document and resubmit.",
  "resubmittable": true,
  "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`, `resubmittable`, `failureCode`, and `failureMessage` — the same information the webhook delivers — and the list endpoint (`GET /v2/applications`) carries them on each rejected row, so a polling integrator can read the outcome 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.

This path applies when `resubmittable` is `true`. When it is `false` the decision is final and a new submission will reach the same outcome, so stop here and route the customer to support instead.

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