Integration guide

KYC and KYB

Your KYC or your KYB is built as a flow: create it, publish it, open a session and read its verdict. The whole integration, end to end.

KYC or KYB: what changes

This guide covers both. A flow is born with its kind — kyc for an individual, kyb for a company — and that kind stays: it decides which steps the catalog accepts, what the form asks by default and, above all, the bar its dossier judges the run against when it closes.

KYC · an individual
kind: "kyc"

The default bar is three: a verified identity, the CURP against RENAPO and the sanction lists. The form asks for name, surnames, CURP and email. INE, RFC, proof of address, phone and the judicial search are evidence you may add, not a requirement.

KYB · a company
kind: "kyb"

The default bar is the seven documents: tax status certificate, incorporation deed, notarial power of attorney, the representative’s ID (their INE or, since MasterApi 1.130.0, their identity verification with an INE, passport or resident card), proof of address, e.firma and a verified email. The form asks for legal name, trade name, RFC and email. Lists, judicial, the RFC against the SAT and the representative’s identity widen the cross-check when they are there; when they are missing, they do not hold the dossier back.

Templates come per kind too: GET /app/flows/templates?kind=kyc or ?kind=kyb returns only the ones that publish under that bar — kyc_basico and kyb_basico, plus one per vertical. Since MasterApi 1.130.0 their identity step offers the INE, the passport and the resident card; in the KYC ones the CURP is optional on the form (a foreigner with a passport has none), the CURP step only runs with a CURP and the INE against the Lista Nominal only when the identity read an INE (a branch on verification.document_type); kyb_basico identifies the representative with the identity verification instead of an uploaded INE, and legaltech no longer repeats the INE. A person with an INE who leaves the CURP blank goes to review: the identity does not copy their CURP onto the customer, and only a foreign passport excuses it. The flows you already created do not change.

That bar is the factory default, not a hard limit: your organization’s verification policy moves it with required_components, and publishing validates that your steps cover it.

The rest of the journey is identical: it publishes the same, the session opens the same, it runs over the same three channels, it fires the same webhook, it is reviewed the same and it is tested the same in the sandbox.

The three channels

The same flow, the same published version and the same verdict run in three shapes. What separates them is WHO runs the capture steps, and that is what the session stores in channel — which has only two values, because the link and the SDK are the same page of ours.

UI
channel: "hosted"
Captures: Our page

You send the link (or the QR) and the person captures on the page hosted by Singula. It does not matter that you opened the session from your backend: this is still the channel.

SDK
channel: "hosted"
Captures: Our page, inside your app

The same run embedded with @singula_systems/identity-js, under your brand and your allowed origins. Server-side it is the same channel as the link.

API
channel: "api"
Captures: Your front

Your screens run the capture steps: you ask for the next step, call the tool on your customer, attach the folio and close. It demands customer_id.

Creating the session from your backend and sending the person to our page is the UI channel opened from the backend, NOT the API channel: the session comes back as channel: "hosted" and our page runs the whole capture. The API channel is the other one: the one your own front runs. This guide documents both hosted channels end to end; the API channel has its own.

The API channel guide: KYC and KYB over the API

The four pieces

A flow is an ordered list of steps, each with a rule. Publishing it freezes a version. Opening a session runs that version for one person. Each step is one of the tools you already use, and it writes its own record.

Flow

The draft an admin edits: name, kind (kyc or kyb), steps and settings.

Version

What was published. Immutable, and the only thing a new session runs.

Session

One run for one person, with its link, its token and its expiry.

Step

A tool with its parameters and its rule: what happens on failure or on doubt.

The flow orchestrates; each product does its whole job. A tool does the same on its own as inside a flow — over the API, the dashboard, the SDK or a link — and the flow never adds hidden checks: it reads and compares what each product returned, in the dossier. The only extra thing it does is wait, with a ceiling, for a product to finish. That is why identity verification already brings, at no extra charge, the check of its document against RENAPO (with an INE, a passport or a resident card and a customer that has a CURP) and, when it read an INE, that INE’s Lista Nominal check; a CURP step you add to the flow is charged, because that check is one you ask for.

1. Create the flow

A flow is born as a draft at version 0. Each step carries a unique key, the tool it runs, whether it is required and its rules: on_fail (decline | review | continue), on_review (review | continue) and on_error. The full catalog, with each step’s parameters, is read from GET /app/flows/catalog.

Creating, editing, publishing and archiving a flow works with your API key or with an Admin JWT from the dashboard: the key is issued by an Admin and counts as the organization’s credential. This guide uses the key from beginning to end, so no dashboard session is needed at any point.

terminal
curl -X POST https://api.singula.mx/app/flows \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Alta de persona",
    "kind": "kyc",
    "steps": [
      { "key": "datos",     "tool": "form",                 "required": true,
        "params": { "fields": ["name","last_name","curp","email"] } },
      { "key": "identidad", "tool": "identityVerification", "required": true,
        "params": { "document_types": ["ine","passport"] },
        "on_fail": "decline", "on_review": "review" },
      { "key": "curp",      "tool": "validateCurp",         "required": true,
        "on_fail": "review" },
      { "key": "listas",    "tool": "checkBlacklist",       "required": true,
        "params": { "review_on_level": "medium" },
        "on_fail": "decline" }
    ],
    "settings": { "language": "es", "expires_in_hours": 168 }
  }'
200 OK
{
  "id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
  "name": "Alta de persona",
  "kind": "kyc",
  "status": "draft",
  "version": 0,
  "steps_count": 4,
  "estimate_centavos": 3300,
  "requires_customer": false,
  "created_by": { "kind": "api_key", "id": "ak_4f21", "label": "Onboarding prod" },
  "versions_count": 0
}

Or start from a template

GET /app/flows/templates returns the ready-made flows you can copy, with ?kind= to filter by kind. Each one carries its key, its name and description in Spanish and English, the vertical that recommends it, its steps and estimate_centavos at your organization’s prices. POST /app/flows/from-template/:key turns one into an editable DRAFT at version 0 — the same permissions as POST /app/flows, and a 404 when the key does not exist — so publishing is still your call. The body’s name is optional: without it, the draft takes the template’s own name.

terminal
# The published templates, estimated at your own prices
curl "https://api.singula.mx/app/flows/templates?kind=kyc" \
  -H "Authorization: Bearer sk_live_xxx"

# Create the draft with the steps of one of them
curl -X POST https://api.singula.mx/app/flows/from-template/fintech \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Alta de cuenta" }'
200 OK
{
  "items": [
    {
      "key": "fintech",
      "kind": "kyc",
      "name": { "es": "Alta fintech", "en": "Fintech onboarding" },
      "description": {
        "es": "Identidad con prueba de vida, CURP ante RENAPO, listas de sanciones y judicial.",
        "en": "Identity with liveness, CURP against RENAPO, sanctions lists and courts."
      },
      "vertical": "fintech",
      "steps": [
        { "key": "datos", "tool": "form", "required": true,
          "params": { "fields": ["name","last_name","curp","email"] } }
      ],
      "estimate_centavos": 3300
    }
  ]
}

2. Publish the version

Publishing validates and freezes. What it demands: at least one step; unique keys matching ^[a-z0-9_]{2,40}$; every tool present in the catalog and accepting the flow kind; a single form step, first, asking for at least one field; every tool only once, except the document step (extractDocument), which repeats with any document_kind, a KYB’s representatives step (representatives) and the same tool on branches that exclude each other (see repeated_tool under errors); valid values in on_fail, on_review and on_error; COVERAGE: every component your dossier policy requires must have a step that collects it; and PREREQUISITES: in a flow with a form step, whatever a step reads must be asked for by the form or produced by an earlier step (unmet_need). Since MasterApi 1.114.0 it also demands that every branch can open — a value the field never holds, or an operator its type does not take, is invalid_condition — and that the data step can create the customer: name and last_name required on a KYC, legal_name or name on a KYB (form_cannot_create_customer). When something does not add up, the answer is a 400 with code: "invalid_flow" and a problems[] naming the step — or step_key: null with code: "missing_required_component" when what is missing is a dossier component. When a tool is not unlocked, a 403 with code: "tools_locked" and locked_tools[] (see “If your account has no KYB yet”, below).

terminal
curl -X POST https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/publish \
  -H "Authorization: Bearer sk_live_xxx"

# The response comes back with status: "published" and version: 1.
# From here on, published_steps is immutable.

Editing the flow after publishing touches the DRAFT, never a published version or an open session. Every session finishes on the version it started with.

If your account has no KYB yet

Without an approved KYB — on the trial credit of a corporate domain, or with a top-up and the KYB still pending — almost the whole catalog publishes and runs the same. This is where the compliance tools stand:

Runs as usual

Identity verification with liveness and the sanction and PEP lists publish and run like any other step.

Behind the identity

A person’s judicial records (getJudicialRecordsOfPerson): only in a KYC flow, with an identityVerification step BEFORE them.

Needs the KYB

A company’s judicial records (getJudicialRecordsMoral): a flow that carries them does not publish and answers 403 tools_locked.

Publishing is the judge: a person’s judicial step with no identity above it answers 403 with code: "tools_locked", locked_tools: ["getJudicialRecordsOfPerson"] and the reason. In the run, the step runs only when two things hold. THAT run’s identity came back approved, read from its own evidence: a rule of yours that turns a decline into a pass does not open the gate. And the document it verified names the customer, with the KYC dossier’s comparator and threshold (kyc.name_match_threshold); the CURP decides only when the document carries no readable name. The search runs on that name.

When they do not hold, the step neither calls the provider nor charges: it lands skipped with reason: "kyb_required", outcome: "review" and action: "continue", and neither its on_fail nor its on_error gets a say. It is review and not pass so that a branch reading the judicial result never opens on a search that never ran. When the gate cannot be read, the step does not run either: it lands in error, charged 0, and its on_error decides. The sandbox skips the gate: the mock runs, free.

GET /app/flows/catalog announces it: the tool comes in entitlements.allowed — the editor may add it — and also in entitlements.after_identity, which the editor uses to say «runs after the identity». With an approved KYB, after_identity comes back empty and judicial records run in any order. GET /app/flows/templates carries locked_tools and publishable per template: without KYB only legaltech stays locked, for the company’s judicial records.

Outside a flow — a direct query, over a customer, over the API or the MCP — a person’s judicial records still ask for the KYB in production: 403 with the reason. The KYB is self-service, from the dashboard in Settings → Account verification, and it opens judicial records on any person, those of companies and electronic signature.

3. Open a session

A session requires a published flow and a balance at or above estimate_centavos. The body is optional: customer_id to run over a customer you already have, external_id and metadata to tie it to your system, and language, expires_in_hours or redirect_url to override the flow settings. It also accepts channel — "hosted" by default, which is what this guide documents; "api" opens the run for your own front to drive the steps, and demands customer_id.

terminal
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 '{ "external_id": "ord_123", "metadata": { "plan": "pro" } }'
201 Created
{
  "session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
  "request_id": "66f1a2b3c4d5e6f708192a3b",
  "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-22T12:00:00.000Z",
  "flow_version": 1,
  "estimate_centavos": 3300,
  "reused": [],
  "missing": []
}

Only the token starts with flw_ — that prefix is what the SDK reads to know it is mounting a flow and not an identity verification. flow_id and session_id are bare uuids, with no prefix.

If a server step needs a value nobody collects, the flow publishes requires_customer: true along with its missing_needs, and opening a session without customer_id answers 400 with code: "customer_required". Mind the order: requires_customer counts the steps that PRODUCE the value, so a form → INE → CURP flow now opens with no customer; and a flow WITH a form step whose gap nobody fills no longer publishes at all (unmet_need). See “Prerequisites”, below.

