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

# Flows with Corbado Observe

> Model complete authentication journeys with flows and track them consistently in Corbado Observe.

Flows represent an end-to-end user journey in **Corbado Observe**, for example login or sign-up. The flow concept powers dashboards that show the different paths of a given flow. For example, the **Login Funnel** dashboard can render an overview of all variants that exist for a login journey.

<Frame>
  <iframe className="w-full aspect-video rounded-xl" src="https://www.youtube.com/embed/n_ztfoPszsM" title="Find Passkey Drop-Offs in your Funnel | Corbado Observe" frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen />
</Frame>

Each flow is built from smaller units:

* [**Decisions**](/corbado-observe/tracking/decisions) (for example method selection or route choice)
* [**Subflows**](/corbado-observe/tracking/subflows) (for example passkey login, password login, or email OTP)
* **Nested flows** (for example a recovery flow like a password reset that completes a surrounding login flow)

Nested flows are flows that happen inside another flow. They are useful when a user journey temporarily enters another complete journey, such as a password reset that completes a surrounding login flow. Nested flows are described in more detail in the examples below.

## 1. Flow names

Use one of these predefined flow names when you send flow events:

<table>
  <thead>
    <tr>
      <th style={{ width: "20%", textAlign: "left" }}>Flow name</th>
      <th style={{ width: "20%", textAlign: "left" }}>Outcome states</th>
      <th style={{ width: "60%", textAlign: "left" }}>Description</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td><code>login</code></td>
      <td><code>completed</code> or <code>incomplete</code>.</td>
      <td>Existing user authentication journey. Typically, we observe auth method decisions (<code>pre-identifier</code>, <code>post-identifier</code>), subflows (<code>provide-identifier</code>, <code>passkey-login</code>, <code>social-login</code>, <code>password-login</code>, <code>email-link</code>, <code>email-otp</code>, <code>sms-otp</code>, etc.), and potentially nested flows (<code>enrollment</code>, <code>recovery</code>).</td>
    </tr>

    <tr>
      <td><code>signup</code></td>
      <td><code>completed</code> or <code>incomplete</code>.</td>
      <td>New user registration journey. Typically, we observe auth method decisions (<code>pre-identifier</code>, <code>post-identifier</code>), subflows similar to login but often with different <code>explicitSpecType</code> values, and potentially nested flows (<code>enrollment</code>).</td>
    </tr>

    <tr>
      <td><code>recovery</code></td>
      <td><code>completed</code> or <code>incomplete</code>.</td>
      <td>Account recovery journey that a user goes through to regain access to the account, for example a password reset. This is often nested inside a login flow.</td>
    </tr>

    <tr>
      <td><code>enrollment</code></td>
      <td><code>completed</code>, <code>skipped</code>, <code>invisible</code>, or <code>incomplete</code>.</td>
      <td>Authenticator enrollment journey, for example passkey setup after a successful login. Typically, we observe auth method decisions and subflows such as <code>passkey-enrollment</code>, <code>email-link</code>, or <code>sms-otp</code>.</td>
    </tr>
  </tbody>
</table>

You can also use custom flow names when your product has authentication-related journeys that do not fit the predefined flow names. For example, you can track a `profile` flow when users manage authentication methods from their account settings.

Custom flow names can be used to build custom dashboards. The pre-existing dashboards in **Corbado Observe** do not automatically work with custom flow names.

## 2. Flow events

These **Corbado Observe SDK** events define and update the state of a flow:

* `flowStarted()`
* `flowDecided()`
* `flowFinished()`
* `flowReset()`
* `flowAutoFinished()`

### 2.1 `flowStarted()`

Emit `flowStarted()` when a new flow has started, for example when the user enters a login, sign-up, recovery, or enrollment journey.

Use `flowName`, `flowNames`, and `defaultFlowName` to indicate which flow name has been started:

* Use `flowName` if the flow name is known from the start. For example, if your application has separate login and sign-up pages, use `login` on the login page and `signup` on the sign-up page.
* Use `flowNames` if multiple flow names are possible at the beginning of the journey. This is common when login and sign-up are merged into one identifier-first journey: the user enters an identifier first, and your system later decides whether this is a login or a sign-up. In this case, list all possible flow names, usually `login` and `signup`.
* Use `defaultFlowName` together with `flowNames` to indicate which flow name Corbado Observe should prefer if no more information is provided through `flowDecided()` or `flowFinished()`.

