Skip to main content
Subflows are the concrete building blocks inside a flow. Each subflow is one authentication method attempt, for example a passkey login, a password enrollment or an email OTP verification. Subflows are auto-discovered by Corbado Observe from the step events you send.
Install and set up the Corbado Observe SDK, including your first tracked event, in Custom events. This page explains the subflow concept and documents all available subflow types.

1. How subflows work

On SDK level, every subflow type has an operation helper on the tracker, for example passkeyLoginFullOperation(). The helper stamps the subflow type and step names for you, so your code only wraps the application logic of each step.

1.1 Start

Creating the helper emits subflow_started: the attempt begins. Construction always emits the start, even without an input field or configuration. Do not add a manual subflowStart() on top of it. Repeated starts of the same subflow type with nothing in between are merged. A different, known spec type starts a new attempt. When to create the helper depends on how the method is engaged:
Older SDK versions used a separate subflow_trigger event to mark the moment of interaction. It is deprecated and carries no classification value. Interaction is read from the first step and from the bound input field.

1.2 Steps

Each step is one phase of the attempt, identified by its step name, and tracked with three events: Every helper defines its steps (section 3). Custom steps beyond the predefined ones are available through op.customStep("my-step"). Never finish a subflow. There is no subflow-finished event. The classifier derives each attempt’s outcome from its steps. The outcome-bearing step is postResponse for almost every subflow (exchangeCode for social login, ceremony for app confirmation). Track it unless a trusted-device check settles an early negative result. Earlier steps are enrichment you may skip when the effort outweighs the value, with one exception: the WebAuthn ceremony steps of the passkey subflows. Track those whenever passkeys are in play, because ceremony start, finish and error power all passkey analytics and none of it is recoverable from postResponse alone. A completed ceremony without a postResponse still classifies as incomplete, because only the backend confirmation proves the method worked. On failure, call .error() on the step that failed and stop. A retry is simply a new sequence of step events. When the options for a ceremony arrived with an earlier response, for example a passkey request returned by a preceding server call, call getOptions.start({}) and getOptions.finished({ ... }) back to back with that payload. The attempt then carries the same three steps as one that fetched its options itself.

1.3 Spec types

explicitSpecType distinguishes variants of one subflow type, for example a passkey login with a known identifier from one without. Supply it in the helper’s config whenever it is known. If it only becomes known mid-attempt, send it on a later step’s start data. The last spec type wins.

1.4 Errors

Error diagnostics are optional for most methods. Trusted-device checks are an exception when a typed error code supplies the negative result. Otherwise, an attempt that just stops already classifies as incomplete, and an explicit step error is a different, more diagnostic outcome. The backend groups every reported error by its exact signature (subflow type, step, code, name, message, spec type, latency bucket) into error flavours that are then curated into named errors with impact analysis. That yields four rules:
  • Platform errors go in raw. For failures the browser or operating system produces, such as a WebAuthn exception, pass the caught exception to .error(e). The platform’s own vocabulary is bounded and groups well.
  • Your own errors deserve a deliberate shape. For failures from your API, pass a plain { code, name?, message? } object (omit unknown fields; code-only errors are valid): a code naming what the client observed (invalid_password, invalid_otp, http_error), reused wherever the same observation recurs, and the server’s raw error label as the message. Where a helper predefines typed codes, prefer errorTyped() for the compile-time check.
  • Keep volatile tokens out of messages. Request IDs, timestamps and user data fragment the grouping.
  • No fallback codes. A response you cannot classify is neither success nor failure. Leave the step open and it classifies as incomplete.
User cancellation is an error on the step that observed it: a dismissed passkey prompt is a ceremony error with the raw browser error. The exception is an auto-offered method torn down because the user proceeded with another, for example a Conditional UI request aborted by a password submit. That is not an error.
.error() normalizes the first argument into stepData.error; .errorTyped() writes its domain code to stepData.code. A non-empty top-level code takes precedence over error.code, while name and message still come from error.name and error.message. Use the helper’s supported typed shape: password typed errors accept only code; trusted-device check typed errors also accept the original error. For additional provider context, pass rawError in the second argument of either helper:
The browser SDK serializes explicitly supplied diagnostics when tracker.getSdkConfig().rawErrors is true, which is the built-in default. To disable this in your integration, set sdkConfig: { rawErrors: false } in the tracker initialization options. This override takes precedence over fetched configuration. A policy change affects subsequent errors; it does not remove diagnostics from events already queued. Normalized errors are still recorded when raw diagnostics are disabled, and stacks remain separately opt-in. rawError is a bounded { type, value } envelope for raw-event inspection. Pass the caught error as it is. The envelope keeps its name, message and code, its own properties such as a response status, and up to three cause hops, so wrapping the error into a plain object before the call adds nothing. It does not affect classification, outcomes, error groups or severity, and does not fill missing classified fields: the typed example remains code-only. Arrays retain their structure in rawError; arrays passed directly to .error() become a string message. Diagnostics default to a 32 KiB UTF-8 limit with depth and collection limits, stacks omitted and binary contents summarized. Optional rawErrorLimits values are clamped to SDK bounds. Supply deliberate diagnostics without credentials, cookies or personal data; keep the original error for application handling.

