Skip to main content
Cookbooks

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.

1 min read
View MarkdownEdit on GitHub
TIME
~20 min
STACK
HTTP / SDK RESTno typed method
SERVERS
kyc: celcoin

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.

ONBOARDING LIFECYCLE
Open the application → documentoscopy → poll → provisioned
1 · OPEN
Submit the proposal
POST /v1/account-applications → 200 · verification_id
2 · VERIFY
Consumer at the webview
hosted_url, only on the read by id
3 · POLL
Read by id
GET /v1/account-applications/{id}?document_number=
4 · USE
Account and Pix key
consumers · funding-sources · pix-keys
STEP 1
provisionCelcoinFundingSource
inserts fs_celcoin_<consumer_id>, deterministic id
STEP 2
anchorVerifiedConsumer
binds document to consumer, best effort
STEP 3
tryRegisterPixKey
DICT registration, best effort
the read by id is a write: the first poll that finds approval is the call that provisions

Prerequisites

npm install @codespar/sdk

Two 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

kyc-onboarding.ts
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:

  1. Zod parse of the body. A failure here is 400 invalid_body and nothing left the process.
  2. extractBuyerDocument reads, in this order, documentNumber, then document, then cpf, then cnpj. The first one present wins.
  3. effectiveConsumerId is consumer_id when you sent one, otherwise the derived ${orgId}:${document}.
  4. 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-business over onboarding.
  5. Check digits are verified locally. A CPF or CNPJ that does not close is 400 invalid_body with issues[0].message reading CPF check digits do not verify, and nothing was sent to the provider.
  6. strategy.execute('codespar_kyc') calls the provider.
  7. If the router landed somewhere other than Celcoin, the route answers 502 provider_error. The proposal was still opened wherever the router landed.
  8. recordKycOnboardingProposal writes the proposal-to-document link. Best effort.
  9. If opt_ins includes dda, tryDdaOptIn runs now, after submission.
  10. 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:

  1. Load the proposal filtering by org_id. With no stored row, the call refuses before touching the provider.
  2. Compare the canonical document against the stored one. A mismatch refuses and writes nothing.
  3. From here on everything uses the stored facts: document, consumer, project, environment. The arguments are not used again.
  4. Fetch the proposal at the provider.
  5. A provider status matching REPROV, REJECT, DENIED, RECUS, ERROR or CANCEL becomes rejected. PENDING_DOCUMENTSCOPY becomes documentscopy_pending. PENDING, PROCESSING and an empty status become pending.
  6. 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.
  7. provisionCelcoinFundingSource.
  8. anchorVerifiedConsumer, best effort.
  9. tryRegisterPixKey, best effort.
  10. 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.

WhereValues
POST statusThe provider's own word, upper case. PROCESSING when the provider said nothing.
GET statuspending, 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.

CodeHTTPWhat already happenedDoes a retry fix it?
invalid_body400Nothing. The parse and the check digits run before the provider.No. Fix the body. issues names the field.
document_number_required400Nothing. Only on the GET.No. Add the query parameter.
write_scope_refused403Nothing 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_elsewhere409A 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_elsewhere409The account already exists, provider_code CBE022.No. Follow the account that exists.
onboarding_client_code_conflict409The clientCode is already bound and local recovery found nothing of ours, provider_code OBE062.No.
funding_source_owned_elsewhere409Same 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_failed502Two different faults share this code on the GET. See below.Depends on which.
provider_error502On 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_providers502Nothing. The organization has no provider connection wired.Never. This is setup, and it still arrives as a 502.
credential_unavailable502Nothing. Same cause.Never.
transform_unknown, tool_unknown, invalid_args502The error is the code of the typed strategy error.No.
kyc_onboard_failed502The 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 the document_number is 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:

CanonicalDeprecated alias
POST /v1/account-applicationsPOST /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_id and 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 webhooks key, and the event catalogue on Triggers lists no commerce.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 no session.accountApplication(). What exists is the generic REST client above, and session.execute('codespar_kyc', arguments).
  • session.verificationStatus is a different channel. It and session.verificationStatusStream read the tool_call_id of a meta-tool execution and answer approved, rejected, review, expired or pending. They do not read a verification_id and 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 --help has no account-applications family, and surface.js excludes it with the reason that the group belongs with an admin family that has no served routes yet. Those routes are served. codespar kyc and codespar tool codespar_kyc are aliases of each other and the only CLI path, and each opens a session before executing, so each call costs a session. --action is refused client-side: the spelling is --arg check_type=status. See direct commands.
  • codespar consumers is not this journey. Its 19 subcommands do not include onboarding, and codespar sellers is the merchant side.
  • The exported unknown status is unreachable. CelcoinOnboardingStatus has 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. disclosure on the GET, every field of the POST recovery branch, branch on either route, the kyc:write scope on both POSTs, and 403 on the canonical routes. Read the behaviour here, not the schema.
  • An approved body does not prove a consistent holder. anchorVerifiedConsumer is best effort. If the consumer_id already carries a different verified document, it returns document_mismatch, logs a warning, and the funding source is provisioned anyway. The GET answers 200 approved with an inconsistent holder table behind it.
  • pix_key_registration_error absent means two things. Either the key registered, or there is no account yet. Only pix_key separates them, and its presence never makes an application unapproved.

Next steps

KYC Onboarding | CodeSpar