Skip to content
This page describes the ONP API of the platform's Brazilian line. The Spanish version describes the Argentina and Uruguay line.

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.

The integration flow involves four participants:

ParticipantResponsibility
Integrator backendCreates sessions, receives webhooks, queries executions
Guardline APIManages sessions, tokens, executions, linked subjects, and notifications
Integrator frontendRenders the link directly or mounts an iframe pointing at it
Guardline onboardingConducts 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.

EnvironmentBase URLAPI Key prefix
Sandboxhttps://{instance}.onp.dev.guardline.com.brgl_test_
Productionhttps://{instance}.onp.prod.guardline.com.brgl_live_

The {instance} value is provisioned during integrator setup and identifies the dedicated infrastructure. Each instance has its own isolated database, application, and storage.

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_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2

Keys 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:

EndpointAuth
POST /api/v1/onboarding/executions/{execution_id}/resend-representative-linkAdmin 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.

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.

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.

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-LanguageResult
absent, unparseable, or unmatchedthe instance’s default locale (pt-BR unless the instance is configured otherwise)
pt-BRpt-BR
es-AR, es with no region, and Latin American variants such as es-UYSpanish (Argentina)
Terminal window
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.

GET /api/v1/onboarding/flows

Returns 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.

{
"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.

POST /api/v1/onboarding/sessions

Creates 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.

FieldTypeRequiredDescription
flow_typestringOne of flow_type or flow_idJourney 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_idUUIDOne of flow_type or flow_idSpecific 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.
channelenumNoOrigin of the session: web, sdk, api. Default: web. The legacy alias channel_code is also accepted; when both are sent, channel wins.
layout_modeenumNoUI 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_idstring (≤ 255)NoFree-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_idUUIDNoLinks 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.
metadataobjectNoFree-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_hintenumNoSet 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).
primaryobjectRequired for kyc_minor; optional for kyc and kybPrimary 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_subjectsarrayRequired 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.
notifyobjectNoNotification opt-in flags. See Notification Opt-in.
biometricbooleanNoToggles 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).
partnerobjectNoNames 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 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 typeAccepted in primary.tax_id
kyc, kyc_minorA valid CPF: 11 digits, check digits verified.
kybA 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.

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:

FieldTypeRequiredDescription
roleenumYesRole 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.
subjectobjectYesSubject data block. May be empty ({}) for shell creation.

The subject block accepts the following fields:

FieldTypeRequiredDescription
full_namestringPaired with tax_id (see Field Requiredness below)Full name.
tax_idstringPaired with full_namePersonal 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.
phonestring (E.164)NoPhone 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_datestring (YYYY-MM-DD)NoDate of birth. When provided, it must indicate age 18 or over; a younger representative is rejected with 422 REPRESENTATIVE_AGE_BELOW_MINIMUM.
emailstringNoEmail.
relationshipenumYes for kyc_minor; optional otherwiseRepresentative’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_channelenumNoPreferred 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:

ShapeContentEffect
Shellsubject 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.
Mixedsubject: {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.
Materializedsubject: {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.

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_subjects field absent
  • linked_subjects: [] (empty array)
  • More than one entry
  • An entry with role other than representative

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.relationship is required. Omitting it returns 422 LINKED_SUBJECT_RELATIONSHIP_REQUIRED.
  • The representative’s tax_id must differ from the minor’s tax_id. Reusing the same CPF returns 422 CPF_MINOR_REPRESENTATIVE_MATCH.

The notify block controls whether Guardline delivers the URL itself, over SMS, email or WhatsApp.

FieldTypeDefaultDescription
notify.primaryboolfalseReserved. Currently has no effect; future versions will dispatch the primary’s URL.
notify.linkedboolfalseWhen 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.

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).

When the dispatch fires, the channels are selected by:

  1. linked_subjects[i].subject.notification_channel when present: sms sends SMS only, whatsapp sends WhatsApp only, email_sms sends SMS and email.
  2. 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.

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.linked was off, the subject carried neither phone nor email, 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.

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".

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.

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.

{
"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).

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.

{
"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.

{
"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
}
]
}
}
FieldDescription
execution_idUnique execution identifier for the primary subject.
customer_idCustomer 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.
urlPublic URL for the primary subject’s journey.
embed_urlURL pointing to the iframe-friendly route for the primary subject. Use this as the iframe src when rendering inside the integrator’s app.
tokenSlug of the link, also accepted by GET /api/v1/link/{token}.
expires_atLink 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.
statusInitial execution status (created).
reference_idEcho 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].roleRole of the linked subject (representative for kyc_minor, officer for kyb).
linked_subjects[i].execution_idLinked subject’s execution ID.
linked_subjects[i].urlLinked subject’s journey URL.
linked_subjects[i].embed_urlIframe-friendly URL for the linked subject.
linked_subjects[i].subject_statusLinked subject’s initial status (created).
linked_subjects[i].notify_sentAlways 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.