1.5 Cleanup

Input-bound helpers and the passkey helpers hold event listeners. Call op.destroy() when the surface unmounts.

2. Example: passkey login

A “Sign in with passkey” button after the identifier is known. These examples assume the tracker is initialized before the handlers run, as described in Custom events; getTracker()! expresses that prerequisite to TypeScript:
The passkey steps carry the WebAuthn payloads as JSON strings. For login, getOptions.finished takes assertionOptions and ceremony.finished takes assertionResponse. For enrollment, the same steps take attestationOptions and attestationResponse, and the enrollment ceremony.start additionally requires mediation ("conditional", "optional" or "required"). Use the SDK’s serialization helpers for these telemetry fields: sanitizeRequestOptions and sanitizeAssertionResponse for login, sanitizeCreationOptions and sanitizeCreationResponse for enrollment. Pass the WebAuthn publicKey options or credential itself, not your application’s response envelope. They accept native objects, JSON objects or JSON text and return JSON text with binary values encoded as base64url. They do not mutate the original objects; keep those originals for the ceremony and server verification. If a helper returns undefined, omit that telemetry finish as shown and continue the authentication flow. Never fall back to the raw payload. Creation options omit user.name and user.displayName; assertion responses omit response.signature; both credential response helpers omit clientExtensionResults.prf.results. Request options have no fields removed. These helpers are not general-purpose secret detection: IDs, user handles, challenges, attestation material and other extension data remain, and encoded blobs are not decoded for redaction. Review the retained data against your telemetry policy.

3. Subflow types

Helper signatures can move between releases. Read the installed package’s type definitions before applying a recipe. Every config accepts explicitSpecType. There is no autoStart option: create the helper when the attempt should begin. The input-bound helpers additionally accept inputHtmlField for input tracking; it does not control whether the attempt starts. For a subflow type without a helper, such as totp, use the low-level tracker methods trackSubflowStarted(), trackSubflowStepStarted(), trackSubflowStepFinished() and trackSubflowStepError() with the same shape.

3.1 Provide identifier

Tracks when users enter and submit their identifier, typically an email address. Bind the helper to the identifier input so the SDK captures interaction on it. Identifier capture: Supply the configured clear or hashed identifier on each pi-post-response submission, with its type (email, phone or username). Use username for member numbers. Input interaction tracking alone does not capture the value. Each submission is retained separately, including rejected values and corrections; it records the account targeted by the attempt. A later setUser() supplies the user reference without replacing this submission history. See Users and identifiers for the evidence required to link the identifier to a user. Conditional UI’s cui-post-response does not carry a submitted identifier. A failed autofill attempt therefore cannot be found by identifier unless another supported relationship makes it reachable. Passkey Conditional UI (autofill) is bound to the same input field, so its events are tracked through this helper’s cui steps. Conditional UI is not a passkey-login attempt: it resolves the same identifier-email option as a typed identifier, and a Conditional UI request torn down because the user submitted the identifier is neutral, not an error.

3.2 Passkey login

Tracks passkey sign-in attempts where the WebAuthn ceremony is started explicitly, by a button or by your code. Conditional UI on the identifier field belongs to provide identifier instead. See section 2 for the button example. Immediate mediation looks the same with explicitSpecType: "passkey-immediate" and mediation: "immediate" on the WebAuthn call. If the ceremony fails, continue with your regular login and start Conditional UI on the identifier field.

3.3 Passkey enrollment