We recommend setting `touchpoint` to describe where the flow was initiated. The same flow name can start from different places in an application, and metrics often depend heavily on that context. For example, a login started from checkout can behave very differently from a login started from the account page because the user's intent is different.

If the user is already known when the flow starts, provide the user reference as early as possible. This is common for enrollment flows, because the user usually has to be authenticated before enrollment is allowed. In that case, include the `userId` already with `flowStarted()`.

It is fine if `flowStarted()` is emitted multiple times during a journey because it is tricky to deduplicate start events in some applications. Corbado Observe deduplicates repeated start events during classification.

### 2.2 `flowDecided()`

Emit `flowDecided()` when a journey that started with multiple possible flow names is resolved to one concrete flow name.

This usually follows a `flowStarted()` event with `flowNames`. For example, an identifier-first journey might start with `login` and `signup` as possible flow names. After your backend checks whether the identifier belongs to an existing user, use `flowDecided()` to resolve the journey to `login` or `signup`.

It is fine if `flowDecided()` is emitted multiple times during a journey. Corbado Observe uses the last provided flow name. The same applies if a later `flowFinished()` event provides a flow name: the latest resolved flow name wins.

### 2.3 `flowFinished()`

Emit `flowFinished()` when a flow reaches its explicit end state.

For login, this is usually the moment the user is authenticated. For sign-up, it is the moment the account has been created and the user is authenticated. For recovery, it is the moment the user has regained access. For enrollment, it is the moment the authenticator setup has completed, has been skipped, or is known to be invisible.

If available, include `userId` or `identifier` so Corbado Observe can link the completed flow to a user reference. For flows that end without a normal completion, use `explicitOutcome`, for example `skipped` or `invisible` during enrollment.

### 2.4 `flowReset()`

Emit `flowReset()` when an in-progress flow is restarted explicitly by the user.
The typical example use case for this is when a user navigates back to the start of a login (e.g. by changing the identifier).

Use `flowName` when one concrete flow is being reset, or `flowNames` when the reset applies to a still-ambiguous flow chooser.

### 2.5 `flowAutoFinished()`

Emit `flowAutoFinished()` when one flow should be considered complete because another flow completed it implicitly.

This question usually comes up when two related flows can lead to the same final state, for example login and recovery, or login and signup. The important distinction is whether the flow you want to finish has already been started.

* Use `flowFinished()` if the flow has already been started. This is common for nested flows. For example, if a login flow is started first, then a recovery flow is started inside it, both flows can be finished explicitly after recovery completes and the user is logged in.
* Use `flowAutoFinished()` if the flow you want to finish has not been started before. For example, if a user completes a signup flow and is automatically logged in as a result, no separate login flow may have been started. In that case, the signup flow can explicitly finish, and the login flow can be auto-finished by the signup flow.

This can be tricky to decide in real integrations. The examples below show the recommended patterns.

## 3. Examples

In a real application, flow tracking is usually spread across pages, components, server actions, and callback routes. The examples below keep the relevant snippets together to show the complete event sequence for each journey.

### 3.1 Recovery with automatic login

A login flow is started, but the user cannot complete the login directly and has to switch to a recovery flow. After completing recovery through an email link and setting a new password, the user is automatically logged in.

Track this as a recovery flow nested inside the surrounding login flow. Because the login flow was already started before the recovery flow began, finish both flows explicitly: first `flowFinished()` for `recovery`, then `flowFinished()` for `login`.

Because the email link can be opened in a different browser, device, or session, pass a stable `crossEnvironmentTransactionID` from the email-send step to the reset-link verification step.

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

const tracker = () => getTracker()!;

let provideIdentifier: OperationFullProvideIdentifierWithCUI;