Note: error message fields are localized by Accept-Language. Match on code for programmatic branching, not on message text.

HTTPCodeCause
400INVALID_REQUESTMalformed JSON, unknown field (the message names the field), or wrong field type.
400INVALID_IDEMPOTENCY_KEYThe Idempotency-Key header is longer than 255 characters.
400VALIDATION_FAILEDField-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.
400INVALID_TAX_IDprimary.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.
400FLOW_ID_REQUIREDThe 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.
400LINKED_SUBJECTS_REQUIREDkyc_minor request with missing, empty, or oversized linked_subjects array, or with a role other than representative.
400INVALID_FLOW_TYPEflow_type is not one of kyc, kyb, kyc_minor, or flow_id resolves to an internal-use flow type (such as kyc_representative).
400MISSING_REQUIRED_FIELDSkyc_minor request without primary.tax_id or without primary.full_name.
422LINKED_SUBJECT_RELATIONSHIP_REQUIREDkyc_minor request whose linked_subjects[0].subject.relationship is absent or empty. Applies to all kyc_minor shapes (shell, mixed, materialized).
401MISSING_TOKENNeither X-API-Key nor Authorization was sent, or the credential was empty.
401INVALID_API_KEYAPI Key invalid, revoked, or expired.
401INVALID_TOKENJWT callers only: the bearer credential carries no gl_live_/gl_test_ prefix, so it is verified as a JWT, and that verification failed.
403FORBIDDENWith a JWT: the token carries no authorized role, or the account is disabled.
404FLOW_NOT_FOUNDThe flow_id does not exist.
409ACTIVE_SESSION_EXISTSAn 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.
422PHONE_POLICY_VIOLATIONA mobile-only flow received a landline number in primary.phone or linked_subjects[i].subject.phone.
422CPF_MINOR_REPRESENTATIVE_MATCHkyc_minor request where the representative’s tax_id equals the minor’s tax_id.
422REPRESENTATIVE_AGE_BELOW_MINIMUMThe representative’s birth_date indicates age under 18.
422LINKED_SUBJECT_RELATIONSHIP_REQUIREDkyc_minor request whose representative entry omits subject.relationship.
500INTERNAL_ERRORAmong 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.
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).

