> ## 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), the checkpoints where a user or your system picks a method or a route
* [**Subflows**](/corbado-observe/tracking/subflows), one concrete auth method attempt each
* **Nested flows**, a complete journey inside another one, such as a recovery started from the login page

Flows are **not auto-discovered**. You send the flow events yourself, and every event you send attributes to the innermost open flow. Read [Modeling](/corbado-observe/tracking/modeling) first if you have not defined your flow boundaries yet.

## 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: "25%", textAlign: "left" }}>Outcome states</th>
      <th style={{ width: "55%", textAlign: "left" }}>Description</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td><code>login</code></td>
      <td><code>complete</code> or <code>incomplete</code></td>
      <td>Existing user authentication. Typically contains the <code>pre-identifier</code> and <code>post-identifier</code> decisions, subflows such as <code>provide-identifier</code>, <code>passkey-login</code>, <code>password-login</code>, <code>social-login</code>, <code>email-otp</code> or <code>email-link</code>, and possibly a nested <code>signup</code> or <code>recovery</code>.</td>
    </tr>

    <tr>
      <td><code>signup</code></td>
      <td><code>complete</code> or <code>incomplete</code></td>
      <td>New user registration. Similar subflows to login, usually with the enrollment spec types, and possibly a nested <code>recovery</code>.</td>
    </tr>

    <tr>
      <td><code>recovery</code></td>
      <td><code>complete</code> or <code>incomplete</code></td>
      <td>Regaining access to an account, for example a password reset. Usually nested inside a login flow.</td>
    </tr>

    <tr>
      <td><code>enrollment</code></td>
      <td><code>complete</code>, <code>skipped</code>, <code>visible-auto-skip</code>, <code>invisible</code> or <code>incomplete</code></td>
      <td>Authenticator enrollment, for example a passkey setup prompted after login. Typically contains an <code>enrollment-user</code> decision and a <code>passkey-enrollment</code> subflow.</td>
    </tr>
  </tbody>
</table>

You can also use custom flow names for authentication-related journeys that do not fit the predefined ones, such as account renewal, re-authentication or transaction signing. Custom flow names can be used in custom dashboards. The predefined dashboards do not pick them up automatically.

## 2. Flow events

| Event                | SDK method           | Send when                                               |
| -------------------- | -------------------- | ------------------------------------------------------- |
| `flow_started`       | `flowStarted()`      | The user enters the flow. One declared opener per flow. |
| `flow_decided`       | `flowDecided()`      | An ambiguous entry resolves to one flow name.           |
| `flow_finished`      | `flowFinished()`     | The flow succeeds, or is explicitly skipped.            |
| `flow_auto_finished` | `flowAutoFinished()` | A nested flow's terminal also completes its parent.     |
| `flow_reset`         | `flowReset()`        | Rarely. An explicit restart by the user.                |

### 2.1 `flowStarted()`

Emit `flowStarted()` at the flow's own entry screens: the login page, the sign-up page, the "forgot password" screen, the enrollment prompt. Use `flowName` when the flow is known from the start.

Use `flowNames` when several flow names are possible at entry. This is common for identifier-first journeys where login and sign-up share one form: list both, optionally with `defaultFlowName` to indicate which one Corbado Observe should assume if the journey is never resolved.

Set `touchpoint` to describe where the flow was initiated. The same flow started from checkout behaves very differently from the same flow started from the account page, and the funnels are segmented by it.

If the user is already known when the flow starts, which is normal for enrollment, include `userId` in `flowStarted()`.

<Warning>
  Fire `flowStarted()` from one declared place per flow, never from a nested page "to make sure the parent is open". A repeated start of the innermost open flow is merged, but a repeated start of an **outer** flow while a nested flow is open is read as a restart: the nested flow closes as incomplete. Frameworks that mount route components twice per navigation make this an easy mistake.
</Warning>

`flowStarted(data, tags?, experiments?, contexts?)` accepts structured context as its fourth argument. Context is written to the event's `contexts` field without redaction; use it for non-sensitive technical metadata, and use [tags](/corbado-observe/tracking/tags) for analytical dimensions. Omit unused arguments with `undefined`.

### 2.2 `flowDecided()`

