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. Read each row’s required flag, not its presence: a field can be listed and still be optional. A residential address, for example, is listed on every role, and turns required: true where enhanced due diligence applies. A proof-of-address document is listed only where it is owed. 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 document floor: the number of documents the submission must carry. Every document counts, at the top level and on each person. When it is 1, a submission with no document at all is rejected with 422 ONBOARDING_NOT_READY. When it is 0, documents are optional at submit. Submit checks the count, not the type. Review checks each documents[] row and its minCount. An application with a missing row stays in processing until the document arrives. Attach customer-level documents to the top-level documentIds[]. The documents[] rows are the checklist of acceptable types, not per-row required flags. A document with conditions is owed only when they hold on your submission.
The abridged example below includes per-person fields[] and documents[] rows for illustration — those particular rows read as they do under enhanced due diligence; 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.
For an individual customer, taxIdType depends on the person’s nationality and residence. A US national sends SSN. A non-US national who lives in the US sends SSN or ITIN. Everyone else sends NATIONAL_ID, PASSPORT, DRIVER_LICENSE or RESIDENT_CARD, with taxIdCountry set to the country that issued it. Add the optional nationality query parameter to the requirements call to get the list for a given nationality. Without it, discovery lists SSN and ITIN for a US resident because it does not know the nationality, and submit refuses ITIN from a US national with 422 ONBOARDING_NOT_READY at /ownership/persons/0/taxIdType. A malformed SSN or ITIN gets the same 422 at /ownership/persons/0/taxIdNumber, in the same 422 ONBOARDING_NOT_READY list as the other field problems. Submit also refuses a NATIONAL_ID issued by CAN, NLD, IRL, AUS or JPN, and a NATIONAL_ID that does not match the format of its issuing country. See Identifiers that are not accepted for each country.
Some fields are required only conditionally, and discovery advertises this through a conditions[] array on the field rather than a bare required: true — check both. companyClassification.primaryIndustryOther becomes required only once companyClassification.primaryIndustry is "other_industry", and businessActivity.otherActivityDescription becomes required only once businessActivity.regulatedOrRestrictedActivities includes "other_regulated_activity". A request that triggers the condition and omits the follow-up field is rejected with 422 ONBOARDING_NOT_READY. An optional field can carry conditions[] too: the conditions[] of companyClassification.llcTaxClassification match a US LLC only. Send Partnership, C-Corp or S-Corp to state how the IRS taxes the LLC. A multi-member LLC that made no election is a Partnership. Banks that need this answer open the account faster when you send it.
Country codes are normalized server-side. You can send alpha-2 (
US) or
alpha-3 (USA); customer reads return alpha-2 (US), except
activity.countriesOfActivity, which keeps the alpha-3 codes you sent.Fields that depend on an earlier answer
Conduit does not fix the order of your questions. A field or document that depends on an answer carries that answer’s test in itsconditions[]. Show these follow-up questions after the user gives the answer, and use each field’s required flag to know which ones submit enforces.
Until the answer is given, submit does not check the follow-up fields.
Discover requirements for a specific industry
Discovery also takes an optionalindustry query parameter. Adding it
returns the fields, documents, and person requirements tied to that
industry, alongside the country-based set.
key from GET /v2/onboarding/policy-subjects?axis=industry —
fetch that list to render an industry picker, and submit the selected key
back as companyClassification.primaryIndustry. Omit industry to get the
industry-agnostic response.
An industry value the catalog does not recognize is refused rather than
silently ignored, so a typo never passes through as “no industry selected.”
industry applies to a business subject only. Passing it alongside
subjectType=individual is rejected, since an individual carries no industry
classification.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 and identity documents 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.
We clean every address you send — registeredAddress, operatingAddress, and each person’s address — against the country that address declares, before we store it:
- Characters are converted to plain ASCII:
MéxicobecomesMexico,São PaulobecomesSao Paulo. statethat names a subdivision of the address country is stored as its ISO 3166-2 code. We recognize the code (MX-CMX), the code without its country prefix (CMX), and the subdivision’s name (Ciudad de México), so the customer reads backMX-CMX. Discovery shows a valid code for the requested country as the field’sexample.
registeredAddress.state must name a US subdivision — discovery advertises the field as an enum of ISO 3166-2 codes (e.g. US-NY). Submit also accepts NY and New York, stores them as US-NY, and rejects anything else.
Every other address discovery lists follows a looser rule on the same principle. When operatingAddress, or a person’s address where discovery lists one, declares country as the US, its state must name a US state, territory, or DC — US-NY, NY and New York are all accepted — and its postalCode must be a US ZIP (12345 or 12345-6789). Submit rejects a subdivision that belongs to another country, such as Baja California Sur, with 422 ONBOARDING_NOT_READY pointing at that address’s own state. Each address is judged against the country it declares, so a person who lives outside the US keeps a free-text state even when the business is a US entity.
Unlike registeredAddress.state, these two rules are not advertised in discovery. Discovery describes one jurisdiction per request, and these depend on the country each address carries in the payload, which the client has not sent yet. Submit is where they are enforced, and the rejection names the offending field.
A person may need their own address. Discovery lists a residential address on every person’s role, and the person owes it where the row reads required: true — which is jurisdiction- and diligence-dependent, e.g. under enhanced due diligence. A proof-of-address document is owed where discovery lists one on that role. Both are separate from the business address, and 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 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 return502 KYC_UPSTREAM_UNAVAILABLE; retry it once the provider recovers. It carriespersonReferenceId, theurl, and a deprecatedshortUrl. See Webhooks. - Pulled — fetch a fresh link on demand for any person:
409 APPLICATION_INVALID_STATUS.
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. 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, the specific reason on a final rejected_by_ops rejection, and a fixed contact-support line on a final compliance_denied one (see Handling a rejection); it is omitted when no reason was recorded. A resubmittable rejection also carries resubmissionGuidance, a fixed line telling you the documents already uploaded stay valid for the new submission. 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 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 acustomer_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:
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 rejection with compliance_denied 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. On a final rejection with rejected_by_ops it is the specific reason, for you to show or log.
When Conduit needs more information, a resubmittable rejection also carries reviewQuestions: a list of questions, on both the webhook and the resource. Show them to the applicant, and address each one in the corrected data and documents of the fresh submission. The field is absent when there are no questions and on a final rejection.
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, 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 whenresubmittable 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. - the same
documentIds, in both the top-leveldocumentIds[]and eachownership.persons[i].documentIds[]— documents you already uploaded stay valid, so do not re-upload them. Upload a replacement only for a documentfailureMessageasks you to correct, and pass its new id in place of the old one, in the same array it came from.
To abandon an application that is still in review, before any decision, call
POST /v2/applications/{applicationId}/cancel. A repeat cancel of a
cancelled application returns 200 with the application unchanged. Approved
and rejected applications are terminal and return 409. An application in
review whose automated review could not finish also returns 409: contact
support to close it.Onboarding an individual
Everything above onboards a business — the default subject. To onboard a natural person, declaresubjectType and send a person instead of a company. Individual onboarding must be enabled for your organization. Contact your Conduit account manager. Until it is, discovery and submit return 422 SUBJECT_REQUIREMENTS_NOT_AVAILABLE. The lifecycle is the business one without the hosted identity check: you attach the account holder’s identity verification attestation and original ID document, submit one application, then listen for application.approved or application.rejected.
Set subjectType on both the discovery call and the submit body. Omitting it onboards a business, so an existing business integration needs no change (see Onboarding an Individual). For an individual, the country query parameter — and the person’s own address.country — is their country of residence, the jurisdiction that decides what they owe.
Discover individual requirements
fields[] carries the account-holder-level descriptors — the activity object, the politically-exposed-person question, and the consent — while the single ACCOUNT_HOLDER row in individualRequirements[] carries the person’s own fields (legal name, date of birth, nationality, address) and their documents. It never lists business envelopes.
Submit an individual application
An individual application names exactly one person — the account holder, with the roleACCOUNT_HOLDER — and carries no business envelope; sending any is rejected (the individuals reference lists which ones). Alongside the person it sends an activity object (the account holder’s due-diligence descriptors), a regulatoryHistory flag, and the certification consent. Send each enum value verbatim from the discovery response’s closed sets. Attach the account holder’s identity verification attestation id and ID document ids to their documentIds[]. Send the date the verification was completed, as the report shows it, in identityVerificationDate. It is optional, but send it. Without it, approval depends on reading the date from the report, and the application can wait for a manual review.
202 Accepted with the application in processing. A second submission for the same account holder’s tax ID returns 409 ONBOARDING_ALREADY_SUBMITTED with details.applicationId while this application is in review, and 409 CUSTOMER_ALREADY_ONBOARDED with details.customerId after approval. The duplicate check ignores dashes, spaces and letter case: 524456701 and 524-45-6701 are the same tax ID. Send an SSN or ITIN as nine digits. Dashes and spaces are removed before the number is checked. The phone number follows the phone rule. There is no identity-check step for an individual — you already attached the account holder’s verification attestation and ID — so go straight to listen for the decision. For everything a person owes, see Onboarding an Individual. Once approved, Get a customer by ID returns type: "individual" and the account holder’s name fields, residential address, activity answers and regulatoryHistory.isPoliticallyExposedPerson, in the same nested shape you submitted. The address country is ISO 3166-1 alpha-2; activity.countriesOfActivity keeps the alpha-3 codes you sent, so you can send them back in an update. To change an activity answer or the politically-exposed-person answer later, send only the leaves that change under activity or regulatoryHistory in a customer update; after approval the customer holds the new value. An update that makes the person politically exposed, or moves the residence to a country that calls for enhanced due diligence, also owes the source of wealth and the evidence that onboarding asks for.