ONP API Reference
This document describes how to integrate with the Guardline onboarding API. It covers authentication, creating onboarding sessions, declaring linked subjects (the legal representative for kyc_minor at session creation), resolving session links, tracking execution lifecycle, receiving webhook notifications, and polling for missed events.
The integration model follows a clear separation of responsibilities: Guardline verifies and decides, the integrator orchestrates. The integrator declares the onboarding via a single API call, distributes the URLs returned in the response (or asks Guardline to deliver them), and receives progress and final-decision notifications via webhook.
Overview
Section titled “Overview”The integration flow involves four participants:
| Participant | Responsibility |
|---|---|
| Integrator backend | Creates sessions, receives webhooks, queries executions |
| Guardline API | Manages sessions, tokens, executions, linked subjects, and notifications |
| Integrator frontend | Renders the link directly or mounts an iframe pointing at it |
| Guardline onboarding | Conducts the verification journey with the end user |
A single POST /api/v1/onboarding/sessions call creates an onboarding for any flow type. The request carries the primary subject’s data plus an optional array of linked subjects. The response carries one URL per subject created. Notification of linked subjects is opt-in per call and goes out over the channels configured for the flow (SMS and email by default, WhatsApp when requested).
For kyc_minor, the integrator declares both the minor (in primary) and the legal representative (in linked_subjects[]) in the same request. The journey for each is independent: the minor opens their URL, the representative opens theirs, and Guardline orchestrates the consolidation. The integrator decides whether Guardline delivers the representative’s URL over the flow’s notification channels (notify.linked: true) or whether the integrator handles delivery (notify.linked: false or omitted).
All API calls follow the REST pattern over HTTPS, with JSON payloads and responses in Guardline’s standard envelope format.
Environments and Authentication
Section titled “Environments and Authentication”Environments
Section titled “Environments”| Environment | Base URL | API Key prefix |
|---|---|---|
| Sandbox | https://{instance}.onp.dev.guardline.com.br | gl_test_ |
| Production | https://{instance}.onp.prod.guardline.com.br | gl_live_ |
The {instance} value is provisioned during integrator setup and identifies the dedicated infrastructure. Each instance has its own isolated database, application, and storage.
Authentication
Section titled “Authentication”All integrator-facing API calls require the key corresponding to the environment. Two header forms are accepted equally, X-API-Key and Authorization: Bearer. Send one of:
X-API-Key: gl_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Authorization: Bearer gl_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2Keys are provisioned through the Guardline admin panel. The plaintext token is displayed only at creation time. Internally, Guardline stores the SHA-256 hash of the key.
The prefix is a label chosen when the key is created, not a routing mechanism, and it defaults to live when the environment is not specified. A key issued on a sandbox instance without an explicit environment therefore reads gl_live_ while still being a sandbox-only key. The prefix says nothing about which instance will accept it: a key authenticates only against the instance that issued it.
That holds for X-API-Key, whose value is always read as an API key. On Authorization: Bearer the prefix does route: the credential is treated as an API key only when it starts with gl_live_ or gl_test_, and anything else on that header is verified as a JWT. A prefixless key sent as Bearer therefore fails JWT verification and answers 401 INVALID_TOKEN. Send such a key in X-API-Key.
A credential problem always answers 401, never 400. MISSING_TOKEN when no header was sent, INVALID_API_KEY when the key is unknown, revoked or expired. A 400 means the key was accepted and the request body was rejected, so reissuing the key will not resolve it.
HTTP requests are rejected. HTTPS with TLS 1.2 or higher is required in all environments.
Two surfaces depart from that rule:
| Endpoint | Auth |
|---|---|
POST /api/v1/onboarding/executions/{execution_id}/resend-representative-link | Admin or supervisor JWT from the Guardline backoffice. See Resending the Representative Link. |
GET /api/v1/link/{token} | Public. Possession of the token is the capability, and the route is rate limited per source IP. |
Idempotency
Section titled “Idempotency”Mutating endpoints accept an optional Idempotency-Key request header (any unique string of at most 255 characters, typically a UUID; a longer one returns 400 INVALID_IDEMPOTENCY_KEY). When present, the server caches the response for 15 minutes; subsequent calls with the same key return the cached response with the Idempotency-Replayed: true response header. Without the header, each call is processed independently.
The cache is keyed by the pair (key, endpoint), so reusing one key across two different endpoints does not collide.
Use this when retrying after a network timeout to avoid duplicate executions. Generate a fresh key for each new logical operation.
Response Format
Section titled “Response Format”All responses follow the standard envelope:
Success:
{ "error": false, "data": {}}Error:
{ "error": true, "message": "descrição do erro", "code": "ERROR_CODE", "request_id": "550e8400-e29b-41d4-a716-446655440000"}The message field is localized. Integrators must branch on the code field (stable, untranslated), not on the message text.
The request_id field is returned on error responses only, and can be used for tracking in support tickets. A success response carries error and data, plus an optional warnings array of {code, message, context} entries that reports non-fatal degradations without turning the response into an error. warnings is omitted when there is nothing to report.
Error Message Locale
Section titled “Error Message Locale”The message field is resolved from the Accept-Language request header. A caller that sends no header receives the instance’s default locale (pt-BR unless the instance is configured otherwise).
Accept-Language | Result |
|---|---|
| absent, unparseable, or unmatched | the instance’s default locale (pt-BR unless the instance is configured otherwise) |
pt-BR | pt-BR |
es-AR, es with no region, and Latin American variants such as es-UY | Spanish (Argentina) |
curl https://{instance}.onp.dev.guardline.com.br/api/v1/link/invalido \ -H "Accept-Language: es-AR"{ "error": true, "message": "Enlace no encontrado", "code": "LINK_NOT_FOUND", "request_id": "77440a78-6e11-4def-8c08-31b1189ccbd4"}The catalogue is keyed by error code. A code with no entry in the requested locale degrades to its pt-BR message rather than to a blank or a placeholder, so a response is never empty and the code is always authoritative.
For this reason, integrators must never match on message text, and must not build an end-user-facing string from it either: branch on code and render your own copy.
Available Journeys
Section titled “Available Journeys”GET /api/v1/onboarding/flowsReturns the list of configured and active journeys for the integrator, with the metadata needed to create sessions correctly.
kyc_representative and kyb_representative are filtered server-side and never appear: those children are created only as linked_subjects[] entries (representative on a kyc_minor session, officer on a kyb session). The KYC flow of a kyb company’s beneficial owners does not appear either, because only the platform itself opens it. Pointing flow_id at any of them is rejected with 400 INVALID_FLOW_TYPE. Select journeys by the type field rather than by position, and treat kyc, kyb and kyc_minor as the startable set.
Response
Section titled “Response”{ "error": false, "data": [ { "flow_id": "61000000-0000-0000-0000-000000000001", "flow_type": "kyc", "name": "KYC Pessoa Física", "description": "Verificação de identidade para maiores de 18 anos.", "status": "active", "steps_count": 8, "steps": [ { "id": "cpf", "name": "CPF", "order": 1 }, { "id": "personal_data", "name": "Dados pessoais", "order": 2 }, { "id": "contact", "name": "Informações de contato", "order": 3 }, { "id": "professional_data", "name": "Dados profissionais", "order": 4 }, { "id": "address", "name": "Endereço", "order": 5 }, { "id": "terms", "name": "Termos de uso", "order": 6 }, { "id": "document", "name": "Documentoscopia", "order": 7 }, { "id": "biometric", "name": "Biometria facial", "order": 8 } ], "primary_schema": [ { "field": "full_name", "type": "string", "required": false, "description": "Nome completo" }, { "field": "tax_id", "type": "string", "required": false, "description": "CPF (somente dígitos ou com máscara)" }, { "field": "email", "type": "string", "required": false, "description": "Endereço de e-mail" }, { "field": "phone", "type": "string", "required": false, "description": "Telefone com código do país" }, { "field": "birth_date", "type": "string", "required": false, "description": "Data de nascimento (AAAA-MM-DD)" } ] }, { "flow_id": "61000000-0000-0000-0000-000000000010", "flow_type": "kyc_minor", "name": "KYC Menor de Idade", "description": "Verificação de identidade para menores entre 14 e 17 anos, com etapa adicional do representante legal.", "status": "active", "steps_count": 9, "steps": [ { "id": "cpf_birth_date", "name": "CPF e data de nascimento", "order": 1 }, { "id": "welcome", "name": "Boas-vindas", "order": 2 }, { "id": "personal_data", "name": "Dados pessoais do menor", "order": 3 }, { "id": "contact", "name": "Informações de contato", "order": 4 }, { "id": "professional_data", "name": "Dados profissionais e PEP", "order": 5 }, { "id": "address", "name": "Endereço", "order": 6 }, { "id": "terms", "name": "Termos de uso", "order": 7 }, { "id": "document", "name": "Documentoscopia do menor", "order": 8 }, { "id": "biometric", "name": "Biometria facial do menor", "order": 9 } ], "primary_schema": [ { "field": "full_name", "type": "string", "required": true, "description": "Nome completo do menor" }, { "field": "tax_id", "type": "string", "required": true, "description": "CPF do menor" }, { "field": "birth_date", "type": "string", "required": true, "description": "Data de nascimento do menor (AAAA-MM-DD), idade entre 14 e 17 anos" }, { "field": "phone", "type": "string", "required": false, "description": "Telefone do menor com código do país" }, { "field": "email", "type": "string", "required": false, "description": "Email do menor" } ] } ]}Each flow entry also returns flow_id (the specific flow version, usable as flow_id in POST /sessions) and steps_count. steps_count is read from the flow row’s own column rather than counted from the steps array in the same response, so the two can drift apart. When the number has to be right, take the length of steps.
The primary_schema array describes the fields accepted under the primary block of POST /api/v1/onboarding/sessions. For kyc_minor, the integrator additionally declares the legal representative under linked_subjects[] (see Linked Subjects). The representative’s journey runs independently from the minor’s; steps above lists only the minor’s steps.
Creating a Session
Section titled “Creating a Session”POST /api/v1/onboarding/sessionsCreates a new onboarding session and returns a URL per subject. For kyc_minor, the response includes a separate URL for the linked representative alongside the minor’s URL.
Request Schema
Section titled “Request Schema”| Field | Type | Required | Description |
|---|---|---|---|
flow_type | string | One of flow_type or flow_id | Journey type. Possible values: kyc, kyb, kyc_minor. Only resolves when the instance has a single active flow of that type; with more than one, it answers 400 FLOW_ID_REQUIRED. Always prefer flow_id. |
flow_id | UUID | One of flow_type or flow_id | Specific flow version. Use this to pin a particular flow configuration when multiple flows of the same type exist. Resolution to a flow whose type is internal-use (such as kyc_representative) is rejected with INVALID_FLOW_TYPE. |
channel | enum | No | Origin of the session: web, sdk, api. Default: web. The legacy alias channel_code is also accepted; when both are sent, channel wins. |
layout_mode | enum | No | UI layout for the journey: stepper or chat. There is no API-side default: when the field is omitted, the journey renders with the layout configured on the flow itself. |
reference_id | string (≤ 255) | No | Free-form correlator for the integrator’s system. Returned in webhooks and queryable via API. Recommended: a unique stable identifier such as the account ID or onboarding request ID. |
parent_execution_id | UUID | No | Links this execution to a parent execution. Reserved for advanced use cases such as KYB sub-flows; not required for standard kyc/kyc_minor/kyb journeys. |
metadata | object | No | Free-form passthrough. Stored with the session and not returned by any read endpoint, so it is write-only from the integrator’s side. Use reference_id for a correlator that has to be readable back. |
surface_hint | enum | No | Set to embed when the integrator will render the journey inside an iframe. Drives PII sanitization on GET /api/v1/link/{token}. See Surface Hint (Embed Mode). |
primary | object | Required for kyc_minor; optional for kyc and kyb | Primary subject’s data. Fields are described by the primary_schema returned in GET /flows. May be omitted (cold-start) for kyc and kyb, in which case the customer record is materialized when the user submits the document step in the journey. primary.tax_id is validated against the resolved flow’s type; see Primary Tax ID. |
linked_subjects | array | Required for kyc_minor (exactly one entry, role: "representative"); optional for other flow types (role: "officer" entries, used on kyb) | Subjects linked to the primary. See Linked Subjects. |
notify | object | No | Notification opt-in flags. See Notification Opt-in. |
biometric | boolean | No | Toggles the facial proof-of-life step for THIS session only, overriding the journey configuration: false suppresses the step (the user advances directly to the next one), true enables it on a journey that ships it disabled, omitted keeps the journey configuration. Requires the resolved flow to carry the biometric step (400 VALIDATION_FAILED otherwise, in both directions). Applies to the primary execution only; linked-subject journeys keep their flow configuration. The terminal webhook reflects the choice in data.liveness.status (see data.liveness). |
partner | object | No | Names who operates the journey from the integrator’s own portal, such as a store attendant. Its only field is partner.email (up to 254 characters), required when the block is sent: surrounding spaces are trimmed, and an empty block or an invalid address returns 400 VALIDATION_FAILED. It creates no customer and no linked execution. The address is stored with the session; on instances with partner status e-mails enabled, the journey’s status e-mails go to it instead of the customer, and a session created without partner sends them to nobody. It is also where the registration correction notices go (see Registration Correction). |
Primary Tax ID
Section titled “Primary Tax ID”primary.tax_id is validated against the type of the resolved flow, not the raw flow_type of the request, so a request that sends only flow_id goes through the same check.
| Flow type | Accepted in primary.tax_id |
|---|---|
kyc, kyc_minor | A valid CPF: 11 digits, check digits verified. |
kyb | A valid 14-character CNPJ, numeric or alphanumeric (IN RFB 2.229/2024), check digits verified. |
Formatted input is accepted. The server strips the mask before the check (and upper-cases the letters of a CNPJ) and stores the clean form, so 123.456.789-09 and 12345678909 are the same document and find the same customer. A mismatch returns 400 INVALID_TAX_ID.
Linked Subjects
Section titled “Linked Subjects”The linked_subjects array declares any subject linked to the primary by the flow contract. Today, this covers the legal representative for kyc_minor (representative) and the legal representatives of a kyb company (officer).
Each entry has:
| Field | Type | Required | Description |
|---|---|---|---|
role | enum | Yes | Role discriminator: representative or officer. kyc_minor requires exactly one entry with representative. officer declares a legal representative of a kyb company, with no limit on the count: each entry creates a child execution on the representative subflow configured on the parent flow, and an entry with tax_id and full_name creates or reuses the person’s record. A parent flow with no representative subflow refuses the entry with 400 VALIDATION_FAILED, and any other role value also returns 400 VALIDATION_FAILED. |
subject | object | Yes | Subject data block. May be empty ({}) for shell creation. |
The subject block accepts the following fields:
| Field | Type | Required | Description |
|---|---|---|---|
full_name | string | Paired with tax_id (see Field Requiredness below) | Full name. |
tax_id | string | Paired with full_name | Personal tax ID. This field is never validated: any string is accepted and the session is still created with 201. The caller is responsible for sending it normalized, digits only; a formatted value creates a duplicate customer keyed on the formatted text. |
phone | string (E.164) | No | Phone with country code. Not required by notify.linked: dispatch needs phone or email, and stops only when both are absent. Validated against the flow’s phone policy: a mobile-only flow rejects landlines with 422 PHONE_POLICY_VIOLATION. |
birth_date | string (YYYY-MM-DD) | No | Date of birth. When provided, it must indicate age 18 or over; a younger representative is rejected with 422 REPRESENTATIVE_AGE_BELOW_MINIMUM. |
email | string | No | Email. |
relationship | enum | Yes for kyc_minor; optional otherwise | Representative’s relationship with the minor. Required at session creation for kyc_minor regardless of whether the rest of subject is a shell. Values: mother, father, grandparent, legal_guardian, tutor, other. See kyc_minor Cardinality. |
notification_channel | enum | No | Preferred channel for backend-managed delivery: sms, whatsapp, or email_sms. |
Field Requiredness for linked_subjects[i].subject
Section titled “Field Requiredness for linked_subjects[i].subject”The service accepts three shapes:
| Shape | Content | Effect |
|---|---|---|
| Shell | subject without both tax_id and full_name (on kyc_minor it still carries relationship) | Creates the linked execution without materializing a customer record. Integrator delivers the URL manually. |
| Mixed | subject: {tax_id: "..."} or subject: {full_name: "..."} (one of the two only) | Accepted; treated as shell. The single field does not trigger customer materialization. Not an error. |
| Materialized | subject: {tax_id: "...", full_name: "...", ...} (both present) | Creates or reuses a customer record by tax_id and back-links it to the linked execution. Other subject fields (email, phone, birth_date) populate the new customer record on creation; ignored when the customer already exists (see CPF Reuse Semantics). |
Independent of the shape, notify.linked: true needs at least one contact field on the subject: phone, email, or both. Each channel is skipped when its own recipient field is empty, and the dispatch stops before any provider call only when both are absent, in which case notify_sent is recorded as false. An email-only representative is notified over the email leg.
For flow_type: kyc_minor, subject.relationship is required at session creation regardless of which shape above applies. A shell (subject: {}) without relationship is rejected; see kyc_minor Cardinality.
kyc_minor Cardinality
Section titled “kyc_minor Cardinality”For flow_type: kyc_minor, the service requires exactly one entry in linked_subjects with role: "representative". Any of the following returns 400 LINKED_SUBJECTS_REQUIRED:
linked_subjectsfield absentlinked_subjects: [](empty array)- More than one entry
- An entry with
roleother thanrepresentative
The entry must also carry subject.relationship. Missing subject.relationship returns 422 LINKED_SUBJECT_RELATIONSHIP_REQUIRED. The relationship is the canonical source of the guardian-type value consumed downstream by the bond-document gate and the auto-skip of the in-flow relationship step, so it has to be declared up front even when the rest of the subject is a shell.
Shell creation is explicit and still possible; just include relationship: send linked_subjects: [{role: "representative", subject: {relationship: "mother"}}].
For kyc_minor, two additional gates apply to the representative entry:
linked_subjects[0].subject.relationshipis required. Omitting it returns 422LINKED_SUBJECT_RELATIONSHIP_REQUIRED.- The representative’s
tax_idmust differ from the minor’stax_id. Reusing the same CPF returns 422CPF_MINOR_REPRESENTATIVE_MATCH.
Notification Opt-in
Section titled “Notification Opt-in”The notify block controls whether Guardline delivers the URL itself, over SMS, email or WhatsApp.
| Field | Type | Default | Description |
|---|---|---|---|
notify.primary | bool | false | Reserved. Currently has no effect; future versions will dispatch the primary’s URL. |
notify.linked | bool | false | When true, Guardline records the opt-in and dispatches the representative’s URL when the minor finishes their module (not at session creation). The default plan is SMS plus email; WhatsApp is used only when subject.notification_channel names it. The opt-in applies to the kyc_minor representative, and needs subject.phone, subject.email or both. |
Dispatch Timing
Section titled “Dispatch Timing”For kyc_minor, the representative is only needed after the minor completes their journey. Guardline persists the notify.linked opt-in at POST /sessions and fires the message at that later moment (when the parent transitions to pending_representative), not synchronously during session creation. As a result, the linked_subjects[i].notify_sent field in the POST /sessions response is always false. The actual dispatch outcome surfaces later via representative_notify_sent on GET /api/v1/link/{token} for the parent’s token (read from the child execution row).
Channel Selection
Section titled “Channel Selection”When the dispatch fires, the channels are selected by:
linked_subjects[i].subject.notification_channelwhen present:smssends SMS only,whatsappsends WhatsApp only,email_smssends SMS and email.- Otherwise the default plan applies, which is the same set as
email_sms: SMS and email. WhatsApp is never part of the fallback and fires only when it is asked for by name.
Each leg is then skipped when its own recipient field is empty: SMS and WhatsApp need subject.phone, email needs subject.email.
notify_sent Semantics
Section titled “notify_sent Semantics”The linked_subjects[i].notify_sent field in the POST /sessions response is always false, because the dispatch is deferred to the minor’s completion (see Dispatch Timing). It does not indicate whether a message was sent.
To observe the actual dispatch outcome, read representative_notify_sent from GET /api/v1/link/{token}:
true: at least one attempted channel was accepted by its provider. For SMS and WhatsApp that means the messaging provider returned a message identifier; for email it means the mail provider accepted the message for delivery.false:notify.linkedwas off, the subject carried neitherphonenoremail, or every attempted channel was rejected.
A provider message ID does not mean the message was delivered to the recipient. Asynchronous delivery confirmation (provider status callbacks) is not surfaced in this API; it remains in communication-log records accessible via the admin panel. This is a deliberate trade-off to keep the API surface honest and small.
Surface Hint (Embed Mode)
Section titled “Surface Hint (Embed Mode)”Integrators rendering the journey inside an iframe set surface_hint: "embed" on the request. The backend persists the flag on the execution. When the iframe loads /api/v1/link/{token} to render the journey, the response is sanitized (CPF, email, phone, birth_date stripped from the customer block; representative URL fields stripped from the parent payload). See PII Sanitization Behavior.
The surface_hint value is server-internal and is not echoed in any response (neither /sessions nor /link/{token}). Integrators observe its effect only via the sanitized shape of the customer block in /link/{token}.
The legacy GET /api/v1/embed/{token} endpoint, which served as a separate sanitized resolver, has been removed. Embed integrators now use /api/v1/link/{token} after creating the session with surface_hint: "embed".
CPF Reuse Semantics
Section titled “CPF Reuse Semantics”When primary.tax_id matches an existing active record in customers_person, the service reuses the existing customer rather than creating a new one. The fields primary.full_name, primary.email, primary.phone, and primary.birth_date from the request are ignored in this case; the customer keeps the data from its first creation.
The same applies to linked_subjects[i].subject.tax_id when the linked subject’s CPF matches an existing record.
The two fields are not treated alike. primary.tax_id is normalized before the lookup (see Primary Tax ID), so its spelling does not matter. linked_subjects[i].subject.tax_id is neither validated nor normalized: the match is on the exact string sent, so two spellings of the same document are two different keys and produce two different customer records. Send that field as digits only, the same way every time.
Reason: idempotency. Retrying with the same CPF does not duplicate customer records. Trade-off: customer data is sticky to the first creation; corrections via subsequent POST /sessions calls do not propagate.
To update an existing customer’s name, email, or phone, use the backoffice or the customer-management endpoints; POST /sessions is not the path for customer updates. For local development, use a fresh CPF per test or delete the existing record before reusing the CPF.
Examples
Section titled “Examples”Standard KYC, cold-start (no primary data)
Section titled “Standard KYC, cold-start (no primary data)”{ "flow_type": "kyc", "channel": "web", "reference_id": "SOL-2026-00099"}The customer record is materialized when the user submits the document step in the journey. customer_id is absent from the response until then.
Standard KYC, full primary data
Section titled “Standard KYC, full primary data”{ "flow_type": "kyc", "channel": "web", "reference_id": "SOL-2026-00099", "primary": { "full_name": "Carlos Oliveira", "tax_id": "12345678909", "email": "carlos@exemplo.com", "phone": "+5511999990000", "birth_date": "1990-05-15" }}KYC Minor with full data and Guardline-delivered notification
Section titled “KYC Minor with full data and Guardline-delivered notification”{ "flow_type": "kyc_minor", "channel": "web", "reference_id": "SOL-2026-00042", "primary": { "full_name": "João Santos", "tax_id": "12345678909", "birth_date": "2010-05-15" }, "linked_subjects": [ { "role": "representative", "subject": { "full_name": "Maria Santos", "tax_id": "98765432100", "phone": "+5511999998888", "email": "maria@exemplo.com", "birth_date": "1985-03-20", "relationship": "mother", "notification_channel": "sms" } } ], "notify": { "primary": false, "linked": true }}Guardline creates both executions and records the notify.linked opt-in. The SMS to +5511999998888 containing the representative’s URL is dispatched when the minor finishes their module, not at session creation; the POST /sessions response therefore returns notify_sent: false (see Dispatch Timing).
KYC Minor with shell representative (integrator delivers link)
Section titled “KYC Minor with shell representative (integrator delivers link)”{ "flow_type": "kyc_minor", "reference_id": "SOL-2026-00043", "primary": { "full_name": "Ana Pereira", "tax_id": "11144477735", "birth_date": "2009-11-30" }, "linked_subjects": [ { "role": "representative", "subject": { "relationship": "mother" } } ]}The representative’s URL is returned in the response (linked_subjects[0].url). The integrator delivers it through their own channel. No notification is dispatched (notify.linked defaulted to false). The relationship field is mandatory for kyc_minor even in shell mode; see kyc_minor Cardinality.
Embed mode (iframe rendering)
Section titled “Embed mode (iframe rendering)”{ "flow_type": "kyc", "surface_hint": "embed", "primary": { "full_name": "Lucas Almeida", "tax_id": "12345678909" }}The session’s surface_hint flag drives PII sanitization on GET /api/v1/link/{token} when the iframe resolves the token.
Success Response (201)
Section titled “Success Response (201)”{ "error": false, "data": { "execution_id": "9b4771d5-ab60-4b5e-867d-573ed7cb684c", "customer_id": "155384e8-c86b-478e-be99-3937d2fee1ab", "url": "https://acme.onp.prod.guardline.com.br/onboarding/link/abc123def456", "embed_url": "https://acme.onp.prod.guardline.com.br/embed/t/abc123def456", "token": "<token>", "expires_at": "2026-05-13T12:49:34Z", "status": "created", "reference_id": "SOL-2026-00042", "linked_subjects": [ { "role": "representative", "execution_id": "c0aef12b-7c2a-4b3e-9a8d-f1e2d3c4b5a6", "url": "https://acme.onp.prod.guardline.com.br/onboarding/link/def456ghi789", "embed_url": "https://acme.onp.prod.guardline.com.br/embed/t/def456ghi789", "subject_status": "created", "notify_sent": false } ] }}| Field | Description |
|---|---|
execution_id | Unique execution identifier for the primary subject. |
customer_id | Customer record identifier. Absent from the response in cold-start (no primary data sent), because no customer record exists yet; present and stable once the record exists. Test for the presence of the key, not against the zero UUID: earlier builds published 00000000-0000-0000-0000-000000000000 here and integrators read it as an identifier that would be filled in later, which is why the field is now elided instead. |
url | Public URL for the primary subject’s journey. |
embed_url | URL pointing to the iframe-friendly route for the primary subject. Use this as the iframe src when rendering inside the integrator’s app. |
token | Slug of the link, also accepted by GET /api/v1/link/{token}. |
expires_at | Link expiration in ISO 8601. Creation time plus a deployment-wide setting, 7 days by default. It is not configurable per flow, so every link an instance issues carries the same window. |
status | Initial execution status (created). |
reference_id | Echo of the reference_id sent in the request, when provided. |
linked_subjects[] | One entry per linked subject created. Always present (empty array when the request declared no linked subjects). |
linked_subjects[i].role | Role of the linked subject (representative for kyc_minor, officer for kyb). |
linked_subjects[i].execution_id | Linked subject’s execution ID. |
linked_subjects[i].url | Linked subject’s journey URL. |
linked_subjects[i].embed_url | Iframe-friendly URL for the linked subject. |
linked_subjects[i].subject_status | Linked subject’s initial status (created). |
linked_subjects[i].notify_sent | Always false in this response. Representative dispatch is deferred to the minor’s completion, so the live outcome is read later from representative_notify_sent on GET /api/v1/link/{token} (see Dispatch Timing). |
The url field is intended for direct user access via SMS, WhatsApp, email, or any other channel the integrator chooses. The embed_url field is intended for iframe and webview usage. Both point to the same execution and resolve to the same content.
Errors
Section titled “Errors”Note: error
messagefields are localized byAccept-Language. Match oncodefor programmatic branching, not on message text.
| HTTP | Code | Cause |
|---|---|---|
| 400 | INVALID_REQUEST | Malformed JSON, unknown field (the message names the field), or wrong field type. |
| 400 | INVALID_IDEMPOTENCY_KEY | The Idempotency-Key header is longer than 255 characters. |
| 400 | VALIDATION_FAILED | Field-level validation failed: neither flow_id nor flow_type was sent, flow_id is not a UUID, flow_id and flow_type disagree, the resolved flow is inactive, channel outside the enum, relationship or notification_channel outside the enum, kyc_minor birth_date outside the 14-17 range, invalid linked_subjects[i].role, an officer entry on a flow with no representative subflow, biometric sent for a flow without the biometric step, or an empty partner or an invalid partner.email. The envelope does not name which of these fired; correlate with the request_id. |
| 400 | INVALID_TAX_ID | primary.tax_id is not a valid CPF on a kyc or kyc_minor flow, nor a valid CNPJ on a kyb flow. See Primary Tax ID. |
| 400 | FLOW_ID_REQUIRED | The request sent flow_type without flow_id, and the instance has more than one active flow of that type. Send the flow_id read from GET /flows. |
| 400 | LINKED_SUBJECTS_REQUIRED | kyc_minor request with missing, empty, or oversized linked_subjects array, or with a role other than representative. |
| 400 | INVALID_FLOW_TYPE | flow_type is not one of kyc, kyb, kyc_minor, or flow_id resolves to an internal-use flow type (such as kyc_representative). |
| 400 | MISSING_REQUIRED_FIELDS | kyc_minor request without primary.tax_id or without primary.full_name. |
| 422 | LINKED_SUBJECT_RELATIONSHIP_REQUIRED | kyc_minor request whose linked_subjects[0].subject.relationship is absent or empty. Applies to all kyc_minor shapes (shell, mixed, materialized). |
| 401 | MISSING_TOKEN | Neither X-API-Key nor Authorization was sent, or the credential was empty. |
| 401 | INVALID_API_KEY | API Key invalid, revoked, or expired. |
| 401 | INVALID_TOKEN | JWT callers only: the bearer credential carries no gl_live_/gl_test_ prefix, so it is verified as a JWT, and that verification failed. |
| 403 | FORBIDDEN | With a JWT: the token carries no authorized role, or the account is disabled. |
| 404 | FLOW_NOT_FOUND | The flow_id does not exist. |
| 409 | ACTIVE_SESSION_EXISTS | An active session already exists for the same primary subject and flow. The body also carries execution_id and resume_url for the session that blocked this one, so the caller can send the person back into it instead of creating another. resume_url is present only while that session’s link is still live. |
| 422 | PHONE_POLICY_VIOLATION | A mobile-only flow received a landline number in primary.phone or linked_subjects[i].subject.phone. |
| 422 | CPF_MINOR_REPRESENTATIVE_MATCH | kyc_minor request where the representative’s tax_id equals the minor’s tax_id. |
| 422 | REPRESENTATIVE_AGE_BELOW_MINIMUM | The representative’s birth_date indicates age under 18. |
| 422 | LINKED_SUBJECT_RELATIONSHIP_REQUIRED | kyc_minor request whose representative entry omits subject.relationship. |
| 500 | INTERNAL_ERROR | Among other causes: flow_type names a type with no active flow for the integrator. The lookup returns a bare no-rows error that nothing maps to a 4xx, so a request that is really a client mistake surfaces as a server error. Retry does not help; fix the flow_type, or send flow_id. |
Resolving the Link
Section titled “Resolving the Link”GET /api/v1/link/{token}Public endpoint (no authentication). The integrator’s frontend, the iframe, or any browser opening the url from the session response calls this endpoint to load the journey. Returns the flow definition, steps, and the customer block.
When the session was created with surface_hint: "embed", the response is sanitized (see PII Sanitization Behavior).
Rate limit: the cap is shared across the entire /api/v1/link/* namespace (not per token) and is modulated by the environment profile the API runs. The default profile allows 10 requests per hour per source IP; the development profile allows 120 requests per minute. Sandbox is served from a development host, so sandbox gets 120 per minute and production gets 10 per hour. Only failed resolutions (a 400 or above) count toward the cap: successful ones do not consume the bucket, so many valid links resolved from the same IP do not exhaust it. Once an IP has filled the bucket with failures, though, even a valid link from it is refused until the window rolls over.
Resolving a token neither consumes nor rotates it. The same token stays re-resolvable until a clock fires (the link expiry, or the active-session window described under Errors below).
Response (Default)
Section titled “Response (Default)”{ "error": false, "data": { "execution_id": "9b4771d5-ab60-4b5e-867d-573ed7cb684c", "flow": { "id": "61000000-0000-0000-0000-000000000010", "type": "kyc_minor", "name": "KYC Menor", "layout_mode": "stepper", "cpf_match_mode": "block", "phone_policy": "mobile-only", "settings": {}, "steps": [ { "id": "...", "execution_step_id": "...", "step_type_code": "welcome", "step_type_name": "Boas-vindas", "step_order": 1, "title": "Boas-vindas", "config": {}, "is_required": true, "is_enabled": true, "status": "pending" } ] }, "customer": { "id": "155384e8-c86b-478e-be99-3937d2fee1ab", "name": "João Santos", "cpf": "12345678909", "cpf_masked": "***8909", "email": "joao@exemplo.com", "phone": "+5511999887766", "birth_date": "2010-05-15" }, "minor": null, "relationship": "mother", "status": "in_progress", "expires_at": "2026-05-13T12:49:34Z", "allowed_origins": ["https://cliente.com"], "representative_link": "https://acme.onp.prod.guardline.com.br/onboarding/link/def456ghi789", "representative_token": "<linked-token>", "representative_status": "created", "representative_notify_sent": true }}| Field | Description |
|---|---|
execution_id | Execution identifier of the subject this token belongs to. |
flow | Flow definition: id, type, name, layout_mode, cpf_match_mode (alert or block), phone_policy (any or mobile-only), settings, and the ordered steps. Each step carries id, execution_step_id, step_type_code, step_type_name, step_order, title, description, config, is_required, is_enabled, status, and (default surface only) display_data and completed_fields. |
customer | Customer record. Fields below. |
customer.id | UUID of the customer record. In cold-start, and on every KYB execution, this is the zero UUID 00000000-0000-0000-0000-000000000000 with cpf_masked empty. Note the asymmetry with POST /sessions, which elides customer_id in the same situation: this payload always carries the customer block, so the absence of a record shows up as zeros rather than as a missing key. Test for the zero UUID here and for key presence there. |
customer.name | Full name. Empty on a cold-start row. |
customer.cpf | Unmasked CPF. Removed when surface_hint=embed. |
customer.cpf_masked | Masked CPF: three asterisks followed by the last four digits (***8909). The key is always present, never elided, and not conditional on cpf: it carries the empty string when there is no customer record to mask, which is the case in cold start and on every KYB execution, and it survives surface_hint=embed even though cpf itself is removed there. |
customer.email | Email. Removed when surface_hint=embed. |
customer.phone | Phone in E.164. Removed when surface_hint=embed. |
customer.birth_date | Date of birth in YYYY-MM-DD. Removed when surface_hint=embed. |
minor | When this token belongs to the representative of a kyc_minor pair, carries the minor’s customer block (same shape as customer); null otherwise. Set to null when surface_hint=embed. The key is never dropped, so the wire carries "minor": null rather than omitting it. |
relationship | Representative’s relationship to the minor (mother, father, etc.). Present only on kyc_minor tokens; omitted otherwise. |
status | Current execution status. See Execution Lifecycle. |
expires_at | Link expiration. |
allowed_origins | Origins allowed to embed the iframe (CSP frame-ancestors). |
representative_link | URL of a linked child’s journey. Present whenever this execution has no parent and has at least one child, with no check on the flow type: the resolver takes the most recently created child of the execution, whatever its role. Removed when surface_hint=embed. |
representative_token | That same child’s raw link token (FE uses for redirection). Same presence condition as representative_link. Removed when surface_hint=embed. |
representative_status | Current status of that child’s execution, copied verbatim from the child’s status code, so it carries an execution state from Execution Lifecycle and nothing else. A freshly provisioned representative reads created, not pending: pending is not in the status vocabulary and this field can never carry it. Preserved under surface_hint=embed so the FE knows whether to poll. |
representative_notify_sent | true when the representative dispatch (fired on minor completion, see Dispatch Timing) returned a provider message ID; false when notify.linked was off or no channel was reachable; absent when the execution has a parent, or has no children at all. Removed when surface_hint=embed. |
Warning: on a KYB parent with declared representatives (officer), the representative_* fields expose one of them. All four are gated on one condition only, that the execution has no parent, and the query behind them takes the most recently created child with no filter on flow type. The one exclusion is the beneficial owners’ journeys, which never come out here: a beneficial owner’s link goes only to their own e-mail. So resolving a KYB parent’s public link returns one declared representative’s representative_link and their raw representative_token, which is a working credential for that person’s journey. When a company declares several representatives, the payload names exactly one of them, the newest, and which one it is can change as representatives are added. Treat a KYB parent’s link token as reaching the representative’s journey too.
PII Sanitization Behavior
Section titled “PII Sanitization Behavior”When the session was created with surface_hint: "embed", the resolver applies in-process sanitization before returning the payload:
- Removed:
customer.cpf,customer.email,customer.phone,customer.birth_date,minor(set tonull),representative_link,representative_token,representative_notify_sent, allflow.steps[].display_dataandflow.steps[].completed_fields. - Preserved:
customer.id,customer.name,customer.cpf_masked, the entireflowblock (id, type, name, layout_mode, cpf_match_mode, phone_policy, settings, steps),relationship,status,expires_at,allowed_origins,representative_status,execution_id.
Sessions created without surface_hint (default) receive the full payload.
Errors
Section titled “Errors”Note: error
messagefields are localized byAccept-Language. See Error Message Locale.
| HTTP | Code | Cause |
|---|---|---|
| 404 | LINK_NOT_FOUND | Token does not exist. |
| 410 | LINK_EXPIRED | Two triggers, and the second fires while expires_at is still in the future. One: expires_at has passed. Two: the execution is in_progress and its active-session window, counted from the first resolve of the link, has run out. The session window is enforced on in_progress alone, so an execution parked in created or pending_representative is never refused by it and stays resolvable until expires_at. |
| 429 | RATE_LIMITED | Cap for the environment profile exceeded by failed resolutions, shared across all tokens: 10 per hour per source IP on the default profile, 120 per minute on the development profile that sandbox runs. |
Resending the Representative Link
Section titled “Resending the Representative Link”POST /api/v1/onboarding/executions/{execution_id}/resend-representative-linkRe-fires the notification dispatch for the representative of an existing kyc_minor execution. Does not create a new representative execution; it re-uses the existing linked child and re-attempts notification.
Authentication
Section titled “Authentication”This endpoint requires an admin or supervisor JWT obtained via the Guardline backoffice. The X-API-Key header alone is not sufficient. Calls without a valid JWT return 401 MISSING_TOKEN.
The endpoint is intended for administrative recovery flows (an integrator’s support team detects the representative did not receive the link, the operator clicks “resend” in the backoffice). It is not designed to be called from integrator-facing automation. For integrator-driven dispatch, set notify.linked: true on the original POST /sessions and (if the link expires) create a new session.
Idempotency
Section titled “Idempotency”The endpoint honors the Idempotency-Key header; see Idempotency.
A maximum of 3 resends per execution. The 4th attempt returns 429 RATE_LIMITED.
The cap is a lifetime counter on the execution, not a rate over a window. It has no reset: once 3 resends have been recorded, that execution can never be resent again, and waiting does not help. Read the 429 as “this execution is out of resends”, not as “try later”.
A failed resend still consumes one of the three. The counter is incremented before the child is resolved and before dispatch is attempted, deliberately, so that an integrator cannot retry past the cap by triggering errors. A call that answers 404 NO_RESENDABLE_CHILD or that fails during dispatch has already burned a slot, so an operator hitting those can end up with fewer than three usable resends.
Errors
Section titled “Errors”| HTTP | Code | Cause |
|---|---|---|
| 400 | INVALID_ID | execution_id in the path is not a valid UUID. |
| 400 | VALIDATION_FAILED | The call is on the wrong kind of execution: the flow is not kyc_minor (a KYB or plain KYC execution lands here, not on a 404), or the execution has no representative linked to it. |
| 401 | MISSING_TOKEN | JWT not provided. |
| 403 | FORBIDDEN | JWT does not have admin or supervisor role. |
| 404 | EXECUTION_NOT_FOUND | The primary execution does not exist. |
| 404 | NO_RESENDABLE_CHILD | No representative child in a non-terminal state to resend. The resend slot is already spent when this fires. |
| 409 | EXECUTION_TERMINATED | Primary execution is approved, rejected, cancelled or expired. blocked is not checked here. |
| 429 | RATE_LIMITED | 3-resend lifetime cap reached. Does not reset. |
| 502 | INTERNAL_ERROR | Any other failure: reading the execution or flow, reading or incrementing the counter, or the notification dispatch itself. |
The terminal check behind the 409 is narrower than the lifecycle table. Resend refuses only approved, rejected, cancelled and expired; it does not treat blocked as terminal, although Execution Lifecycle marks blocked terminal. A resend on a blocked execution therefore passes the 409 gate and proceeds.
Execution Lifecycle
Section titled “Execution Lifecycle”State Diagram
Section titled “State Diagram”Two rules the edges above encode are worth stating outright. expires_at applies only while the execution is created or in_progress: the expiration query selects no other status, and it is the only writer of expired anywhere. A kyc_minor parent parked in pending_representative therefore never times out on that clock, however long it waits; when a child expires, the worker emits a linkage event on the parent and leaves the parent’s own status alone. A kyb company waiting for its beneficial owners is the exception: it expires when their verification rounds run out (see Beneficial Owners). And created ends on the first step submit, not on the link being opened: resolving the link stamps the session start and nothing else, so a user who opens the link and abandons it stays created.
State Descriptions
Section titled “State Descriptions”| State | Terminal | Description |
|---|---|---|
created | No | Session created, no step submitted yet. Opening the link does not leave this state; the first step submit does. |
in_progress | No | User is navigating through journey steps. |
pending_representative | No | Waiting on one or more linked representatives. Reached two ways: a kyc_minor parent whose minor finished their journey, and a kyb company waiting for its beneficial owners to be verified (status_detail awaiting_beneficial_owners; see Beneficial Owners). Do not read the state as “the subject finished”. |
processing | No | All steps submitted; decision engine processing. |
pending_review | No | Referred for manual review. There is no resolution deadline in the API contract: an internal worker raises an escalation on a case that has sat here past a configurable threshold, which routes attention rather than closing the case. |
approved | Yes | Approved by the engine or by an analyst. |
rejected | Yes | Rejected by the engine or by an analyst. |
completed | Yes | All steps finished with no decision of the execution’s own, which is how a linked child closes when the verdict lives on its parent: the kyc_minor representative is the case integrators meet. It carries no decision block. Query the linked child execution, as this document recommends for kyc_minor, and this is the status you get back. |
blocked | Yes | Journey interrupted by a security rule (CPF restriction, biometric attempt limit). |
cancelled | Yes | Cancelled by the integrator or by an administrator. |
expired | Yes | Session expired before completion. |
Linkage Sub-state for kyc_minor
Section titled “Linkage Sub-state for kyc_minor”For kyc_minor, the parent execution holds the lifecycle state above, while the representative’s progress is tracked separately on the linked child execution. GET /api/v1/link/{token} on the parent’s token surfaces the representative’s status via the representative_status field.
The parent transitions to pending_representative when the minor completes their journey. While the representative is doing their part, the parent stays in pending_representative; the child execution moves through created → in_progress → processing → terminal independently. When the child reaches a terminal positive state, the parent’s consolidation runs, and the parent moves through processing to its own terminal state.
Status Detail
Section titled “Status Detail”Every execution carries status_detail beside status, on the read and on the list. It creates no new status: an integrator that ignores the field reads the execution exactly as before.
status | status_detail | Meaning |
|---|---|---|
pending_review | in_analysis | Under analysis, no registration correction open. |
pending_review | awaiting_correction | A registration correction round is open, waiting for the partner. See Registration Correction. |
pending_review | correction_submitted | The partner sent the correction, which waits for the analyst. |
pending_review | correction_expired | The round’s deadline passed with no submission, and the execution waits for the analyst. |
pending_representative | awaiting_beneficial_owners | A kyb company waits for its beneficial owners to be verified. See Beneficial Owners. |
pending_representative | pending_representative | Every other case, such as a kyc_minor parent waiting for the legal representative. |
| any other | the status itself | Repeats the status. |
Outside pending_review and pending_representative the sub-state is ignored, so a stale sub-state never contradicts a final status.
Deadlines and expiry
Section titled “Deadlines and expiry”There is a recurring belief that the link lasts 24 hours, or that it lasts 7 days. There are three separate counters, each governing a different status, and whichever fires first expires the execution.
| Clock | Source | Default | Only expires while the status is |
|---|---|---|---|
link_expires_at | The installation’s LINK_EXPIRATION_DAYS | 7 days | created |
expires_at | The flow’s own expires_in_hours | 168 h, that is 7 days | created or in_progress |
| Session | The installation’s SESSION_TIMEOUT_HOURS, counted from started_at | 24 h | in_progress |
What each one means in practice:
link_expires_atcovers a link that was never opened. Because it only acts oncreated, opening the link once already takes the execution out of that clock’s reach.expires_atis the deadline for the whole execution, counted from creation and derived from the flow rather than from an environment variable. It is the only one that keeps running after the person starts answering.- The session counter starts when the person first opens the link, and it is what closes a journey abandoned halfway. It is the 24-hour clock, and it is not the link’s lifetime.
What this means for the message an integrator shows its own user: someone who abandoned the journey halfway lost the 24-hour session, not the 7-day link, and those are different things to say.
The first is published on the execution read, as expires_at, so an integrator does not have to recompute it.
Querying an Execution
Section titled “Querying an Execution”GET /api/v1/onboarding/executions/{execution_id}Returns the complete state of an execution, including data collected during the journey. This endpoint is the primary data source for the integrator. Webhooks notify state transitions; details are obtained through this query.
Standard KYC Response (200)
Section titled “Standard KYC Response (200)”{ "error": false, "data": { "execution_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "flow_type": "kyc", "reference_id": "SOL-2026-00099", "status": "approved", "status_detail": "approved", "decision": { "result": "approved", "risk_level": "low", "decided_at": "2026-03-26T15:30:00Z", "decided_by": "engine", "reasons": [] }, "customer": { "full_name": "Carlos Oliveira", "tax_id": "11144477735", "tax_id_masked": "***7735", "birth_date": "1990-05-15", "nationality": "Brasileira", "email": "carlos@exemplo.com", "phone": "+5511999990000", "income": "8500.00", "is_pep": false, "has_foreign_tax_obligation": false, "address": { "zip_code": "01310-100", "street": "Avenida Paulista", "number": "1000", "complement": "Sala 302", "neighborhood": "Bela Vista", "city": "São Paulo", "state": "SP" }, "document": { "type": "cnh", "status": "verified" }, "biometric": { "status": "verified" } }, "data_divergences": [], "links": { "url": "https://acme.onp.prod.guardline.com.br/onboarding/link/abc123def456", "embed_url": "https://acme.onp.prod.guardline.com.br/embed/t/abc123def456" }, "correction": null, "steps_completed": 8, "steps_total": 8, "created_at": "2026-03-26T14:00:00Z", "updated_at": "2026-03-26T14:12:00Z", "started_at": "2026-03-26T14:05:00Z", "completed_at": "2026-03-26T14:12:00Z", "expires_at": "2026-04-02T14:00:00Z" }}Fields
Section titled “Fields”| Field | Description |
|---|---|
execution_id | Execution identifier. |
status | Current execution status. See Execution Lifecycle. |
status_detail | Status detail, always present. See Status Detail. |
flow_type | Journey type. |
reference_id | Echo of the integrator’s correlator, when the session declared one. |
steps_completed / steps_total | Journey progress. |
decision | Decision block: result, risk_level, risk_score, decided_by, decided_at and reasons (see Decision Reasons). Absent until a decision exists. risk_score is the whole-number score risk_level is derived from, omitted when the execution has no score (risk_level then publishes low). result is normalized from the internal decision code, and only the four terminal codes are mapped (auto_approved and manual_approved to approved, auto_rejected and manual_rejected to rejected); any other code is returned raw. pending_review is itself a decision code, so an execution under review publishes "result": "pending_review". Code that branches on approved versus rejected alone mis-handles every case in review. |
customer | Person subject of the execution. Fields below. |
data_divergences | Always present as an array, empty when there is none. Each entry reports a field whose value submitted during the journey diverged from the pre-filled value: field, expected_masked, submitted_masked, step_id, recorded_at. Values are masked at insertion; raw values never leave the backend. field is one of name, email, phone, birth_date, cpf and identity_document: flows outside Brazil record the document divergence as identity_document, while Brazilian flows and older rows keep cpf. Handle both. |
correction | The latest registration correction round, or null when the execution never had one. See Registration Correction. |
beneficial_owners | Present only on a kyb company whose flow has the beneficial owner chain on. See Beneficial Owners. |
created_at / started_at / completed_at | Journey timestamps. |
updated_at | The latest change of the execution or of its latest correction round, whichever is later. |
expires_at | The execution deadline, derived from the flow’s expires_in_hours. |
links | The execution’s journey link, as { url, embed_url }: url is the link to send to the person and embed_url its iframe variant, the same shape as the url and embed_url of POST /sessions, always reflecting the execution’s current link. Integrators can rely on links.url to retrieve the journey link again (for example, to resend it) instead of creating another session. Unlike POST /sessions, this response has no top-level url, embed_url or token: read the link from this block. The block is omitted entirely when the execution was never issued a link. It is returned whatever the link’s state; whether the link still opens depends on the link’s expiry and on the execution status. |
customer block
Section titled “customer block”| Field | Description |
|---|---|
full_name | Full name as collected. |
tax_id | The person’s identity document, raw and unformatted. On Brazilian journeys this is the CPF. |
tax_id_masked | Masked variant, kept for backward compatibility. Soft-deprecated: prefer tax_id. New integrations should ignore it; existing ones keep receiving it unchanged. On this endpoint the mask is three asterisks followed by the last four characters, so 11144477735 publishes as ***7735. The two endpoints do not mask alike. Listing Executions builds the same field with a different rule that keeps the middle digits and hides the ends, so the list value exposes more of the document than the detail value does. Never compare the two masked strings, and never parse either one. |
birth_date, nationality, email, phone, income | As collected. phone carries the dial prefix whenever the source does, so a number collected by a localized journey reads as E.164 rather than as national digits with no country. |
is_pep, has_foreign_tax_obligation | Always emitted, including when false. |
address | zip_code, street, number, complement, neighborhood, city, state. |
document, biometric | Verification status blocks: type and status. |
representative | Representative data nested inside customer. A narrower shape than the block above, not the same one: it carries full_name, tax_id_masked, birth_date, nationality, email, phone, income, is_pep, has_foreign_tax_obligation, address, document, biometric, plus relationship, guardianship_document and status. There is no raw tax_id here; the masked id is the only form of the document you get. Reserved for backward compatibility; see the note on kyc_minor below. |
KYC Minor Response (200)
Section titled “KYC Minor Response (200)”For kyc_minor, the parent execution’s customer block describes the minor. The representative’s data is fetched by querying the linked child execution separately. Embedded representative data inside the parent response is reserved for backwards-compatible cases and may be deprecated in a future version; new integrations should query the linked child execution explicitly using the execution_id returned in linked_subjects[].execution_id from POST /sessions.
The relationship field on the representative’s customer record accepts: mother, father, grandparent, legal_guardian, tutor, other. When the relationship is anything other than mother or father, the representative’s journey requires uploading a guardianship document; the upload status is reflected in guardianship_document (null or "uploaded").
The decision block is only populated when the execution reaches a terminal state (approved, rejected) or is under review (pending_review). In all other states the key is absent from the response, not present as null. The same holds for every optional block on this payload (customer, address, document, biometric): test for the presence of the key. The one deliberate exception is guardianship_document, which is emitted as an explicit null when no document was uploaded, and data_divergences, which is always present as an array.
Registration Correction
Section titled “Registration Correction”On instances with the registration correction enabled, the analyst can ask for specific items of a registration under pending_review to be corrected. The platform then opens a correction round: a separate microjourney the partner fills inside their own portal. The original execution stays pending_review for the whole round, and it is through the original that the integrator follows the correction. The microjourney’s execution never shows up on the list, and reading it by id answers as an execution that does not exist. Enablement is per environment: where the correction is off, correction is always null and status_detail under pending_review is always in_analysis.
No webhook follows the round. The integrator follows it on the original’s read: status_detail changes at each step of the round, and updated_at moves whenever the execution or its latest round changes, so a poll can detect the change.
On instances that send these notices, whoever was named in partner.email at session creation receives an e-mail at each moment of the round: when it is asked, submitted, withdrawn, expired and accepted. The opening notice says only that there is a correction to send and until when; it carries no link, no item and no message, because the correction opens inside the partner portal. Without partner.email no notice is sent, and the customer never receives them.
The correction block carries the latest round, on the read and on the list:
| Field | Description |
|---|---|
id | Round identifier. |
round | Round number, from 1. |
max_rounds | The per-execution ceiling of rounds in force at the time of the read. round can exceed it after the ceiling was lowered; no further round opens then. |
state | open (asked, the partner has not opened it), in_progress (opened), submitted (sent, waiting for the analyst), accepted, rejected, expired (the deadline passed with no submission), withdrawn (the request was withdrawn), cancelled or closed_by_decision (closed by a decision on the execution). |
opened_at / due_at | When the round was asked and its deadline. due_at is the round’s deadline, not the execution’s expires_at. |
submitted_at / closed_at | Submission and closing; null until they happen. |
close_reason | The closing state itself (accepted, rejected, expired, withdrawn, cancelled or closed_by_decision), or null while the round is open. Never free text. |
items_count | How many items the round asks for. |
requires_customer | true when an item includes the proof of life, so the customer (or the company’s representative) has to be present. Only true when the journey had its proof of life on when the round opened. |
links.embed_url | The microjourney’s address, present only while the round takes the partner’s input (open or in_progress). The microjourney opens only embedded in the partner portal, so there is no direct url. |
On the single read, the block also carries:
| Field | Description |
|---|---|
message | The analyst’s message to the partner, only when one was written. |
items | The items asked for: item_id, code and label from the approved catalogue, reason (code, label and guidance), requires_customer, state (pending or answered) and, on a clarification or a supplementary document, question or document with code and label. A label missing from the catalogue is published as the code itself. |
previous_rounds | The earlier rounds, latest first, with the fields of the table above minus max_rounds and links. An empty array on the first round. |
On the list, each item carries the summary only, without message, items and previous_rounds. The block never carries the desk, the analyst, an internal note or an internal reason.
Beneficial Owners
Section titled “Beneficial Owners”On a kyb flow with the beneficial owner verification on, the company is not decided at the end of its own journey. The shareholder declaration is frozen, and each beneficial owner in it (25% or more of effective ownership, every path summed by CPF) gets a KYC journey of their own, its link e-mailed to the address declared for them. The company moves from processing to pending_representative, with status_detail awaiting_beneficial_owners, and is decided only once the verifications end; if a beneficial owner’s rounds run out, it expires. A declaration with no beneficial owner does not park: the company is decided at once.
The beneficial_owners block follows that wait on the read and on the list rows. It appears on a kyb company (a root execution) whose flow has the beneficial owner chain on from the moment its shareholder declaration is frozen, stays after the wait, and is absent on every other execution. It carries counts only, never a name, a CPF or an e-mail, always over each beneficial owner’s current round:
| Field | Description |
|---|---|
total | Every beneficial owner of the frozen declaration. |
sent | Those who have not opened their link yet. |
in_progress | Those filling their journey. |
in_review | Those whose KYC is at the analyst desk. |
approved / rejected | Those approved and those rejected. |
expired / cancelled | Those whose round’s deadline passed and those whose journey was cancelled. |
max_rounds | How many rounds each beneficial owner gets before the company expires; 1 when the verification is off on the flow. |
The webhooks of that wait are emitted on the company and described under linkage.* Events (kyc_minor).
Decision Reasons
Section titled “Decision Reasons”The decision.reasons array carries the canonical reason behind the decision. It is always present as an array: empty ([]) for automatic engine decisions and for approvals, and populated only when a manual decision recorded a catalog reason. Today it carries at most one entry. Each entry has a machine-readable code and a human-readable description:
"decision": { "result": "rejected", "risk_level": "high", "decided_at": "2026-03-26T15:30:00Z", "decided_by": "analyst", "reasons": [ { "code": "pep_restriction", "description": "Restrição PEP" } ]}Possible code values:
| code | description |
|---|---|
invalid_document | Documento inválido |
fraud_suspected | Suspeita de fraude |
pep_restriction | Restrição PEP |
sanctions_match | Match em sanções |
biometric_failed | Biometria reprovada |
high_risk | Risco elevado |
underage | Menor de idade |
incomplete_docs | Documentação incompleta |
expired_document | Documento vencido |
data_inconsistency | Dados inconsistentes |
other | Outro |
Treat code as the stable identifier for programmatic handling. The description is the reason’s name in the instance catalog, which can be tailored per instance and extended over time, so tolerate unknown codes. When the code is missing from the catalog, the description repeats the code itself.
Errors
Section titled “Errors”| HTTP | Code | Cause |
|---|---|---|
| 400 | INVALID_ID | The execution_id in the path is not a valid UUID. |
| 401 | MISSING_TOKEN | No credential was sent, in either X-API-Key or Authorization: Bearer. |
| 401 | INVALID_API_KEY | API Key invalid, revoked, or expired. |
| 404 | EXECUTION_NOT_FOUND | No execution with that execution_id. |
| 502 | INTERNAL_ERROR | Any other failure while reading the execution or its flow. |
The 404 is an existence check and nothing more. The read carries no tenant predicate, so it cannot tell you that an id belongs to somebody else; isolation here is the dedicated per-instance database described under Environments, not a row-level ownership test.
Listing Executions
Section titled “Listing Executions”GET /api/v1/onboarding/executionsReturns a paginated list of executions. By default, executions that are terminal (approved, rejected, blocked, expired, cancelled) or still in the initial created state (created but with no step submitted yet) are excluded from the response; pass an explicit status, with one or more codes, to include them (for example, status=created returns the excluded rows in that state). The exclusion holds under every other filter, reference_id, tax_id and the creation period included: only an explicit status turns it off.
The filter is the status exclusion and nothing else: there is no separate test of whether a row’s link or session is still live, so a row whose link has expired but whose status has not been retired yet is still returned.
Results are ordered by created_at descending, with execution_id descending as a deterministic tie-breaker between rows created at the same instant.
Query Parameters
Section titled “Query Parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Requested page. |
per_page | integer | 20 | Items per page (maximum: 100). Values above 100 are clamped. |
status | list | (none) | One or more status codes, comma-separated (status=approved,rejected) or with the parameter repeated (status=approved&status=rejected); a single code still works. The accepted codes are created, in_progress, processing, pending_review, pending_representative, approved, rejected, blocked, completed, cancelled, expired. When omitted, approved, rejected, blocked, expired, cancelled and created are excluded; any explicit status, one code or a list, turns that exclusion off and returns the rows in the requested statuses, the six included. Any other value, an empty one between commas included, returns 400 INVALID_REQUEST. |
created_from | date (YYYY-MM-DD) | (none) | Start of the creation period (created_at), inclusive, in Brasília time (UTC-3): counts from 00:00 of that day. May come without created_to. |
created_to | date (YYYY-MM-DD) | (none) | End of the creation period, inclusive, in Brasília time: counts through the end of that day. May come without created_from. A created_from after created_to returns 400 INVALID_REQUEST. |
reference_id | string (≤ 255) | (none) | Exact match on the integrator’s reference_id. The default exclusion still applies: without status, an execution in one of the six excluded states does not show up, even with the exact reference_id. To read an execution whose status you do not know, fetch it by id under Querying an Execution. |
flow_id | UUID | (none) | Filter by a specific flow version. A malformed UUID returns 400 INVALID_REQUEST. |
flow_type | string | (none) | Filter by journey type: kyc, kyb or kyc_minor. Any other value, kyc_representative included, returns 400 INVALID_REQUEST. |
status_detail | list | (none) | One or more of in_analysis, awaiting_correction, correction_submitted and correction_expired, comma-separated or with the parameter repeated. Keeps only the executions under pending_review whose status_detail is one of them, and combines with status: with a status other than pending_review the list comes back empty. awaiting_beneficial_owners is not accepted here. Any other value, an empty one between commas included, returns 400 INVALID_REQUEST. |
correction_state | list | (none) | The state of the latest correction round: one or more of open, in_progress, submitted and expired, with the same rules as status_detail. An execution with no round never matches. Combines with status and with status_detail. Any other value returns 400 INVALID_REQUEST. |
name | string (≤ 255) | (none) | Case-insensitive substring match on the customer’s name. Matches both customers_person.name (kyc, kyc_minor, kyc_representative) and customers_company.company_name (kyb). |
tax_id | string | (none) | Exact match on the customer’s document: a person’s CPF or a company’s CNPJ. The value is normalized server-side to alphanumerics only, upper-cased, so 123.456.789-09 and 12345678909 are the same lookup. After normalization it has to be a CPF (11 digits) or a CNPJ (14 characters, numeric or alphanumeric, the last two numeric); any other shape returns 400 INVALID_REQUEST before the query runs. |
Filters are combinable: ?reference_id=SOL-2026-00042&status=approved returns approved executions whose reference_id matches exactly; ?name=silva&status=in_progress returns in-progress executions whose customer name contains silva.
The period and the status list narrow the rows and pagination.total alike. A period without status keeps the default exclusion: ?created_from=2026-09-01&created_to=2026-09-30 returns the executions created in September, minus those in the six omitted statuses.
The five latest registrations across a set of statuses, such as those of a latest-registrations dashboard tile, come from a single call:
GET /api/v1/onboarding/executions?page=1&per_page=5&status=in_progress,processing,pending_review,pending_representative,approved,rejected,completedSince the listing is ordered by created_at descending, the first page holds the five most recently created executions among those statuses. Since status is explicit, approved and rejected are included, even though they are among the statuses omitted by default.
Response (200)
Section titled “Response (200)”{ "error": false, "data": { "items": [ { "execution_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "flow_type": "kyc_minor", "reference_id": "SOL-2026-00042", "status": "in_progress", "status_detail": "in_progress", "steps_completed": 5, "steps_total": 9, "customer": { "full_name": "Marcus Vinicius Sales", "tax_id": "23117555854", "tax_id_masked": "***175558**", "email": "msales@exemplo.com.br", "is_pep": false, "has_foreign_tax_obligation": false }, "data_divergences": [], "correction": null, "created_at": "2026-03-31T09:00:00Z", "updated_at": "2026-03-31T09:41:00Z", "started_at": "2026-03-31T09:03:00Z", "expires_at": "2026-04-07T09:00:00Z", "links": { "url": "https://acme.onp.prod.guardline.com.br/onboarding/link/bcd234efg567", "embed_url": "https://acme.onp.prod.guardline.com.br/embed/t/bcd234efg567" }, "customer": { "full_name": "João Santos", "tax_id": "12345678909", "tax_id_masked": "***456789**", "email": "joao@exemplo.com" } } ], "pagination": { "page": 1, "per_page": 20, "total": 1, "total_pages": 1 } }}Pagination metadata is nested under data.pagination (page, per_page, total, total_pages). The decision block is detail-only: fetch it via the individual query endpoint.
The customer block carries the same shape on the list and on the per-id detail endpoint. On the list, only summary identification fields are populated (full_name, tax_id, tax_id_masked, email, is_pep, has_foreign_tax_obligation). Address, document, and biometric sub-blocks plus the decision envelope are only returned by GET /api/v1/onboarding/executions/{execution_id}. The customer field is omitted (rather than emitted as null) on rows where no customer record is linked yet (e.g., probes or sessions abandoned before the first step). For kyb executions, tax_id is the company’s document (the CNPJ) and email is omitted (companies do not carry an email on the customer record).
The tax_id field is the raw document, unmasked: on a person row the identity document (the CPF), on a company row the company’s registration (the CNPJ). The legacy tax_id_masked is still emitted for backwards compatibility and will be removed in a future version; new integrations should consume tax_id.
Five more fields ride on every item and are easy to miss:
| Field | Description |
|---|---|
status_detail | The same status detail as on the detail read. See Status Detail. |
correction | The summary of the latest correction round, or null. See Registration Correction. |
updated_at | The latest change of the execution or of its latest correction round, whichever is later. |
links | The same { url, embed_url } block as on the detail read, built from the row’s own link with no extra query, and omitted the same way when the row was never issued a link. links.url is how to retrieve a row’s journey link again. |
data_divergences | Always emitted, and on the list it is always the empty array, whatever the execution recorded. Per-item divergence rows would be a query per item, so the list does not fetch them. Read real divergences from the detail endpoint; do not read a [] here as “no divergences”. |
tax_id_masked on a list item is not the mask the detail endpoint produces. Here the value keeps the middle of the document and hides the ends, so 12345678909 publishes as ***456789**, against ***7735 for the detail read’s 11144477735. The list mask exposes more digits than the detail one. Treat both as display strings from their own endpoint and compare neither.
The rows of a kyb company with the beneficial owner chain on also carry beneficial_owners, in the same shape as the read (see Beneficial Owners).
Errors. Every filter except page and per_page validates before the query runs, and a refused value answers 400 INVALID_REQUEST. page and per_page are clamped to safe values rather than rejected. For status, created_from and created_to, the messages are those of Execution Statistics.
| HTTP | Code | Cause |
|---|---|---|
| 400 | INVALID_REQUEST | status outside the 11 codes, created_from or created_to not in YYYY-MM-DD or not a real date, created_from after created_to, reference_id longer than 255 characters, flow_id that is not a UUID, name longer than 255 characters, an invalid tax_id, flow_type outside kyc, kyb and kyc_minor, or status_detail or correction_state outside the accepted values. The code does not say which one fired; the message names the filter, except under a Spanish Accept-Language, which gets the generic INVALID_REQUEST text. |
| 401 | MISSING_TOKEN | Neither X-API-Key nor Authorization was sent, or the credential was empty. |
| 401 | INVALID_API_KEY | API Key invalid, revoked, or expired. |
| 502 | INTERNAL_ERROR | The listing itself failed. |
Execution Statistics
Section titled “Execution Statistics”GET /api/v1/executions/statsReturns faceted counts over the whole filtered set, without the pagination ceiling. Use it to render dashboard tiles without paging the full listing and aggregating client-side. Accepts the integrator’s API key or a backoffice JWT.
This endpoint counts every execution, including terminal ones. Unlike Listing Executions, it applies no default exclusion: approved, rejected, blocked, expired, cancelled and created are all present in by_status. total counts the executions in every status, while the public listing GET /api/v1/onboarding/executions omits those six statuses when status is not sent, so its total can be smaller. Registration correction executions are left out of every count, as they are left out of the listings.
Query Parameters
Section titled “Query Parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
flow_id | UUID | (unset) | Filter by the flow that originated the executions. |
flow_type | string | (unset) | Filter by journey type: kyc, kyb, kyc_minor, kyc_representative. |
status | list | (unset) | One or more status codes, comma-separated (status=approved,rejected) or with the parameter repeated (status=approved&status=rejected); a single code still works. The accepted codes are created, in_progress, processing, pending_review, pending_representative, approved, rejected, blocked, completed, cancelled, expired. Any other value returns 400 INVALID_REQUEST. |
created_from | date (YYYY-MM-DD) | (unset) | Start of the creation period (created_at), inclusive, in Brasília time (UTC-3): counts from 00:00 of that day. May come without created_to. |
created_to | date (YYYY-MM-DD) | (unset) | End of the creation period, inclusive, in Brasília time: counts through the end of that day. May come without created_from. |
status_id | UUID | (unset) | Filter by status identifier. Prefer status. |
queue_id | UUID | (unset) | Filter by review queue. Takes precedence over queue. |
queue | string | (unset) | Filter by review queue name, resolved to queue_id server-side. |
Filters are combinable. status, created_from and created_to are validated: a date not in YYYY-MM-DD or not a real date (such as 2026-02-30 or year 0000), a created_from after created_to, or a status outside the 11 codes returns 400 INVALID_REQUEST, with the messages listed under Errors, below. The others are not refused: a malformed UUID in flow_id, status_id or queue_id, and a queue name that does not exist, are ignored, and the response is a 200 with the counts without that filter; an unknown flow_type zeroes the counts. The response alone does not tell an applied filter from an ignored one, so check the values before sending them.
Each month of a monthly chart is one call with that month’s period. For September:
GET /api/v1/executions/stats?created_from=2026-09-01&created_to=2026-09-30The total and the counts of that response cover the executions created from September 1 through September 30, Brasília time, both days included.
Response (200)
Section titled “Response (200)”{ "error": false, "data": { "total": 1228, "by_status": { "created": 9, "in_progress": 5, "processing": 1, "pending_review": 10, "pending_representative": 8, "approved": 204, "rejected": 24, "blocked": 0, "completed": 1, "cancelled": 250, "expired": 716 }, "by_risk": { "approve": 214, "review": 6, "block": 0 }, "by_type": { "kyc": 682, "kyb": 400, "kyc_minor": 74, "kyc_representative": 72 } }}total is the count over the filtered set and equals the sum of by_status. by_type only has keys for the four types above, so it sums to total only when there is no execution of another type: a kyb_representative execution, for instance, counts in total and in by_status but in no by_type key.
by_risk does not sum to total, by design. It only classifies executions that carry a final risk decision (approved, rejected, completed or blocked) and a non-null risk score; executions still in flight, and decided executions without a score, fall outside the three bands. The cuts come from the review_threshold and block_threshold of the instance’s risk matrix, on the default domain:
| Band | Condition |
|---|---|
approve | score below review_threshold |
review | score at or above review_threshold and below block_threshold |
block | score at or above block_threshold |
The cuts are read from the matrix at query time, so a band count is only comparable across time if the matrix has not been recalibrated in between: recalibration does not rewrite past scores, it changes where the cuts fall. These bands are not the risk_level of an execution read either, which uses the fixed 150, 350 and 700 ladder, so the two classifications can disagree for the same execution.
Errors
Section titled “Errors”| HTTP | Code | Cause |
|---|---|---|
| 400 | INVALID_REQUEST | status with a code outside the 11 accepted, created_from or created_to not in YYYY-MM-DD or not a real date, or created_from after created_to. |
| 401 | MISSING_TOKEN / INVALID_API_KEY | Neither X-API-Key nor Authorization was sent, or the credential was invalid. |
| 500 | INTERNAL_ERROR | The instance has no risk matrix configured on the default domain, so the bands cannot be computed. The endpoint fails loudly rather than reporting zeroed bands that would read as “no risk anywhere”. Contact support if you see this. |
The 400 message is one of these pt-BR texts, except under a Spanish Accept-Language, which gets the generic INVALID_REQUEST text:
| Cause | message |
|---|---|
status outside the 11 codes | status inválido (valores aceitos: created, in_progress, pending_review, approved, rejected, cancelled, expired, processing, pending_representative, blocked, completed) |
invalid created_from | created_from inválido: use o formato AAAA-MM-DD com uma data existente |
invalid created_to | created_to inválido: use o formato AAAA-MM-DD com uma data existente |
created_from after created_to | período inválido: created_from é posterior a created_to |
Cancelling an Execution
Section titled “Cancelling an Execution”POST /api/v1/executions/{execution_id}/cancelTransitions the execution to the cancelled state. When the execution has child executions (the representative under a kyc_minor parent), the call cascades and cancels them in the same operation.
Because the partial unique index idx_executions_active_person_flow excludes terminal statuses, cancelling frees the (customer_person_id, flow_id) slot. After a successful cancel, a new POST /onboarding/sessions for the same CPF and flow stops returning 409 ACTIVE_SESSION_EXISTS and a fresh session can be created. Typical use cases: reopening a kyc_minor journey for retesting, recovering from a wrong submission, or rotating a session that the end user could not finish in time.
A onboarding.cancelled webhook event is emitted on success; see Event Families.
Authentication
Section titled “Authentication”Accepts either:
X-API-Key(gl_live_*orgl_test_*), orAuthorization: Bearer <JWT>(backoffice JWT).
Idempotency
Section titled “Idempotency”Honors the Idempotency-Key header; see Idempotency.
Request Body
Section titled “Request Body”The body is optional. The only accepted field is reason, recorded in the audit trail and in the onboarding.cancelled event payload:
{ "reason": "Duplicate registration, recreating with correct data"}| Field | Type | Required | Description |
|---|---|---|---|
reason | string (max 500) | No | Free-text reason recorded in audit and event payload. |
Success Response (200)
Section titled “Success Response (200)”{ "error": false, "data": { "execution_id": "dc8fc63f-f25a-4ce7-a9a7-2005bb3f1689", "cancelled_children": [ "b11d2b44-3a8e-4a0f-9c4d-90d1f8a2e111" ], "status_code": "cancelled", "cancelled_at": "2026-05-27T13:50:00Z" }}| Field | Type | Description |
|---|---|---|
execution_id | UUID | Execution that was cancelled. |
cancelled_children | UUID array | UUIDs of child executions cancelled in cascade (the representative under a kyc_minor). The key is omitted entirely, not sent as [], when nothing cascaded: no children, or none that were still cancellable. Test for the presence of the key before reading its length. |
status_code | string | Always cancelled on success. |
cancelled_at | ISO 8601 | Cancellation timestamp. |
Errors
Section titled “Errors”| HTTP | Code | Cause |
|---|---|---|
| 400 | INVALID_ID | The path UUID is malformed. |
| 400 | INVALID_REQUEST | The request body is present but malformed. |
| 400 | VALIDATION_FAILED | reason is longer than 500 characters. |
| 401 | MISSING_TOKEN / INVALID_API_KEY | Authentication missing, invalid, or expired. |
| 404 | NOT_FOUND | No execution with the given UUID. |
| 409 | EXECUTION_TERMINAL | Execution already in a terminal state. The refusal set is exactly four codes: approved, rejected, expired and cancelled. |
| 429 | RATE_LIMITED | Rate limit exceeded. |
Cancel accepts a wider set of states than the lifecycle table’s terminal column suggests. blocked is one of them: cancelling a blocked execution succeeds and answers 200, not 409. So does cancelling one in completed, and in processing, pending_review or pending_representative. The full accepted set is created, in_progress, pending_review, pending_representative, processing, completed and blocked.
Webhooks
Section titled “Webhooks”Webhooks are HTTP notifications sent by Guardline to the integrator’s backend when an execution changes state. Payloads are intentionally lean: they contain the event identifiers and a small data block. Complete data should be obtained via the query endpoint.
Event Families
Section titled “Event Families”Two families coexist post-rewrite:
onboarding.*: state of the onboarding as a whole. Emitted on every execution.linkage.*: state of a linked subject within a paired flow. This covers the legal representative forkyc_minor. Emitted on the parent execution, with the linked subject’s outcome reflected in the payload.
The compliance desk emits a third family, cmp.case.*, which is internal and does not reach the integrator. It is named here only so that its absence from your endpoint is not read as a lost delivery.
onboarding.* Events
Section titled “onboarding.* Events”| Event | Description |
|---|---|
onboarding.started | Session created via POST /sessions. |
onboarding.completed | All steps submitted; decision engine processing. |
onboarding.approved | Approved by the engine or by an analyst. |
onboarding.rejected | Rejected by the engine or by an analyst. |
onboarding.review | Referred for manual review. |
onboarding.blocked | Journey interrupted by a security rule. |
onboarding.expired | Session expired before completion. |
onboarding.cancelled | Cancelled by the integrator or by an administrator. |
linkage.* Events (kyc_minor)
Section titled “linkage.* Events (kyc_minor)”| Event | Description |
|---|---|
linkage.pending | The minor completed their journey and the parent transitioned to pending_representative. The representative’s URL was created at POST /sessions; this event marks the parent’s transition, not the child’s creation. |
linkage.started | The legal representative accessed their link and submitted the first step. |
linkage.completed | The legal representative completed their journey, which triggers the parent’s consolidation. The event fires before the consolidation gates are evaluated, so it is not a guarantee that consolidation ran: when a gate fails, no consolidation follows. |
linkage.rejected | An analyst rejected the representative’s execution in manual review. |
linkage.expired | The representative’s execution expired before completion. |
On a kyb company waiting for its beneficial owners, the same events are emitted on the company. linkage.pending marks the park and carries data.beneficial_owners (total and pending). linkage.started, linkage.completed, linkage.rejected and linkage.expired follow each beneficial owner and carry data.linked: the execution_id of the beneficial owner’s journey, role (beneficial_owner), round and state. The company’s onboarding.completed is emitted only when it is released for the decision, and a company whose rounds run out receives onboarding.expired.
Legacy representative.* Events
Section titled “Legacy representative.* Events”The integrator API rewrite replaced representative.{pending, started, completed} with the linkage.* family above. The legacy event types are not emitted on the new code paths, but historical webhook deliveries in integrator-side audit logs may reference them. Treat both taxonomies as informationally equivalent for historical data; only linkage.* is emitted today.
Emission Order
Section titled “Emission Order”Events follow a strict emission order within each execution. Due to retries, events may arrive out of order at the integrator’s endpoint. The event_sequence field (monotonically increasing per execution) should be used as the canonical ordering criterion, not the timestamp or delivery order. To recognize a repeated event, on the other hand, use the body event_id; see Deduplication.
Standard KYC
Section titled “Standard KYC”Automatic approval:
onboarding.started (1) session createdonboarding.completed (2) all steps submittedonboarding.approved (3) approved by engineRejection:
onboarding.started (1)onboarding.completed (2)onboarding.rejected (3)Manual review, then resolved:
onboarding.started (1)onboarding.completed (2)onboarding.review (3)onboarding.approved (4) analyst decision (or onboarding.rejected)Blocked:
onboarding.started (1)onboarding.blocked (2)Expired:
onboarding.started (1)onboarding.expired (2)KYC Minor
Section titled “KYC Minor”The linkage.* events reference the parent (minor’s) execution_id, so the representative’s progress surfaces on the parent’s chain. That is not true of every event the pairing produces: a representative child emits its own onboarding.expired, onboarding.approved and onboarding.rejected on the child’s execution_id.
Because event_sequence is computed per execution id, those child events land on a separate sequence chain that restarts at 1. An integrator ordering by event_sequence and keying on the parent will see a stray event carrying an unknown execution_id and sequence 1. Those payloads carry parent_execution_id, which is the field to correlate on.
Automatic approval:
onboarding.started (1) session createdlinkage.pending (2) minor completed; parent in pending_representativelinkage.started (3) representative accessed their linklinkage.completed (4) representative finishedonboarding.completed (5) consolidation; decision engine processingonboarding.approved (6) approved by engineRejection:
onboarding.started (1)linkage.pending (2)linkage.started (3)linkage.completed (4)onboarding.completed (5)onboarding.rejected (6)Manual review, then resolved:
onboarding.started (1)linkage.pending (2)linkage.started (3)linkage.completed (4)onboarding.completed (5)onboarding.review (6)onboarding.approved (7) analyst decision (or onboarding.rejected)Blocked during the minor module:
onboarding.started (1)onboarding.blocked (2)Expired waiting for the representative:
onboarding.started (1)linkage.pending (2)onboarding.expired (3) parent expiredlinkage.expired (4) representative's link expiredThe onboarding.expired is dispatched before the linkage.expired, not after it.
Representative rejected by analyst:
onboarding.started (1)linkage.pending (2)linkage.started (3)linkage.completed (4)onboarding.completed (5)onboarding.review (6)onboarding.rejected (7) parent decisionlinkage.rejected (8) analyst rejected the representativeAs with expiry, the onboarding.rejected is dispatched before the linkage.rejected.
Payload
Section titled “Payload”{ "event": "onboarding.approved", "execution_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "flow_type": "kyc_minor", "reference_id": "SOL-2026-00042", "event_id": "7d3f2a9c-5b1e-4c8a-9f0d-2e6b4a8c1d35", "event_sequence": 6, "timestamp": "2026-04-01T10:45:00Z", "data": { "decision": { "result": "approved", "risk_level": "low", "decided_by": "engine", "decided_at": "2026-04-01T10:45:00Z" }, "liveness": { "status": "executed", "result": "passed" } }}| Field | Description |
|---|---|
event | Event type. |
execution_id | Parent execution identifier. For linkage.* events on kyc_minor, this is the minor’s execution ID. The linked child’s identifier is not carried in the data block of the kyc_minor linkage events; only the beneficial owner events of a kyb company carry it, in data.linked.execution_id. A consumer that needs the child id has to fetch it. |
flow_type | Journey type. |
reference_id | Integrator identifier, as sent during session creation. |
event_id | Event UUID, the same value as the X-Guardline-Event-ID header. It travels inside the signed body, so the HMAC signature covers it, and it is identical on every retry of the same delivery. This is the preferred deduplication key. Deliveries that predate the field do not carry the key; see Deduplication. |
event_sequence | Sequential event number within the execution, for canonical ordering. Together with execution_id it identifies the event uniquely, and it is the fallback deduplication key when event_id is missing. |
timestamp | Event emission timestamp in ISO 8601. |
data | Additive block, absent when it would be empty. Contents below. |
The data block
Section titled “The data block”Everything here is additive: a key is simply absent when there is nothing to report, so a receiver written against an older envelope keeps working.
| Key | When present | Contents |
|---|---|---|
data.decision | Once the execution has a decision, and on several events that have none | result, risk_level, risk_score, decided_by, decided_at. result does not always hold a decision; see below. |
data.liveness | On terminal events, whether or not the flow carries a proof-of-life step | Proof-of-life outcome. Detailed below. |
Despite the name, data.decision does not always carry a decision. On several events result carries an execution status instead: pending_representative, in_progress, completed, blocked, cancelled and expired all appear there. Those same events ship risk_level: "low", which is a default value rather than a real risk assessment. Do not branch on result against the decision vocabulary alone, and do not read risk_level as an assessment unless result genuinely holds a decision.
Two events are an exception to the shape above: linkage.rejected and linkage.expired carry only decision.
data.decision
Section titled “data.decision”Present on events that carry a decision or a linkage state transition (onboarding.approved/rejected/review/blocked and linkage.*).
| Field | Description |
|---|---|
result | approved, rejected, pending_review, blocked, or the linkage state (pending_representative, in_progress, completed, expired). |
risk_level | Derived from the decision score by a fixed ladder: low (score up to 150), medium (up to 350), high (up to 700), critical (above 700). An execution with no score publishes low. |
risk_score | The whole-number score risk_level is derived from, on engine and analyst decisions. Omitted when the execution was never scored. |
decided_by | engine on engine decisions and analyst on analyst decisions, on the webhook and on the query endpoint. |
decided_at | Decision timestamp in ISO 8601. |
data.liveness
Section titled “data.liveness”Present on terminal events: onboarding.completed, onboarding.approved, onboarding.rejected, onboarding.review and onboarding.blocked emitted by the engine, the same events when an analyst decides, and onboarding.expired. The proof-of-life step (iProov) is modular per flow: a journey may run it, skip it by configuration, or belong to a flow variant without it, and the integrator can toggle it per session via the biometric field on POST /api/v1/onboarding/sessions. This block lets the integrator tell “not executed by configuration” apart from “failed”.
status | Meaning |
|---|---|
executed | The proof of life ran and passed. Step completion requires passed liveness evidence, so executed implies approval; result: "passed" accompanies it. |
not_executed | The flow carries the step enabled but the journey ended without completing it (failure, attempt cap, abandonment). |
skipped_by_config | The step exists in the flow but was switched off by configuration: disabled on the flow, disabled for this session ("biometric": false), or an unmet flow condition. |
not_configured | The flow variant has no proof-of-life step. |
The result field is present only when status is executed and currently only takes passed. Best-effort semantics: if the block cannot be resolved at emission time it is omitted; its absence must not fail the integrator’s parser.
Notes:
- Absence of the block means “unknown”, never
not_configured. Envelopes emitted before this contract existed also lack it. Do not infer configuration from the absence of thelivenessobject; “the journey has no proof of life” is signaled exclusively by the explicitnot_configuredvalue. onboarding.cancelleddeliberately does NOT carry the block: it is scoped to outcome-bearing terminal events, and a cancellation is not an outcome.
Headers
Section titled “Headers”| Header | Description |
|---|---|
Content-Type | Always application/json. |
X-Guardline-Event-ID | Stable event UUID, identical on every attempt of the same event and always equal to the body event_id. The header is still sent, but the signature does not cover it: deduplicate on the event_id from the signed body and use the header to correlate logs. See Deduplication. |
X-Guardline-Delivery-ID | The delivery row identifier. It is stable across all attempts of that delivery, not unique per attempt: a retry carries the same value the first attempt carried. Do not deduplicate on it, or every retry is silently discarded. |
X-Guardline-Attempt-Number | Sequential attempt number (1 for first delivery, 2 for first retry, etc.). This is the only per-attempt discriminator. |
X-Guardline-Timestamp | Unix timestamp of the signature moment (hmac mode only). |
X-Guardline-Signature | HMAC-SHA256 signature in hexadecimal, made with the current secret (hmac mode only). |
X-Guardline-Signatures | Comma-separated list with one signature per valid secret, the current secret’s first (hmac mode only). Outside a rotation it has a single item, equal to X-Guardline-Signature. During a rotation window it also carries the signature made with the previous secret. Accept the delivery if any item matches. See Credential Rotation. |
Ocp-Apim-Subscription-Key | API gateway subscription key. Sent on every delivery in mtls_apim mode, where it is mandatory. |
X-API-Key | Static key the integrator’s endpoint expects, when one is configured on the webhook endpoint. Optional and independent of the HMAC signature. |
Deduplication
Section titled “Deduplication”Every event carries its own UUID, event_id, inside the delivery body, next to event_sequence. It is the same value the X-Guardline-Event-ID header carries, it stays identical on every retry of the same delivery, and it is never repeated across two events. Because what gets signed is {timestamp}.{body}, the signature covers event_id; the header sits outside the signed material, so a replayed delivery with a tampered header would verify like the original if the receiver took the event identity from the header alone.
Deduplication keys, in order of preference:
event_idfrom the body, authenticated by the signature. This is the recommended key in both authentication modes: the field is injected into the body of every delivery, whatever the mode.execution_id+event_sequence, when the body carries noevent_id. The pair identifies an event uniquely: the sequence is allocated per execution under a lock, is stable across retries of the same delivery, and is never reused for two events of the same execution.
The X-Guardline-Event-ID header is still sent and is always equal to the body event_id. Use it to correlate logs and to debug; do not use it as the only deduplication key.
Deliveries that predate the field carry no event_id key in the body. The receiver must tolerate its absence and fall back to the second key, instead of rejecting the delivery.
Do not deduplicate on X-Guardline-Delivery-ID: it identifies the delivery row, not the event, and would silently drop every retry.
Webhook Authentication
Section titled “Webhook Authentication”Guardline supports two authentication modes for webhook delivery, hmac and mtls_apim. The mode is configured per webhook endpoint, and a database constraint refuses any other value. There is no bearer-token or OAuth delivery mode: Guardline never sets an Authorization header on a webhook request.
The two modes are exclusive rather than cumulative. hmac signs the body; mtls_apim authenticates at the transport layer and sends no signature headers at all.
| Mode | HMAC Signature | Client Certificate | Subscription Key |
|---|---|---|---|
hmac (default) | Yes | No | No |
mtls_apim | No | Yes | Yes |
HMAC-SHA256 Authentication
Section titled “HMAC-SHA256 Authentication”HMAC authentication uses a shared secret provisioned during integrator setup in the admin panel.
Verification process on the receiver:
- Extract the
X-Guardline-Timestampheader. - Reject if the timestamp is older than 5 minutes relative to the server clock.
- Concatenate timestamp and request body:
{timestamp}.{body}. - Compute
HMAC-SHA256of the concatenation using the shared secret. - Encode the result in hexadecimal.
- Compare with the
X-Guardline-Signatureheader using constant-time comparison. - Reject if there is a mismatch.
The signed body includes the event’s event_id, so the same verification authenticates the identity you later deduplicate on. The X-Guardline-Event-ID header is not part of the signed material.
Pseudocode:
import hmac, hashlib, time
def verify_webhook(body: bytes, timestamp: str, signature: str, secret: str) -> bool: if abs(time.time() - int(timestamp)) > 300: return False
message = f"{timestamp}.".encode() + body expected = hmac.new( secret.encode(), message, hashlib.sha256 ).hexdigest()
return hmac.compare_digest(expected, signature)mTLS Authentication
Section titled “mTLS Authentication”Mutual TLS authenticates the delivery at the transport layer instead of signing the body. The mode targets an API gateway rather than a bare HTTPS listener, and it requires the Ocp-Apim-Subscription-Key header described above.
Setup is operator-side. Guardline does not issue a certificate for the integrator to trust; it presents whatever it has been given:
- The operator uploads, through the admin API, the client certificate and its private key that Guardline should present.
- The operator uploads a CA bundle. This is optional, and when present it is used by Guardline to verify the destination server’s certificate. It is not distributed to the integrator.
- The operator configures the gateway subscription key, which is required for this mode.
- On each delivery, Guardline presents the uploaded client certificate and sends the subscription key in
Ocp-Apim-Subscription-Key. - The gateway validates the certificate against whatever trust store it was configured with.
Nothing in Guardline sets or checks a CN, so do not gate acceptance on that field: the certificate carries whatever subject the operator uploaded. Peer identity on Guardline’s side is ordinary hostname verification against the configured URL.
When mtls_apim is enabled, the X-Guardline-Signature and X-Guardline-Timestamp headers are not sent.
Endpoint Configuration
Section titled “Endpoint Configuration”The webhook endpoint is configured in the Guardline admin panel. Requirements:
| Requirement | Detail |
|---|---|
| Protocol | HTTPS required. |
| TLS version | 1.2 or higher. |
| Success response | Any 2xx code (body ignored). |
| Redirects | Not followed. A 301 or 302 is refused. |
| Response headers | Send the status line within 5 seconds. |
| Timeout | Complete the response within 10 seconds. |
| Idempotency | Deduplicate deliveries using the event_id from the signed body; when the delivery does not carry it, use execution_id + event_sequence. See Deduplication. |
Implementation recommendation: receive the webhook, enqueue for asynchronous processing, and respond immediately with 200. This avoids timeouts and ensures reliable delivery.
Two of those rows cost integrations time, so they are worth stating plainly.
The response budget is not one 10 second window. The transport also enforces a 5 second response-header timeout, so a receiver that takes 7 seconds to send its status line fails even though it is inside the documented 10 seconds. Send headers early and finish the body afterwards.
Redirects are refused outright, not followed. An endpoint sitting behind a 301 or 302 never delivers. Worse, the refusal surfaces as a transport error rather than an HTTP status, so it counts as retryable and burns the entire retry budget before the delivery is exhausted. Configure the final URL.
Credential Rotation
Section titled “Credential Rotation”When mTLS certificates or the HMAC secret are rotated, in-flight retries for previously dispatched deliveries continue using the credentials that were active at the time of dispatch. New deliveries use the updated credentials immediately.
Rotating the HMAC secret does not break a receiver that has not switched values yet. The previous secret stays valid for an overlap window, 24 hours by default, and during it every delivery carries in X-Guardline-Signatures one signature with the new secret and one with the previous. X-Guardline-Signature remains the new secret’s only. To switch secrets without losing deliveries, verify against every item of X-Guardline-Signatures, deploy the new value on the receiver within the window, and only then ask Guardline to close the previous secret. For a leaked secret, Guardline can revoke the previous one at once, which interrupts any receiver still using it.
An operator has two resend paths, and they do not behave the same way:
| Path | Credentials used | Delivery row |
|---|---|---|
| Resend rebuilt from live configuration | The configuration as it stands now, so a rotation is picked up. | Creates a new delivery. The original is kept intact for audit purposes. |
| Re-queue of the existing delivery | The credential snapshot captured when the delivery was enqueued. | Reuses the same row. |
The distinction matters after a rotation: an operator who rotates a certificate and then re-queues the existing delivery re-sends it with the revoked credentials, and it fails again for the same reason.
The rebuilding path has a consequence for the receiver. Because it creates a new delivery, the resend carries a new event id and a new sequence number. That breaks both keys this document tells integrators to rely on: event_id (in the body and in the X-Guardline-Event-ID header) no longer matches the original, so deduplication will not recognize the resend as a repeat of an event already processed, and the fresh event_sequence does not place the event where it belongs in the execution’s chain.
Retry Policy
Section titled “Retry Policy”When a delivery fails, Guardline retries it with exponential backoff. The interval below is the wait after the numbered attempt fails:
| Failed attempt | Wait before the next attempt |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 30 minutes |
| 5 | 1 hour |
| 6 | 2 hours |
| 7 | 4 hours |
| 8 | 8 hours |
| 9 | 12 hours |
| 10 | None. The delivery is exhausted. |
Every interval above is nominal, for two reasons.
Each scheduled delay is multiplied by a random jitter factor between 0.8 and 1.2, so a nominal 1 hour gap lands anywhere between 48 and 72 minutes.
The dispatcher drains the queue on a 30 second tick rather than continuously. A delivery whose delay has already elapsed still waits for the next tick, and the same tick delays the very first delivery of any event by up to 30 seconds after the event itself. Do not treat a webhook as a real-time signal.
Not every failure earns the ladder above. It applies to timeouts, transport errors, 5xx responses, and to exactly two 4xx codes. Any other 4xx is treated as non-retryable and exhausts the delivery on the first attempt, with no retries at all. That covers the most likely real failure: a receiver that rejects the envelope, an inbound key rotated on the integrator’s side, or a route that is not live yet. All of them consume the delivery immediately instead of leaving a window to fix the problem. The two 4xx exceptions that are retried are 408 Request Timeout and 429 Too Many Requests.
After 10 failed attempts, the delivery is marked as exhausted and an internal alert is generated. Notifications remain accessible via the polling endpoint (see Notification Polling). Manual resend is also available in the admin panel.
Notification Polling
Section titled “Notification Polling”The polling endpoint is the fallback mechanism to ensure no notification is lost, regardless of webhook failures. It can also be used as the primary integration mechanism by integrators who prefer a pull model.
Polling reads the same deliveries the webhook sends, so it inherits the configuration’s event subscription. A webhook configuration may subscribe to some events only; an event outside the list is dropped before it becomes a delivery, and is then neither sent by webhook nor listed here. A configuration with no list subscribes to every event, which is how every configuration predating this feature behaves. If you rely on polling as a safety net, confirm with Guardline that the configuration subscribes to every event you handle.
Querying Pending Notifications
Section titled “Querying Pending Notifications”GET /api/v1/onboarding/notificationsThis endpoint uses cursor-based pagination.
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | pending | Filter by state: pending, retrying, exhausted. Comma-separated for multiple values. |
limit | integer | 50 | Items per page (1 to 200; values outside the range fall back to 50). |
after_cursor | string | (none) | Cursor for the next page: the base64-encoded delivery_id returned in next_cursor by the previous call. Read the pagination warning below before using it. |
failed is accepted by the filter but the dispatcher never assigns that state, so asking for it returns nothing. The terminal state an integrator actually needs is exhausted, which works.
Cursor pagination is reliable only for the first page. The query orders rows by creation time descending, but it compares the cursor against the random UUID primary key. The two orderings have nothing to do with each other, so every page after the first is an arbitrary subset that both skips rows and repeats them. Do not paginate deeply. Fetch the first page, acknowledge what you processed, then fetch the first page again: acknowledged rows drop out of the result, so the queue drains without a cursor.
Response (200):
{ "error": false, "data": { "items": [ { "delivery_id": "d4e5f6a7-b8c9-0123-def0-123456789abc", "execution_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "event": "onboarding.approved", "flow_type": "kyc_minor", "reference_id": "SOL-2026-00042", "event_sequence": 6, "status": "pending", "attempts": 3, "timestamp": "2026-04-01T10:45:00Z" } ], "next_cursor": null }}next_cursor is a base64 string when more pages may exist (returned when the page is full), or null when there are no further items. Pass it back as after_cursor to fetch the next page, subject to the warning above. Each item carries flow_type and reference_id, the current attempts count and the timestamp of the delivery.
attempts is pre-incremented on failure, so it runs one ahead of the attempts actually made: a row showing 3 has been tried twice.
last_attempt_at is not returned. The field is sourced from the delivery timestamp, which is written only on success, and this endpoint returns only non-delivered rows, so it is structurally always absent. Do not write a consumer that depends on it.
The list is not scoped to the integrator’s webhook configuration. On an instance that also runs the case-management egress, deliveries addressed to that internal receiver appear in the same list, carrying event names outside the catalogue documented here. Filter on the event names you recognize and ignore the rest.
Acknowledging Processing
Section titled “Acknowledging Processing”POST /api/v1/onboarding/notifications/{delivery_id}/ackMarks a notification as processed. After acknowledgment, the notification no longer appears in queries with pending status. The operation is idempotent: repeated calls for the same delivery_id return success with no side effects.
Response (200):
{ "error": false, "data": { "delivery_id": "d4e5f6a7-b8c9-0123-def0-123456789abc", "status": "acknowledged" }}Complete Flow Diagrams
Section titled “Complete Flow Diagrams”Standard KYC
Section titled “Standard KYC”KYC Minor with Guardline-Delivered Notification
Section titled “KYC Minor with Guardline-Delivered Notification”Error Reference
Section titled “Error Reference”HTTP Status Codes
Section titled “HTTP Status Codes”| Code | Meaning | Guidance |
|---|---|---|
| 200 | Success | Process normally. |
| 201 | Resource created | Process normally. |
| 400 | Invalid request | Fix the payload. Do not retry. |
| 401 | Authentication failed | Check the API Key (or JWT for resend-representative-link). Do not retry. |
| 403 | Forbidden | The credential verified but carries no authorized role (JWT) or no required scope (API key). Do not retry. |
| 404 | Resource not found | Check the identifiers. Do not retry. |
| 405 | Method not allowed | The path exists but not for this verb. Fix the call. Do not retry. |
| 409 | Conflict | Handle according to the specific error code. |
| 410 | Gone | The resource was valid but is no longer accessible (e.g., expired link). |
| 413 | Payload too large | The body exceeded the 13 MiB global cap or the route’s cap. Do not retry. |
| 422 | Unprocessable | The body parsed but violates a domain rule. Fix the request. Do not retry. |
| 429 | Rate limited | Two different limiters answer 429; see below. |
| 500 | Internal error | Retry with exponential backoff (maximum 3 attempts). |
| 502 | Internal error during processing | Retry with exponential backoff. |
The 429 needs splitting, because only one of its two sources can be waited out. The per-IP limiter on link resolve sends a Retry-After header and clears on its own: wait the interval indicated and retry. The resend cap on POST .../resend-representative-link is a lifetime counter on the execution, not a window. It sends no Retry-After, it never resets, and retrying can never succeed. Read that 429 as “this execution is out of resends” and stop; see Cap.
Domain Error Codes
Section titled “Domain Error Codes”Note: error
messagefields are localized byAccept-Language(see Error Message Locale). Match oncodefor programmatic branching.
| Code | HTTP | Description |
|---|---|---|
MISSING_TOKEN | 401 | No credential reached the handler: neither X-API-Key nor Authorization was sent, or the credential was empty. This is the common 401 on every authenticated endpoint, including the API-key ones. It also covers the JWT missing on resend-representative-link. |
INVALID_API_KEY | 401 | API Key invalid, revoked, or expired. |
INVALID_TOKEN | 401 | A Bearer JWT was sent and failed verification: bad signature, wrong issuer, or expired. |
FORBIDDEN | 403 | JWT lacks the required role (admin or supervisor). |
FLOW_ID_REQUIRED | 400 | flow_type without flow_id on an instance with more than one active flow of that type. |
INVALID_REQUEST | 400 | Malformed JSON, unknown field, or wrong field type. |
INVALID_ID | 400 | A path UUID is malformed. |
VALIDATION_FAILED | 400 | Field-level validation failed, including a request that sends neither flow_id nor flow_type. |
INVALID_TAX_ID | 400 | primary.tax_id does not match the shape the resolved flow’s country issues. |
INVALID_IDEMPOTENCY_KEY | 400 | Idempotency-Key header longer than 255 characters. |
LINKED_SUBJECTS_REQUIRED | 400 | kyc_minor request with missing/empty/oversized linked_subjects or wrong role. |
INVALID_FLOW_TYPE | 400 | flow_id resolves to an internal-use flow type. |
MISSING_REQUIRED_FIELDS | 400 | kyc_minor missing primary.tax_id or primary.full_name. |
PHONE_POLICY_VIOLATION | 422 | A mobile-only flow received a landline phone number. |
UNPROCESSABLE | 422 | A URL policy was violated. |
CPF_MINOR_REPRESENTATIVE_MATCH | 422 | The representative’s CPF equals the minor’s CPF. |
LINKED_SUBJECT_RELATIONSHIP_REQUIRED | 422 | A kyc_minor entry omitted linked_subjects[0].subject.relationship. |
REPRESENTATIVE_AGE_BELOW_MINIMUM | 422 | The representative’s birth_date indicates age under 18. |
ACTIVE_SESSION_EXISTS | 409 | Active session exists for the same primary subject and flow. |
CUSTOMER_NOT_FOUND | 404 | No active record for the given id, on the session-by-customer-id routes. Distinct from FLOW_NOT_FOUND, because both can be missing on one request. |
DUPLICATE | 409 | A unique constraint rejected the write, typically two concurrent calls racing to create the same row. |
EXECUTION_NOT_FOUND | 404 | Execution not found. The read carries no tenant predicate, so this is an existence check and not an ownership test; isolation is the per-instance database (see Environments). |
NOT_FOUND | 404 | Generic miss. Cancel returns this, not EXECUTION_NOT_FOUND, when the execution id matches nothing. |
EXECUTION_TERMINAL | 409 | A cancel was requested on an execution already in a terminal state. |
EXECUTION_TERMINATED | 409 | The primary execution has reached a terminal state (resend-representative-link). |
NO_RESENDABLE_CHILD | 404 | No representative child to resend (resend-representative-link only). |
LINK_NOT_FOUND | 404 | Link token does not exist. |
LINK_EXPIRED | 410 | Link token expired. |
FLOW_NOT_FOUND | 404 | Flow not found. |
RATE_LIMITED | 429 | Request limit exceeded. |
PAYLOAD_TOO_LARGE | 413 | The request body exceeded the global 13 MiB cap, or the route’s own cap. The journey’s document upload route accepts a file of up to 8 MiB, about 11.7 MiB of body after base64 expansion. Above the global cap the connection is refused before it reaches the API, so that refusal carries no JSON error envelope. |
ROUTE_NOT_FOUND | 404 | The path matches no registered route. |
METHOD_NOT_ALLOWED | 405 | The path exists but does not accept this verb. |
INTERNAL_ERROR | 500 / 502 | The generic fallback for an unmapped failure, raised across most endpoints. 500 from the shared error handler; 502 where a handler wraps a downstream call, as the session endpoint does. |
Endpoint Summary
Section titled “Endpoint Summary”| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/onboarding/flows | API Key or JWT | List available journeys. |
| POST | /api/v1/onboarding/sessions | API Key or JWT | Create an onboarding session (with optional linked subjects). |
| GET | /api/v1/link/{token} | Public (rate-limited) | Resolve a link token to flow + customer payload. Sanitized when the session was created with surface_hint=embed. |
| GET | /api/v1/onboarding/executions/{execution_id} | API Key or JWT | Query execution with complete data. |
| GET | /api/v1/onboarding/executions | API Key or JWT | List active executions. |
| GET | /api/v1/executions/stats | API Key or JWT | Faceted counts by status, risk band and flow type, over the whole filtered set. |
| POST | /api/v1/onboarding/executions/{execution_id}/resend-representative-link | Admin/Supervisor JWT | Resend the representative link over the flow’s notification channels. |
| POST | /api/v1/executions/{execution_id}/cancel | API Key or JWT | Cancel an execution (and its representative child for kyc_minor), releasing the active-session slot for reuse. |
| GET | /api/v1/onboarding/notifications | API Key or JWT | Poll pending webhook notifications. |
| POST | /api/v1/onboarding/notifications/{delivery_id}/ack | API Key or JWT | Acknowledge a webhook notification. |
Version 18, updated on