Emit `flowDecided()` when a journey that started with `flowNames` resolves to one concrete flow, typically after your backend has checked whether the identifier belongs to an existing user. If it is emitted more than once, the last flow name wins. A flow name provided by a later `flowFinished()` also wins.

### 2.3 `flowFinished()`

Emit `flowFinished()` when the flow reaches its explicit end state. For login, that is the moment the user is authenticated. For sign-up, the moment the account exists and the user is authenticated. For recovery, the moment access is regained. For enrollment, the moment the authenticator is set up.

Include `userId` and `identifier` so the completed flow links to a [user](/corbado-observe/tracking/user).

**Do not model non-completion.** A flow the user abandons is classified as incomplete from the absence of a `flowFinished()`. The one exception is an explicit skip, which is semantically different from abandoning: use `explicitOutcome`.

| `explicitOutcome`   | Meaning                                                                                                      |
| ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `skipped`           | The user declined, for example "not now" on an enrollment prompt or "continue as guest" on a checkout login. |
| `visible-auto-skip` | The prompt was shown and your system skipped it automatically.                                               |
| `invisible`         | The flow was never shown to the user, for example an enrollment suppressed by policy.                        |

A skip carries the user reference whenever identity is already known.

### 2.4 `flowAutoFinished()`

Only `login` and `signup` can contain nested flows. A flow that itself establishes the session, such as a sign-up or a recovery started from the login page, nests inside `login`. When the nested flow's terminal fires, finish it with `flowFinished()` and complete the parent with `flowAutoFinished()`:

```typescript theme={null}
getTracker().flowFinished({ flowName: "signup", userId, identifier });
getTracker().flowAutoFinished({ flowName: "login", finishedByFlowName: "signup", userId });
```

A `flowAutoFinished()` without an open parent is dropped silently. A flow that runs after the session already exists, typically an enrollment prompted after login, is not nested. It is a sibling flow started after the login finished, with its own `flowStarted()` and `flowFinished()`.

When the user leaves a nested flow without finishing it, close it with `flowFinished({ flowName, explicitOutcome: "skipped" })`. That records the right outcome and keeps the parent's subsequent events out of the nested flow.

### 2.5 `flowReset()`

`flowReset()` exists for an explicit restart by the user, for example navigating back to the start of a login. It is rarely needed: whether a user restarted is inferable later from revisited decisions and subflows. Do not emit it just to be tidy.

## 3. Ordering

Ordering requirements are causal, not temporal:

1. `flowStarted()` before any event of that flow. A `flowFinished()` or `flowDecided()` without an open flow invalidates the whole session's classification.
2. When a screen renders, the decision `started` comes before the operation helpers are created.
3. Settle the previous screen before opening the next: a navigational decision `finished` precedes the next screen's decision `started`.

Nothing else. The backend orders by timestamp and emission sequence and repairs known race patterns. Use `explicitTimestamp` on steps and decisions to back-date an event when the semantic moment precedes the tracking call.

A flow carried across page loads also needs the same Observe session. The SDK checks inactivity at initialization, so an expired session can split a cross-load flow. Ensure the project's inactivity window spans the journey and initialize the same project on each page. See [session lifetime](/corbado-observe/overview/constraints#7-storage-and-consent).

## 4. Tags on flows

Flows are the natural carrier for [tags](/corbado-observe/tracking/tags). Configuration known at entry, such as product, variant or device class, goes on `flowStarted()`. Values only known on success go on `flowFinished()`. The last value per key wins across a flow's events. Never put identity into tags.

## 5. Conversions

`conversion()` records a business conversion outside authentication, such as a completed purchase, so that authentication outcomes can be related to what happened next:

```typescript theme={null}
getTracker().conversion({ name: "purchase", touchpoint: "checkout", userId });
```

## 6. Examples

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

### 6.1 Recovery with automatic login

A login flow is started, the user cannot log in and switches to recovery. After completing recovery through an email link and setting a new password, the user is logged in automatically.

Recovery nests inside the surrounding login. When recovery completes, finish `recovery` explicitly and complete `login` with `flowAutoFinished()`. Because the email link can be opened in a different browser or device, pass a stable `crossEnvironmentTransactionID` from the send step to the verification step.

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

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