Tracks passkey registration, for example after login or during sign-up. The ceremony.start data requires mediation, which tells Observe how the credential manager was invoked.
For conditional-auto-manual, run the same steps with mediation: "conditional" first. If the conditional ceremony errors, a regular prompt is a new sequence of step events on the same helper with mediation: "required". explicitSpecType also accepts conditional for a silent conditional-create attempt and auto for an automatically triggered regular prompt. Use a new operation for each attempt when using these values; keep manual for a user-triggered attempt. The combined values above remain supported for existing integrations that model one offer across its fallbacks. The enrollment helper’s configuration also accepts ignoreAsInteraction: true for a programmatic start without a user gesture or visible surface, such as a conditional attempt. It marks the emitted start as non-interactive; it does not suppress the operation’s later step events. subflowStart() and trigger() data also accept this flag. Do not apply it to a user-triggered start.

3.4 Password login

Tracks password-based login attempts. Bind the helper to the password input so the SDK captures interaction on it. Create it when the password field renders. Typed error codes: invalid_identifier_or_password, invalid_password, user_not_found, account_locked. Use invalid_identifier_or_password when the host does not disclose which credential was rejected; do not infer a more specific failure. When identifier and password share one screen, Conditional UI is bound to that screen too. This helper therefore carries the same cui steps as provide identifier. Conditional UI fired through it resolves the passkey-login-cui option.

3.5 Password enrollment

Tracks password creation during sign-up (password-set) or a password reset during recovery (password-reset). Bind it to the new-password input. Typed error code: requirements_not_fulfilled.

3.6 Email OTP and SMS OTP

Track one-time-password verification. For email and SMS, use send for the initial code request, postResponse for the code the user submitted, and resend when a new code is requested. A failed send can be recorded with send.error(error). Spec types distinguish the login variant from the enrollment variant, for example email verification during sign-up. Bind the code input: inputHtmlField for one field, inputHtmlFields for a row of digit boxes. This makes SMS autofill and paste visible to the analysis.
Tracks sign-in or verification through a link sent by email. Steps: send, postResponse for the token from the clicked link, resend. Email links often continue in a different tab, browser or device. Pass the same crossEnvironmentTransactionID in the user reference of the send step and the later postResponse step so both sides are correlated. Generate one or reuse an existing transaction ID from your system. It does not replace userId, which you still provide as soon as it is known. See the recovery example.

3.8 Social login

Tracks authentication with social identity providers. Steps: getRedirectUrl when your app initiates the OAuth redirect, exchangeCode when the user returns and the code is exchanged. exchangeCode is the outcome-bearing step. Spec types: pre-identifier when the button is shown before any identifier, post-identifier when it is offered after one.

3.9 Provide data

Covers form fields that request user data but map to no deeper subflow concept, such as name, address, birth date or bank details. One screen, one provide-data subflow bound to the most important field. Pass fieldName on postResponse.start for the semantic name of what is collected. The spec type names the surrounding flow.

3.10 App confirmation

Tracks a login approved in another app, for example by scanning a QR code. ceremony is the approval and the outcome-bearing step. Start it when the request is shown and report the terminal result only: finished on approval, errorTyped with declined or expired, error for any other final state. Pending poll responses stay untracked. retry covers a re-issued request and postResponse the call that turns the approval into a session.

3.11 Captcha

Tracks a captcha challenge inside an authentication journey. Steps: ceremony for the challenge, postResponse for the server-side check. Spec types visible and invisible.

3.12 Trusted-device check and enrollment

Use these helpers when your app already checks or registers a device binding for additional verification or an MFA exemption. Observe records the host’s verdict; it does not implement device trust. Keep the subflow in the active authentication flow. Enrollment after login finishes belongs in a chained enrollment flow. Both configs require explicitSpecType (token or key), purpose (additional-verification or mfa-exemption) and storage (cookie, indexeddb, localstore or sessionstore). Optional trustName identifies the mechanism (max 128 characters). Create the helper when the operation begins and call .start({}) on each observed step: the helper repeats purpose, storage and trust name on step starts and finishes so early exits retain them. getOptions and ceremony are optional when the host has no corresponding operation. A silent check never resolves a method decision and needs no offered option. A missing key is not itself a technical error: report a normal negative result unless an actual error supplies the observation. Only a trusted check completes successfully.
For an accepted binding, include bindingReference when known: a stable pseudonymous reference (max 255 characters) reused across enrollment and later checks. Never report cookie or token values, proofs or key material, and do not invent unknown metadata.

4. Parallel subflows

Parallel subflows are fine as long as only one can plausibly receive interaction at a time: a password field plus a passkey button on one screen is classifiable. A large form where several tracked fields are filled and submitted together is not. Model the simplified version and track only the most important field, for example the password field on a sign-up form.

5. Next steps

  • Continue with Users to link attempts to your user entities.
  • Read Modeling for when to create helpers and a journey catalog.