4. Run it: link, QR or widget

The url is the link hosted by Singula: send it by email or WhatsApp, or paint it as a QR to move from a desktop to a phone. It needs no allowed origins and nothing installed. The embed_url is the same run inside your app, and there the allowed origins ARE required (Settings › Branding › Domains). All three are the hosted channel: the capture is run by our page, even when you opened the session from your backend. That url belongs to ONE person; for a link or a QR that serves many people, see “Shareable link and QR”, below.

flow-widget.ts
npm install @singula_systems/[email protected]

import { Identity } from '@singula_systems/identity-js';

Identity.open({
  token: 'flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17',   // flw_ prefix → the widget mounts the flow runner
  // kind: 'flow',             // only needed when your token has no prefix
  onStage: ({ stage, tool, status, reason }) => {
    // stage:  the step key of your published version ('identidad', 'curp'…)
    // tool:   the tool that step runs, or 'form'
    // status: 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
    renderStep(stage, status, reason);
  },
  onComplete: async ({ decision, sessionId }) => {
    // decision: 'approved' | 'declined' | 'review' | 'withheld'
    // Never authorize here. Ask YOUR backend, which holds the API key.
    const run = await fetch(`/api/flows/${sessionId}`).then((r) => r.json());
    route(run.decision);
  },
  onError: ({ code }) => {
    // 'session_expired' | 'session_abandoned' — the run closed without a
    // verdict, so onComplete never fires. Open a new session.
    showExpired(code);
  },
});

Since MasterApi 1.114.0, a run whose steps have all finished closes on the page’s next poll: the verdict lands as soon as the last step finishes — or as soon as what the close waits for arrives — not a minute or two later. And reloading while the identity is being reviewed no longer restarts the capture: the page keeps waiting for the verdict of the one already sent.

onComplete is UX, not authorization. Showing the verdict to the person is OFF by default (expose_decision_to_user: false): with the switch off, the runner’s public routes report status: "completed", decision: null and expediente_overall: null for every terminal close — approved, review or declined alike — the page shows a neutral screen and the SDK receives decision: "withheld". The verdict of record is read server-side, with your API key or over a webhook.

Shareable link and QR

A shareable link (flk_…) is the fixed door to a published flow: the same link, or its QR, serves many people. Every use opens a NEW verification and its data step creates a NEW customer, with origin link; it never attaches to a customer that already exists. It is not the url of a session (flw_…), which belongs to one person and one run.

Only a published flow that starts with the data step and demands no customer can have a link: the link brings no customer, and the data step is what creates one. Otherwise creating answers 400 with code flow_not_published, link_requires_form or customer_required (with missing_needs). It is checked again on EVERY open: the link always opens the current published version, and if a new version stops qualifying, it closes with 410 flow_unavailable.

terminal
curl -X POST https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/links \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "QR de la sucursal Centro",
    "max_sessions": 500,
    "expires_at": "2026-12-31T23:59:59.000Z"
  }'
201 Created
{
  "id": "3d9b7e21-5c44-4f0a-9b6e-8a1f2c7d4e90",
  "flow_id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
  "token": "flk_6a2e4c19-8b73-4d5f-a0c1-7e9d3b5f2a84",
  "label": "QR de la sucursal Centro",
  "env": "production",
  "active": true,
  "status": "active",
  "max_sessions": 500,
  "sessions_count": 0,
  "expires_at": "2026-12-31T23:59:59.000Z",
  "last_used_at": null,
  "created_by": { "kind": "api_key", "id": "ak_4f21", "label": "Onboarding prod" },
  "created_at": "2026-09-23T17:00:00.000Z",
  "updated_at": "2026-09-23T17:00:00.000Z",
  "url": "https://app.singula.mx/l/flk_6a2e4c19-8b73-4d5f-a0c1-7e9d3b5f2a84"
}

# The QR is that very url. sessions_count counts OPENED verifications, not finished ones.
# Without max_sessions or expires_at, a production link is born with 100 uses and expires in 30 days.
label

For you: how you recognize it in the list. 1 to 120 characters.

max_sessions

1 to 100,000: how many verifications it may OPEN. A production link always has a ceiling: without it, 100, and null is 400 link_requires_cap. In the sandbox, without it there is no ceiling.

expires_at

ISO 8601, in the future and at most a year away. A production link always expires: without it, in 30 days, and null is 400 link_requires_expiry. In the sandbox, without it, it never expires.

env

Only a dashboard user picks it. An API key creates links of its own environment: with sk_test_, the link opens sandbox verifications.

Creating and editing links belongs to a dashboard Admin or an API key, like creating a flow; a Normal user gets a 403. The url is https://app.singula.mx/l/flk_… and the QR is that same url drawn as a code: print it, put it on your site or send it in a message.

Pause, edit and read its verifications

GET /app/flows/:id/links returns the flow’s links, each with status: active, paused, expired or exhausted — pause wins, then the date, then the ceiling. An API key sees the ones of its environment; the dashboard, all of them. PATCH /app/flows/:id/links/:linkId moves active, label, max_sessions or expires_at without deleting anything: active: false switches a printed QR off and active: true resumes it. The ceiling and the expiry move, but on a production link they are never removed: null answers 400 link_requires_cap or link_requires_expiry (in the sandbox it does remove them). There is no route to delete a link: you switch it off by pausing it.

Every verification it opens carries link_id — in the detail and in the flow.session.created webhook — and its created_by is whoever created the link, never the person who opened it. GET /app/flows/sessions?link_id= lists them with the link’s id, not its token.

terminal
# Pause the link: a printed QR stops opening verifications. "active": true resumes it.
curl -X PATCH https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/links/3d9b7e21-5c44-4f0a-9b6e-8a1f2c7d4e90 \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "active": false }'

# The ceiling and the expiry move; on a production link null is 400 link_requires_cap / link_requires_expiry
#   -d '{ "max_sessions": 1000, "expires_at": "2027-03-31T23:59:59.000Z" }'

# The flow’s links, each with its status
curl https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/links \
  -H "Authorization: Bearer sk_live_xxx"

# The verifications that link opened (its id, not its token)
curl "https://api.singula.mx/app/flows/sessions?link_id=3d9b7e21-5c44-4f0a-9b6e-8a1f2c7d4e90" \
  -H "Authorization: Bearer sk_live_xxx"

What happens when it opens

The link’s page makes two public calls, with no key. GET /app/flow-embed/link/:token answers { open, reason?, kind, ui } WITHOUT spending a use, to paint «no longer available» with your brand. POST /app/flow-embed/link/:token opens the verification and answers 201 { token, url } with a new flw_, and the person carries on at that url like in any hosted run. The page only calls it once the person taps «Comenzar verificación»: a link preview or an email scanner spends no uses. When the same person comes back on the same browser with an unfinished verification, the page resumes it without asking only within 60 minutes; after that it asks whether to continue or start another one.

Opening checks in this order: that the link is open (410 link_closed with reason paused, expired or exhausted); that the flow still qualifies (410 link_closed with reason flow_unavailable); the per link and IP brake (429 too_many_opens: more than 20 opens in a minute, 60 in an hour or 300 in a day from the same IP on the same link); the counter, which is atomic — with one free use left and ten people scanning at once, one opens —; and last the usual open. This route is public, so it shows nothing of your account: when the balance does not cover the estimate it answers 402 link_unavailable_balance, with no amounts, and when your account cannot open the verification, 403 link_unavailable, with no account state; both carry only the code and a generic message. The keyed routes keep their usual bodies. A 402, or any failure after the counter, gives the use back. There is no captcha in this phase.

On a verification a link opened, what the person triggers is braked too, per link and IP: an ACCEPTED data step — it starts the server steps that charge — answers 429 too_many_form_submits past 60 in an hour or 300 in a day, and a form refused with a 400 does not count; every email Singula sends on what the person typed — the email step’s code and the representatives’ links with delivery: "email" — answers 429 too_many_emails with the same ceilings. The 429 carries { code, window: "hour" | "day", limit, message }. A verification no link opened is never braked. What bounds a link’s spending is its use ceiling, not these brakes: give it the one you are willing to pay for.

the link’s page
# The link’s page, with no key. It asks first, WITHOUT spending a use:
GET https://api.singula.mx/app/flow-embed/link/flk_6a2e4c19-8b73-4d5f-a0c1-7e9d3b5f2a84
{ "open": false, "reason": "paused", "kind": "kyc", "ui": { "theme": { "primary": "#0057DF" }, "layout": "card" } }

# Then it opens, once the person taps «Comenzar verificación»: every POST is one use and a NEW verification
POST https://api.singula.mx/app/flow-embed/link/flk_6a2e4c19-8b73-4d5f-a0c1-7e9d3b5f2a84
201 { "token": "flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17", "url": "https://app.singula.mx/flow/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17" }

# Closed, with the reason:
410 { "statusCode": 410, "error": "Gone", "code": "link_closed", "reason": "exhausted",
      "message": "Este link ya alcanzó su número máximo de usos." }

# Too many opens from the same IP on the same link:
429 { "statusCode": 429, "error": "Too Many Requests", "code": "too_many_opens",
      "window": "hour", "limit": 60,
      "message": "Demasiadas aperturas de este link desde tu red. Intenta más tarde." }

# No balance, or your account cannot open it: no amounts and no account state
402 { "statusCode": 402, "error": "Payment Required", "code": "link_unavailable_balance",
      "message": "Este link no está disponible por ahora. Intenta más tarde o avísale a quien te lo compartió." }
403 { "statusCode": 403, "error": "Forbidden", "code": "link_unavailable", "message": "…" }
No longer available

The link is paused, expired, out of uses, or the flow can no longer open without a customer. You fix it: resume it, raise the ceiling, move the date or publish a qualifying version.

Too many opens

More than 20 in a minute, 60 in an hour or 300 in a day from the same network on the same link. It clears on its own. Reading the link has its own limit: 60 per minute.

No balance

Your organization’s balance does not cover the estimate of one verification: 402 link_unavailable_balance, with no amounts and no account details. It does not spend a use.

Billing

The link charges nothing. Every use is an ordinary verification: the balance is checked against the estimate at open and the steps that finish are charged, each with its tool’s standalone charge. Since the data step goes first, opening charges nothing until someone hands in their data. A sandbox link opens sandbox verifications: mocks, no charge and no 402.

The link is hosted only: the SDK does not open a flk_. Identity.open with a flk_ token mounts nothing and answers onError({ code: "invalid_token" }) (SDK 0.6.0). To run the flow inside your page, open one session per person with POST /app/flows/:id/sessions and embed its flw_.

Over MCP: createFlowLink, listFlowLinks, updateFlowLink and listFlowSessions with link_id.

Representatives (KYB)

A KYB verifies the company and its people. The representatives step — only in a kyb flow — opens ONE child KYC run per person with a published kyc flow of your own organization. There are four roles: representante_legal, apoderado, socio and ubo.

flow step
{
  "key": "representantes",
  "tool": "representatives",
  "required": true,
  "params": {
    "roles": ["representante_legal", "apoderado", "socio", "ubo"],
    "min": 1,
    "max": 5,
    "child_flow_id": "b93f5c27-8e14-4a02-9d6b-71c5e0a8f34d",   // a published kyc flow of YOURS
    "delivery": "email"                                        // email | links
  },
  "on_fail": "decline",
  "on_review": "review"
}

