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

# Enrich journeys with backend data

> Optionally attach what only your backend knows to the journeys Corbado Observe captures, such as the user ID, the account's login methods, a bot or risk verdict or any other tag.

The browser shows **Corbado Observe** how a login went. Your backend often knows facts the page does not. Frequent examples are **who** signed in (when your internal user ID is not exposed in the frontend), **which login methods** the account has and **how trustworthy** the attempt was. Enrichment attaches such facts to the journey the browser captured, so you can analyze them together.

Enrichment is optional. [Autocapture](https://docs.corbado.com/corbado-observe/get-started/autocapture.md) and [custom events](https://docs.corbado.com/corbado-observe/get-started/custom-events.md) already record the complete journey: methods, attempts, errors and outcomes. Add enrichment only when you need an analysis that depends on a fact the browser cannot observe, as described in [where Autocapture stops](https://docs.corbado.com/corbado-observe/overview/integration-paths.md#3-where-autocapture-stops).

![A login flow in the browser: open login, enter email, password, signed in. Below it, your backend adds three facts at matching moments: the account lookup adds login\_methods passkey when the email is entered, the bot check adds bot\_level 1 during the password step and the confirmed login adds userId user\_42. Corbado Observe shows one login flow carrying all three values.](https://mintcdn.com/corbado-43/lijGACEUrKMGy5KS/images/corbado-observe/server-enrichment-overview-light.webp?fit=max\&auto=format\&n=lijGACEUrKMGy5KS\&q=85\&s=9d9079a17e9ff6bb8ac13ab9566d1611)

![A login flow in the browser: open login, enter email, password, signed in. Below it, your backend adds three facts at matching moments: the account lookup adds login\_methods passkey when the email is entered, the bot check adds bot\_level 1 during the password step and the confirmed login adds userId user\_42. Corbado Observe shows one login flow carrying all three values.](https://mintcdn.com/corbado-43/lijGACEUrKMGy5KS/images/corbado-observe/server-enrichment-overview-dark.webp?fit=max\&auto=format\&n=lijGACEUrKMGy5KS\&q=85\&s=b93537315429395d2814ca5e353cd066)

In this example, each backend fact attaches at its own moment of the same login. Observe stores all of them on that one flow.

## 1. Possible use cases

You can send any fact your backend knows as a user reference, a tag or an experiment assignment. These are common examples; add only what your analyses need:

| Use case | Question it answers | Typical moment |
| - | - | - |
| **User ID** | Which journeys belong to the same user, across retries, devices and days? | Login confirmed |
| **Account login methods** | Did a user with a passkey still use a password? How many logins could have used a passkey? | Account lookup after the identifier, and login confirmed |
| **Bot or risk verdict** | Which journeys come from bots? Do the numbers match the bot filter of your other analytics tools? | Bot or risk check |
| **Test or internal traffic** | Which journeys come from test accounts or your own staff, so you can exclude them? | Account lookup or login confirmed |
| **Experiment variant** | Which variant of an experiment your backend decides did the user get, and how did it change the login? | When your backend assigns the variant |

The user ID is usually the most valuable one. It powers [user search](https://app.corbado.com/observe/debugging/user-search), per-user analysis and exports that join your own records. Account login methods turn adoption numbers into a planning basis: they separate users who cannot use a passkey yet from users who have one and do not use it. A bot verdict keeps automated traffic out of your conversion rates.

## 2. Choose how the value reaches Observe

There are two routes. Both produce the same `flow_enriched` event and the same result in analytics.

| Route | Use when | How |
| - | - | - |
| **From the page** | Your frontend already receives the value, for example in the login or lookup response. | Call [`setUser()`](https://docs.corbado.com/corbado-observe/tracking/user#3-send-user-references-from-the-sdk) or [`setFlowTags()`](https://docs.corbado.com/corbado-observe/tracking/tags#2-3-update-tags-while-a-flow-is-open) in the SDK. |
| **From your backend** | The value never reaches the browser, as with most bot verdicts, or you do not want it to. | Send the event to the authenticated [Create new events](https://docs.corbado.com/api-reference/observe/send-events.md) endpoint. |

Prefer the page when the value is already there: it needs no API key and no correlation. With [Autocapture](https://docs.corbado.com/corbado-observe/get-started/autocapture.md), the backend route adds the values without changing your frontend.

Send values that remove traffic from your KPIs, such as a bot verdict, from your backend even if the page knows them. A value set in the browser can be faked, so a bot could mark itself as human.

## 3. Choose the moment

Send each value when your backend learns it, while the journey runs:

* **Account lookup:** after the user enters an identifier, your backend checks whether the account exists. It knows the account's login methods at this point. Use `"at": "during"`.
* **Bot or risk check:** send the verdict with the time of the check. Run the check while the login is in progress, not on page load before it starts. Use `"at": "during"`: the default `finished` only reaches journeys that finished, so blocked or abandoned attempts would get no verdict.
* **Login confirmed:** your backend knows the authenticated user. Use the default `"at": "finished"`.

> [!WARNING]
>
> Logins without an identifier entry, such as a passkey from autofill or a direct social login, never reach the account lookup. Send account values again when the login is confirmed, or these logins arrive without them and distort your rates.

A user ID sent at the account lookup names the account that was targeted. It does not prove who made the attempt. Only the confirmed login establishes the user; see [users and identifiers](https://docs.corbado.com/corbado-observe/overview/users-and-identifiers.md).

## 4. Connect the backend to the journey

Skip this section if you send from the page. A backend request has to identify the browser session. Set exactly one of these fields:

| Field | How it works | Use when |
| - | - | - |
| `crossEnvironmentTransactionID` | The browser sends a UUID as `user.crossEnvironmentTransactionID`. Your backend sends the same UUID. Observe matches the flows of every browser session that carried it. | Recommended. Browser and backend already share an ID per attempt. |
| `sessionID` | The browser reads its Observe session ID and passes it to your backend with the login request. | You can add one field to the existing login request. |

A good shared ID is one your page already sends to your backend, such as a request, correlation, debug or support ID per attempt in UUID format. Use one ID per attempt, not per user. Never use a secret such as a session token or the token of a bot check.

**1. Create an API key**

Create a key with permission `observe:events:write` under [**Observe → Settings → API keys**](https://app.corbado.com/observe/settings/api-keys). Keep it in your backend. See [authentication](https://docs.corbado.com/api-reference/observe/authentication.md).

**2. Share the ID from the browser**

With the transaction ID, record it on the open login flow:

```typescript theme={null}
getTracker()!.setUser({ crossEnvironmentTransactionID: attemptId });
```

With the session ID, read it and send it with your login request instead:

```typescript theme={null}
const observeSessionId = getTracker()?.getSessionId();
```

If the session ID is empty, your backend skips the enrichment.

**3. Send events from your backend**

Post to `https://api.cloud.corbado.io/v1/observe/events` with the key as bearer token. The examples in section 5 show the request body. Use a new event `id` per event and keep it on retries: retries with the same transaction ID and event `id` are deduplicated. The request may arrive before or after the browser events.

## 5. Examples

> [tab] User ID
>
> Send the authenticated user when the login is confirmed.
>
> From the page:
>
> ```typescript theme={null}
> getTracker()!.setUser({ userId: "user_42" });
> ```
>
> From your backend:
>
> ```json theme={null}
> {
>   "crossEnvironmentTransactionID": "3f2b8c1e-7d4a-4f6b-9e2c-1a5d8b7c6e4f",
>   "events": [{
>     "id": "0c9e7a51-2b4d-4f8e-9a36-5d1c8b7e2f40",
>     "type": "predefined",
>     "name": "flow_enriched",
>     "timestamp": 1761487205000,
>     "user": { "userId": "user_42" }
>   }]
> }
> ```
>
> Without `data`, the event matches the login finishing nearest to that time. You can send a hash as `userId`; see [user references](https://docs.corbado.com/corbado-observe/tracking/user.md#2-user-reference-model).

> [tab] Account login methods
>
> Send the login methods the account has, as one tag with a small fixed set of values, for example `passkey`, `social:google`, `passkey,social:google` or `none`. Name social logins by provider as `social:<provider>`, so you can compare how each provider performs.
>
> From the page, after the lookup response:
>
> ```typescript theme={null}
> getTracker()!.setFlowTags({ login_methods: "passkey" }, { flowType: "login" });
> ```
>
> From your backend, at the lookup and again when the login is confirmed:
>
> ```json theme={null}
> {
>   "crossEnvironmentTransactionID": "3f2b8c1e-7d4a-4f6b-9e2c-1a5d8b7c6e4f",
>   "events": [{
>     "id": "7a1d3f60-5c2e-4b9a-8e47-0f6b2d9c3a18",
>     "type": "predefined",
>     "name": "flow_enriched",
>     "timestamp": 1761487201000,
>     "data": { "match": { "flowType": "login", "at": "during" } },
>     "tags": { "login_methods": "passkey" }
>   }]
> }
> ```
>
> Send the methods the account has, not the method the user chose: Observe already records the choice from the journey.

> [tab] Bot verdict
>
> Send the verdict after each check. Section 6 explains this case in detail.
>
> ```json theme={null}
> {
>   "crossEnvironmentTransactionID": "3f2b8c1e-7d4a-4f6b-9e2c-1a5d8b7c6e4f",
>   "events": [{
>     "id": "b5c1f0a2-9d3e-4c7b-8a61-2f4e9d0c7a13",
>     "type": "predefined",
>     "name": "flow_enriched",
>     "timestamp": 1761487200000,
>     "data": { "match": { "flowType": "login", "at": "during" } },
>     "tags": { "bot_level": "4", "risk_action": "block" }
>   }]
> }
> ```

The backend requests use these fields:

| Field | Meaning |
| - | - |
| `crossEnvironmentTransactionID` | The ID per attempt that the browser also sent. Use `sessionID` instead if your backend received the Observe session ID. Send exactly one of the two. |
| `events` | One or more events in one request. |
| `id` | A new UUID per event. Keep it when you retry, so Observe does not count the event twice. |
| `type` | Always `predefined` for enrichment. |
| `name` | Always `flow_enriched` for enrichment. |
| `timestamp` | When your backend learned the value, in Unix milliseconds. Observe uses it to find the journey running at that time. |
| `data.match.flowType` | Which journey to enrich, for example `login`. Optional, the default is `login`. |
| `data.match.at` | `during` attaches to the journey running at that time. Optional, the default `finished` attaches to the journey finishing nearest to that time. |
| `user.userId` | Your internal user ID, in clear text or as a hash. |
| `tags` | Key-value pairs to attach. Values are always strings, for example `"4"`. |

## 6. Example in detail: bot verdict

Bot detection is the typical value that never reaches the browser. The detection service scores the attempt and returns the result to your backend only. Your other analytics tools usually receive it the same way, through a backend export.

![Two lanes joined by a shared transaction ID. In the browser lane, the Observe SDK captures the login flow and sends the transaction ID with it. In the backend lane, your server receives a bot level from its bot-detection service and sends a flow\_enriched event with the same transaction ID and the tag bot\_level 4. Observe matches both by the transaction ID and the time of the check and attaches bot\_level 4 to the login flow. A saved filter then excludes high bot levels from the dashboard.](https://mintcdn.com/corbado-43/OklK5dHc3GmPtdXi/images/corbado-observe/server-enrichment-light.webp?fit=max\&auto=format\&n=OklK5dHc3GmPtdXi\&q=85\&s=4fc8eee07f1be69d604bb483b6895fd1)

![Two lanes joined by a shared transaction ID. In the browser lane, the Observe SDK captures the login flow and sends the transaction ID with it. In the backend lane, your server receives a bot level from its bot-detection service and sends a flow\_enriched event with the same transaction ID and the tag bot\_level 4. Observe matches both by the transaction ID and the time of the check and attaches bot\_level 4 to the login flow. A saved filter then excludes high bot levels from the dashboard.](https://mintcdn.com/corbado-43/OklK5dHc3GmPtdXi/images/corbado-observe/server-enrichment-dark.webp?fit=max\&auto=format\&n=OklK5dHc3GmPtdXi\&q=85\&s=2bca52316a5cdfe1049d1986aa445672)

The browser and your backend share one transaction ID per attempt. Observe uses it to attach the backend verdict to the login running at that time.

1. **Map the verdict:** send a level or category, not a raw score or the vendor response. Agree which levels count as bots, for example 4 and 5.
2. **Send one event per check:** use the time of the check as `timestamp`. If a journey has several checks, the last level wins.
3. **Add your decision (optional):** send what your backend did with the attempt, for example `risk_action` with `allow`, `challenge` or `block`. This separates attempts you blocked from attempts where the user failed.
4. **No verdict, no event:** if the check did not run, send nothing. The journey then carries no level and stays in your KPIs.
5. **Filter in analytics:** save a filter that excludes the bot levels, so dashboards match the bot filter of your other analytics tools.

## 7. How Observe matches the event

Observe attaches each enrichment to the journey that runs at the time of the event, by default the login. Small clock differences between browser and backend are tolerated. If a value arrives twice, the latest one wins. If no journey matches, the value is dropped.

You can choose another flow type or target a specific browser event instead. The [Create new events](https://docs.corbado.com/api-reference/observe/send-events.md) reference lists all options.

## 8. Test before a production rollout

You can test the backend route without deploying it:

1. Run your login page locally with the Observe SDK pointed at a test project.
2. Complete a login and note the transaction ID or session ID.
3. Send the example requests from section 5 with test values, for example `bot_level: "5"`.
4. Open the flow in [**Observe → Debugging → User search**](https://app.corbado.com/observe/debugging/user-search) and check the user and tags.

Also test a login without identifier entry. Volume and timing under real traffic are only visible in staging or production.

## 9. Use the result in analytics

User IDs work in user search and exports without further setup. Enriched tags behave like any other [tag](https://docs.corbado.com/corbado-observe/tracking/tags.md#2-4-use-tags-in-analytics): ask Corbado to add the key to the relevant time series, then filter or segment by it.

* **Values:** short, predictable, lowercase strings.
* **Missing values:** leave the key out instead of sending `unknown`.
* **No personal data:** never send IP addresses, locations, network providers or device fingerprints as tags.

## 10. Next steps

* Continue with [User references](https://docs.corbado.com/corbado-observe/tracking/user.md) for the user model and hashing.
* Continue with [Tags](https://docs.corbado.com/corbado-observe/tracking/tags.md) for tag limits and analytics configuration.
* See [Create new events](https://docs.corbado.com/api-reference/observe/send-events.md) for the full request schema.


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