1. How subflows work
On SDK level, every subflow type has an operation helper on the tracker, for examplepasskeyLoginFullOperation(). 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 emitssubflow_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 and SSO, ceremony for app confirmation, sign for key signing, submit or skip for named forms). 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, except for the required ordered steps of key signing and registration and 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. Passkey login keeps the first known spec type within an attempt, except that a later type can replace passkey-immediate. A new start with a different known type creates a separate attempt.
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, prefererrorTyped()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.
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:
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. Callop.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:
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: by default 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.
Control WebAuthn user identifiers
The serializers retain creationuser.id and credential response.userHandle by default. To omit these account references from telemetry, pass the same synchronous userId projector to sanitizeCreationOptions, sanitizeCreationResponse and sanitizeAssertionResponse:
undefined to omit it or null to keep an explicit null. Use a consistent mapping across registration, login and network telemetry; replacement user handles must remain base64url-compatible. Missing or null inputs do not call the projector. A thrown exception, Promise or unsupported return value omits the identifier.
This controls only those two fields. Credential IDs, credential lists and encoded attestation and authenticator data remain, and encoded blobs are not decoded or redacted. The original authentication objects are unchanged. Continue to identify the authenticated user separately when your integration needs user-level analysis.
3. Subflow types
Helper signatures can move between releases. Read the installed package’s type definitions before applying a recipe.
The authentication helpers accept
explicitSpecType; key signing, key registration and named forms have no spec types. 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. Use the low-level tracker methods trackSubflowStarted(), trackSubflowStepStarted(), trackSubflowStepFinished() and trackSubflowStepError() only when no helper covers the operation.
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: Pass the submitted identifier in the step options ofprovideIdentifier.postResponse, with its type (email, phone or username): { identifier: { value, type } }, once per submission, typically on start. Use username for member numbers. The value is sent exactly as given, so normalize it (for example trim and lowercase an email) and hash it first if Corbado should not receive the raw value; search uses the same representation.
Any other step accepts the option too, for example a password form that submits identifier and password together. 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 when a submission links 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; it appears only among the flows of a user it was attributed to.
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.
For immediate mediation, a finished ceremony always counts as interaction. With window activity captured, a failed ceremony counts when the window lost focus during it; a failure without that signal is treated as a silent rejection. Without window evidence, a failed ceremony uses the runtime fallback: more than 1,200 ms counts as interaction. The known no-user-activation and no-discoverable-credential rejections never count. A shown prompt settles the offered decision at ceremony start even when authentication fails; a later fallback opens the decision again.
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.
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, usesend 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. Rebind replaced inputs with op.setInputFields(fields) without creating a new attempt; an empty array detaches them. If your application fills a value after observation begins, call op.capturePrefill() immediately after setting the initial value so the SDK records the prefill. Prefill alone does not establish user interaction.
For email OTP, emailDomain in the helper config can report the domain of the recipient, such as example.com. Send only the domain, never the full address in this field. Domain analysis covers reported values; missing metadata remains unknown and older attempts are not filled retrospectively.
3.7 Email link
Tracks sign-in or verification through a link sent by email. Steps:send, postResponse for the token from the clicked link, resend.
Create emailLinkOperationFull() where the email is sent, then use continueEmailLinkOperation() on the link target. The first helper exposes transactionId; pass it to the continuation when the link can open in another browser or session. You can supply your existing transaction UUID in the first helper’s config. Transport this correlation ID through your application alongside the link, separately from its authentication token.
Within the same browser and Observe session, the continuation can reuse the pending start automatically. Across sessions it emits a continuation that the backend joins by transaction ID. If the ID arrives later, call setTransactionId(id) before flush() or page exit; flushing without an ID cannot link the sessions. The receiving page still needs its own flow when no flow is open. Report userId when your host establishes it. 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. Pass the provider on the steps (provider, e.g. "google", "line"): the attempt first tries the offered decision option social-<provider>. Google, Apple and Facebook retain their named options; other providers fall back to social-other when it is offered. With no reported provider, it tries social-other. Option names starting with social- are reserved for providers.
The JavaScript SDK accepts google, apple, facebook, github, microsoft, line, kakao, singpass, naver, yahoo and other as provider values. For Naver and Yahoo, offer social-naver and social-yahoo in the decision and report the matching provider on each observed step start. For example, after the user selects Naver:
provider: "naver" on exchangeCode.start() and track the exchange result. Keep the same spec type available on that attempt.
Pass entry in the helper’s config to say how the attempt started. It is independent of the spec type:
One Tap counts toward social attempts and conversion like any other entry, but not toward provider clicks or the return rate, since no click preceded it. Track only
exchangeCode for it:
entry in the exchangeCode start data alongside provider.
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. PassfieldName on postResponse.start for the semantic name of what is collected. The spec type names the surrounding flow. For a coherent form in a process journey, use the named-form helper.
3.10 App confirmation
Tracks a login approved in another app. Useqr-code for a QR handoff, same-device for a companion app on the current device or push for approval on an already enrolled device. 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 chainedenrollment 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.
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.
3.13 TOTP
CreatetotpOperationFull() when the authenticator-code input appears. Bind one input with inputHtmlField or ordered digit boxes with inputHtmlFields. It supports the same setInputFields() rebinding as email and SMS OTP. Track submission with postResponse.start({}), then .finished({}) only when your host accepts the code or .error(error) on rejection. There is no send or resend step. Use totp-login for verification and totp-enrollment for setup verification.
3.14 Key signing and registration
Use these helpers for direct key operations, such as assembling a signed transaction or registering a key with an authority. WebAuthn authentication and enrollment keep their passkey helpers.- Signing:
keySigningOperationFull()has orderedprepareandsignsteps. Finishsignwhen the usable signed artifact is assembled; this does not mean it has been submitted. - Registration:
keyRegistrationOperationFull()has orderedprepareandpostResponsesteps. FinishpostResponseonly when the authority accepts the key and it is usable.
3.15 Enterprise SSO
UsessoLoginOperationFull() for SAML or OIDC authentication through an organization’s identity provider. Track getRedirectUrl when initiating the redirect and exchangeCode when your host accepts the return. Supply pre-identifier or post-identifier for the screen that initiated SSO.
Set actor: "system" when your application routes to SSO automatically, such as from an email domain; otherwise the user is choosing SSO. Both can resolve an offered sso-login option, but a system-routed start needs a real step before it counts as an attempt. Optional organizationDomain contains only the organization’s domain, such as example.com, never an email address. Keep SSO separate from social login; aggregate completing-method reports may still group it under “other”.
3.16 Named process form
UseformOperationFull({ name, fields? }) for one coherent set of data in a process journey, such as an onboarding form. Start the enclosing flow before showing the form and keep it open until its steps are reported. Create one operation when the form is shown and a new one on each return visit, using the same stable name. A shown form counts as reached even without interaction. It never resolves an authentication-method decision.
The backend normalizes names to lowercase words joined by hyphens, up to 40 characters: personalDetails and personal_details identify the same form. A missing or blank name produces a classification error and the form is not counted. Optional fields contains field names only, never submitted values: at most 50 names, each up to 40 characters using A–Z, a–z, 0–9, ., _ or -. Unsupported names are omitted with a warning.
submit only when your application accepts the form. For a forward action such as Do it later, report skip.start({}) and skip.finished({}) once the application proceeds without submission. A form with neither successful step remains incomplete. Do not report a skip for abandonment or a failed save.
Pass inputHtmlFields to observe focus, typing, paste and autofill signals without collecting field values. For a form spanning several pages, keep the operation and call form.setInputFields(fields) with each page’s input elements; an empty array detaches them. Call form.destroy() when the form unmounts. Field interaction and form completion are separate facts.
If you only confirm a visible form after a short delay, startedAt can supply its original visibility time in epoch milliseconds, at most one minute before the call and never in the future. Invalid values warn and use the call time. If this predates the enclosing flow’s start event, use flowStarted(data, tags, experiments, contexts, { explicitTimestamp }) with the flow’s actual earlier start time too. Keep event times within the enclosing flow.