# On publish the KYB estimate turns into a "from": the step line is worth
# min × one run of the child flow, and the flow publishes estimate_is_minimum: true.

Publishing validates it: child_flow_id has to exist, be yours, be kyc and be published (bad_child_flow), that child may not carry a representatives step of its own (nested_children, one single level) and min may not end up above max (bad_people_range). The step covers no dossier component: whatever each person collects is filed in THAT person’s own dossier.

The hosted journey

The KYB run reaches the step and sits in awaiting_user. The hosted page asks for the list — name, email and role of each person, between min and max — and delivers it. With delivery: "email" Singula mails the link to each address, with the same sender and the same templates as the email code, and the subject is "<Company> asks you to verify your identity"; with delivery: "links" it hands them back for you to distribute. The progress of each person shows right there, without leaving the run.

terminal
curl -X POST https://api.singula.mx/app/flows/sessions/2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7/steps/representantes/representatives \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "people": [
      { "name": "MARIA LOPEZ GARCIA", "email": "[email protected]", "role": "representante_legal" },
      { "name": "ANA RUIZ",           "email": "[email protected]",   "role": "socio" }
    ]
  }'

# The same delivery from the hosted page, with no key and the token in the path:
#   POST /app/flow-embed/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17/steps/representantes/representatives
POST …/steps/:key/representatives
{
  "children": [
    {
      "session_id": "a41d6e02-7b58-4c39-9f71-3d8e5c0b7a62",
      "role": "representante_legal",
      "name": "MARIA LOPEZ GARCIA",
      "email": "[email protected]",
      "customer_id": null,
      "status": "not_started",
      "decision": null,
      "url": "https://app.singula.mx/flow/flw_3c8d5a91-72e4-4f06-b1a8-5d90c26e7b43",
      "charged_centavos": 0
    }
  ]
}

# customer_id comes back null: the person record is created by the form step of THEIR own run.
# A second delivery on the same step answers 409 people_already_submitted with these very children.

Each child is an ordinary hosted run, even when the parent runs over the API. It inherits the environment, the expiry, the language, the metadata and who opened it from the parent, is born with no customer_id — its own form step creates the person record — and demands no balance to open: the parent’s estimate already reserved min × one run of the child flow. Its config carries the name and the email the KYB typed, already prefilled. When they ALL close, the parent’s step closes on the worst one: any declined leaves fail; any review, expired or abandoned leaves review; all approved leave pass. The step charges 0 on the parent’s row, because every child charged on its own.

The same step over the API channel

The loop does not change: next dictates the step, you resolve it and you ask again. The only thing particular to this step is that nothing is attached: you deliver the list on the route that comes in call and you wait for the children to close. The children stay hosted — children over the API are a separate phase.

GET /app/flows/sessions/:id/next
# 1 · The step comes out for you to resolve: how "api" and interaction "people", and NO attach.
GET /app/flows/sessions/2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7/next
{
  "done": false,
  "next": {
    "step_key": "representantes",
    "tool": "representatives",
    "kind": "user",
    "interaction": "people",
    "required": true,
    "params": { "roles": ["representante_legal", "socio"], "min": 1, "max": 5, "delivery": "email" },
    "how": "api",
    "call": {
      "method": "POST",
      "path": "/app/flows/sessions/2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7/steps/representantes/representatives"
    }
  }
}

# 2 · You deliver the list. Attaching a folio answers 409 not_attachable_step (how: "people").

# 3 · While the children walk, next is how "wait" with the progress of each one.
GET /app/flows/sessions/2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7/next
{
  "done": false,
  "next": {
    "step_key": "representantes",
    "how": "wait",
    "children": [
      { "session_id": "a41d6e02-7b58-4c39-9f71-3d8e5c0b7a62", "role": "representante_legal",
        "name": "MARIA LOPEZ GARCIA", "status": "approved", "decision": "approved",
        "charged_centavos": 3300 },
      { "session_id": "7d2b8f36-1c40-4e95-83af-b6045e9d1c28", "role": "socio",
        "name": "ANA RUIZ", "status": "in_progress", "decision": null,
        "charged_centavos": 0 }
    ]
  }
}

# 4 · With children still alive, close answers 409 steps_pending. When they ALL close, the step closes on the worst one:
#   any declined → fail · any review, expired or abandoned → review · all approved → pass.
POST /app/flows/sessions/2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7/close

What the webhook says

The flow.session.created and flow.completed events of a child carry parent_session_id and role; the parent’s flow.completed carries children[] with each person’s verdict. A child’s detail carries parent and the parent’s carries steps[].children, and GET /app/flows/sessions?parent= lists the children of a run.

webhook
# The child: the very same envelope, with two more fields in data.
X-Singula-Event: flow.completed
{
  "data": {
    "session_id": "a41d6e02-7b58-4c39-9f71-3d8e5c0b7a62",
    "tool_type": "kyc",
    "status": "success",
    "decision": "approved",
    "parent_session_id": "2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7",
    "role": "representante_legal"
  }
}

# The parent: its children, summarized.
X-Singula-Event: flow.completed
{
  "data": {
    "session_id": "2f7c11a8-5d94-4b60-8e13-6a0c4f9b21d7",
    "tool_type": "kyb",
    "status": "completed",
    "decision": "review",
    "children": [
      { "session_id": "a41d6e02-7b58-4c39-9f71-3d8e5c0b7a62", "role": "representante_legal", "decision": "approved" },
      { "session_id": "7d2b8f36-1c40-4e95-83af-b6045e9d1c28", "role": "socio", "decision": "review" }
    ]
  }
}

# If the parent expires, is cancelled or closes, its live children close with it: reason "parent_closed".

What a KYB with representatives charges

Every child charges its own steps, like any KYC, and every step charges the same as its tool on its own. The whole KYB is the sum of the parent’s steps and its children’s. Flows carry no discounts: what a child charged is already what it owes, so closing the parent credits nothing back for it in discount_credited_centavos. In the sandbox everything is 0.

The company ↔ person relation

The child relates the person to the company when its form step creates or links their record, with the role they were given. The relation is kept, and one person can represent several companies. The standalone KYB dossier resolves the representative’s identity control with it; the one that closes a run reads, since MasterApi 1.114.0, only THAT run’s children: someone related through another run, on any date, does not answer for this one.

Since MasterApi 1.114.0 the representative is bound to the power of attorney. Every signer — legal representative or attorney — whose identity was approved has to be one of the attorneys the power of attorney or the deed names, by CURP or by name with both surnames; when they are not, representative_identity_verified comes back false and the dossier goes to review. A signer’s declined KYC is false too: the run is not approved even if the step’s on_fail lets it continue. The control is still not critical by default; with kyb.checks.representative_identity_verified.critical: true, it declines.

/customer/:id/relations
# The people of a company, with their role and their verified identity.
GET /customer/:id/relations
{
  "data": {
    "people": [
      { "relation_id": "9a1c…", "customer_id": "6622f8a1c1d2e3f4a5b6c7d8",
        "name": "MARIA LOPEZ GARCIA", "role": "representante_legal",
        "source": "flow", "flow_session_id": "a41d6e02-7b58-4c39-9f71-3d8e5c0b7a62",
        "created": "2026-09-18T12:10:00.000Z",
        "identity": { "verified": true, "request_id": "66f1…c7", "checked_at": "2026-09-18T12:09:41.000Z" } }
    ]
  }
}

# On an individual, the same route returns data.companies: one person can represent several companies.

# Manual relation, for the companies that did not arrive through a flow:
POST   /customer/:id/relations  { "person_customer_id": "6622…d8", "role": "apoderado" }
DELETE /customer/:id/relations/:relationId

Questionnaire: the data step, your way

The data step asks for the usual customer fields and, on top of them, questions of your own, each with its type. params.fields takes the old names and objects as well. A known field fills the customer; one of your own keeps its answer in the verification, in form_data.

flow step
{
  "key": "datos",
  "tool": "form",
  "required": true,
  "params": {
    "fields": [
      "name",
      "last_name",
      "curp",
      { "name": "ocupacion", "type": "select", "maps_to": null,
        "label": { "es": "Ocupación", "en": "Occupation" },
        "options": [
          { "value": "empleado",    "label": { "es": "Empleado", "en": "Employee" } },
          { "value": "comerciante", "label": { "es": "Comerciante", "en": "Merchant" } },
          { "value": "otro",        "label": { "es": "Otro", "en": "Other" } }
        ] },
      { "name": "es_pep", "type": "boolean", "maps_to": null,
        "label": { "es": "¿Eres persona políticamente expuesta?",
                   "en": "Are you a politically exposed person?" } },
      { "name": "ingreso_mensual", "type": "number", "maps_to": null,
        "required": false, "min": 0, "max": 10000000,
        "label": { "es": "Ingreso mensual", "en": "Monthly income" },
        "help": { "es": "Antes de impuestos.", "en": "Before taxes." } }
    ]
  },
  "rules": [
    { "field": "answers.es_pep", "op": "eq", "value": true, "outcome": "review" }
  ],
  "on_review": "review"
}

# name, last_name and curp are known fields: they fill the customer, with their usual type, and are required.
# The other three are the form’s own (maps_to: null): their answer goes to the verification form_data, never to the customer.
# The rule sends the verification to review when the person answers yes.

A list of old names stays as it was: it stores the same bytes, and each name is a known, required field. An object carries name (^[a-z0-9_]{2,40}$, unique in the step), type, label and help in Spanish and English (up to 80 and 200 characters; without a label, the catalog’s), required (true by default), options, min, max and maps_to. A form asks for up to 40 fields.

Ten types

textFree text. min and max measure the length: up to 500 characters, or 200 when it fills the customer.
numberA number. min and max bound the value.
dateA date that exists, YYYY-MM-DD. birth_date also takes DD/MM/YYYY.
selectOne of its options: 1 to 50, each with a value (^[a-z0-9_-]{1,40}$) and an optional label.
multiselectA list of options values. min and max count how many are picked.
booleantrue or false.
emailA valid email address.
phoneTen digits or international format (+52…).
curpA CURP in RENAPO’s format, 18 characters.
rfcAn RFC of 12 or 13 characters.

Known fields and your own

maps_to decides where the answer goes. With a known field — name, last_name, mothers_last_name, legal_name, curp, rfc, email, phone, birth_date, gender or birth_place — it fills that customer column. Its type is fixed: curp, rfc, email and phone the one of their name, birth_date date, gender a select of H, M or X, birth_place a select of state codes and the names text. One column is filled by one field only.

With maps_to: null the field is your own and its answer goes to form_data. Without maps_to, a known name is that field and any other name is your own. birth_place stores RENAPO’s state code (JC, DF, NE…): when the state’s name arrives, with or without accents, or an alias such as CDMX or Edomex, it is normalized to its code, and only a value that names no state is refused.

GET /app/flows/catalog publishes form_fields: the types, the known fields with their type and options, the patterns and the limits. Publishing names every badly built field with invalid_form_field; nothing is silently corrected.

The server validates every answer

Validation is the same on the hosted page and on the API: required, type, option, range, length and format. An error answers 400 invalid_form with problems[], all of them at once. Each problem carries field, code and message, and the codes are required, bad_type, bad_format, bad_option, out_of_range and too_long. Each answer of your own holds up to 500 characters and all of them together up to 16 KB. Keys the step does not ask for are ignored.

POST /app/flows/sessions/:id/steps/:key/form
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": { "name": "MARIA", "last_name": "LOPEZ", "curp": "LOGM900101",
                   "ocupacion": "comerciante", "ingreso_mensual": -5 } }'