{
"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
}
}
FieldDescription
execution_idExecution identifier of the subject this token belongs to.
flowFlow 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.
customerCustomer record. Fields below.
customer.idUUID 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.nameFull name. Empty on a cold-start row.
customer.cpfUnmasked CPF. Removed when surface_hint=embed.
customer.cpf_maskedMasked 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.emailEmail. Removed when surface_hint=embed.
customer.phonePhone in E.164. Removed when surface_hint=embed.
customer.birth_dateDate of birth in YYYY-MM-DD. Removed when surface_hint=embed.
minorWhen 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.
relationshipRepresentative’s relationship to the minor (mother, father, etc.). Present only on kyc_minor tokens; omitted otherwise.
statusCurrent execution status. See Execution Lifecycle.
expires_atLink expiration.
allowed_originsOrigins allowed to embed the iframe (CSP frame-ancestors).
representative_linkURL 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_tokenThat same child’s raw link token (FE uses for redirection). Same presence condition as representative_link. Removed when surface_hint=embed.
representative_statusCurrent 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_senttrue 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.

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 to null), representative_link, representative_token, representative_notify_sent, all flow.steps[].display_data and flow.steps[].completed_fields.
  • Preserved: customer.id, customer.name, customer.cpf_masked, the entire flow block (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.

Note: error message fields are localized by Accept-Language. See Error Message Locale.

HTTPCodeCause
404LINK_NOT_FOUNDToken does not exist.
410LINK_EXPIREDTwo 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.
429RATE_LIMITEDCap 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.
POST /api/v1/onboarding/executions/{execution_id}/resend-representative-link

Re-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.

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.

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.

HTTPCodeCause
400INVALID_IDexecution_id in the path is not a valid UUID.
400VALIDATION_FAILEDThe 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.
401MISSING_TOKENJWT not provided.
403FORBIDDENJWT does not have admin or supervisor role.
404EXECUTION_NOT_FOUNDThe primary execution does not exist.
404NO_RESENDABLE_CHILDNo representative child in a non-terminal state to resend. The resend slot is already spent when this fires.
409EXECUTION_TERMINATEDPrimary execution is approved, rejected, cancelled or expired. blocked is not checked here.
429RATE_LIMITED3-resend lifetime cap reached. Does not reset.
502INTERNAL_ERRORAny 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.

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.

StateTerminalDescription
createdNoSession created, no step submitted yet. Opening the link does not leave this state; the first step submit does.
in_progressNoUser is navigating through journey steps.
pending_representativeNoWaiting 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”.
processingNoAll steps submitted; decision engine processing.
pending_reviewNoReferred 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.
approvedYesApproved by the engine or by an analyst.
rejectedYesRejected by the engine or by an analyst.
completedYesAll 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.
blockedYesJourney interrupted by a security rule (CPF restriction, biometric attempt limit).
cancelledYesCancelled by the integrator or by an administrator.
expiredYesSession expired before completion.

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.

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.

statusstatus_detailMeaning
pending_reviewin_analysisUnder analysis, no registration correction open.
pending_reviewawaiting_correctionA registration correction round is open, waiting for the partner. See Registration Correction.
pending_reviewcorrection_submittedThe partner sent the correction, which waits for the analyst.
pending_reviewcorrection_expiredThe round’s deadline passed with no submission, and the execution waits for the analyst.
pending_representativeawaiting_beneficial_ownersA kyb company waits for its beneficial owners to be verified. See Beneficial Owners.
pending_representativepending_representativeEvery other case, such as a kyc_minor parent waiting for the legal representative.
any otherthe status itselfRepeats the status.

Outside pending_review and pending_representative the sub-state is ignored, so a stale sub-state never contradicts a final status.

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.

ClockSourceDefaultOnly expires while the status is
link_expires_atThe installation’s LINK_EXPIRATION_DAYS7 dayscreated
expires_atThe flow’s own expires_in_hours168 h, that is 7 dayscreated or in_progress
SessionThe installation’s SESSION_TIMEOUT_HOURS, counted from started_at24 hin_progress

What each one means in practice:

  • link_expires_at covers a link that was never opened. Because it only acts on created, opening the link once already takes the execution out of that clock’s reach.
  • expires_at is 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.

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.

{
"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"
}
}
FieldDescription
execution_idExecution identifier.
statusCurrent execution status. See Execution Lifecycle.
status_detailStatus detail, always present. See Status Detail.
flow_typeJourney type.
reference_idEcho of the integrator’s correlator, when the session declared one.
steps_completed / steps_totalJourney progress.
decisionDecision 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.
customerPerson subject of the execution. Fields below.
data_divergencesAlways 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.
correctionThe latest registration correction round, or null when the execution never had one. See Registration Correction.
beneficial_ownersPresent only on a kyb company whose flow has the beneficial owner chain on. See Beneficial Owners.
created_at / started_at / completed_atJourney timestamps.
updated_atThe latest change of the execution or of its latest correction round, whichever is later.
expires_atThe execution deadline, derived from the flow’s expires_in_hours.
linksThe 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.
FieldDescription
full_nameFull name as collected.
tax_idThe person’s identity document, raw and unformatted. On Brazilian journeys this is the CPF.
tax_id_maskedMasked 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, incomeAs 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_obligationAlways emitted, including when false.
addresszip_code, street, number, complement, neighborhood, city, state.
document, biometricVerification status blocks: type and status.
representativeRepresentative 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.

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.

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:

