KYC Onboarding
From zero to a provisioned consumer account: open the application, poll the status, and read what exists after approval. The order each handler runs in, the two shapes the POST answers in, and which refusal a retry actually fixes.
Two calls take a consumer from nothing to a provisioned account: open the application, then read it by id until the provider approves. The read is what provisions the account, the funding source and the Pix key.
Prerequisites
npm install @codespar/sdkTwo scopes. kyc:write for the POST, kyc:read for the GET. Only kyc:read is written into the served document; kyc:write is enforced by the route and appears nowhere in the spec. A key carrying the * wildcard passes regardless. See Authentication.
The organization needs the Celcoin connection wired. Without it the POST answers 502 with no_eligible_providers or credential_unavailable, and no retry ever clears that.
The POST only exists from @codespar/sdk 0.16.0
@codespar/sdk 0.15.0 carries 227 operations in its generated table and only the two GETs of this journey. cs.api.post("/v1/account-applications", ...) does not exist there. 0.16.0 carries 276 and closes the gap. The SDK page for this group still says the POST is available from 0.12.0 and that npm is on 0.15.0. Both sentences are wrong. Pin 0.16.0.
The whole journey in one file
import { CodeSpar } from "@codespar/sdk";
const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });
const consumerId = "csm_0a1b2c3d4e5f6071";
const documentNumber = "11144477735";
// The recovery branch of the POST. The generated types do not declare it:
// they say verification_id is a required string.
type Recovered = {
verification_id: null;
account: string;
funding_source_id: string;
pix_key: string | null;
pix_key_type: string | null;
already_submitted: true;
pix_key_registration_error?: string;
};
// 1. Open the application. Needs kyc:write.
const opened = await cs.api.post("/v1/account-applications", {
body: {
consumer_id: consumerId,
buyer: { documentNumber, fullName: "Maria Souza" },
opt_ins: ["dda"],
},
});
// 2. Two shapes come back here. A null verification_id is the recovery branch:
// the account already existed and was adopted, so there is nothing to poll.
if (opened.verification_id === null) {
const adopted = opened as unknown as Recovered;
console.log(adopted.account, adopted.funding_source_id, adopted.pix_key);
} else {
const verificationId = opened.verification_id;
// 3. Render the disclosure verbatim, in pt-BR, exactly as it arrives.
// institution_document can be null and a null renders as nothing.
console.log(opened.disclosure);
// 4. Poll. document_number is required. This call is what provisions.
let state = await cs.api.get("/v1/account-applications/{id}", {
path: { id: verificationId },
query: { document_number: documentNumber, consumer_id: consumerId },
});
while (state.status === "pending" || state.status === "documentscopy_pending") {
if (state.status === "documentscopy_pending" && state.hosted_url) {
// Send the consumer to state.hosted_url. It appears only here.
console.log(state.hosted_url);
}
await new Promise((r) => setTimeout(r, 15_000));
state = await cs.api.get("/v1/account-applications/{id}", {
path: { id: verificationId },
query: { document_number: documentNumber, consumer_id: consumerId },
});
}
if (state.status === "rejected") {
throw new Error("the provider rejected the application");
}
// 5. approved. account, funding_source_id and pix_key are filled in.
// pix_key_registration_error is absent unless DICT registration failed,
// and its presence does not make the account unusable.
console.log(state.account, state.funding_source_id, state.pix_key);
}
// 6. After approval, the consumer is the hub for everything else.
const consumer = await cs.api.get("/v1/consumers/{id}", {
path: { id: consumerId },
});
console.log(consumer.wallet_id, consumer.funding_source_ids, consumer.mandate_ids);
// 7. The branch lives only on the funding source, in metadata.
const fundingSource = await cs.api.get("/v1/funding-sources/{id}", {
path: { id: `fs_celcoin_${consumerId}` },
});
console.log(fundingSource.metadata);
// 8. The live DICT inventory, read at the provider on every call.
const pixKeys = await cs.api.get("/v1/consumers/{consumerId}/pix-keys", {
path: { consumerId },
});
console.log(pixKeys);Step 0. The consumer row is optional
You can create the holder first with POST /v1/consumers, without a document. Nothing forces you to. The onboarding handler never checks that the row exists, and approval is what creates and anchors the consumer: anchorVerifiedConsumer runs an INSERT ... ON CONFLICT during the poll that finds the proposal approved. See the consumers reference.
Step 1. Open the application
curl -X POST https://api.codespar.dev/v1/account-applications \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"consumer_id": "csm_0a1b2c3d4e5f6071",
"buyer": {
"documentNumber": "11144477735",
"fullName": "Maria Souza"
},
"opt_ins": ["dda"]
}'buyer is the only required field. Everything else you put inside it passes through to the provider untouched, and what the provider refuses comes back as the provider's refusal.
The handler runs in this order, and the order is what tells you what exists after a refusal:
- Zod parse of the body. A failure here is
400 invalid_bodyand nothing left the process. extractBuyerDocumentreads, in this order,documentNumber, thendocument, thencpf, thencnpj. The first one present wins.effectiveConsumerIdisconsumer_idwhen you sent one, otherwise the derived${orgId}:${document}.- The person type comes from the canonical length of the document. Fourteen characters means CNPJ, including the alphanumeric CNPJ of IN RFB 2229/2024, and that picks
onboarding-businessoveronboarding. - Check digits are verified locally. A CPF or CNPJ that does not close is
400 invalid_bodywithissues[0].messagereadingCPF check digits do not verify, and nothing was sent to the provider. strategy.execute('codespar_kyc')calls the provider.- If the router landed somewhere other than Celcoin, the route answers
502 provider_error. The proposal was still opened wherever the router landed. recordKycOnboardingProposalwrites the proposal-to-document link. Best effort.- If
opt_insincludesdda,tryDdaOptInruns now, after submission. - The response.
Always pass consumer_id
Omit it and the id is derived as ${orgId}:${documentNumber}. The raw CPF or CNPJ then lives inside the consumer id, and the consumer id is what composes the funding source id (fs_celcoin_${consumerId}), so the document ends up in logs, in navigation and in every response that names the funding source. Passing consumer_id is not optional in practice.
A malformed buyer comes back as 502, not 400
The per-field validation of buyer happens in the transform, not in the route schema. A buyer with no fullName or no document throws inside the transform, and real-strategy.ts wraps any throw as MetaToolStrategyError('provider_error'). The caller sent a bad body and receives a 502 naming the provider. Read message before you blame the provider for a 502 on the POST.
Step 2. The POST answers in two shapes
The served document describes one. There are two.
Normal shape. verification_id is a string, status is the provider's word, hosted_url is null, disclosure is the text to render. The response is the proposal, not the account.
Recovery shape. When the provider answers OBE062 and local recovery finds an account of ours, the route answers 200 with verification_id: null plus account, funding_source_id, pix_key, pix_key_type, already_submitted: true, and sometimes pix_key_registration_error. Here the response is the account. None of those fields exist in the served schema, which declares verification_id as a required non-nullable string.
Branch on verification_id, not on the status code
Both shapes are 200. The branch you land in when you re-onboard the same consumer is the one the document does not describe. Code that reads verification_id and polls it will poll null forever. Check for null first.
Step 3. Keep the verification_id
It is the only key the read has. There is no collection route to find it again, and no route to list applications at all. Persist it next to the consumer before you do anything else.
A normal verification_id can already be unreadable
recordKycOnboardingProposal is best effort. If the write to celcoin_onboarding_proposals fails, the POST still returns a perfectly ordinary verification_id, and every future read of that id refuses with 502 kyc_status_failed carrying document_ownership_unproven. The warning goes to the server log only. The caller gets no signal at all.
Step 4. Render the disclosure
The GET and the POST both return disclosure, and it carries institution, institution_document, codespar_role valued correspondente, and text in pt-BR. Render it as it arrives. institution_document can be null, and a null renders as nothing rather than as an empty label.
The handler repeats the disclosure on the read by id on purpose, for a consumer who resumes the onboarding on a second device. That field is real and it is absent from both GET schemas in the served document.
Step 5. Documentoscopy
hosted_url on the POST is always null. It is a fixed hosted_url: null in the transform, not the "null when everything is done by API" the served description suggests. The webview link exists only on the read by id, and only while the status is documentscopy_pending. It comes from the proposal's first documentscopys entry, and it is null again once the application is approved or rejected.
Step 6. Poll the read by id
curl -H "Authorization: Bearer $CODESPAR_API_KEY" \
"https://api.codespar.dev/v1/account-applications/$VERIFICATION_ID?document_number=11144477735&consumer_id=csm_0a1b2c3d4e5f6071"document_number is required. Its absence is 400 document_number_required, a single field in a raw body.
Order inside resolveCelcoinOnboarding:
- Load the proposal filtering by
org_id. With no stored row, the call refuses before touching the provider. - Compare the canonical document against the stored one. A mismatch refuses and writes nothing.
- From here on everything uses the stored facts: document, consumer, project, environment. The arguments are not used again.
- Fetch the proposal at the provider.
- A provider status matching
REPROV,REJECT,DENIED,RECUS,ERRORorCANCELbecomesrejected.PENDING_DOCUMENTSCOPYbecomesdocumentscopy_pending.PENDING,PROCESSINGand an empty status becomepending. - Past the pending states, fetch the account, on the personal or the business endpoint according to the stored document. No readable account means
pending, with the raw body truncated into the log. provisionCelcoinFundingSource.anchorVerifiedConsumer, best effort.tryRegisterPixKey, best effort.approved.
Stop polling and the consumer stays unprovisioned
The read by id is not a pure read. The first poll that finds the proposal approved is the call that inserts the funding source, registers the Pix key and creates the holder. A client that stops polling leaves the consumer without an account until the Celcoin onboarding webhook reconciles it internally, or until someone calls the meta-tool. There is no published event you can wait on instead.
The two status fields do not share a vocabulary
The POST returns the provider's word in upper case, produced by String(r.status ?? 'PROCESSING').toUpperCase(). The GET returns one of four normalized lower-case values. One switch over both is always wrong.
| Where | Values |
|---|---|
POST status | The provider's own word, upper case. PROCESSING when the provider said nothing. |
GET status | pending, documentscopy_pending, approved, rejected. |
pending is the widest bucket of the four
It covers PENDING and PROCESSING at the provider, it covers the provider returning no status at all, and it covers "past the pending states, but no account number could be parsed out of the fetch response". That third case is logged with the body truncated at 300 characters. An account that is genuinely not minted yet and a parse that is wrong look identical to the caller.
The query is not validated by a schema
The handler reads request.query directly and only checks that document_number is present. Any other parameter is ignored in silence rather than refused. A document_number made only of punctuation passes the presence check and then canonicalizes to an empty string, which skips the ownership comparison entirely. The damage is contained, because the proposal is loaded filtered by org_id and the account is always fetched with the stored document, but "the document is always compared" is not true as written.
consumer_id in the query works backwards from what its name suggests. The consumer stored on the proposal wins. The parameter is used only when the proposal stored none. Omit both and the proposal id itself takes that slot.
Step 7. What exists after approval
GET /v1/consumers/{id} is the hub. It carries wallet_id, funding_source_ids, mandate_ids and dda_subscription_documents, with the document masked down to its last four characters. See the consumers reference.
GET /v1/funding-sources/{id} is the single line. The id is deterministic: fs_celcoin_ followed by the consumer id. balance_minor on this route is always null, and metadata is echoed raw, which means it can carry the consumer's document. branch comes out of no route in this journey: it is resolved in the same fetch as the account and written into metadata.branch here. See funding sources.
GET /v1/consumers/{consumerId}/pix-keys reads the DICT inventory at the provider on every call. A failed read is an error, never an empty list. POST on the same path registers another key. See Pix keys.
GET /v1/consumers/{id}/wallet is the unified per-currency wallet, and GET /v1/wallets/{id}/receive returns a deposit address only for USDC. For BRL it returns guidance with address, network and asset_contract all null. See Wallets and the wallets reference.
From here codespar_wallet and codespar_pay work against the same consumer_id.
Step 8. The DDA opt-in is not what the document says
Two claims in the served description of opt_ins do not hold.
The first onboarding returns the dda_opt_in block as deferred, not pending. tryDdaOptIn requires a document that is already verified, through the consumer anchor or through a funding source. On a first onboarding neither exists yet, so checkDdaDocumentOwnership refuses and the answer is deferred. pending happens on the OBE062 recovery branch or on a re-onboarding, not on the first pass.
The served description also says the DDA opt-in here requires a signed dda_allowlist. It does not. This path passes authorization: { kind: 'onboarding_opt_in' }, and no mandate is charged for it. The allowlist check raises dda_document_unauthorized on the governed paths only. That is POST /v1/consumers/{consumerId}/dda/subscriptions, the route to use when the opt-in is not enough. See the DDA reference.
Step 9. Lost the verification_id
The HTTP route cannot help: the id is in the path. The one published way back is the codespar_kyc meta-tool with check_type=status and no verification_id, passing consumer_id and document_number. recoverExistingOnboarding finds the proposal we opened for that consumer and document pair, and provisions.
codespar kyc -i '{
"check_type": "status",
"buyer": {},
"consumer_id": "csm_0a1b2c3d4e5f6071",
"document_number": "11144477735"
}'buyer is required even to poll a status
The served /meta-tools.json declares required: ["buyer", "check_type"]. Measured on CLI 0.11.2, codespar kyc --arg check_type=status --arg document_number=123 --arg verification_id=prop_1 answers codespar_kyc requires buyer. Nothing was sent. An empty buyer: {} passes validation and reaches the network. The empty object is not a placeholder, it is the way through.
The meta-tool description contradicts the meta-tool
The served description says a status poll with only a document_number provisions nothing and refuses with document_ownership_unproven. That is false whenever a proposal of ours exists: executeKycStatus without a verification_id calls recoverExistingOnboarding, which finds the open proposal and provisions. It refuses only when there is neither a binding nor a proposal. The error table on the codespar_kyc page repeats the same claim. The same served description also cites check_type=identity|document and a sanctions path, and neither is in the five-value enum any more.
The meta-tool and the HTTP route also disagree about privacy. The meta-tool returns account_masked, four bullets plus the last two digits, and proposal_status, the provider's raw word. The HTTP route returns the whole account and no proposal_status. Same journey, two contracts.
Scope enforcement differs too. The per-action scope that maps codespar_kyc/onboarding to kyc:write sits behind META_TOOL_ACTION_SCOPES_ENFORCE, which defaults to off. The HTTP routes charge the scope unconditionally.
Refusals, and which one a retry fixes
Bodies on this journey are raw. There is no { error: { code, message } } envelope and no request_id.
| Code | HTTP | What already happened | Does a retry fix it? |
|---|---|---|---|
invalid_body | 400 | Nothing. The parse and the check digits run before the provider. | No. Fix the body. issues names the field. |
document_number_required | 400 | Nothing. Only on the GET. | No. Add the query parameter. |
write_scope_refused | 403 | Nothing was written. | No. The write named a resource outside the org or project of the credential. Fix the project the request resolves to. |
onboarding_proposal_open_elsewhere | 409 | A proposal is already open for this document, provider_code OBE064. | No. An identical retry returns the identical 409. Change the consumer or the document, or follow the proposal that exists. |
onboarding_account_exists_elsewhere | 409 | The account already exists, provider_code CBE022. | No. Follow the account that exists. |
onboarding_client_code_conflict | 409 | The clientCode is already bound and local recovery found nothing of ours, provider_code OBE062. | No. |
funding_source_owned_elsewhere | 409 | Same OBE062 recovery arm, different body. No provider_code, which the served schema declares required. | No. The code marks this branch dead since ent#1036, and ent#874 is what retires it. |
kyc_status_failed | 502 | Two different faults share this code on the GET. See below. | Depends on which. |
provider_error | 502 | On the POST. Either the provider failed, or the router landed off Celcoin, or the transform threw on your buyer. | Only when it is genuinely the provider. Read message. |
no_eligible_providers | 502 | Nothing. The organization has no provider connection wired. | Never. This is setup, and it still arrives as a 502. |
credential_unavailable | 502 | Nothing. Same cause. | Never. |
transform_unknown, tool_unknown, invalid_args | 502 | The error is the code of the typed strategy error. | No. |
kyc_onboard_failed | 502 | The fallback, used only when the error was not typed. | Not blind. Quote the message. |
kyc_status_failed is two faults under one code
The catch is blind, so the typed ownership refusals land in the same 502 as a provider outage:
document_ownership_unproven, meaning no proposal is stored for that id. Retrying never helps.onboarding_document_mismatch, meaning thedocument_numberis not the one the proposal verified. Retrying never helps.- Anything else, meaning the provider failed. Retrying helps.
Only message separates them. Through the meta-tool the two ownership refusals arrive as themselves, marked dispatch='unsent', which states that nothing was sent and nothing was written.
The 409 family is caller state, not a provider outage
That is why these are 409 and not 502. The provider answered. What it answered is that this document or this client code is already spoken for. An identical retry reproduces it exactly.
There is no Idempotency-Key here
The handler never reads the header. The only idempotency string in the onboarding route is an internal key for the DDA step. Repeating the POST repeats the submission, and the provider answers OBE064, OBE062 or CBE022, so you get a 409 or the recovery branch.
The real idempotency sits on the other side. fs_celcoin_${consumerId} is deterministic and the upsert merges metadata with metadata || EXCLUDED.metadata, specifically so a repeat does not erase a Pix key that was already registered.
Deprecated aliases
Two aliases reach the same two handlers. deprecated: true in the served document, same body, same scopes, same responses:
| Canonical | Deprecated alias |
|---|---|
POST /v1/account-applications | POST /v1/kyc/onboard |
GET /v1/account-applications/{id} | GET /v1/kyc/onboard/{proposalId}/status |
Both compile on the typed REST client. Prefer the canonical path. The alias group has its own page at the kyc reference.
The alias is the only place where 403 write_scope_refused is documented, even though the handler raises it on all four paths. The promise that the alias is the same handler with a different path is broken by the document, not by the code.
What this does not do
- No list route. Two canonical operations and two aliases are everything that exists. Lose the
verification_idand no collection GET returns it. The recovery path is the meta-tool in step 9. - No cancel, no withdraw, no resubmit. There is no route to retract an application and no route to reissue the documentoscopy link.
- No published webhook event. The served snapshot has an empty
webhookskey, and the event catalogue on Triggers lists nocommerce.onboarding.*at all. The events exist in the provider webhook router and feed internal reconciliation, but nothing published lets you wait on approval. See the webhook listener cookbook for the shape of what you would subscribe to if there were one. - No typed SDK method. There is no
session.onboard()and nosession.accountApplication(). What exists is the generic REST client above, andsession.execute('codespar_kyc', arguments). session.verificationStatusis a different channel. It andsession.verificationStatusStreamread thetool_call_idof a meta-tool execution and answerapproved,rejected,review,expiredorpending. They do not read averification_idand they do not replace the read by id. See SDK status.- No CLI command for the four routes. Measured on CLI 0.11.2,
codespar --helphas noaccount-applicationsfamily, andsurface.jsexcludes it with the reason that the group belongs with an admin family that has no served routes yet. Those routes are served.codespar kycandcodespar tool codespar_kycare aliases of each other and the only CLI path, and each opens a session before executing, so each call costs a session.--actionis refused client-side: the spelling is--arg check_type=status. See direct commands. codespar consumersis not this journey. Its 19 subcommands do not include onboarding, andcodespar sellersis the merchant side.- The exported
unknownstatus is unreachable.CelcoinOnboardingStatushas five values and these routes produce four. Code generated from the published type carries a dead branch. - Several real fields are absent from the spec.
disclosureon the GET, every field of the POST recovery branch,branchon either route, thekyc:writescope on both POSTs, and403on the canonical routes. Read the behaviour here, not the schema. - An approved body does not prove a consistent holder.
anchorVerifiedConsumeris best effort. If theconsumer_idalready carries a different verified document, it returnsdocument_mismatch, logs a warning, and the funding source is provisioned anyway. The GET answers200 approvedwith an inconsistent holder table behind it. pix_key_registration_errorabsent means two things. Either the key registered, or there is no account yet. Onlypix_keyseparates them, and its presence never makes an application unapproved.
Next steps
Sandbox to First Live Charge
Put money in a test wallet, run the whole flow against it, and see what changes when you swap the key. The order the writes happen in, what has already happened when each refusal arrives, and which refusals a retry can fix.
Find a Capability by Intent
Turn a sentence into a tool name. Three different searches answer that question over three different corpora, and only one of them costs a single call.