# 400 Bad Request
{
  "statusCode": 400,
  "error": "Bad Request",
  "code": "invalid_form",
  "message": "Hay datos del formulario que corregir.",
  "problems": [
    { "field": "curp", "code": "bad_format", "message": "no es una CURP válida (18 caracteres)" },
    { "field": "es_pep", "code": "required", "message": "este dato es obligatorio" },
    { "field": "ingreso_mensual", "code": "out_of_range", "message": "tiene que estar entre 0 y 10000000" }
  ]
}

Where the answers land

Known fields write the customer row, as always. Your own go to the verification form_data and show up in the detail, the API, the MCP and the dashboard. They never reach the customer or the webhook: they are personal data. The questionnaire charges nothing and does not move the estimate.

GET /app/flows/sessions/:id
GET https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4
{
  "status": "review",
  "decision": "review",
  "steps": [
    { "key": "datos", "tool": "form", "status": "done", "request_id": null,
      "outcome": "review", "reason": "rule:form_submitted", "action": "review",
      "charged_centavos": 0 }
  ],
  "form_data": {
    "ocupacion": "comerciante",
    "es_pep": true,
    "ingreso_mensual": 42000
  }
}

# The rule on answers.es_pep forced review and the step on_review held it: the verification closes in review.
# form_data shows up in the detail, the API and the MCP. Never on the customer, never in the webhook: it is personal data.

An answer decides

The data step rules read answers.<field>. A rule that forces review or fail follows the step on_review or on_fail, and the step comes out with reason: "rule:form_submitted". Publishing refuses a data-step rule that reads anything else (invalid_rule).

Branches read the questionnaire too. A when with source: "form" over a field of your own reads form_data, and over a known one, the customer row. If the branch cannot be resolved, the step runs.

flow step
{
  "key": "judicial",
  "tool": "getJudicialRecordsOfPerson",
  "required": true,
  "on_fail": "review",
  "when": [
    { "source": "form", "key": "ingreso_mensual", "op": "gt", "value": 50000 }
  ]
}

# A field of the form’s own is read from form_data; a known one, from the customer row.
# A multiselect is only compared with in, not_in (by intersection), exists or missing.

No customer row answers a field of your own. Over a customer you already have, a required field of your own keeps the data step open for the person. Over the API channel, a form with fields of its own is not born resolved: next dictates it with interaction: "form" and input_contract, and it is answered at POST /app/flows/sessions/:id/steps/:key/form.

Branches: steps that only run if…

A step can run only when something that already happened in the same run holds. The condition lives in when, inside the step, and it is optional: a flow with no when runs exactly as it did before.

flow step
{
  "key": "judicial",
  "tool": "getJudicialRecordsOfPerson",
  "required": true,
  "on_fail": "review",
  "when": [
    { "source": "step", "key": "listas", "field": "risk_summary.level",
      "op": "in", "value": ["medium", "high"] },
    { "source": "customer", "key": "birth_country", "op": "eq", "value": "MX" }
  ]
}

# Both conditions hold or the step does not run: conditions are ANDed, and the OR is written with in.
# field exists only with source "step": outcome (the default), action or a path inside the answer.
# Operators: eq, ne, in, not_in, gt, gte, lt, lte, exists and missing.

The branch is evaluated when the step is about to start, against what the run already resolved. A missing value only matches missing, so ne and not_in never hold over a field that never arrived — the same guard the rules use.

Where the value comes from

source: "step"

An EARLIER step of the same run: its outcome, its action or a path inside the answer its tool wrote. A skipped step leaves no answer, so its paths read empty. On an identity step, verification.document_type says which document it read: INE, PASSPORT or RESIDENT_CARD (since MasterApi 1.130.0).

source: "form"

A field of that flow’s form. Only the ones THAT data step asks for, its own questions included.

source: "customer"

The customer row of the run: kind, CURP, RFC, country of birth, risk, email, phone.

On a known field, form and customer read the SAME row: the data step turns what the person typed into the run’s customer, and there is no other copy. A field of the questionnaire’s own does not live on the customer: it is read from form_data. GET /app/flows/catalog publishes everything a branch may read in conditions, and it is the SAME list publishing refuses on.

GET /app/flows/catalog
GET /app/flows/catalog
{
  "conditions": {
    "sources": ["step", "form", "customer"],
    "operators": ["eq", "ne", "in", "not_in", "gt", "gte", "lt", "lte", "exists", "missing"],
    "step": [
      { "tool": "checkBlacklist", "fields": [
        { "field": "outcome", "type": "enum", "options": ["pass", "fail", "review", "error"] },
        { "field": "action",  "type": "enum", "options": ["continue", "review", "decline"] },
        { "field": "risk_summary.level", "type": "enum",
          "options": ["clear", "low", "informational", "medium", "high"] }
      ] }
    ],
    "form": ["name", "last_name", "mothers_last_name", "legal_name", "curp", "rfc",
             "email", "phone", "birth_date", "gender", "birth_place"],
    "customer": [
      { "field": "type", "type": "enum", "options": ["PHYSICAL", "MORAL"] },
      { "field": "birth_country", "type": "string" },
      { "field": "risk", "type": "enum", "options": ["LOW", "MEDIUM", "HIGH"] }
    ]
  }
}

# Every field arrives with its type, its possible values and its label for the editor.
# form lists the known fields; a branch reads any field THAT flow asks for, by its name, the questionnaire’s own included.

A step that did not run

A step whose branch does not open is status: "skipped", outcome: "pass", reason: "condition_not_met", action: "continue" and charges 0. The verdict ignores it, like an optional step nobody did, and it comes out that way in the run detail, in status/:token and in next. The condition BEATS required: a required step whose branch does not open is skipped all the same, because a step this person’s answers took out of the flow cannot be demanded.

GET /app/flows/sessions/:id
GET /app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4
{
  "steps": [
    { "key": "listas", "tool": "checkBlacklist", "status": "done",
      "request_id": "66f1...b1", "outcome": "pass", "reason": "blacklist_low",
      "action": "continue", "charged_centavos": 1000 },
    { "key": "judicial", "tool": "getJudicialRecordsOfPerson", "status": "skipped",
      "request_id": null, "outcome": "pass", "reason": "condition_not_met",
      "action": "continue", "charged_centavos": 0,
      "completed_at": "2026-09-19T12:04:12.000Z" }
  ]
}

# The skipped step leaves no folio, charges nothing and the verdict ignores it.

If the branch cannot be resolved, the step RUNS. A read that failed takes a verification away from nobody, and the dossier still judges at the close.

What publishing validates

Publishing checks every branch and names the step that carries it: 400 invalid_flow with code: "invalid_condition" when it points at a LATER or missing step, at a field the form never asks for, or at a response field the tool does not publish; also when the source or the operator do not exist, when the operator needs a value and arrives without one, or when an in or a not_in arrives with an empty list. Since MasterApi 1.114.0 it also checks that the branch can open: a value the field never holds (verification.status eq approved, when its values are verified, partial, rejected and failed) and an operator its type does not take (gt, gte, lt and lte only order numbers: verification.status gt 0 never opened) are invalid_condition too, and the message says what can be written instead. Before, those branches published and the step was skipped free of charge on every run. Nothing reads the future and nothing is silently corrected.

With branches the estimate is the WORST case: every branch is priced, the answer says so with estimate_is_maximum: true and lines[].is_conditional, and the charge reads as an «up to». Balance is demanded against that worst case when the session opens (402), so a run that takes every branch is never left halfway. A branch with no charge — the data step — does not turn the total into an «up to».

The editor draws them

The flow editor is a canvas: each step is the screen the person will see — the real one, in miniature — in the order they walk it, and the verdict closes the line with its three exits. A step with when drops to its own lane and earns the dashed path of the jump the run takes when the condition does not hold. Positions are stored in settings.canvas and frozen with the version, like the appearance; a flow with no canvas lays itself out.

Automatic mid-run cross-checks

Everything is verified by default. The cross-checks between what a step collects and what you already know about the person run on their own, with nothing to configure: whoever builds their first flow turns nothing on to get them. Relaxing one is the advanced decision, and it lives in your organization’s verification policy (Settings › Verification).

One brain: what the dossier crosses at the close is what can cut the run in half. Today the signal comes from the INE step, which already checks the CURP it read off the credential against RENAPO (cross_ref_curp_document_vs_renapo), and the control that decides what the run does with that disagreement is kyc.checks.ine_curp_vs_renapo. Only the CURP cuts: the name is judged at the close, in the dossier (kyc.checks.ine_name_vs_renapo), with your policy’s threshold and tolerant of a surname the reading missed. The identity verification’s cross-checks do not cut their step either: the dossier reads them at the close.

Enabled and critical
the default

The step fails right there: outcome "fail" and reason "cross_ref_ine_curp_vs_renapo". From there the step’s on_fail decides, and by default it declines.

Enabled, not critical
enabled, critical: false

The step does not move. The dossier reports the disagreement at the close, in failed.

Off
enabled: false

Nothing happens, neither mid-run nor at the close.

step of the run
{
  "key": "ine", "tool": "ineExtraction", "status": "done",
  "request_id": "66f1...d3",
  "outcome": "fail",
  "reason": "cross_ref_ine_curp_vs_renapo",
  "action": "decline",
  "charged_centavos": 1500,
  "completed_at": "2026-09-19T12:03:48.000Z"
}

# The CURP read off the credential is not the one RENAPO holds for that person.
# The control is kyc.checks.ine_curp_vs_renapo, enabled and critical by default; from there the step on_fail decides.

The cross-check only hardens a reading that was going to come out pass or review. A withdrawn credential fails earlier and with its own reason (ine_dada_de_baja), and a service failure stays error: «this credential is dead» and «the registry never answered» are different facts from «the reading did not match». Only a row that really says yes or no counts; a malformed row is not a disagreement.

The dossier still crosses EVERYTHING at the close. Mid-run only the INE’s CURP against RENAPO cuts, and only while your organization keeps it enabled and critical; every other critical control decides at the close, in expediente.failed and expediente.missing.

In the sandbox the test INE and RENAPO describe the same person, so the cross-checks agree and no step cuts.

Per-flow appearance and preview

Each flow can look different. settings.ui holds its appearance — palette, logo, layout, document types and the copy of every screen in Spanish and English — and it is published with the version, exactly like the steps: an open run finishes with the appearance it started with. A flow with no settings.ui looks exactly as it does today, with the organization’s branding.

PUT /app/flows/:id
PUT https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91
{
  "settings": {
    "ui": {
      "theme": {
        "primary": "#FF6600",       // required, #RRGGBB
        "secondary": "#111827",
        "background": "#FFFFFF",
        "text": "#0F172A",
        "radius": "md",             // sm | md | lg  → 8 | 12 | 16 px
        "font": "sans"              // sans | serif | mono
      },
      "logo_url": "https://acme.mx/logo.svg",  // https, ≤ 2 KB, null = the organization's
      "layout": "card",             // card | full
      "document_types": ["ine", "passport"],
      "texts": {
        "welcome": {
          "es": { "title": "Verifica tu identidad", "body": "Toma 2 minutos.", "cta": "Empezar" },
          "en": { "title": "Verify your identity",  "body": "It takes 2 minutes.", "cta": "Start" }
        },
        "result_review": {
          "es": { "title": "Estamos revisando tus datos", "body": "Te avisamos por correo." }
        }
      }
    }
  }
}

# Screens that accept copy: welcome, form, doc_selection, doc_capture, liveness_intro,
# liveness, verifying, upload, otp, result_approved, result_review, result_declined, result_done.

