KYC and KYB over the API
Your front, our tools: the flow dictates the next step, you run it on your customer, attach the folio and close with one call. Same verdict and same total as the hosted page.
What changes and what does not
The flow is defined exactly as always: same editor, same step catalog, same published version. The only thing that changes is WHO runs the user steps. Instead of our page, your front runs them: you show your own screens, call our tools on your customer and attach the result to the run. Server steps — CURP, RFC, lists, judicial, phone, email, e.firma — are still run by the runner alone, as in the other two channels. At the end you ask to close, and the dossier judges.
channel: "hosted"You open the session and send the person to the url (or paint the QR). Our page runs EVERY capture step. Your backend only reads the verdict.
channel: "hosted"The same run in an iframe through @singula_systems/identity-js, under your brand. Server-side it is still channel: "hosted".
channel: "api"Your screens, our tools. You ask for the next step, call the route it dictates on your customer, attach the folio and close with one call.
Opening the session from your backend and sending the person to our page is NOT the API channel: it is the UI channel opened from the backend, and it comes back as channel: "hosted". The API channel is only the one where YOUR front runs the capture steps.
1. Create the customer with your own front
On the UI channel the data step is captured by our page. Here you capture it yourself, with your own form, and create the customer before opening the run: the API channel DEMANDS customer_id. The published version’s form step resolves itself — status: "skipped", outcome: "pass", reason: "customer_provided" — and charges nothing.
curl -X POST https://api.singula.mx/customer \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"type": "physical",
"name": "MARIA",
"last_name": "LOPEZ",
"mothers_last_name": "GARCIA",
"curp": "LOGM900101MDFPRR02",
"email": "[email protected]"
}'
# Returns the customer with its id. That id is what the API channel demands.When the form carries questions of its own
No customer row answers a question of the questionnaire’s own, so with a single one the data step is not born resolved. next dictates it with how: "api", interaction: "form" and input_contract: the fields still missing, with their type. It is answered on the route in call with { fields } and it is not attached. Validation is the hosted page’s: a bad value answers 400 invalid_form with problems[], all of them at once.
{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"status": "awaiting_user",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"done": false,
"next": {
"step_key": "datos",
"tool": "form",
"kind": "form",
"interaction": "form",
"required": true,
"params": { "fields": [ … ] },
"attempt": 1,
"how": "api",
"call": {
"method": "POST",
"path": "/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/datos/form",
"body": { "fields": "required" }
},
"input_contract": [
{ "name": "ocupacion", "type": "select",
"label": { "es": "Ocupación", "en": "Occupation" }, "help": null,
"required": true, "min": null, "max": null, "maps_to": null,
"options": [{ "value": "empleado" }, { "value": "comerciante" }, { "value": "otro" }] },
{ "name": "es_pep", "type": "boolean", "label": null, "help": null,
"required": true, "options": null, "min": null, "max": null, "maps_to": null }
]
}
}
# input_contract holds the fields still missing, with their type: whatever your customer already has is not asked again.curl -X POST https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/datos/form \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "fields": { "ocupacion": "comerciante", "es_pep": false } }'
# → 201 with the run detail, already advanced. A bad value answers 400 invalid_form with problems[].
# Answers of the form’s own stay in the run form_data: never on the customer, never in the webhook.2. Open the run on the API channel
The flow has to be published and the balance has to cover estimate_centavos, exactly as in any run. What is new is channel: "api" — without it the session is born hosted (the default is "hosted", and the SDK is hosted server-side too).
curl -X POST https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/sessions \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"channel": "api",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"external_id": "ord_123"
}'{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"request_id": "66f1a2b3c4d5e6f708192a3b",
"channel": "api",
"token": "flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17",
"url": "https://app.singula.mx/flow/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17",
"embed_url": "https://app.singula.mx/embed/flow/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17",
"expires_at": "2026-09-25T12:00:00.000Z",
"flow_version": 1,
"estimate_centavos": 4000,
"reused": [],
"next": {
"step_key": "identidad",
"tool": "identityVerification",
"kind": "user",
"interaction": "kyc",
"required": true,
"params": { "document_types": ["ine", "passport"] },
"how": "sdk",
"identity_start": {
"method": "POST",
"path": "/app/flow-embed/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17/steps/identidad/identity/start"
},
"attach": {
"method": "POST",
"path": "/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/identidad/attach"
}
}
}The response already carries next, so you can start without a second call. url, embed_url and token are still there: the token is what the run’s identity routes use.
Opening with channel: "api" and no customer_id answers 400 with code: "customer_required". It is the same answer a flow with requires_customer already gave, for the same reason: with no customer there is nothing for the tools to run on.
3. The flow dictates: GET …/next
You never guess the order. You ask for the next step, do what how tells you, and ask again. done: true means nothing is left to resolve: time to close.
curl https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/next \
-H "Authorization: Bearer sk_live_xxx"how: "api"Run the tool on YOUR customer, wait for it to finish and attach its request_id. On upload steps (INE, document, tax certificate and power of attorney) upload dictates it: you reserve with upload.init, upload each file straight to S3 and confirm with upload.complete; no file goes through the API. On the email-code step call dictates it (you collect the code with your own UI and attach the request_id of otp/verify). The data step with questions of its own also comes out as api, but it is answered with its answers and never attached.
how: "sdk"Identity verification only. Call identity_start, hand the verification_token to the browser and open it with Identity.open({ token }). It is NOT attached: the record is born inside the step and the runner advances on its own when it closes.
how: "wait"The next step is a server step and it is already running (CURP, RFC, lists, judicial, phone, email footprint, digital footprint, e.firma). There is nothing to do: ask next again.
{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"status": "awaiting_user",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"done": false,
"next": {
"step_key": "ine",
"tool": "ineExtraction",
"kind": "user",
"interaction": "upload",
"required": true,
"params": {},
"how": "api",
"upload": {
"init": {
"method": "POST",
"path": "/app/ine-extraction/customer/6622f8a1c1d2e3f4a5b6c7d8/init",
"body": { "ine_front": "required", "ine_back": "required", "min_ocr_confidence": "optional" }
},
"complete": {
"method": "POST",
"path": "/app/ine-extraction/request/{request_id}/complete"
}
},
"attach": {
"method": "POST",
"path": "/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/ine/attach"
}
}
}{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"status": "in_progress",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"done": false,
"next": {
"step_key": "curp",
"tool": "validateCurp",
"kind": "server",
"required": true,
"params": {},
"how": "wait",
"attach": {
"method": "POST",
"path": "/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/curp/attach"
}
}
}{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"status": "in_progress",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"done": true,
"next": null
}params carries the step’s own parameters exactly as the flow published them (document_types, document_kind…), so your screen asks for the right thing. attach always comes with the step’s route; on a how: "sdk" step you never use it.
4. Run the tool and attach the folio
The tool is called like any other query on a customer, with your key, and it charges right then. On an upload step that is three steps and no file goes through the API: you reserve, upload each file straight to S3 with its signed POST and confirm (the detail is in Files: straight to S3). When it finishes, you attach its request_id to the step. Attaching charges nothing: the run’s row keeps charged_centavos: 0 and the informational tool_charged_centavos, which already carries what the tool charged — the base charge of a check that failed after a paid call included — even when you attach it the moment it finished.
# 1 · Reserve both sides: the name and type of each photo (JPG or PNG), as JSON.
curl -s -X POST https://api.singula.mx/app/ine-extraction/customer/6622f8a1c1d2e3f4a5b6c7d8/init \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "ine_front": { "filename": "ine_frente.jpg", "content_type": "image/jpeg" },
"ine_back": { "filename": "ine_reverso.jpg", "content_type": "image/jpeg" } }' > init.json
# → { "request_id": "66f1a2b3c4d5e6f708192a7c",
# "uploads": { "ine_front": { "url": "https://…", "fields": { … } },
# "ine_back": { "url": "https://…", "fields": { … } } } }
# 2 · Each photo, straight to S3: its fields first and the photo last, under file. No API key.
s3_post() {
local args=()
while IFS= read -r kv; do args+=(--form-string "$kv"); done \
< <(jq -r "${1}.fields | to_entries[] | \"\(.key)=\(.value)\"" init.json)
curl -s -X POST "$(jq -r "${1}.url" init.json)" "${args[@]}" -F "file=@${2}" -w "%{http_code}\n"
}
s3_post .uploads.ine_front ine_frente.jpg # → 204
s3_post .uploads.ine_back ine_reverso.jpg # → 204
# 3 · Confirm: we check both photos in S3 and the reading and the Lista Nominal start.
curl -s -X POST "https://api.singula.mx/app/ine-extraction/request/$(jq -r .request_id init.json)/complete" \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{}'
# → { "request_id": "66f1a2b3c4d5e6f708192a7c", "status": "processing" }
# Async: wait for it to finish before you attach it.
# GET /app/ine-extraction/status/66f1a2b3c4d5e6f708192a7ccurl -X POST https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/ine/attach \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "request_id": "66f1a2b3c4d5e6f708192a7c" }'{
"step": {
"key": "ine",
"tool": "ineExtraction",
"status": "done",
"request_id": "66f1a2b3c4d5e6f708192a7c",
"outcome": "pass",
"reason": "ine_valid",
"action": "continue",
"attached": true,
"charged_centavos": 0,
"tool_charged_centavos": 500,
"completed_at": "2026-09-18T12:06:41.000Z"
},
"next": {
"step_key": "correo",
"tool": "verifyEmail",
"kind": "user",
"interaction": "otp",
"required": true,
"params": {},
"how": "api",
"call": {
"method": "POST",
"path": "/app/email/otp/customer/6622f8a1c1d2e3f4a5b6c7d8/send"
},
"attach": {
"method": "POST",
"path": "/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/correo/attach"
}
}
}The step resolves down the normal path: the query’s outcome is read and the rule the flow set on it is applied (on_fail, on_review, on_error). Attaching a query that came back bad is not an attach error — it is a step that failed, and the rule decides whether the run continues, goes to review or is declined.
5. Identity goes through the SDK
Liveness is the one capture you do not reimplement: you start the step, and the verification_token it returns opens with our widget as-is — it is an ordinary identity token. Nothing is attached: that verification’s record is born INSIDE the step, so the runner advances on its own the moment it closes.
curl -X POST https://api.singula.mx/app/flow-embed/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17/steps/identidad/identity/start \
-H "Content-Type: application/json"
# → { "status": "started", "verification_token": "…", "embed_url": "…", "expires_at": "…" }
# If this attempt’s capture was already sent (less than 15 min ago), no other one opens:
# → { "status": "submitted", "verification_token": null, "embed_url": null, "expires_at": "…" }
# This route authenticates with the run token in the URL: it carries no API key.import { Identity } from '@singula_systems/identity-js';
// The verification_token travels from your backend to the browser. The API key NEVER does.
Identity.open({
token: verificationToken,
onComplete: ({ requestId }) => {
// There is no attach: that identity record is BORN inside the step,
// so the runner advances on its own the moment it closes.
// Ask next again whenever you want to continue.
pollNext();
},
});Embedding the widget requires the allowed origins (Settings › Branding › Domains): with an empty list the widget never starts. onComplete is UX, never authorization — the verdict of record is read server-side.
If you call identity_start again once that attempt’s capture was sent — your screen reloaded while it was being reviewed — since MasterApi 1.114.0 it answers status: "submitted", with no verification_token or embed_url: do not open another capture, the run waits for the verdict of the one sent and next says so when it closes. Before, it cancelled the sent capture and the person started over. It holds while the submission is less than 15 minutes old; after that it answers status: "started" as always.
Where the person connected from (network, see the KYC and KYB guide): on the API channel only the person’s browser counts. On a channel: "api" run no public runner route records an IP — identity_start included: your server calls it, and your server is not the person — so the network comes from the identity capture the widget does in their browser or on their phone, and it is merged into the run when that step closes. With no IP at all, the dossier’s three network controls stay null and do not hold the verdict.
Identity verification does its whole job, the same as on its own: with an INE, a passport or a resident card and a customer that has a CURP it checks the document against RENAPO, and it also checks an INE against the Lista Nominal, at no extra charge. You do not need a CURP step for that; if your flow has one, that one is charged.
6. Skip what is optional
A step the flow published with required: false can be skipped, exactly as on the hosted page. It is the authenticated version of the same route.
curl -X POST https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/comprobante/skip \
-H "Authorization: Bearer sk_live_xxx"
# Only on a step with required: false. On a required one it answers 409.And if you never skip it, it does not block you either: on close, unresolved optional steps are marked skipped with reason "skipped_optional".
7. Close: the dossier judges
Closing is the only way to finish a run on the API channel: it builds the dossier from THAT run’s request_ids — the attached ones included — applies the billing adjustment and closes. The response is the run detail, with its decision, its dossier and its charges. It is idempotent: on a run that already closed it answers 200 with the same detail.
curl -X POST https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/close \
-H "Authorization: Bearer sk_live_xxx"{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"request_id": "66f1a2b3c4d5e6f708192a3b",
"channel": "api",
"flow_id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
"flow_version": 1,
"kind": "kyc",
"status": "approved",
"decision": "approved",
"decided_by": "rules",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"external_id": "ord_123",
"steps": [
{ "key": "datos", "tool": "form", "status": "skipped", "request_id": null,
"outcome": "pass", "reason": "customer_provided", "action": "continue",
"charged_centavos": 0 },
{ "key": "identidad", "tool": "identityVerification", "status": "done",
"request_id": "66f1a2b3c4d5e6f708192a40", "outcome": "pass",
"reason": "identity_approved", "action": "continue",
"charged_centavos": 2000 },
{ "key": "ine", "tool": "ineExtraction", "status": "done",
"request_id": "66f1a2b3c4d5e6f708192a7c", "outcome": "pass",
"reason": "ine_valid", "action": "continue", "attached": true,
"charged_centavos": 0, "tool_charged_centavos": 500 },
{ "key": "correo", "tool": "verifyEmail", "status": "done",
"request_id": "66f1a2b3c4d5e6f708192a91", "outcome": "pass",
"reason": "email_verified", "action": "continue", "attached": true,
"charged_centavos": 0, "tool_charged_centavos": 200 },
{ "key": "curp", "tool": "validateCurp", "status": "done",
"request_id": "66f1a2b3c4d5e6f708192a44", "outcome": "pass",
"reason": "curp_valid", "action": "continue", "charged_centavos": 300 },
{ "key": "listas", "tool": "checkBlacklist", "status": "done",
"request_id": "66f1a2b3c4d5e6f708192a51", "outcome": "pass",
"reason": "blacklist_clear", "action": "continue",
"charged_centavos": 1000 }
],
"expediente": {
"request_id": "66f1a2b3c4d5e6f708192a60",
"overall": "pass",
"failed": [],
"missing": []
},
"charged_centavos": 3300,
"tool_charged_centavos": 700,
"discount_credited_centavos": 0,
"estimate_centavos": 4000,
"checked_at": "2026-09-18T12:11:03.000Z"
}Closing is not deciding. POST …/decision is the human override of a run that landed in review; close is the normal end of an API-channel run, and the verdict is still set by the rules and the dossier. Without a dossier there is no approval.
In a production KYC whose identity session read an INE (and with no INE step), close may answer with the run STILL OPEN, decision: null: the close waits for the Lista Nominal the identity checks itself, up to 10 minutes from its verdict (it usually lands in one or two). The verdict arrives later through flow.completed or at GET /app/flows/sessions/:id; past the ceiling it closes with ine_vigente null.
On a KYB, since MasterApi 1.114.0, close crosses the representative’s INE against RENAPO with the CURP the card prints — it reuses a validation of that CURP or runs one included in the INE, at no extra charge — and waits a few seconds for it. When it is still running, close answers with the run STILL OPEN (decision: null) and the verdict arrives through flow.completed or at GET /app/flows/sessions/:id. A lookup that fails, or does not answer within 2 minutes, leaves the dossier in review: a KYB never approves without that cross-check. Since MasterApi 1.130.0, with no INE read, the representative’s identity session (INE, passport or resident card) is their ID when it is verified and belongs to the person the power of attorney or the deed names (otherwise the component is missing and it goes to review), and close also waits for its Lista Nominal; a passport issued outside Mexico with no CURP, of a holder who is not a Mexican national, leaves its cross-check null without holding.
A run nobody closes expires like any other: past its expires_at it lands in expired, with the same billing adjustment over whatever you managed to attach. Closing is yours; expiring is not.
When a reviewer asks to repeat a step
A run that closed in review or declined can be reopened with POST /app/flows/sessions/:id/resubmit, from your backend or from the dashboard. The flow.resubmitted webhook tells you, and next dictates the reopened steps again with attempt and resubmission: { n, reason, steps }. You run the tool again and attach the new folio: attach demands a query newer than the step’s reset_at (409 stale_evidence) and the previous attempt’s folio no longer enters (already_attached).
{
"session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
"status": "awaiting_user",
"customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
"done": false,
"next": {
"step_key": "ine",
"tool": "ineExtraction",
"kind": "user",
"interaction": "upload",
"required": true,
"params": {},
"attempt": 2,
"resubmission": { "n": 1, "reason": "document_unreadable", "steps": ["ine"] },
"how": "api",
"upload": {
"init": {
"method": "POST",
"path": "/app/ine-extraction/customer/6622f8a1c1d2e3f4a5b6c7d8/init",
"body": { "ine_front": "required", "ine_back": "required", "min_ocr_confidence": "optional" }
},
"complete": {
"method": "POST",
"path": "/app/ine-extraction/request/{request_id}/complete"
}
},
"attach": {
"method": "POST",
"path": "/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/steps/ine/attach"
}
}
}
# Run the INE again and attach the NEW folio: one older than the resubmission answers 409 stale_evidence with reset_at.
# The previous attempt folio no longer enters (already_attached). Then close closes again.Then close closes again: the dossier judges the new evidence and another flow.completed goes out, with resubmission_count. There is no automatic retry here: your screens decide how many times to ask for the photo.
8. Billing is the same as in the other channels
A tool you call yourself charges when you call it, like any loose query: that is its list charge, under the same failure rule as the loose query. A step the run executes charges exactly that same charge. Flows carry no discounts, so a step charges the same on both channels. With no hosted page there is no «Upload your documents» screen either: unused_uploads always comes back [].
That is why an attached step leaves nothing to credit back at close. The run keeps all three numbers: charged_centavos (what the run charged), tool_charged_centavos (what the tools you called charged) and discount_credited_centavos (what was credited back at close: 0, unless a step was repeated).
charged_centavos 3300 + tool_charged_centavos 700 − discount_credited_centavos 0 = 4000 = estimate_centavosExactly what the same run would have charged through the hosted page. The three channels pay the same for the same flow.
Repeating a step is not charged again. When a reviewer reopens a step, the tool you call again charges its list charge when you call it and the close credits it back in full, except when what paid was a check that failed after a paid call (its base charge) — a document that was read and then failed; since MasterApi 1.113.0 also an INE, an identity, an e.firma or an investigation —: then the next attempt owes the difference up to its own charge (0 on a fixed charge) and the close credits only the rest. The credit accumulates across closes: each close pays only the difference against what was already credited, so nothing is credited twice.
In the sandbox (sk_test_) everything is zero and there is no credit: the response shape is identical, the numbers are 0.
9. KYB over the API: the same loop
There is no second contract for companies. You create the moral customer with your own front (POST /customer with type: "moral", name, legal_name and rfc), open the run of the kyb flow with channel: "api" and its customer_id, and run the same loop: next → the route it dictates → attach → close.
The only thing that changes is which steps come up: the company’s documents arrive as how: "api" with their upload — the tax certificate, the incorporation deed, the power of attorney, the proof of address and, when your flow asks for it, the representative’s ID card — while e.firma, RFC, company judicial records and sanction lists are server steps (how: "wait"), and the representative’s identity is still how: "sdk" (since MasterApi 1.130.0, kyb_basico identifies the representative that way, with an INE, passport or resident card). The document step carries its document_kind in params, so your screen asks for the right paper. On close, the dossier that runs is the KYB one: it crosses the representative’s INE against RENAPO (see “7. Close”) and, since MasterApi 1.114.0, every control carries holds_approval, which says whether THAT control is what keeps the dossier from pass.
The bar is still set by your dossier policy, and publishing validates coverage: if the flow lacks a step that collects a required component, it does not publish (400 invalid_flow with code: "missing_required_component" and step_key: null). The API channel does not loosen that bar — it only changes who captures.
10. The guards and their errors
They live on attach, on the data step and on close, and every one of them says exactly what happened. None of them silently corrects anything.
409 · not_api_channelThe run belongs to the UI channel (channel: "hosted"). attach, close and the API channel’s next only exist on a run opened with channel: "api"; a hosted run is driven end to end by our page.
409 · not_current_stepThe step you named is not the one the run is on. The response carries current_step_key: attach that one. The flow dictates the order, and the API channel does not skip it.
404 · (the folio is not yours)That request_id does not exist, belongs to another organization, or belongs to the OTHER environment than your key (a sandbox query does not attach to a production run, nor the other way round).
409 · customer_mismatchThe query ran on a different customer. This is the guard that stops one person’s ID card from entering another person’s run: the query’s customer and the run’s customer must be the same.
409 · tool_mismatchThe query belongs to a different tool than the step expects. The response carries expected and received.
409 · bundled_evidenceAnother product ran that query internally (bundled_with on the record): for example, the CURP an identity verification validated to check against RENAPO. It is included in that product and is not attached as a step: run the step’s tool.
409 · log_not_terminalThe query is still running. Wait for it to reach a terminal state (GET /app/<tool>/status/:requestId) and attach it again.
409 · already_attachedThat folio is already attached to a run, or to the previous attempt of a reopened step. One piece of evidence counts in ONE run and one attempt: for another one, run it again.
409 · stale_evidenceThe query is OLDER than the run or, on a step a reviewer reopened, older than the resubmission (the response carries reset_at). Old evidence does not enter a fresh onboarding; if you want to use it, it is the dossier that reads it, not the flow.
409 · not_attachable_stepThat step is not attached. The data step is answered at POST …/steps/:key/form, the representatives step with its list, and identity and server steps advance on their own. The response carries how.
400 · invalid_form · 409 · not_form_step (data step)invalid_form: one or more answers do not fit their field; problems[] brings each one with field, code (required | bad_type | bad_format | bad_option | out_of_range | too_long) and message, and the whole form is sent again. not_form_step: the key you named is not the data step’s. session_closed and not_current_step apply too.
409 · steps_pending (on close)REQUIRED steps are still unresolved. The response carries pending[] with { step_key, tool, how } so you know what is missing. Optional ones do not block the close: they are marked skipped with reason "skipped_optional".