# One person across browser and backend

> Identity stitching end to end in ClickClacks: an anonymous visitor on the marketing site, the hop into the app, identify at sign-up, server events with the same ID, linking the browser from your backend, sign-out, a second device, checking it in People, and the pitfalls that split one person in two.

- Canonical URL: https://clickclacks.io/docs/walkthroughs/one-person
- Section: Walkthroughs
- Last updated: 2026-09-26

A visitor reads Acme’s pricing page, clicks into the app, signs up, and later pays from a webhook. That is four places and at least two domains, and it should be one person in every report. This walkthrough wires it up end to end and shows you how to check it.

## The story {#story}

1. Maya lands on `acme.com/pricing` from an ad. She’s anonymous.
2. She clicks _Start free trial_ and arrives on `app.acme.com`.
3. She signs up. Acme’s ID for her is `user_4821`.
4. The API records `Workspace created`; a week later billing records `Invoice paid`.
5. She signs in on her phone, and signs out of a shared laptop.

ClickClacks never guesses who someone is from an IP address or device. It joins these moments only through the IDs you give it, so each step below is about passing the right ID at the right time. This walkthrough assumes the tracker is installed on both domains and at least one server source exists (see the [first events](https://clickclacks.io/docs/walkthroughs/first-events.md) and [Node](https://clickclacks.io/docs/walkthroughs/node.md) walkthroughs).

## 1. Choose the ID {#id}

Pick one ID per user and use it everywhere: the browser’s `identify`, every server `distinctId`, every `distinct_id` over HTTP.

- **Stable and internal**, such as your database key. Not an email address, which can change.
- **The exact same string** on every side. `user_4821`, `4821` and `USER_4821` are three different people.
- 1 to 128 characters. If your IDs are numbers, turn them into strings the same way everywhere.

## 2. List and carry your domains {#domains}

Browsers keep storage separate for every hostname, so `acme.com` can’t read the ID set on `app.acme.com`. ClickClacks carries the [anonymous ID](https://clickclacks.io/docs/glossary.md#term-anonymous-id) across on link clicks between listed domains.

1. Open **Sources** › **Identity**.
2. Check every hostname visitors see is listed: `acme.com`, `www.acme.com` and `app.acme.com`. Click **Add a domain** for any that are missing.
3. Turn on **Carry identity between these domains**.
4. If you added a domain, copy the updated snippet from the source (or add the hostname to `data-domains` yourself). The browser checks the attribute; the server checks the saved list.

> **What you should see**
>
> All three hostnames listed. After a day of traffic, the **Is it working** card shows **Kept the same ID across a hop** for _acme.com → app.acme.com_. 80% or more is healthy.

## 3. Follow the anonymous visit {#anonymous}

On her first page load the tracker gives Maya’s browser a random anonymous ID (`per_…`) in local storage. When she clicks a link to `app.acme.com`, the tracker adds a short `_ccid` parameter carrying that ID and her session; the app adopts it and removes the parameter from the address bar. Try it yourself:

```js title="console"
// On acme.com, then again on app.acme.com after clicking through
localStorage.getItem('ccp')   // 'per_…' the anonymous ID: the same on both after the hop
localStorage.getItem('cci')   // null until identify; then 'user_4821'
```

> **What you should see**
>
> The same `per_…` value on both domains. In **Realtime**, open both of your pageviews in **Event details**: they name the same person.

Only real link clicks carry identity. A typed address, a bookmark, a link in an email, a form submission or navigation done in script starts a fresh anonymous visitor on the other domain. [Where carry-over stops](https://clickclacks.io/docs/domains.md#limits) has the full list.

## 4. Identify at sign-up and sign-in {#identify}

When Maya signs up, call `identify` with her ID. Everything the browser did while anonymous, including the carried-over pricing page visit, now belongs to `user_4821`. Call it again at every sign-in and on page loads while she’s signed in: repeating it is harmless, and `$identify` events are free.

```ts title="auth.ts"
// app.acme.com: after sign-up, after sign-in, and on each page load while signed in
if (currentUser) {
  window.clickclacks?.('identify', currentUser.id, { plan: currentUser.plan })
}
```

The identified ID is stored per hostname and doesn’t travel with the link. If people are also signed in on `acme.com` (say, a “Go to app” button), call `identify` there too.

> **What you should see**
>
> `localStorage.getItem('cci')` returns `'user_4821'`, and in **People** a search for `user_4821` finds her, with the pricing page visit in her **Activities**.

## 5. Send server events with the same ID {#server}

Server events join the person who owns the `distinctId`. The order doesn’t matter: if the server sends an ID no browser has identified with yet, ClickClacks creates the person, and the browser joins them at its first `identify`.

**Node**

```ts title="routes/workspaces.ts"
clickclacks.track({
  event: 'Workspace created',
  distinctId: String(req.user.id),   // 'user_4821': the same string the browser identified with
  properties: { template: 'blank' },
})
```

**PHP**

```php title="BillingWebhookController.php"
ClickClacks::track([
    'event' => 'Invoice paid',
    'distinctId' => (string) $invoice->user_id,   // 'user_4821' again
    'insertId' => "inv_{$invoice->id}_paid",
    'properties' => ['$revenue' => 49, '$currency' => 'USD'],
]);
```

A server event without a session ID isn’t part of any browser visit, so it doesn’t add to session counts. To put a server event in the visit’s path, pass the browser’s `ses_…` session ID as `sessionId`; the next step shows how to get it to your server, and [Sessions](https://clickclacks.io/docs/server-identity.md#sessions) has the rules.

## 6. Link the browser from your backend {#link}

The browser `identify` can be missed: an ad blocker, a tab closed during a redirect. Your backend is more reliable, and it can link the browser too. Send the tracker’s IDs with the sign-up request:

```ts title="signup.ts"
// app.acme.com: send the tracker's IDs with the sign-up request
await fetch('/api/signup', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-clickclacks-anonymous-id': localStorage.getItem('ccp') ?? '',
    'x-clickclacks-session-id': sessionStorage.getItem('ccs') ?? '',
  },
  body: JSON.stringify(form),
})
```

Then identify with both IDs on the server:

**Node**

```ts title="routes/signup.ts"
app.post('/api/signup', async (req, res) => {
  const user = await createUser(req.body)
  const anonymousId = req.get('x-clickclacks-anonymous-id') ?? ''

  // Links that browser to the new user, exactly as identify in the browser would.
  clickclacks.identify({
    distinctId: String(user.id),
    anonymousId: anonymousId.startsWith('per_') ? anonymousId : undefined,
    properties: { plan: 'free' },
  })

  res.status(201).json(user)
})
```

**PHP**

```php title="SignupController.php"
$user = User::create($request->validated());
$anonymousId = (string) $request->header('x-clickclacks-anonymous-id', '');

// Links that browser to the new user, exactly as identify in the browser would.
ClickClacks::identify(array_filter([
    'distinctId' => (string) $user->id,
    'anonymousId' => str_starts_with($anonymousId, 'per_') ? $anonymousId : null,
    'properties' => ['plan' => 'free'],
]));
```

- An identify with both `distinctId` and `anonymousId` links that browser to the ID, exactly as the browser’s own `identify` would.
- An anonymous ID that doesn’t start with `per_` is refused (`invalid_anonymous_id`). Before the tracker has run, or after the visitor opted out, there isn’t one, so leave the field out, as above.
- Keep the browser’s `identify` as well. It sets the ID in the browser, so later events carry it.

## 7. Reset on sign-out {#reset}

```ts title="auth.ts"
async function signOut() {
  await fetch('/api/sign-out', { method: 'POST' })
  window.clickclacks?.('reset')
}
```

`reset` forgets the identified ID and starts a new anonymous ID and session. Without it, the next person on a shared laptop is recorded as Maya until they sign in, and a browser that already belongs to one ID isn’t moved to another.

## 8. Check the person {#check}

1. Open **People** and search for `user_4821`.
2. Open the profile. Read **Activities**, then the **Identity** panel in the right-hand rail.

> **What you should see**
>
> One person whose timeline holds the `acme.com` pricing page, the app sign-up, the API’s `Workspace created` and billing’s `Invoice paid`. **Identity** lists her user ID, her browser profiles, devices and the domains she used. **Traits** show the newest values from any identify, browser or server.

Links between IDs are recorded just after the request is accepted, so a brand-new link can take a moment to show. Reports read identity when they run, so a later identify also joins events sent before it.

## 9. A second device {#devices}

There’s no separate linking call. When Maya signs in on her phone and your app calls `identify('user_4821')`, that browser is linked to her, with its earlier anonymous history. The first browser to identify with an ID owns it; later ones join it.

## 10. Fix a split person {#fix}

If one human still shows up as two records (say, two sign-ups with different emails before you fixed the ID), merge them by hand:

1. Open one of them in **People** and choose **⋯** › **Merge with…**.
2. Search for the other, and under **Keep ID and traits from** choose which record wins.
3. Review the totals and click **Merge records**.

Events are never rewritten, and **Unmerge** splits them again. Merging needs the Manage Organization Settings permission and is part of the Pro plan. See [Merging two records](https://clickclacks.io/docs/guides/people.md#merge).

## Pitfalls {#pitfalls}

- **Email as the ID.** Someone changes their email and becomes a new person. Put email in traits if your team needs it, never in the ID.
- **A different format on each side.** The browser sends `user_4821`, the server sends `4821`. Build the ID in one shared function.
- **Identify only on the sign-up page.** People already signed in before you added ClickClacks, or signed in on another domain, never get identified. Call it on every page load while signed in.
- **Calling `reset` anywhere but sign-out.** On every page load it throws the visitor’s history away each time.
- **A redirect that drops the query string** between your domains removes `_ccid`. So does `rel="noreferrer"` or a `no-referrer` policy, because the destination only accepts the ID when the referrer is a listed domain. A hop below 80% usually means one of these.
- **Sending a guessed anonymous ID.** Only send a `per_…` value you read from the tracker; anything else is refused.
- **Separate projects for site and app.** People are joined within one project. Put every surface of the product in one project, as separate sources.

## Next {#next}

Not on Node or PHP? The last developer walkthrough sends the same events from any language: [Any language over HTTP](https://clickclacks.io/docs/walkthroughs/http.md). Or jump to the analyst track and [find where signups drop off](https://clickclacks.io/docs/walkthroughs/activation-funnel.md), which depends on everything above.

## Related {#related}

- [Identify people](https://clickclacks.io/docs/identify.md): The browser side, traits and reset.
- [Identity from the server](https://clickclacks.io/docs/server-identity.md): distinct_id, anonymous_id and sessions.
- [Domains and identity](https://clickclacks.io/docs/domains.md): Allowed domains and carry-over.
- [Identity guide](https://clickclacks.io/docs/guides/identity.md): The carry-over measure in the app.