It is validated on save: colors only as #RRGGBB, every text clamped to 200 characters, the logo over https and under 2 KB, and keys that do not exist are dropped instead of stored. layout: "card" is a centered card and "full" takes the whole screen. document_types is the default for the identity step, for when the step does not fix it; if the platform stops offering the resident card, a saved step that offered it opens its capture with INE + passport, and one that asked for the card alone answers 400 document_type_not_available. logo_url set to null falls back to the organization’s logo.

You edit it while looking at it

In the dashboard, the flow editor has an «Apariencia» tab with the color pickers, the logo, the layout, the radius, the font, the document types and the copy per screen and locale. Next to it, the preview paints every screen of the journey — welcome, form, document choice, front and back capture, liveness, verifying, document upload, email code and the result — with sample data, in a phone frame and with the real animations. It never asks for the camera and never opens a session: it is the same runner running in preview mode over your draft.

Who wins: three layers

Each layer overrides only the fields it carries, so nothing is all-or-nothing.

1 · Organization
Settings › Branding

The base: logo, color, name and language for the whole organization.

2 · Flow
settings.ui

That flow’s appearance, frozen into the published version. Overrides the organization.

3 · SDK
Identity.open({ theme })

That session only, in the browser only. Overrides both of the above.

The SDK’s theme is the narrow slice — colors, logo and brand name — and it is the only layer that can differ between two runs of the same flow: reach for it when one integration serves several brands. Layout, per-screen copy and document types are published with the flow, never sent per call.

Your brand, not ours

A company that already has its own header, its own title and its own progress bar does not need ours. settings.ui.chrome switches off the seven pieces of our page, one by one, and layout: "bare" also drops the frame and the background: what is left is the capture, inside your own screen, with no two of anything. All seven flags ship true: a flow that never touched them looks exactly as it did before.

PUT /app/flows/:id
PUT https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91
{
  "settings": {
    "ui": {
      "layout": "bare",             // card | full | bare  (bare = no frame, no background)
      "chrome": {
        "header": false,            // logo + name          (default true)
        "brand_name": false,        // the name beside the logo (default true)
        "progress": false,          // step bar / rail      (default true)
        "titles": false,            // screen titles        (default true)
        "bodies": false,            // supporting copy          (default true)
        "legal": false,             // legal footer / "Secured by" (default true)
        "background": "transparent" // canvas | transparent      (default canvas)
      }
    }
  }
}

It is stored and validated like the rest of ui: only the flags you sent that are booleans survive, only a background from the catalog (canvas or transparent), and everything else is dropped. On publish, a value that is neither true nor false is named with invalid_ui, exactly like an invalid color. It freezes with the version: changing the chrome never repaints an open run.

What the runner sees

The public embed config returns the chrome COMPLETE: all seven keys, always, with the default where the flow said nothing. Your front — or ours — asks for chrome.header and gets back a boolean, never “the key was not there”. The branding block did not move.

GET /app/flow-embed/config/:token
GET https://api.singula.mx/app/flow-embed/config/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17

{
  "flow": { "kind": "kyc", "version": 1 },
  "ui": {
    "layout": "bare",
    "chrome": {
      "header": false, "brand_name": false, "progress": false,
      "titles": false, "bodies": false, "legal": false,
      "background": "transparent"
    }
  },
  "branding": { "app_name": "Acme", "logo_url": "https://acme.mx/logo.svg" }
}

# All SEVEN keys, always, with the default where the flow said nothing.
# The runner asks for chrome.header and gets a boolean, never "the key was absent".
# branding did not move: the runner deployed today still reads it the same.

With everything off and layout: "bare", the embedded capture is the camera, its instructions and its button, over your own background: no header of ours, no name of ours, no step bar of ours, none of our titles or supporting copy and no legal footer. What shows is your product. This is appearance and it touches neither the charge nor the verdict.

Prerequisites: one step leaves the value for the next

Half of what a step needs was already read by another one two steps earlier. The catalog says so: every tool publishes provides beside needs, and only what the platform actually READS out of that response shows up there.

GET /app/flows/catalog
GET https://api.singula.mx/app/flows/catalog

{
  "items": [
    { "tool": "ineExtraction", "kind": "both",
      "needs": [],
      "provides": ["curp", "name", "birth_date"] },
    { "tool": "validateCurp",  "kind": "kyc",
      "needs": ["curp"],
      "provides": ["name", "birth_date"] },
    { "tool": "verifyEmail",   "kind": "both",
      "needs": ["email"],
      "provides": ["email"] }
  ]
}

# Only what the platform actually READS out of that response.
# identityVerification declares nothing on purpose: a passport may carry no CURP.

There are three and no more, on purpose: the INE leaves CURP, name and date of birth; the validated CURP leaves name and date of birth; the verified email leaves the email. identityVerification declares nothing because a passport may carry no CURP — a foreign one does not print it — and a session that never reached the document carries no block: promising it would let you publish a flow that structurally cannot keep it.

Publishing demands it

A need is satisfied if the form asks for it or if an EARLIER step produces it: an INE above the CURP unlocks it, the same INE below does not, and a step never satisfies itself. If the flow HAS a form step it is promising “send this link to a stranger and it works”, so a gap is refused at publish time.

POST /app/flows/:id/publish
POST https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/publish

400 {
  "code": "invalid_flow",
  "problems": [
    { "step_key": "curp", "code": "unmet_need",
      "need": "curp",
      "providers": ["ineExtraction"] }
  ]
}

# The flow reads the CURP in a step, the form never asks for it and no EARLIER step produces it.
# Move the INE above the CURP step and publish: the order is everything.
# Only a REQUIRED step with no branch counts as a provider: an optional one gets skipped and a conditional one may never open.
# GET /app/flows/:id reports the same thing in problems, beside the step.

A flow with NO form step is left alone: it can only be opened over a customer_id and its values come out of that row. The nine templates publish without touching them.

In the run, the step fills the blanks

When the step finishes, what it produced is written into the customer record — but ONLY into the blanks. A value your organization typed, the person filled in or another step already wrote is never overwritten: that row is what the dossier compares against, and silently replacing a typed CURP would erase the very disagreement the dossier exists to report. A column of nothing but spaces does count as a blank. On a KYB it never fills person data: the INE is the representative’s, and since MasterApi 1.114.0 their CURP, surnames and birth date are no longer written into the company row. And it is never fatal: it runs after the step verdict is written, so neither the charge nor the decision depends on a write that is a convenience.

form → INE → CURP
# form → INE → CURP, without asking for the CURP in the form
{
  "steps": [
    { "key": "datos", "tool": "form",
      "params": { "fields": ["name", "last_name", "email"] } },
    { "key": "ine",   "tool": "ineExtraction" },
    { "key": "curp",  "tool": "validateCurp" }
  ]
}

GET https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91
{ "requires_customer": false, "missing_needs": ["curp"], "problems": [] }

# requires_customer counts the steps that PRODUCE the value, so this run opens with no customer_id.
# missing_needs did not change: it still answers "what the form does not ask for".

That is the effect you feel: requires_customer now counts the steps that produce the value, so a form → INE → CURP flow opens with no customer_id. A step skipped by its branch never even reads the row.

Two refinements that change what publishes. Only a REQUIRED step with no branch satisfies a need: an optional one is skipped by whoever closes over the API or by the person, and a conditional one may never open, so counting them let form → INE(optional) → CURP publish and the run where the INE did not pass reached RENAPO with an empty row. And there is a belt in the run: a server step whose need is still blank when it starts is skipped with reason: "need_missing", pass, continue and charge 0 instead of blowing up. It fails open: if the row cannot be read, the step runs.

Run a KYC on a customer you already have

A customer whose CURP you validated two weeks ago has no reason to pay for it again. Open the run with reuse_evidence: true and the steps whose terminal check already exists are born done, with that folio, reused: true and charge 0. It demands customer_id: with no row there is nobody to look for evidence on (400 customer_required). It works the same on the hosted channel and on the API channel.

terminal
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 '{ "customer_id": "c7b1e940-52af-4d38-a6c0-8e35f1d0742b", "reuse_evidence": true }'
POST /app/flows/:id/sessions
{
  "session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
  "token": "flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17",
  "url": "https://app.singula.mx/flow/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17",
  "flow_version": 1,
  "estimate_centavos": 1800,
  "reused": [
    { "step_key": "curp",   "request_id": "66f1a2b3c4d5e6f708192a3b",
      "request_date": "2026-09-02T18:11:07.000Z" },
    { "step_key": "listas", "request_id": "66f0c1d2e3f405162738a9bc",
      "request_date": "2026-09-11T09:40:22.000Z" }
  ],
  "missing": [
    { "step_key": "identidad", "tool": "identityVerification", "how": "person" }
  ]
}

Reuse is resolved BEFORE the estimate, so the balance the opening holds — and the estimate_centavos it stores — is the cost of what is LEFT to run: a reused line is quoted at 0 and the 402 stops rejecting runs over money that was never going to be spent.

The response tells you what was taken and what is left. reused[] carries the step, the folio and the date of the check that was reused; missing[] carries the step, its tool and how it gets resolved: how: "auto" is run by the platform itself and how: "person" needs somebody to show up, with the link or with the SDK. That is what tells you whether to send the link or simply let the run go.

What a step reuses is evidence you paid for. The CURP validation an identity verification or an INE ran internally, at no charge (bundled_with on the record), never fills a CURP step, neither by reuse nor attached: that step runs and is charged as always.

A run can be born declined

The verdict of a reused step comes out of its own record, with the on_fail that step carries in the published version. If that verdict declines, the opening closes the run right there: no step starts, nothing else is charged and it answers next: null. Dictating a step the close would not accept would be sending you to pay for a tool for nothing.

POST /app/flows/:id/sessions
POST https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91/sessions
  -d '{ "customer_id": "c7b1e940-52af-4d38-a6c0-8e35f1d0742b", "reuse_evidence": true }'

{
  "session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
  "estimate_centavos": 0,
  "reused": [
    { "step_key": "listas", "request_id": "66f1a2b3c4d5e6f708192a3b",
      "request_date": "2026-09-11T09:40:22.000Z" }
  ],
  "missing": [],
  "next": null
}

# The reused step carries the on_fail of ITS record, so the opening can end declined.
# The run closes right there: no step starts, nothing is charged and next comes back null.

What counts as fresh

The window is settings.evidence_max_age_hours, 1 to 8760 hours and 720 (30 days) by default. It freezes with the version, like all of settings, and it is only stored if you chose one. A check is reused when it is terminal and belongs to the same customer, the same environment, the same tool and — on the generic document step — the same document kind: without that, a KYB with an incorporation deed and a proof of address would take the two newest files in whatever order they came. A check is spent once, and a check with no date is never fresh.

PUT /app/flows/:id
PUT https://api.singula.mx/app/flows/7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91
{ "settings": { "evidence_max_age_hours": 168 } }   // 1..8760, default 720 = 30 days

Two things a reused step does NOT do. The record is not re-stamped: the folio keeps the run that actually collected it. And it credits nothing: it was paid in its own call, weeks earlier, on a row this run knows nothing about, with the same charge it would carry as a step. What it does do, and this is the point: its verdict comes out of the record under your organization verification policy, so the automatic cross-checks cut the same over a reused INE as over one just uploaded. Reusing is not a back door around the controls.

What is this customer missing?