let provideIdentifier: OperationFullProvideIdentifierWithCUI;
let passwordLogin: PasswordLoginOperationFull;

// Step 1: The login page renders.
function showLoginPage(emailInput: HTMLInputElement) {
  tracker().flowStarted({ flowName: "login", touchpoint: "account" });

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

  provideIdentifier = tracker().provideIdentifierOperationFull({
    inputHtmlField: emailInput,
    explicitSpecType: "email",
  });
}

// Step 2: The user submits the identifier.
async function submitIdentifier(email: string) {
  provideIdentifier.provideIdentifier.postResponse.start({});
  const result = await checkIdentifier(email);
  provideIdentifier.provideIdentifier.postResponse.finished({}, { userReference: { userId: result.userId } });
}

// Step 3: The password screen renders.
function showPasswordScreen(passwordInput: HTMLInputElement) {
  tracker().authMethodsDecisionStarted({
    decisionName: "post-identifier",
    options: ["password-login-known-identifier", "forgot-password"],
  });

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

// Step 4 (optional): The password is wrong.
async function submitPassword(password: string) {
  passwordLogin.postResponse.start({});
  const result = await loginWithPassword(password);
  if (!result.success) {
    passwordLogin.postResponse.errorTyped({ code: "invalid_password" });
  }
}

// Step 5: The user clicks "forgot password". The navigational choice is finished
// explicitly, then the nested recovery flow opens.
async function startPasswordRecovery(email: string) {
  tracker().authMethodsDecisionFinished({
    decisionName: "post-identifier",
    explicitDecisionValue: "forgot-password",
  });
  tracker().flowStarted({ flowName: "recovery", touchpoint: "login" });

  const crossEnvironmentTransactionID = crypto.randomUUID();
  const emailLink = tracker().emailLinkOperationFull({ explicitSpecType: "email-link-login" });

  emailLink.send.start({}, { userReference: { crossEnvironmentTransactionID } });
  await sendPasswordResetEmail(email, { crossEnvironmentTransactionID });
  emailLink.send.finished({});
}

// Step 6: The user opens the reset link, possibly on another device.
async function verifyResetLink(token: string, crossEnvironmentTransactionID: string) {
  const emailLink = tracker().emailLinkOperationFull({ explicitSpecType: "email-link-login" });

  emailLink.postResponse.start({}, { userReference: { crossEnvironmentTransactionID } });
  const result = await verifyPasswordResetToken(token);
  emailLink.postResponse.finished({}, { userReference: { userId: result.userId, crossEnvironmentTransactionID } });
}

// Step 7: The 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().flowAutoFinished({ flowName: "login", finishedByFlowName: "recovery", userId: result.userId });
}
```

### 6.2 Recovery without automatic login

Same journey, but the user is not logged in after the reset and has to log in again with the new password. Recovery is still nested inside login. The difference is that `login` stays open after the nested recovery finished and contains a further `password-login` subflow before it finishes explicitly.

```typescript theme={null}
// Steps 1 to 6 as above.

// Step 7: The user sets a new password, but is not logged in.
async function setNewPassword(token: string, newPassword: string, newPasswordInput: HTMLInputElement) {
  const passwordReset = tracker().passwordEnrollmentFullOperation({
    inputHtmlField: newPasswordInput,
    explicitSpecType: "password-reset",
  });

  passwordReset.postResponse.start({});
  const result = await completePasswordReset(token, newPassword);
  passwordReset.postResponse.finished({});

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

// Step 8: The user is sent back to the login page. The login flow is still open,
// so showLoginPage() must NOT fire flowStarted() again. Only the decision and the
// identifier helper are re-created; the pre-identifier checkpoint is genuinely re-presented.

// Step 9: The password screen renders again (showPasswordScreen() from above).

// Step 10: The user logs in with the new password.
async function loginWithNewPassword(password: string, userId: string) {
  passwordLogin.postResponse.start({});
  await loginWithPassword(password);
  passwordLogin.postResponse.finished({});

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

More journeys, including sign-up with automatic login and combined forms, are in the [journey catalog](/corbado-observe/tracking/modeling#6-journey-catalog).

## 7. Next steps

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