FieldDescription
idRound identifier.
roundRound number, from 1.
max_roundsThe 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.
stateopen (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_atWhen the round was asked and its deadline. due_at is the round’s deadline, not the execution’s expires_at.
submitted_at / closed_atSubmission and closing; null until they happen.
close_reasonThe closing state itself (accepted, rejected, expired, withdrawn, cancelled or closed_by_decision), or null while the round is open. Never free text.
items_countHow many items the round asks for.
requires_customertrue 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_urlThe 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:

FieldDescription
messageThe analyst’s message to the partner, only when one was written.
itemsThe 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_roundsThe 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.

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:

FieldDescription
totalEvery beneficial owner of the frozen declaration.
sentThose who have not opened their link yet.
in_progressThose filling their journey.
in_reviewThose whose KYC is at the analyst desk.
approved / rejectedThose approved and those rejected.
expired / cancelledThose whose round’s deadline passed and those whose journey was cancelled.
max_roundsHow 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).

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:

codedescription
invalid_documentDocumento inválido
fraud_suspectedSuspeita de fraude
pep_restrictionRestrição PEP
sanctions_matchMatch em sanções
biometric_failedBiometria reprovada
high_riskRisco elevado
underageMenor de idade
incomplete_docsDocumentação incompleta
expired_documentDocumento vencido
data_inconsistencyDados inconsistentes
otherOutro

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.

HTTPCodeCause
400INVALID_IDThe execution_id in the path is not a valid UUID.
401MISSING_TOKENNo credential was sent, in either X-API-Key or Authorization: Bearer.
401INVALID_API_KEYAPI Key invalid, revoked, or expired.
404EXECUTION_NOT_FOUNDNo execution with that execution_id.
502INTERNAL_ERRORAny 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.

GET /api/v1/onboarding/executions

Returns 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.