You can ask before opening anything. Both standalone dossiers — the KYC one and the KYB one — accept ?flow_id= and answer the coverage of THAT flow with the very rules of reuse: what would be reused, what is missing and what comes next. With flow_id it crosses nothing, writes no record and charges nothing. The window it uses is the PUBLISHED version’s, not the draft you happen to be editing, because that is the one the run will use. A flow_id that is not a published flow, of your organization and of that kind, is a 404 flow_not_found — and so is a customer of another organization: coverage is resolved with the tenant, never with the environment alone.

POST /app/kyc-expediente/customer/:id?flow_id=
POST https://api.singula.mx/app/kyc-expediente/customer/c7b1e940-52af-4d38-a6c0-8e35f1d0742b?flow_id=7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91

{
  "flow_id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
  "coverage": {
    "reused":  [{ "step_key": "curp", "request_id": "66f1a2b3c4d5e6f708192a3b" }],
    "missing": [{ "step_key": "identidad", "tool": "identityVerification", "how": "person" }]
  },
  "next": "open_session"
}

# With flow_id it crosses nothing, writes no record and charges nothing: it only answers what is missing.
# next: "run" when everything left is run by the platform itself; "open_session" when somebody has to show up.

And if you have several KYCs, pick which one: with no flow_id the dossier is still the dossier, plus flows[] with your organization published ones and missing_required[] with the how of each component. If the flow listing breaks, the dossier still answers.

POST /app/kyc-expediente/customer/:id
POST https://api.singula.mx/app/kyc-expediente/customer/c7b1e940-52af-4d38-a6c0-8e35f1d0742b

{
  "overall": "review",
  "missing_required": [
    { "component": "identity", "how": "person" }
  ],
  "flows": [
    { "id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91", "name": "Alta de persona", "kind": "kyc" },
    { "id": "e1d4c7b0-2a93-4f58-8c16-7b0d5e93a2f4", "name": "Alta exprés", "kind": "kyc" }
  ]
}

# With no flow_id it is the dossier as always, plus your organization published flows: if you have several KYCs, pick which one.

5. Listen for the webhook

Opening the session fires flow.session.created; closing it fires flow.completed — whether it approves, declines, lands in review or expires. When a reviewer requests a resubmission, flow.resubmitted goes out too, and closing again fires another flow.completed (see “Repeating a step”, below). All of them ride the envelope signed with HMAC-SHA256 in X-Singula-Signature. flow.session.created also carries flow_id, flow_version and link_id: the shareable link that opened the verification, or null. flow.completed goes out when the run closes, and two closes wait for something after the last step: a production KYC with identity (INE) waits for that session’s Lista Nominal, and a KYB waits a few seconds for RENAPO on its representative’s INE (see “6. Read the run”).

webhook
POST https://tu-servidor.com/webhooks/singula
X-Singula-Signature: t=1758000251,v1=<hmac-sha256("<t>.<raw body>", secret)>
X-Singula-Event: flow.completed
X-Singula-Delivery-Id: <delivery id>

{
  "id": "6b1f…",                     // delivery id
  "type": "flow.completed",
  "created": 1758000251,             // unix, in seconds
  "data": {
    "request_id": "66f1a2b3c4d5e6f708192a3b",
    "tool_type": "kyc",              // kyc | kyb
    "status": "completed",           // success | completed | cancelled
    "session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
    "decision": "review",            // approved | review | declined | null
    "expediente_overall": "review"   // pass | review | fail | null
  }
}

# The envelope is thin, like every webhook: it carries the ids and the verdict, not the detail.
# expediente_overall is the verdict of the dossier the run closed with (null when there was none: a mid-run decline, an expiry or a cancellation). The run detail carries the dossier summary; the full cross-check is read with its request_id at GET /dashboard/request/:id.
# status summarizes what happened to the RUN, not the verdict (that travels in decision): success = it closed with a verdict (approved or declined); completed = it landed in review, or the run did charge; cancelled = it closed without charging anything (it expired or was abandoned before the first billable step).
# Read the detail with the session_id, NOT with the request_id:
#   GET /app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4

6. Read the run

The session detail carries the verdict and the list of steps, each with its outcome (pass | fail | review | error), the action its rule took, the folio of the check it ran and what it added to the charge. status describes the session; decision is the only thing to act on, and it is null while the run is open — and on the ones that close without a verdict, expired and abandoned.

A step charges under the same rule as its tool on its own (MasterApi 1.113.0): what was delivered, its charge; when it fails after a paid call — a document or an INE already read, also when the Lista Nominal is under maintenance; an identity whose photo was already reviewed; an e.firma or an investigation that already queried —, its base charge, even when the failure is ours; when it fails before any paid call, or no source replied, 0. A CURP, RFC, phone, list, court-records or email step that fails charges 0. Repeating a capture on the page is free.

charged_centavos is what the run’s row charged: its steps and, since MasterApi 1.113.0, the files of the «Upload your documents» screen that had already been read and no step used. Each one comes in unused_uploads[] — { request_id, file_name, document_type, pages, reason: removed (the person removed it) | unused (left over at the close) | failed (it could not be read), charged_centavos } —: one already read is charged once, at its standalone charge; one never read, 0. Its request_id answers 404 to your organization and the file shows up in neither the activity, the customer nor the dossier. On the API channel the list is always [].

expediente is the close: { request_id, overall (pass | review | fail), failed[], missing[] }. It is built from the request_ids of THAT run — never from the customer’s history — and costs 0. It is the summary: its request_id opens the full cross-check, control by control, at GET /dashboard/request/:id, like any other check. failed are the controls that contradict each other; a control that could not be evaluated accuses nobody, and shows up in missing as evidence that is absent. It arrives null while the run is open and on runs that closed without it (a mid-run decline, an expiry, a cancellation); when it could not be built, request_id is null and failed carries a single code: expediente_error, no_customer or expediente_no_evidence.

In a KYC with no INE step, the three INE controls — ine_name_vs_renapo, ine_curp_vs_renapo and ine_vigente — read the identity session: its document against the RENAPO record and the Lista Nominal check it ran itself. Since MasterApi 1.112.0, a run whose identity contradicts RENAPO (another CURP, or a really different name), or whose INE has expired or is not current in the Lista Nominal, closes declined: those controls are critical by default. If you prefer review, set them to critical: false in the verification policy. Since MasterApi 1.128.0, when the identity read a passport — and since 1.130.0 a resident card —, identity_doc_vigente checks it has not expired — it is critical: an expired one closes declined; a permanent card does not expire — and identity_doc_dob_vs_renapo compares its date of birth with RENAPO’s — it is not critical: a mismatch goes to review —; with an INE both come back null and hold nothing. A control whose evidence the run never collected — a foreign passport’s CURP, a Lista Nominal that never arrived — comes back null and does not hold the verdict, unless your policy requires that evidence. Since MasterApi 1.130.0, when the identity read a passport issued outside Mexico that prints no CURP and whose holder is not a Mexican national, the curp component comes back in excused rather than missing — a foreigner has no CURP — and holds nothing; a Mexican passport and a resident card do carry a CURP and still ask for it.

For the same reason, a production KYC whose identity read an INE (and with no INE step) waits for that Lista Nominal before closing: up to 10 minutes from the session’s verdict. The person sees their step done all the same; flow.completed goes out when it lands — since MasterApi 1.114.0, on the page’s next poll, not a minute or two later — and past the ceiling the run closes with ine_vigente null. The Lista Nominal is not waited for in sandbox, in a KYB or with a passport.

On a KYB, since MasterApi 1.114.0, the close crosses the representative’s INE against RENAPO with the CURP the card prints — the company has no CURP —: 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. ine_renapo_match compares the CURP, the date of birth, the sex and the name. When they match, a coherent KYB approves on its own; when they contradict, it declines (it is critical); when RENAPO does not have that CURP or the printed CURP is malformed, it goes to review; and when the lookup failed or the INE carries no readable CURP, it stays null and holds: a KYB never approves without crossing the person against RENAPO. Since MasterApi 1.130.0, with no INE read and the ine component required, the representative’s identity session — INE, passport or resident card, with liveness — is their ID (ine_source: identity_session) only when it is VERIFIED and belongs to the person the power of attorney or the deed names: one in review, declined or clearly someone else’s leaves ine missing, and the dossier goes to review. Then the cross-check uses the CURP it prints, included, the close waits for its Lista Nominal when it read an INE, a passport’s or a card’s validity takes the Lista Nominal’s place, and its image-quality advisory at false sends to review, never fails. A passport issued outside Mexico that prints no CURP, of a holder who is not a Mexican national, leaves ine_renapo_match null without holding.

A KYB’s person names — the attorney against the INE and against the deed, the signer against the power of attorney — are judged at the KYC bar: kyc.name_match_threshold (0.8 by default; 0.6 before) and, when one side splits the surnames, the other has to carry both. One that reaches the threshold without a surname goes to review. And a deed with no RFC — the normal case — no longer holds the approval when its name is IDENTICAL to the tax certificate’s legal name, leaving aside the legal form at the end («GRUPO SINGULA, S.A. DE C.V.» = «GRUPO SINGULA»); a merely similar one still holds, because two companies of one group are told apart only by their RFC.

Since MasterApi 1.114.0 every dossier control, KYC or KYB, carries holds_approval: true when THAT control is what keeps overall from pass, under the same rule that decides overall. It is true on every control in false — a critical one declines, any other sends to review — and on a null that holds (the policy requires its evidence and the dossier does not excuse it); it is false on a control in true and on a null that holds nothing. Read it instead of recomputing it. It comes in the detail of the dossier’s request (GET /dashboard/request/:id with expediente’s request_id, in response.checks[]) and in the direct routes POST /app/kyc-expediente/customer/:id and POST /app/kyb/expediente/customer/:id (data.checks[]). A dossier closed before 1.114.0 does not carry it.

Every step also says whether it was born reused. reused is a boolean per step, so a step charged 0 taken from that customer’s history is never confused with one attached over the API channel (attached: true) or with one that was skipped. And a server step skipped because its value was still blank arrives with reason: "need_missing" and charge 0. On an account with no KYB, a person’s judicial step that did not clear the identity gate arrives skipped with reason: "kyb_required", outcome: "review" and charge 0 (see “If your account has no KYB yet”, above).

The verification also carries link_id, the shareable link that opened it (null when it was not a link), and network, where the person connected from. See “Shareable link and QR”, above, and “IP analysis”, below.

The verdict follows this order: a step with a decline rule closes declined right there, with no dossier; expediente.overall of fail closes declined; a step that asked for review, a dossier in review or a run with no dossier all close review; only a dossier in pass with no step asking for review closes approved. Without a dossier there is no approval.