// Step 1: User navigates to the login page.
async function showLoginPage(emailInput: HTMLInputElement) {
  tracker().flowStarted({
    flowName: "login",
    touchpoint: "account",
  });

  tracker().authMethodsDecisionStarted({
    decisionName: "pre-identifier",
    options: ["identifier-email", "passkey-login-cui", "social-other"],
  });

  provideIdentifier = tracker().provideIdentifierOperationFull(emailInput);
}

// Step 2: User submits their identifier.
async function submitIdentifier(email: string) {
  provideIdentifier.provideIdentifier.postResponse.start({
    explicitSpecType: "email",
  });

  const result = await checkIdentifier(email);

  provideIdentifier.provideIdentifier.postResponse.finished(
    {},
    { userReference: { userId: result.userId } },
  );

  tracker().authMethodsDecisionStarted({
    decisionName: "post-identifier",
    options: ["password-login-known-identifier", "reset-flow"],
  });
}

// Step 3 (optional): User tries password login, but the password is wrong.
// Not every user submits a password before starting recovery.
async function submitWrongPassword(password: string, passwordInput: HTMLInputElement) {
  const passwordLogin = tracker().passwordLoginFullOperation({
    inputHtmlField: passwordInput,
    explicitSpecType: "password-known-identifier",
  });

  passwordLogin.postResponse.start({});

  const result = await loginWithPassword(password);

  if (!result.success) {
    passwordLogin.postResponse.errorTyped({ code: "invalid_password" });
  }
}

// Step 4: User starts password recovery from the login flow.
async function startPasswordRecovery(email: string) {
  tracker().flowStarted({
    flowName: "recovery",
    touchpoint: "login",
  });

  const crossEnvironmentTransactionID = crypto.randomUUID();
  const emailLink = tracker().emailLinkOperationFull();

  emailLink.send.start(
    { explicitSpecType: "email-link-login" },
    { userReference: { crossEnvironmentTransactionID } },
  );

  await sendPasswordResetEmail(email, { crossEnvironmentTransactionID });

  emailLink.send.finished({});

  return crossEnvironmentTransactionID;
}

// Step 5: User opens the password reset link from the email.
async function verifyResetLink(token: string, crossEnvironmentTransactionID: string) {
  const emailLink = tracker().emailLinkOperationFull();

  emailLink.postResponse.start(
    { explicitSpecType: "email-link-login" },
    { userReference: { crossEnvironmentTransactionID } },
  );

  const result = await verifyPasswordResetToken(token);

  emailLink.postResponse.finished(
    {},
    {
      userReference: {
        userId: result.userId,
        crossEnvironmentTransactionID,
      },
    },
  );

  return result.userId;
}