ParameterTypeDefaultDescription
pageinteger1Requested page.
per_pageinteger20Items per page (maximum: 100). Values above 100 are clamped.
statuslist(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_fromdate (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_todate (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_idstring (≤ 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_idUUID(none)Filter by a specific flow version. A malformed UUID returns 400 INVALID_REQUEST.
flow_typestring(none)Filter by journey type: kyc, kyb or kyc_minor. Any other value, kyc_representative included, returns 400 INVALID_REQUEST.
status_detaillist(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_statelist(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.
namestring (≤ 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_idstring(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,completed

Since 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.

{
"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:

FieldDescription
status_detailThe same status detail as on the detail read. See Status Detail.
correctionThe summary of the latest correction round, or null. See Registration Correction.
updated_atThe latest change of the execution or of its latest correction round, whichever is later.
linksThe 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_divergencesAlways 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.

HTTPCodeCause
400INVALID_REQUESTstatus 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.
401MISSING_TOKENNeither X-API-Key nor Authorization was sent, or the credential was empty.
401INVALID_API_KEYAPI Key invalid, revoked, or expired.
502INTERNAL_ERRORThe listing itself failed.
GET /api/v1/executions/stats

Returns 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.

ParameterTypeDefaultDescription
flow_idUUID(unset)Filter by the flow that originated the executions.
flow_typestring(unset)Filter by journey type: kyc, kyb, kyc_minor, kyc_representative.
statuslist(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_fromdate (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_todate (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_idUUID(unset)Filter by status identifier. Prefer status.
queue_idUUID(unset)Filter by review queue. Takes precedence over queue.
queuestring(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-30

The total and the counts of that response cover the executions created from September 1 through September 30, Brasília time, both days included.

{
"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:

BandCondition
approvescore below review_threshold
reviewscore at or above review_threshold and below block_threshold
blockscore 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.

HTTPCodeCause
400INVALID_REQUESTstatus 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.
401MISSING_TOKEN / INVALID_API_KEYNeither X-API-Key nor Authorization was sent, or the credential was invalid.
500INTERNAL_ERRORThe 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:

Causemessage
status outside the 11 codesstatus inválido (valores aceitos: created, in_progress, pending_review, approved, rejected, cancelled, expired, processing, pending_representative, blocked, completed)
invalid created_fromcreated_from inválido: use o formato AAAA-MM-DD com uma data existente
invalid created_tocreated_to inválido: use o formato AAAA-MM-DD com uma data existente
created_from after created_toperíodo inválido: created_from é posterior a created_to
POST /api/v1/executions/{execution_id}/cancel

Transitions 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.

Accepts either:

  • X-API-Key (gl_live_* or gl_test_*), or
  • Authorization: Bearer <JWT> (backoffice JWT).

Honors the Idempotency-Key header; see Idempotency.

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"
}
FieldTypeRequiredDescription
reasonstring (max 500)NoFree-text reason recorded in audit and event payload.
{
"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"
}
}
FieldTypeDescription
execution_idUUIDExecution that was cancelled.
cancelled_childrenUUID arrayUUIDs 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_codestringAlways cancelled on success.
cancelled_atISO 8601Cancellation timestamp.
HTTPCodeCause
400INVALID_IDThe path UUID is malformed.
400INVALID_REQUESTThe request body is present but malformed.
400VALIDATION_FAILEDreason is longer than 500 characters.
401MISSING_TOKEN / INVALID_API_KEYAuthentication missing, invalid, or expired.
404NOT_FOUNDNo execution with the given UUID.
409EXECUTION_TERMINALExecution already in a terminal state. The refusal set is exactly four codes: approved, rejected, expired and cancelled.
429RATE_LIMITEDRate 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 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.

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 for kyc_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.

EventDescription
onboarding.startedSession created via POST /sessions.
onboarding.completedAll steps submitted; decision engine processing.
onboarding.approvedApproved by the engine or by an analyst.
onboarding.rejectedRejected by the engine or by an analyst.
onboarding.reviewReferred for manual review.
onboarding.blockedJourney interrupted by a security rule.
onboarding.expiredSession expired before completion.
onboarding.cancelledCancelled by the integrator or by an administrator.
EventDescription
linkage.pendingThe 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.startedThe legal representative accessed their link and submitted the first step.
linkage.completedThe 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.rejectedAn analyst rejected the representative’s execution in manual review.
linkage.expiredThe 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.

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.

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.

Automatic approval:

onboarding.started (1) session created
onboarding.completed (2) all steps submitted
onboarding.approved (3) approved by engine

Rejection:

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)

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 created
linkage.pending (2) minor completed; parent in pending_representative
linkage.started (3) representative accessed their link
linkage.completed (4) representative finished
onboarding.completed (5) consolidation; decision engine processing
onboarding.approved (6) approved by engine

Rejection:

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 expired
linkage.expired (4) representative's link expired

The 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 decision
linkage.rejected (8) analyst rejected the representative

As with expiry, the onboarding.rejected is dispatched before the linkage.rejected.

{
"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"
}
}
}
FieldDescription
eventEvent type.
execution_idParent 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_typeJourney type.
reference_idIntegrator identifier, as sent during session creation.
event_idEvent 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_sequenceSequential 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.
timestampEvent emission timestamp in ISO 8601.
dataAdditive block, absent when it would be empty. Contents below.

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.

KeyWhen presentContents
data.decisionOnce the execution has a decision, and on several events that have noneresult, risk_level, risk_score, decided_by, decided_at. result does not always hold a decision; see below.
data.livenessOn terminal events, whether or not the flow carries a proof-of-life stepProof-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.

Present on events that carry a decision or a linkage state transition (onboarding.approved/rejected/review/blocked and linkage.*).

FieldDescription
resultapproved, rejected, pending_review, blocked, or the linkage state (pending_representative, in_progress, completed, expired).
risk_levelDerived 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_scoreThe whole-number score risk_level is derived from, on engine and analyst decisions. Omitted when the execution was never scored.
decided_byengine on engine decisions and analyst on analyst decisions, on the webhook and on the query endpoint.
decided_atDecision timestamp in ISO 8601.

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”.

statusMeaning
executedThe proof of life ran and passed. Step completion requires passed liveness evidence, so executed implies approval; result: "passed" accompanies it.
not_executedThe flow carries the step enabled but the journey ended without completing it (failure, attempt cap, abandonment).
skipped_by_configThe 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_configuredThe 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 the liveness object; “the journey has no proof of life” is signaled exclusively by the explicit not_configured value.
  • onboarding.cancelled deliberately does NOT carry the block: it is scoped to outcome-bearing terminal events, and a cancellation is not an outcome.
HeaderDescription
Content-TypeAlways application/json.
X-Guardline-Event-IDStable 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-IDThe 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-NumberSequential attempt number (1 for first delivery, 2 for first retry, etc.). This is the only per-attempt discriminator.
X-Guardline-TimestampUnix timestamp of the signature moment (hmac mode only).
X-Guardline-SignatureHMAC-SHA256 signature in hexadecimal, made with the current secret (hmac mode only).
X-Guardline-SignaturesComma-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-KeyAPI gateway subscription key. Sent on every delivery in mtls_apim mode, where it is mandatory.
X-API-KeyStatic key the integrator’s endpoint expects, when one is configured on the webhook endpoint. Optional and independent of the HMAC signature.

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:

  1. event_id from 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.
  2. execution_id + event_sequence, when the body carries no event_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.

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.

ModeHMAC SignatureClient CertificateSubscription Key
hmac (default)YesNoNo
mtls_apimNoYesYes

HMAC authentication uses a shared secret provisioned during integrator setup in the admin panel.

Verification process on the receiver:

  1. Extract the X-Guardline-Timestamp header.
  2. Reject if the timestamp is older than 5 minutes relative to the server clock.
  3. Concatenate timestamp and request body: {timestamp}.{body}.
  4. Compute HMAC-SHA256 of the concatenation using the shared secret.
  5. Encode the result in hexadecimal.
  6. Compare with the X-Guardline-Signature header using constant-time comparison.
  7. 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)

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:

  1. The operator uploads, through the admin API, the client certificate and its private key that Guardline should present.
  2. 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.
  3. The operator configures the gateway subscription key, which is required for this mode.
  4. On each delivery, Guardline presents the uploaded client certificate and sends the subscription key in Ocp-Apim-Subscription-Key.
  5. 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.

The webhook endpoint is configured in the Guardline admin panel. Requirements:

RequirementDetail
ProtocolHTTPS required.
TLS version1.2 or higher.
Success responseAny 2xx code (body ignored).
RedirectsNot followed. A 301 or 302 is refused.
Response headersSend the status line within 5 seconds.
TimeoutComplete the response within 10 seconds.
IdempotencyDeduplicate 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.

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:

PathCredentials usedDelivery row
Resend rebuilt from live configurationThe 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 deliveryThe 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.

When a delivery fails, Guardline retries it with exponential backoff. The interval below is the wait after the numbered attempt fails:

Failed attemptWait before the next attempt
130 seconds
22 minutes
310 minutes
430 minutes
51 hour
62 hours
74 hours
88 hours
912 hours
10None. 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.

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.

GET /api/v1/onboarding/notifications

This endpoint uses cursor-based pagination.

ParameterTypeDefaultDescription
statusstringpendingFilter by state: pending, retrying, exhausted. Comma-separated for multiple values.
limitinteger50Items per page (1 to 200; values outside the range fall back to 50).
after_cursorstring(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.

POST /api/v1/onboarding/notifications/{delivery_id}/ack

Marks 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"
}
}

KYC Minor with Guardline-Delivered Notification

Section titled “KYC Minor with Guardline-Delivered Notification”
CodeMeaningGuidance
200SuccessProcess normally.
201Resource createdProcess normally.
400Invalid requestFix the payload. Do not retry.
401Authentication failedCheck the API Key (or JWT for resend-representative-link). Do not retry.
403ForbiddenThe credential verified but carries no authorized role (JWT) or no required scope (API key). Do not retry.
404Resource not foundCheck the identifiers. Do not retry.
405Method not allowedThe path exists but not for this verb. Fix the call. Do not retry.
409ConflictHandle according to the specific error code.
410GoneThe resource was valid but is no longer accessible (e.g., expired link).
413Payload too largeThe body exceeded the 13 MiB global cap or the route’s cap. Do not retry.
422UnprocessableThe body parsed but violates a domain rule. Fix the request. Do not retry.
429Rate limitedTwo different limiters answer 429; see below.
500Internal errorRetry with exponential backoff (maximum 3 attempts).
502Internal error during processingRetry 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.

Note: error message fields are localized by Accept-Language (see Error Message Locale). Match on code for programmatic branching.

CodeHTTPDescription
MISSING_TOKEN401No 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_KEY401API Key invalid, revoked, or expired.
INVALID_TOKEN401A Bearer JWT was sent and failed verification: bad signature, wrong issuer, or expired.
FORBIDDEN403JWT lacks the required role (admin or supervisor).
FLOW_ID_REQUIRED400flow_type without flow_id on an instance with more than one active flow of that type.
INVALID_REQUEST400Malformed JSON, unknown field, or wrong field type.
INVALID_ID400A path UUID is malformed.
VALIDATION_FAILED400Field-level validation failed, including a request that sends neither flow_id nor flow_type.
INVALID_TAX_ID400primary.tax_id does not match the shape the resolved flow’s country issues.
INVALID_IDEMPOTENCY_KEY400Idempotency-Key header longer than 255 characters.
LINKED_SUBJECTS_REQUIRED400kyc_minor request with missing/empty/oversized linked_subjects or wrong role.
INVALID_FLOW_TYPE400flow_id resolves to an internal-use flow type.
MISSING_REQUIRED_FIELDS400kyc_minor missing primary.tax_id or primary.full_name.
PHONE_POLICY_VIOLATION422A mobile-only flow received a landline phone number.
UNPROCESSABLE422A URL policy was violated.
CPF_MINOR_REPRESENTATIVE_MATCH422The representative’s CPF equals the minor’s CPF.
LINKED_SUBJECT_RELATIONSHIP_REQUIRED422A kyc_minor entry omitted linked_subjects[0].subject.relationship.
REPRESENTATIVE_AGE_BELOW_MINIMUM422The representative’s birth_date indicates age under 18.
ACTIVE_SESSION_EXISTS409Active session exists for the same primary subject and flow.
CUSTOMER_NOT_FOUND404No 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.
DUPLICATE409A unique constraint rejected the write, typically two concurrent calls racing to create the same row.
EXECUTION_NOT_FOUND404Execution 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_FOUND404Generic miss. Cancel returns this, not EXECUTION_NOT_FOUND, when the execution id matches nothing.
EXECUTION_TERMINAL409A cancel was requested on an execution already in a terminal state.
EXECUTION_TERMINATED409The primary execution has reached a terminal state (resend-representative-link).
NO_RESENDABLE_CHILD404No representative child to resend (resend-representative-link only).
LINK_NOT_FOUND404Link token does not exist.
LINK_EXPIRED410Link token expired.
FLOW_NOT_FOUND404Flow not found.
RATE_LIMITED429Request limit exceeded.
PAYLOAD_TOO_LARGE413The 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_FOUND404The path matches no registered route.
METHOD_NOT_ALLOWED405The path exists but does not accept this verb.
INTERNAL_ERROR500 / 502The 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.
MethodPathAuthDescription
GET/api/v1/onboarding/flowsAPI Key or JWTList available journeys.
POST/api/v1/onboarding/sessionsAPI Key or JWTCreate 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 JWTQuery execution with complete data.
GET/api/v1/onboarding/executionsAPI Key or JWTList active executions.
GET/api/v1/executions/statsAPI Key or JWTFaceted counts by status, risk band and flow type, over the whole filtered set.
POST/api/v1/onboarding/executions/{execution_id}/resend-representative-linkAdmin/Supervisor JWTResend the representative link over the flow’s notification channels.
POST/api/v1/executions/{execution_id}/cancelAPI Key or JWTCancel an execution (and its representative child for kyc_minor), releasing the active-session slot for reuse.
GET/api/v1/onboarding/notificationsAPI Key or JWTPoll pending webhook notifications.
POST/api/v1/onboarding/notifications/{delivery_id}/ackAPI Key or JWTAcknowledge a webhook notification.

Version 18, updated on