> ## Documentation Index
> Fetch the complete documentation index at: https://docs.corbado.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Subflows with Corbado Observe

> Track concrete authentication method attempts with subflows and the SDK's operation helpers.

Subflows are the concrete building blocks inside a [flow](/corbado-observe/tracking/flows). Each subflow is **one authentication method attempt or named process form**, for example a passkey login, a password enrollment or an onboarding form. Subflows are **auto-discovered** by **Corbado Observe** from the step events you send.

A raw subflow start does not always establish engagement. See the [engagement rules by subflow](/corbado-observe/tracking/engagement#2-subflow-rules) for which starts, steps and input interactions qualify.

<Frame>
  <iframe className="w-full aspect-video rounded-xl" src="https://www.youtube.com/embed/ImkgPLTKKcQ" title="Track Passkey Enrollment in Real-Time | Corbado Observe" frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen />
</Frame>

Install and set up the **Corbado Observe SDK**, including your first tracked event, in [Custom events](/corbado-observe/get-started/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:

| Method kind | Examples | Create the helper when |
| - | - | - |
| **Input-bound** | password, OTP, identifier, provide-data | The input renders. The field is the attempt surface, and the helper captures interaction on it when you pass `inputHtmlField`. That capture is what tells typed, pasted, autofilled and prefilled values apart in the analysis, so bind the field wherever the helper accepts one. |
| **Process forms** | named onboarding or profile form | The form is shown. Create one helper per visit with the same stable name. |
| **System checks** | trusted-device check | The existing background check begins. It carries no user interaction and never resolves a method decision. |
| **Action-bound** | passkey button, social button, app confirmation | The action happens. A visible button is not an attempt. A helper created for a button nobody pressed classifies as an attempt with no follow-up, which is an error. |

<Note>
  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.
</Note>

### 1.2 Steps

Each step is one phase of the attempt, identified by its step name, and tracked with three events:

| Event | SDK call | Meaning |
| - | - | - |
| `subflow_step_started` | `op.<step>.start(data)` | The step began. |
| `subflow_step_finished` | `op.<step>.finished(data)` | The step returned a result, including an expected negative trusted-device verdict. |
| `subflow_step_error` | `op.<step>.error(err)` or `op.<step>.errorTyped({ code })` | The step failed. |

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](#3-12-trusted-device-check-and-enrollment) 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.

| Requirement | Subflow types |
| - | - |
| **Required.** The classifier drops an attempt without a spec type on any of its events. | passkey-login, passkey-enrollment, password-enrollment, provide-data, email-link, social-login, sso-login, trusted-device-check, trusted-device-enrollment |
| **Defaulted** when absent. | email-otp, sms-otp and totp assume the login variant, password-login assumes `password-known-identifier`, provide-identifier assumes `email`, app-confirmation assumes `qr-code`. |

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

```typescript theme={null}
op.postResponse.start({});
const body = await submitPassword(password);
if (body.rejected) {
  op.postResponse.error({ code: "invalid_password", message: body.errorLabel });
  // or, compile-time checked where the helper types the code:
  op.postResponse.errorTyped({ code: "invalid_password" });
}
```

`.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:

```typescript theme={null}
op.postResponse.error(
  { code: "invalid_password", name: "AuthenticationError", message: "Password rejected" },
  { rawError: providerDiagnostic },
);
// Or a typed code with additional diagnostic context:
op.postResponse.errorTyped(
  { code: "invalid_password" },
  { rawError: providerDiagnostic },
);
```

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](/corbado-observe/get-started/custom-events); `getTracker()!` expresses that prerequisite to TypeScript:

| # | Event | Step | Description |
| - | - | - | - |
| 1 | `subflow_started` | | The user clicks the button. The helper is created with `passkey-known-identifier`. |
| 2 | `subflow_step_started` | `get-options` | Assertion options are requested from your server. |
| 3 | `subflow_step_finished` | `get-options` | The server returns them. |
| 4 | `subflow_step_started` | `ceremony` | The browser WebAuthn ceremony begins. |
| 5 | `subflow_step_finished` | `ceremony` | The user completes it. A cancellation is a `subflow_step_error` here instead. |
| 6 | `subflow_step_started` | `post-response` | The assertion is sent to your server. |
| 7 | `subflow_step_finished` | `post-response` | The server confirms the login. The attempt is complete. |

```typescript theme={null}
import { getTracker, sanitizeRequestOptions, sanitizeAssertionResponse } from "@corbado/observe";

const handlePasskeyLogin = async (email: string) => {
  const op = getTracker()!.passkeyLoginFullOperation({ explicitSpecType: "passkey-known-identifier" });

  let options, response;
  try {
    op.getOptions.start({});
    options = await fetchAssertionOptions(email);
    const assertionOptions = sanitizeRequestOptions(options);
    if (assertionOptions !== undefined) op.getOptions.finished({ assertionOptions });
  } catch (e) {
    op.getOptions.error(e);
    return;
  }

  try {
    op.ceremony.start({});
    response = await startWebAuthnAuthentication(options);
    const assertionResponse = sanitizeAssertionResponse(response);
    if (assertionResponse !== undefined) op.ceremony.finished({ assertionResponse });
  } catch (e) {
    op.ceremony.error(e); // also when the user cancels the prompt
    return;
  }

  try {
    op.postResponse.start({});
    const result = await verifyOnServer(response);
    op.postResponse.finished({});
    getTracker()!.setUser({ userId: result.userId });
  } catch (e) {
    op.postResponse.error(e);
  }
};
```

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: 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 creation `user.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`:

```javascript theme={null}
import { sanitizeCreationOptions } from "@corbado/observe";

const projectors = { userId: () => undefined };
const attestationOptions = sanitizeCreationOptions({ user: { id: new Uint8Array([1, 2]) } }, projectors);
// Send the serialized telemetry only. Keep the original options for WebAuthn.
```

A projector receives binary identifiers as base64url strings and JSON strings as supplied. Return a string to replace the value, `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](/corbado-observe/tracking/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.

| Helper on the tracker | Subflow type | Steps | Spec types |
| - | - | - | - |
| `provideIdentifierOperationFull(config?)` | `provide-identifier` (plus passkey Conditional UI) | `provideIdentifier.clientValidation`, `provideIdentifier.postResponse`; `cui.getOptions`, `cui.ceremony`, `cui.postResponse` | `email`, `phone`, `username`; CUI: `passkey-cui` |
| `passkeyLoginFullOperation(config?)` | `passkey-login` | `getOptions`, `ceremony`, `postResponse` | `passkey-known-identifier`, `passkey-known-identifier-auto`, `passkey-no-identifier`, `passkey-no-identifier-auto`, `passkey-cui`, `passkey-immediate` |
| `passkeyEnrollmentFullOperation(config?)` | `passkey-enrollment` | `getOptions`, `ceremony`, `postResponse` | `conditional`, `auto`, `manual`; legacy `conditional-auto-manual`, `auto-manual` |
| `passwordLoginFullOperation(config?)` | `password-login` | `clientValidation`, `postResponse`; `cui.getOptions`, `cui.ceremony`, `cui.postResponse` | `password-known-identifier`, `password-with-identifier`; CUI: `passkey-cui` |
| `passwordEnrollmentFullOperation(config?)` | `password-enrollment` | `clientValidation`, `postResponse` | `password-set`, `password-reset` |
| `emailOtpOperationFull(config?)` | `email-otp` | `send`, `postResponse`, `resend` | `email-otp-login`, `email-otp-enrollment` |
| `smsOtpOperationFull(config?)` | `sms-otp` | `send`, `postResponse`, `resend` | `sms-otp-login`, `sms-otp-enrollment` |
| `emailLinkOperationFull(config?)` | `email-link` | `send`, `postResponse`, `resend` | `email-link-login`, `email-link-enrollment` |
| `continueEmailLinkOperation(config?)` | Continues `email-link` | `postResponse`, `resend` | `email-link-login`, `email-link-enrollment` |
| `totpOperationFull(config?)` | `totp` | `postResponse` | `totp-login`, `totp-enrollment` |
| `keySigningOperationFull(config?)` | `key-signing` | `prepare`, `sign` | None |
| `keyRegistrationOperationFull(config?)` | `key-registration` | `prepare`, `postResponse` | None |
| `ssoLoginOperationFull(config?)` | `sso-login` | `getRedirectUrl`, `exchangeCode` | `pre-identifier`, `post-identifier` |
| `socialLoginOperationFull(config?)` | `social-login` | `getRedirectUrl`, `exchangeCode` | `pre-identifier`, `post-identifier` |
| `formOperationFull(config)` | `form` | `submit`, `skip` | None |
| `provideDataOperationFull(config?)` | `provide-data` | `clientValidation`, `postResponse` | `signup`, `login`, `recovery`, `enrollment` |
| `appConfirmationOperationFull(config?)` | `app-confirmation` | `ceremony`, `retry`, `postResponse` | `qr-code`, `same-device`, `push` |
| `captchaOperationFull(config?)` | `captcha` | `ceremony`, `postResponse` | `visible`, `invisible` |
| `trustedDeviceCheckOperationFull(config)` | `trusted-device-check` | `getOptions`, `ceremony`, `postResponse` | `token`, `key` |
| `trustedDeviceEnrollmentOperationFull(config)` | `trusted-device-enrollment` | `getOptions`, `ceremony`, `postResponse` | `token`, `key` |

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 of `provideIdentifier.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](/corbado-observe/overview/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.

| Step | Description |
| - | - |
| `provideIdentifier.clientValidation` | Optional client-side validation of the identifier. |
| `provideIdentifier.postResponse` | Server-side identifier check: does the user exist, which methods are available. |
| `cui.getOptions` | Assertion options for the Conditional UI request. |
| `cui.ceremony` | The pending autofill ceremony. Finishes when the user picks a passkey. |
| `cui.postResponse` | Server verification of the Conditional UI assertion. |

```typescript theme={null}
import { getTracker, sanitizeRequestOptions, sanitizeAssertionResponse } from "@corbado/observe";

const identifierInput = document.getElementById("email") as HTMLInputElement;
const op = getTracker()!.provideIdentifierOperationFull({
  inputHtmlField: identifierInput,
  explicitSpecType: "email",
});

// Conditional UI, started when the identifier surface renders.
const startConditionalUI = async () => {
  let options;
  try {
    op.cui.getOptions.start({ explicitSpecType: "passkey-cui" });
    options = await fetchConditionalUIOptions();
    const assertionOptions = sanitizeRequestOptions(options);
    if (assertionOptions !== undefined) op.cui.getOptions.finished({ assertionOptions });
  } catch (e) {
    op.cui.getOptions.error(e);
    return;
  }

  op.cui.ceremony.start({});
  try {
    const response = await startWebAuthnAuthentication(options, { useBrowserAutofill: true });
    const assertionResponse = sanitizeAssertionResponse(response);
    if (assertionResponse !== undefined) op.cui.ceremony.finished({ assertionResponse });

    op.cui.postResponse.start({});
    const result = await verifyOnServer(response);
    op.cui.postResponse.finished({});
    getTracker()!.setUser({ userId: result.userId });
  } catch (e) {
    // Torn down because the user submitted the identifier or left: not an error.
    if (!displacedByUser) op.cui.ceremony.error(e);
  }
};

// Manual path: the user submits the form.
const handleIdentifierSubmit = async (email: string) => {
  op.provideIdentifier.postResponse.start({}, {
    identifier: { value: email.trim().toLowerCase(), type: "email" },
  });
  try {
    const result = await checkUserOnServer(email);
    op.provideIdentifier.postResponse.finished({});
    getTracker()!.setUser({ userId: result.userId });
  } catch (e) {
    op.provideIdentifier.postResponse.error(e);
  }
};

// When the component unmounts.
op.destroy();
```

### 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](#3-1-provide-identifier) instead.

| Spec type | Description |
| - | - |
| `passkey-known-identifier` | The identifier is known and the user clicks a passkey button. The server can scope the allowed credentials. |
| `passkey-known-identifier-auto` | Same, but your code starts the ceremony automatically after the identifier is known. |
| `passkey-no-identifier` | No identifier is known. The user clicks a discoverable passkey button. |
| `passkey-no-identifier-auto` | Same, but started automatically by your code. |
| `passkey-cui` | Conditional UI. Prefer the provide-identifier or password-login helper for this. |
| `passkey-immediate` | Immediate mediation (`mediation: "immediate"`): the browser offers a passkey prompt right when the login page loads. A finished ceremony or a failed prompt that counts as interaction resolves an offered `passkey-login-immediate` option. A silent rejection resolves no option. |

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](#2-example-passkey-login) 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.

| Spec type | Description |
| - | - |
| `conditional-auto-manual` | Your code first attempts conditional (auto-register) enrollment, falls back to a regular prompt and finally lets the user trigger it manually. |
| `auto-manual` | Your code auto-triggers the enrollment prompt, with a manual fallback. |
| `manual` | The user explicitly triggers enrollment, for example by clicking "Create passkey". |

The `ceremony.start` data requires `mediation`, which tells Observe how the credential manager was invoked.

```typescript theme={null}
import { getTracker, sanitizeCreationOptions, sanitizeCreationResponse } from "@corbado/observe";

const handleManualEnrollment = async (userId: string) => {
  const op = getTracker()!.passkeyEnrollmentFullOperation({ explicitSpecType: "manual" });

  let options, response;
  try {
    op.getOptions.start({});
    options = await fetchAttestationOptions(userId);
    const attestationOptions = sanitizeCreationOptions(options);
    if (attestationOptions !== undefined) op.getOptions.finished({ attestationOptions });
  } catch (e) {
    op.getOptions.error(e);
    return;
  }

  try {
    op.ceremony.start({ mediation: "required" });
    response = await startWebAuthnRegistration(options);
    const attestationResponse = sanitizeCreationResponse(response);
    if (attestationResponse !== undefined) op.ceremony.finished({ attestationResponse });
  } catch (e) {
    op.ceremony.error(e);
    return;
  }

  try {
    op.postResponse.start({});
    await verifyOnServer(response);
    op.postResponse.finished({});
  } catch (e) {
    op.postResponse.error(e);
  }
};
```

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.

| Spec type | Description |
| - | - |
| `password-known-identifier` | The identifier was submitted in a previous step. Only the password is entered on this screen. |
| `password-with-identifier` | Identifier and password are entered together on one screen. |

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.

```typescript theme={null}
import { getTracker, type PasswordLoginOperationFull } from "@corbado/observe";

let passwordOp: PasswordLoginOperationFull;

// When the login component mounts. Returns the cleanup for unmount.
const handleOnLoad = () => {
  const passwordInput = document.getElementById("password") as HTMLInputElement;
  passwordOp = getTracker().passwordLoginFullOperation({
    inputHtmlField: passwordInput,
    explicitSpecType: "password-known-identifier",
  });
  return () => passwordOp.destroy();
};

const handlePasswordSubmit = async (email: string, password: string) => {
  passwordOp.postResponse.start({});
  const result = await verifyPasswordOnServer(email, password);
  if (result.ok) {
    passwordOp.postResponse.finished({});
    getTracker().setUser({ userId: result.userId });
  } else {
    passwordOp.postResponse.errorTyped({ code: "invalid_password" });
  }
};
```

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

```typescript theme={null}
// One input for the whole code.
const op = getTracker().smsOtpOperationFull({
  explicitSpecType: "sms-otp-login",
  inputHtmlField: codeInput,
});

// One input per digit. Pass the boxes in reading order.
const digits = Array.from(document.querySelectorAll<HTMLInputElement>(".otp-digit"));
const op = getTracker().smsOtpOperationFull({
  explicitSpecType: "sms-otp-login",
  inputHtmlFields: digits,
});
```

### 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](/corbado-observe/tracking/flows#6-1-recovery-with-automatic-login).

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

```typescript theme={null}
const op = getTracker()!.socialLoginOperationFull({ explicitSpecType: "pre-identifier" });
op.getRedirectUrl.start({ provider: "naver" });
```

On the return page, report `provider: "naver"` on `exchangeCode.start()` and track the exchange result. Keep the same spec type available on that attempt.

### 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. For a coherent form in a process journey, use the [named-form helper](#3-16-named-process-form).

### 3.10 App confirmation

Tracks a login approved in another app. Use `qr-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 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.

| Operation | Result to report |
| - | - |
| Check accepted by the host | `postResponse.finished({ result: "trusted" })`. Discovery or signing alone does not establish trust. |
| Expected negative check | `.finished({ result })` on the step that establishes it: `not-trusted` for either spec, or `no-eligible-local-key` / `server-key-no-local-match` for `key` only. An early negative needs no later server step. |
| Error establishing a negative check | `.errorTyped({ code, error })`, with `not_trusted`, `no_eligible_local_key` or `server_key_no_local_match` (last two for `key` only). The original `error` is optional. These codes preserve the negative outcome; other error codes remain technical errors. |
| Enrollment accepted by the host | `postResponse.finished({ result })` with `created`, `renewed` or `unchanged`. |
| Explicit enrollment opt-out | `postResponse.finished({ result: "skipped" })`. This is not an error or a completed binding, and does not skip the enclosing flow. Abandonment has no finish. |

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.

```typescript theme={null}
const check = getTracker().trustedDeviceCheckOperationFull({
  explicitSpecType: "key",
  purpose: "mfa-exemption",
  storage: "indexeddb",
});
check.ceremony.start({});
// When discovery establishes that no eligible local key exists:
check.ceremony.finished({ result: "no-eligible-local-key" });
// Continue your existing MFA fallback.
```

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.

### 3.13 TOTP

Create `totpOperationFull()` 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 ordered `prepare` and `sign` steps. Finish `sign` when the usable signed artifact is assembled; this does not mean it has been submitted.
* **Registration:** `keyRegistrationOperationFull()` has ordered `prepare` and `postResponse` steps. Finish `postResponse` only when the authority accepts the key and it is usable.

Neither helper has a spec type or resolves an authentication-method decision. Creating one alone does not establish interaction: track the real operation steps. Record a failed or unfinished retry as it happens, even after an earlier successful attempt. Never send key material, signatures or proofs in telemetry.

### 3.15 Enterprise SSO

Use `ssoLoginOperationFull()` 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

Use `formOperationFull({ 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.

```typescript theme={null}
const tracker = getTracker()!;
// The enclosing process flow is already open; the form has just been shown.
const form = tracker.formOperationFull({
  name: "personal-details",
  fields: ["firstName", "lastName"],
});

// When the user submits, track your application's existing save operation.
form.submit.start({});
try {
  await savePersonalDetails();
  form.submit.finished({});
} catch (error) {
  form.submit.error(error);
}
```

Finish `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.

## 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. Avoid creating a separate authentication subflow for every field submitted together. Track the most important authentication field, for example the password field on a sign-up form. For a coherent process form, use one named-form operation and bind its inputs together.

## 5. Next steps

* Continue with [Users](/corbado-observe/tracking/user) to link attempts to your user entities.
* Read [Modeling](/corbado-observe/tracking/modeling) for when to create helpers and a journey catalog.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.