// Step 6: User sets a new password and is logged in automatically.
async function setNewPasswordAndLogin(
  token: string,
  newPassword: string,
  newPasswordInput: HTMLInputElement,
) {
  const passwordReset = tracker().passwordEnrollmentFullOperation({
    inputHtmlField: newPasswordInput,
    explicitSpecType: "password-reset",
  });

  passwordReset.postResponse.start({});

  const result = await completePasswordResetAndLogin(token, newPassword);

  passwordReset.postResponse.finished({});

  tracker().flowFinished({
    flowName: "recovery",
    userId: result.userId,
  });

  tracker().flowFinished({
    flowName: "login",
    userId: result.userId,
  });
}
```

### 3.2 Recovery without automatic login

A login flow is started, but the user cannot complete the login directly and has to switch to a recovery flow. The user completes recovery through an email link and sets a new password, but is not logged in automatically. Instead, the user has to run through a fresh login, for example by using the password they just reset.

Track this similar to the previous example: recovery is nested inside the surrounding login flow. The difference is that the login flow continues after the nested recovery flow and includes additional subflows, such as `password-login`, before the login flow is finished.

Because the email link can be opened in a different browser, device, or session, pass a stable `crossEnvironmentTransactionID` from the email-send step to the reset-link verification step.

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

const tracker = () => getTracker()!;

let provideIdentifier: OperationFullProvideIdentifierWithCUI;
let passwordLogin: PasswordLoginOperationFull;
let passwordReset: PasswordEnrollmentOperationFull;

// Step 1: User navigates to the login page.
async function showLoginPage(emailInput: HTMLInputElement) {
  tracker().flowStarted({
    flowName: "login",
    touchpoint: "account",
  });

  tracker().authMethodsDecisionStarted({
    decisionName: "pre-identifier",
    options: ["identifier-email", "passkey-login-cui", "social-other"],
  });

  provideIdentifier = tracker().provideIdentifierOperationFull(emailInput);
}

// Step 2: User submits their identifier.
async function submitIdentifier(email: string) {
  provideIdentifier.provideIdentifier.postResponse.start({
    explicitSpecType: "email",
  });

  const result = await checkIdentifier(email);

  provideIdentifier.provideIdentifier.postResponse.finished(
    {},
    { userReference: { userId: result.userId } },
  );
}

// Step 3: Password login page loads after the identifier check.
function showPasswordLoginMethod(passwordInput: HTMLInputElement) {
  tracker().authMethodsDecisionStarted({
    decisionName: "post-identifier",
    options: ["password-login-known-identifier", "reset-flow"],
  });

  passwordLogin = tracker().passwordLoginFullOperation({
    inputHtmlField: passwordInput,
    explicitSpecType: "password-known-identifier",
  });
}

// Step 4: User clicks "forgot password" and starts recovery from the login flow.
async function startPasswordRecovery(email: string) {
  tracker().flowStarted({
    flowName: "recovery",
    touchpoint: "login",
  });

  const crossEnvironmentTransactionID = crypto.randomUUID();
  const emailLink = tracker().emailLinkOperationFull();

  emailLink.send.start(
    { explicitSpecType: "email-link-login" },
    { userReference: { crossEnvironmentTransactionID } },
  );

  await sendPasswordResetEmail(email, { crossEnvironmentTransactionID });

  emailLink.send.finished({});

  return crossEnvironmentTransactionID;
}

// Step 5: User opens the password reset link from the email.
async function verifyResetLink(token: string, crossEnvironmentTransactionID: string) {
  const emailLink = tracker().emailLinkOperationFull();

  emailLink.postResponse.start(
    { explicitSpecType: "email-link-login" },
    { userReference: { crossEnvironmentTransactionID } },
  );

  const result = await verifyPasswordResetToken(token);

  emailLink.postResponse.finished(
    {},
    {
      userReference: {
        userId: result.userId,
        crossEnvironmentTransactionID,
      },
    },
  );

  return result.userId;
}

// Step 6: Password reset page loads.
function showSetNewPasswordPage(newPasswordInput: HTMLInputElement) {
  passwordReset = tracker().passwordEnrollmentFullOperation({
    inputHtmlField: newPasswordInput,
    explicitSpecType: "password-reset",
  });
}

// Step 7: User submits the new password, but is not logged in automatically.
async function setNewPassword(token: string, newPassword: string) {
  passwordReset.postResponse.start({});

  const result = await completePasswordReset(token, newPassword);

  passwordReset.postResponse.finished({});

  tracker().flowFinished({
    flowName: "recovery",
    userId: result.userId,
  });

  return result.userId;
}

// Step 8: User returns to login and submits their identifier again.
// Reuse showLoginPage() and submitIdentifier() from above; no new functions are needed.

// Step 9: Password login page loads again after the second identifier check.
function showPasswordLoginAfterRecovery(passwordInput: HTMLInputElement) {
  tracker().authMethodsDecisionStarted({
    decisionName: "post-identifier",
    options: ["password-login-known-identifier", "reset-flow"],
  });

  passwordLogin = tracker().passwordLoginFullOperation({
    inputHtmlField: passwordInput,
    explicitSpecType: "password-known-identifier",
  });
}

// Step 10: User logs in with the password they just set.
async function loginWithNewPassword(newPassword: string, userId: string) {
  passwordLogin.postResponse.start({});

  await loginWithPassword(newPassword);

  passwordLogin.postResponse.finished({});

  tracker().flowFinished({
    flowName: "login",
    userId,
  });
}

```

## 4. Next steps

* Continue with [Decisions](/corbado-observe/tracking/decisions) to model branch points inside flows.
* Continue with [Subflows](/corbado-observe/tracking/subflows) to learn about subflow types and their steps.
