Test this flow in sandbox. Drive it end-to-end with simulated money and deterministic controls — start with the sandbox quickstart, then customer KYC simulation for this flow, and the cheat sheet for every magic value and simulate endpoint.
Prerequisites
- An API key for the environment you’re integrating against. See Authentication.
- A webhook endpoint subscribed to
application.approvedandapplication.rejected. See Webhooks. - The customer’s primary country (ISO 3166-1 alpha-2 or alpha-3, e.g.
USorUSA).
Flow
- Discover the onboarding requirements for the customer’s country.
- Upload each required document and keep the returned
doc_...ids. - Submit the application with the collected fields and document ids.
- Each person completes a Conduit-hosted identity check — Conduit emails them the link, or you deliver it yourself.
- Listen for
application.approvedorapplication.rejected. - 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.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.
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 for the full schema.
Country codes are normalized server-side. You can send alpha-2 (
US) or
alpha-3 (USA); the response always echoes alpha-3.Step 2 — Upload documents
Upload each document the requirements ask for. The endpoint is multipart with an optionalpurpose form field. Ordinary onboarding documents can omit it; identity attestations use purpose=kyc. Repeat once per file.
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 eachfields[].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.
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.
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.Step 4 — Each person verifies their identity
Once the application is inprocessing, 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.createdwebhook 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 carriespersonReferenceId, theurl, and ashortUrlsuited to SMS/chat. See Webhooks. - Pulled — fetch a fresh link on demand for any person:
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:
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.Step 5 — Listen for the result
When review completes, Conduit delivers one of two webhooks. Both include yourclientReferenceId.
application.approved — the customer is now active. customerId is populated.
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.
Step 6 — Fetch the customer
On approval, retrieve the customer with thecustomerId from the webhook.
Handling a rejection
A rejected application is terminal. For acustomer_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:
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 newPOST /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.
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.