GET /app/flows/sessions/:id
{
  "session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
  "request_id": "66f1a2b3c4d5e6f708192a3b",
  "flow_id": "7c2a91e4-6b03-4f5d-9a1e-2c8d40b7ff91",
  "flow_name": "Alta de persona",
  "flow_version": 1,
  "kind": "kyc",
  "status": "review",
  "decision": "review",
  "decided_by": "rules",
  "reviewer_note": null,
  "steps": [
    { "key": "datos", "tool": "form", "status": "done", "request_id": null,
      "outcome": "pass", "reason": "form_submitted", "action": "continue",
      "charged_centavos": 0, "completed_at": "2026-09-15T12:01:02.000Z" },
    { "key": "identidad", "tool": "identityVerification", "status": "done",
      "request_id": "66f1...9c", "outcome": "pass", "reason": "identity_approved",
      "action": "continue", "charged_centavos": 2000,
      "completed_at": "2026-09-15T12:02:40.000Z" },
    { "key": "curp", "tool": "validateCurp", "status": "done",
      "request_id": "66f1...a4", "outcome": "pass", "reason": "curp_valid",
      "action": "continue", "charged_centavos": 300, "reused": false,
      "completed_at": "2026-09-15T12:03:05.000Z" },
    { "key": "listas", "tool": "checkBlacklist", "status": "done",
      "request_id": "66f1...b1", "outcome": "review", "reason": "blacklist_medium",
      "action": "review", "charged_centavos": 1000,
      "completed_at": "2026-09-15T12:04:10.000Z" }
  ],
  "expediente": {
    "request_id": "66f1...c7",
    "overall": "review",
    "failed": [],
    "missing": []
  },
  "charged_centavos": 3300,
  "unused_uploads": [],
  "estimate_centavos": 3300,
  "checked_at": "2026-09-15T12:04:11.000Z"
}
not_startedThe session exists and the link has not been opened.
in_progressRunning: server steps are under way.
awaiting_userWaiting on a capture from the person.
resubmittedA reviewer asked to repeat steps («Resubmission requested» in the dashboard). It is not a verdict: it turns into awaiting_user or in_progress as soon as the run picks up the first reopened step.
reviewWaiting on your team: a step asked for review, the dossier closed as review, or there was no dossier.
approvedThe dossier closed as pass and no step asked for review. It is the only way to approve.
declinedA step with a decline rule stopped it, or the dossier closed as fail.
expiredPassed its expires_at without finishing.
abandonedCancelled over the API before it finished.

7. Decide by hand what landed in review

A run in review waits for a person. Approve or decline it with a note, from the dashboard or over the API. The run keeps decided_by as "reviewer", stores the note and fires flow.completed again.

terminal
curl -X POST https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/decision \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "decision": "approved", "note": "CURP con apellido compuesto, verificado contra el acta" }'

# Only on a run in review. Afterwards, decided_by turns into "reviewer".

Deciding closes the run as it stands. When what is missing is the person repeating a photo, a document or a value, request a resubmission instead (below).

Repeating a step: resubmission and retry

A verification in review or declined does not have to start over. A reviewer asks the person to repeat only the steps that went wrong, on the SAME link, and when it closes again the dossier judges the new evidence. Repeating a step is not charged again.

terminal
curl -X POST https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4/resubmit \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "steps": ["datos", "listas"],
    "reason": "data_mismatch",
    "note": "El segundo apellido no coincide con la INE; se pide corregirlo.",
    "expires_in_hours": 72
  }'
201 Created
{
  "session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
  "status": "resubmitted",
  "decision": null,
  "decided_by": null,
  "expediente": null,
  "url": "https://app.singula.mx/flow/flw_9f1c0b7a-3d21-4c8e-9a55-0f2b6d4e8a17",
  "expires_at": "2026-09-22T12:00:00.000Z",
  "resubmission_count": 1,
  "steps": [
    { "key": "datos", "tool": "form", "status": "pending", "attempt": 2,
      "reset_at": "2026-09-16T10:00:00.000Z", "retry": null,
      "attempts": [
        { "attempt": 1, "request_id": null, "outcome": "pass",
          "reason": "form_submitted", "charged_centavos": 0,
          "ended_by": "resubmit", "resubmission": 1 }
      ] },
    { "key": "listas", "tool": "checkBlacklist", "status": "pending", "attempt": 2,
      "reset_at": "2026-09-16T10:00:00.000Z", "retry": null,
      "attempts": [
        { "attempt": 1, "request_id": "66f1...b1", "outcome": "review",
          "reason": "blacklist_medium", "action": "review",
          "charged_centavos": 1000, "ended_by": "resubmit", "resubmission": 1 }
      ] }
  ],
  "resubmissions": [
    { "n": 1, "at": "2026-09-16T10:00:00.000Z", "steps": ["datos", "listas"],
      "reason": "data_mismatch",
      "note": "El segundo apellido no coincide con la INE; se pide corregirlo.",
      "previous": { "status": "review", "decision": "review", "decided_by": "rules",
        "reviewer_note": null, "completed_at": "2026-09-15T12:04:11.000Z",
        "expediente": { "request_id": "66f1...c7", "overall": "review",
                        "failed": [], "missing": [] } } }
  ],
  "charged_centavos": 3300
}

# The same url. expires_at never gets shorter: here the original validity already covered the 72 hours.
# charged_centavos never goes down: what each attempt charged stays in its attempts[].
# attempts[] also carries tool_charged_centavos, attached, reused, started_at, completed_at and ended_at.
steps

1 to 20 keys, no repeats. The person’s steps, the data step (the person corrects it) or server steps: those are run again by the platform itself.

reason

document_unreadable, document_expired, wrong_document, selfie_retake, data_mismatch or other. The page translates it for the person and it travels in the webhook.

note

Internal note, 3 to 1000 characters. It shows up in the detail and the MCP; never on the page or in the webhook.

expires_in_hours

Optional, 1 to 720: how many more hours the link lives from now. Validity never gets shorter. Without it, the flow’s.

Who asks, and over what

It is asked over a verification in review or declined: from the dashboard («Request resubmission»), over the API or over the MCP (resubmitFlowSession), with the same permissions as deciding by hand. A run is reopened up to 3 times. A KYB child run is not reopened separately: it is reviewed from the company’s run.

On a declined run, the step that declined it has to be in steps: without it, the run is declined again the moment it reopens. The representatives step and a step its branch skipped cannot be asked for. A step that never ran, because the run stopped earlier, runs when the run reaches it.

What the person sees

The same link opens again. The page starts with «We need you to repeat a step», with the reason in their language, and only asks for the reopened steps. The run stays resubmitted («Resubmission requested» in the dashboard) until it picks up the first reopened step. It can be cancelled, and it expires like any live run.

Retry on the page

On the hosted page, a bad capture goes back to the person before it settles: «Please try again (2 of 3)». There are up to 3 attempts per round, and the count starts over with every resubmission. On the last attempt the step settles under its rule, as always, and a repeated attempt runs neither on_fail nor on_review.

Retried

identity_failed, ine_low_confidence, ine_missing_fields, ine_bad_field_format, ine_unsupported_model and document_wrong_kind: the document is not the one the step asks for.

Never retried

A face or liveness failure (identity_declined), a negative Lista Nominal answer, a cross_ref_*, a tampered document (document_forged) or a service error.

The API channel does not retry: your screens decide how many times to ask for the photo.

A step pays at most once

The first attempt that bought its tool pays as always; the ones after it pay 0, page retries and resubmissions alike. An attempt that bought nothing — one that failed before any paid call, a document that was never read, an empty answer because no source replied, a reused step or a skipped one — does not count: the next one is the first purchase. The exception is an attempt that failed after a paid call — a document or an INE that was already read, an identity whose photo was already reviewed; since MasterApi 1.113.0 also e.firma and person investigation —: it charges its base, just as on its own, but that does not close the purchase. The next attempt pays only the difference up to its own charge: 0 on a fixed charge; on a document, the next one that is read — a success or another read error — pays its extra pages on top of the base, and the first one read that is not an error closes the purchase. charged_centavos never goes down, because what each attempt charged stays in its attempts[]. Over the API channel, the tool you call again charges when you call it and the close credits it back in full in discount_credited_centavos, except that difference when what paid was an attempt that failed after a paid call. In the sandbox everything is 0.

What the webhook says

Requesting the resubmission fires flow.resubmitted, with the steps, the reason and the round number, and without the reviewer note. Closing again fires another flow.completed, which carries resubmission_count.

webhook
X-Singula-Event: flow.resubmitted
{
  "id": "8c4d…",
  "type": "flow.resubmitted",
  "created": 1758016800,
  "data": {
    "request_id": "66f1a2b3c4d5e6f708192a3b",
    "tool_type": "kyc",
    "status": "pending",
    "session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
    "steps": ["datos", "listas"],
    "reason": "data_mismatch",
    "resubmission": 1
  }
}

X-Singula-Event: flow.completed
{
  "id": "9e2a…",
  "type": "flow.completed",
  "created": 1758020400,
  "data": {
    "request_id": "66f1a2b3c4d5e6f708192a3b",
    "tool_type": "kyc",
    "status": "success",
    "session_id": "5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4",
    "decision": "approved",
    "expediente_overall": "pass",
    "resubmission_count": 1
  }
}

# flow.resubmitted never carries the reviewer note: it is internal.
# resubmission_count only shows up on a run that was reopened; on every other one the envelope does not change.

How the history reads

Every step carries attempt, which attempt it is counting from the first one, and attempts[], the PREVIOUS attempts with their folio, their reading, their charge and ended_by: retry when it was a retry on the page, resubmit when a reviewer reopened it. Also reset_at, when it was reopened, and retry, { code, max } while the person repeats a capture and null otherwise.

The run carries resubmission_count and resubmissions[]: every round with n, at, steps, reason, note and previous, the status, verdict and dossier it had when it was reopened. The previous verdict is not erased: it stays as history.

IP analysis: where the person connected from

Every verification keeps where the person connected from, in network. It is a signal of the run, not a tool: it charges nothing, writes no record of its own and never shows up on your statement.

We keep the trusted IP — the one our server sees, never an X-Forwarded-For the client sends — of the hosted page (on load and on every step), of the link open and of the identity session, which is often the phone that scanned the QR and is merged into the run when that step closes. Up to 10 distinct IPs per run, each with how many times it came in and when it came first and last. Only the person’s browser is evidence: on a channel: "api" run the runner’s public routes record nothing — your server calls identity_start, and your server is not the person — and the network comes from the identity capture their browser does.

country

The IP’s country (ISO 3166-1). On an iCloud Private Relay, the country Apple publishes.

asn · as_org

The IP’s network and who runs it.

is_tor

It is a Tor exit.

is_hosting

It belongs to a cloud, a hosting provider or a known VPN. A Private Relay does NOT count: it is an iPhone running Safari, not a server.

is_relay

It is an iCloud Private Relay egress.

countries · any_*

The run’s summary: the countries without repeats and whether any IP is Tor, hosting or relay.

Every flag is true, false or null. null means «no data» — a private IP, a list that did not load — never «clean». network as a whole in null is a run that recorded no IP at all.

GET /app/flows/sessions/:id
GET https://api.singula.mx/app/flows/sessions/5b8e2d10-4a7c-4e69-b3f0-91d25c6ae8b4
{
  "link_id": "3d9b7e21-5c44-4f0a-9b6e-8a1f2c7d4e90",
  "network": {
    "ips": [
      { "ip": "203.0.113.24", "first_at": "2026-09-23T18:04:00.000Z",
        "last_at": "2026-09-23T18:09:12.000Z", "count": 4,
        "country": "MX", "asn": 8151, "as_org": "Uninet S.A. de C.V.",
        "is_tor": false, "is_hosting": false, "is_relay": false },
      { "ip": "198.51.100.7", "first_at": "2026-09-23T18:05:30.000Z",
        "last_at": "2026-09-23T18:07:02.000Z", "count": 2,
        "country": "US", "asn": 16509, "as_org": "Amazon.com, Inc.",
        "is_tor": false, "is_hosting": true, "is_relay": false }
    ],
    "countries": ["MX", "US"],
    "any_tor": false,
    "any_hosting": true,
    "any_relay": false
  },
  "expediente": {
    "request_id": "66f1...c7",
    "overall": "review",
    "failed": ["ip_country_vs_document", "ip_not_hosting"],
    "missing": []
  }
}

# The full cross-check: GET /dashboard/request/66f1...c7 (the dossier’s request_id), in response.checks[]
{ "code": "ip_country_vs_document", "ok": false, "critical": false, "holds_approval": true,
  "detail": "IP desde US; el documento es de MX" }
{ "code": "ip_not_hosting", "ok": false, "critical": false, "holds_approval": true,
  "detail": "IP de nube, hosting o VPN: 198.51.100.7 (AS16509 Amazon.com, Inc.)" }

It shows up in the verification detail (GET /app/flows/sessions/:id and the list), in GET /app/identity-verification/status/:requestId and …/result/:requestId, and over MCP (getFlowSession, getVerificationStatus). Never in the webhooks or on the person’s page.

Three KYC dossier controls

ip_country_vs_document

Each IP’s country against the country of the identity document or the INE. With a foreign passport, both its country and Mexico are accepted; with a resident card, Mexico and the country of its nationality: only an IP from a third country is flagged.

ip_not_tor

No IP is a Tor exit.

ip_not_hosting

No IP belongs to a cloud, a hosting provider or a known VPN.

By default they are on and not critical: false sends the verification to review, never declines it. Nor do they approve on their own: the network is a signal, not evidence, so with no other control that can be evaluated the dossier stays in review even when the network comes out clean. With no data, the control stays null and does not count — it neither holds the verdict nor accuses anyone — so a run with no IP decides as before. They are switched off in the verification policy, with kyc.checks.ip_* ({ "ip_not_hosting": { "enabled": false } }): a control that is off does not even show up. If your people come in through a corporate VPN or a cloud network, that verification will go to review; switch ip_not_hosting off if it does not serve you. On a KYB the network is kept and shown, with no control.

The same when you upload the identity photos yourself (presign, S3, confirm and submit): POST /app/identity-verification/submit/:token keeps the IP of whoever calls it. From a backend in the cloud, that IP is hosting and the dossier goes to review. Let the capture run on the person’s device — the hosted page or the widget — or switch kyc.checks.ip_not_hosting off (and ip_country_vs_document if your server is outside Mexico).

In the sandbox your real IP is kept with the fixed signals of the test person — Mexico, AS8151, no Tor, no cloud, no relay — so the sandbox dossier does not move even when you test from a VPN.

Country and ASN come from an open IP geolocation database. If you show them on your screens, include the data credits listed at singula.mx/en/credits.

8. Try it without spending

With an sk_test_ key the flow runs end to end: every step answers with its tool’s deterministic mock, the run closes with the same response shape — dossier included — and it consumes no balance. Balance is not required to open the session either. Build the whole integration there before the first real run.

The dashboard in «Sandbox» (test mode) is just as free: it sends X-Singula-Env: sandbox on every call and runs like an sk_test_ key — the same mocks, no charge, on your organization’s sandbox data. In that mode, whatever would start work in production answers 409 sandbox_mode and writes nothing; reading, deciding, closing or cancelling a production verification still works. An API key never reads that header: its environment is the key’s. The sandbox skips the judicial gate too: it runs with or without KYB.

The sandbox describes ONE single person — MARIA LOPEZ GARCIA, CURP LOGM900101MDFPRR02, born 1990-01-01 — across identity, INE, CURP and email; the lists and the judicial search answer with the name actually searched, your customer’s. That is why a test run’s dossier can close as pass: crossing two different people, it never would.

Resubmission and the questionnaire run the same in the sandbox, with no charge. The page retry does not show up there: the test INE comes back vigente before any filter and a test document is never retried. A link created with an sk_test_ key opens sandbox verifications, with no charge and no 402, and its ceiling and expiry are optional; a sandbox run’s network is your real IP with the fixed signals of the test person.

9. Errors you will meet

400 · invalid_flow (on publish)

A duplicated or malformed key, a form step that is not first or appears twice, a tool missing from the catalog or not accepting the kind, or an on_fail / on_review / on_error carrying a value that does not exist. The response brings problems[] with the step and the reason; nothing is ever silently corrected.

400 · invalid_flow · missing_required_component

The same 400, with one problem per component your dossier policy requires and no step collects (identity ⇐ identityVerification, curp ⇐ validateCurp, blacklist ⇐ checkBlacklist; on KYB csf ⇐ verifyCsf, acta and proof of address ⇐ extractDocument, poder ⇐ verifyPoder, ine ⇐ ineExtraction, fiel ⇐ checkFiel, email ⇐ verifyEmail…). It carries step_key: null because the gap belongs to the whole flow, not to one step. GET /app/flows/:id brings the same list in problems, so you see it before publishing.

400 · invalid_flow · bad_child_flow / nested_children / bad_people_range

All three belong to the representatives step. bad_child_flow: the child_flow_id does not exist, belongs to another organization, is archived or unpublished, or is a KYB — it has to be a published kyc flow of yours. nested_children: that child flow carries a representatives step of its own, and there is only one level. bad_people_range: min ended up above max.

400 · invalid_flow · invalid_condition

A branch (when) pointing at a LATER or missing step, at a field that flow’s form never asks for, or at a response field the tool does not publish. Also a source or an operator that do not exist, an operator that needs a value and arrives without one, and an in or a not_in with an empty list. Since MasterApi 1.114.0, also a value the field never holds — verification.status eq approved, when its values are verified, partial, rejected and failed —, checked against the catalog options, a form select’s options or true / false; and a wrong type: gt, gte, lt and lte only on a numeric field, and a numeric field only with digits. Those branches never opened and the step was skipped free of charge on every run. It arrives with the step_key of the step that carries it, the message names the values or operators that do work, and the fields are read from conditions, inside GET /app/flows/catalog.

400 · invalid_flow · form_cannot_create_customer

Since MasterApi 1.114.0. The data step does not ask, as REQUIRED, for the fields the customer is created from: name and last_name on a KYC; legal_name or name on a KYB. Without them, every run opened with no customer — a link, a QR — would stop at its first step. Ask for them as known fields or with required: true. A version already published keeps running as it was frozen.

400 · invalid_flow · unmet_need

A flow WITH a form step that reads something neither the form asks for nor an EARLIER step produces. It arrives with step_key, need and providers[] — the tools that do leave that value — so the way out is usually to move one of them above the step that needs it. A flow with NO form step is not validated this way: that one only opens over a customer_id.

400 · invalid_flow · invalid_form_field / invalid_rule

Both belong to the data step questionnaire. invalid_form_field is a badly built field: name, type, label, options or min / max; a maps_to that is not a known field or has another type; two fields filling the same column; more than 40 fields. One problem arrives per field. invalid_rule is a data-step rule that does not read answers.<a field of that form>, or a multiselect compared with anything but in, not_in, exists or missing.

400 · repeated_tool (on saving the draft) · invalid_flow · repeated_tool (on publish)

The flow repeats a tool. Each tool goes into a flow only once; only the document step (extractDocument), with any document_kind, and a KYB’s representatives step (representatives) repeat. The data step has its own rule. POST /app/flows and PUT /app/flows/:id refuse it on save, with problems[] — one problem per repeated step — and write nothing. The exception is branches that exclude each other (since 1.110.0): the same tool may sit on two steps when a condition of each reads the SAME thing — the outcome or action of one same earlier step, a field the data step asks for or a customer column that does not change mid-run (risk does) — and no value meets both: eq with different values, eq against ne of the same value, eq against an in that lacks it or a not_in that has it, in with no value in common (on a multiselect it proves nothing), in against a not_in that covers it, or gt/gte against lt/lte ranges that do not touch. For example «if foreign → identity with passport» and «if Mexican → identity with INE». Whatever cannot be proven is still refused, and the message says so. The estimate counts every branch (an «up to»); a run charges only the step that ran. A draft saved earlier names it on publish, inside invalid_flow. A version that was already published keeps running as it was frozen.

400 · invalid_form (on answering the data step)

One or more answers do not fit their field. problems[] brings all of them, each with field, code (required | bad_type | bad_format | bad_option | out_of_range | too_long) and message. Fix those and send the whole form again. Keys the step does not ask for are ignored.

403 · tools_locked (on publish)

A step uses a tool your account state has not unlocked yet. Without an approved KYB that is a company’s judicial records, or a person’s with no identity step BEFORE them in a KYC flow. The response brings locked_tools[] and the reason, which points you to Settings → Account verification; since MasterApi 1.131.0 also step_keys[] (the steps that use them) and problems[], one per step.

400 · customer_required (on opening the session)

The published version has requires_customer: true — some server step needs a field the form never asks for — and the call sent no customer_id.

402 · on opening the session

The organization balance is below estimate_centavos. With branches that number is the worst case (estimate_is_maximum): the run that takes them all has to fit. The sandbox never asks for it. Opening through a link answers 402 link_unavailable_balance, with no amounts, and does not spend a use.

400 · on creating a link

flow_not_published: the flow is not published. link_requires_form: it has no data step, and without one nobody creates the customer. customer_required (with missing_needs): some step reads a value the form never asks for, so it demands a customer the link does not bring. bad_expires_at: the date is not in the future or is more than a year away.

400 · link_requires_cap / link_requires_expiry (on creating or editing a link)

A production link always has a use ceiling and an expiry: max_sessions or expires_at in null is refused. They move, they are never removed. Omitted on create, it is born with 100 uses and 30 days. In the sandbox both are optional.

410 · link_closed (on opening a link)

reason says why: paused, expired, exhausted (its uses ran out) or flow_unavailable (the flow was archived, or its published version no longer starts with the data step or now demands a customer). GET /app/flow-embed/link/:token answers it without spending a use.

403 · link_unavailable (on opening a link)

Your account cannot open the verification. The public route gives neither the reason nor your account state; with your key, opening a session carries the usual body. It does not spend a use.

429 · too_many_opens (on opening a link)

More than 20 opens in a minute, 60 in an hour or 300 in a day from the same IP on the same link. On the hour and day ones, window says which (hour or day) and limit, the ceiling. It clears on its own.

429 · too_many_form_submits · too_many_emails (on a verification opened by a link)

More than 60 in an hour or 300 in a day from the same IP on the same link: accepted data steps (a form refused with a 400 does not count) and emails Singula sends — the email step’s code and the representatives’ links with delivery: "email". The same body as too_many_opens. A verification no link opened is never braked.

409 · on deciding

The run is no longer in review: someone decided first, or the rules closed it.

409 · on requesting a resubmission

not_resubmittable: the run is neither in review nor declined, or it changed while you asked. close_in_progress: its verdict is still being published; retry in a few seconds. child_run: it is the child run of a KYB and it is reviewed from the company’s run. step_not_resubmittable: a key that does not exist, the representatives step or one its branch skipped. declining_step_not_included: a declined run is missing the step that declined it, and steps names it. max_resubmissions: the run was already reopened 3 times.

409 · on skipping a step

That step is required. Only a step with required: false can be skipped.

409 · sandbox_mode (from the dashboard)

The dashboard is in «Sandbox» and the action would start work in production: opening a production verification or moving one forward (attach, skip, data step, representatives, resubmission). Nothing is written. Switch the dashboard to production. An API key never gets it.

400 / 409 · on delivering the representatives list

400 bad_people_count when you sent fewer than min or more than max; bad_role when a role is not in params.roles; email_required when the step delivers by email and a person arrives without one; bad_name when the name is not between 2 and 120 characters; bad_email when the address is not valid. 409 people_already_submitted when the step already opened its children — the response carries those very children — and 409 not_attachable_step (with how: "people") if you try to attach a folio to it.

410 · in the runner

The session token expired or the session already closed. Open a new session, or request a resubmission if the run closed in review or declined: the same link opens again.

Where to go next