# Identity across connectors

> One random id per person, minted and mapped by you; every connector sends that id, a hash of it, or nothing.

Source: https://www.patterna.nl/docs/identity-across-connectors

Patterna never learns who a person is. It knows a random id that your backend minted, and only your backend can turn that id back into an account. This page shows how to keep it that way when events arrive from several places: your backend, PostHog, GA4, Shopify and one-off imports.

## The rule

1. **Your backend mints one random id per person per tenant**: 128 random bits, for example `u_` plus 26 base32 characters, or a UUIDv4. Never derive it from the email or the account id without a key only you hold.
2. **The mapping stays with you**: a table from your account id to the Patterna id. Patterna has no copy.
3. **Every connector sends that id, a keyed hash of an id, or no user id at all.** Never an email, a phone number, an account id or an identity-provider subject such as `auth0|…`.

What enforces it:

- The SDKs refuse email-shaped and `provider|id`-shaped user ids before anything is sent.
- The engine refuses user ids containing `@` or whitespace, or longer than 128 characters.
- The engine also rejects, per event, an event with a PII field name (`email`, `name`, …) or an email or `provider|id` user id (`pii_rejected`).

Those checks catch accidents. They cannot catch an account id that merely looks random, so the rule above is yours to keep.

## Per connector

| Source | Id it sends | What Patterna stores | What you must do |
|---|---|---|---|
| Your backend ([Backend API](https://www.patterna.nl/docs/connector-backend-api), SDKs) | the Patterna id from your mapping table, or a session id in `non_profiled` mode | the Patterna id, or the session id with no user row | look up the id per request; never forward an id from the client |
| [PostHog](https://www.patterna.nl/docs/connector-posthog) webhook | PostHog's `distinct_id` | never the raw distinct id: an import-minted `u_…` is kept, anything else becomes a Patterna-minted id (see below) or is dropped | make the distinct id the Patterna id (`posthog.identify(patternaId)`) or a keyed hash; send only `personalized` users |
| PostHog batch export | `distinct_id`, optionally hashed by the reader (`id_mapping: "hmac"`) | `u_` + a Patterna-minted hash, never the raw value | if Patterna must not be able to derive the id, hash before the export with your own key |
| [GA4](https://www.patterna.nl/docs/connector-ga4) | GA4 `user_id` (yours), `user_pseudo_id` (device) | `user_id` as is or as a Patterna-minted hash; the device id only as an input to derive session and event ids | set GA4 `user_id` to the Patterna id, or turn on hashing |
| [Shopify](https://www.patterna.nl/docs/connector-shopify) | no customer id; the `patterna_uid` or `patterna_sid` cart attribute if your storefront sets it (webhook `/webhooks/shopify/{tenant}`, HMAC only) | `order_id_hash`, product ids, value | forward orders from your backend with the Patterna id, or link through the cart attribute |
| [Quiz CSV](https://www.patterna.nl/docs/connector-quiz-csv) | the id column you name | tag affinities on users that already exist | export the file with the Patterna id column, never your account id |
| One-off imports from your own database | your ids, replaced by Patterna-minted hashes during the import | the hashed ids; the mapping goes to you as a file | load the mapping file into your mapping table, then delete it |

## Ids that Patterna mints

Where a connector replaces a source id with a hash (the PostHog webhook and batch reader, GA4 with hashing on, the one-off imports), Patterna mints the id itself: `u_` plus base32 of HMAC-SHA256 under the tenant's `PATTERNA_ID_SECRET`. **Patterna holds that secret, not you.** That is what lets PostHog events and imported history join up without a mapping step.

The consequence: anyone with the secret can compute the Patterna id again from the source id (for example from a PostHog distinct id that is your account id). Patterna never stores the source id, but it could re-derive the link. If that is not acceptable for your data protection assessment, do not let Patterna hash: map the source id to your own random id, or hash it with your own key as below, before the data reaches Patterna.

Imported history joins your live ids only if you load the mapping file into your mapping table. Ids your backend mints itself never pass through this secret.

## Hashing an external id with your own key

When a connector only knows an id from another system (a PostHog distinct id, a GA4 user id, a Shopify customer id), turn it into a Patterna id with a keyed hash under a key you hold:

```text
patterna_id = "u_" + base32( HMAC-SHA256(key, "user" + 0x1F + external_id) )[:26]
```

- The key is at least 32 random bytes and lives in your secret store.
- The same external id always gives the same Patterna id, so events from several sources join up.
- Without the key nobody can go from the external id to the Patterna id, or back.
- Store the result in your mapping table too, so deletion can find it.

Keep one scheme per tenant. If your backend already mints random ids, map the external id to that id in your backend rather than hashing it a second way.

## Sessions in `non_profiled` mode

A `non_profiled` request carries a `session_id` and no `user_id`, and Patterna stores no user row. Mint the session id like a user id (128 random bits) and rotate it, for example when the app returns after 30 minutes in the background. It is not a device id and is never persisted on the device beyond the session.

## Assignment unit

Rotating session ids have one cost: the holdout arm comes from the session id, so the same person can be in treatment in the morning and control in the afternoon, and the ledger's intervals are clustered on sessions, not people.

The recommendation request takes an optional `assignment_unit` on the recommendation request:

```json
{
  "surface": "more_to_read",
  "mode": "non_profiled",
  "session_id": "s_k3j2h4g5f6d7s8a9",
  "assignment_unit": "9c1f0e6d…64 hex characters…",
  "context": { "locale": "nl", "anchor_item_id": "doc_123" }
}
```

- You compute it: lowercase hex HMAC-SHA256 of your own user id under a key you hold, per experiment or per tenant. It must be exactly 64 hex characters. It counts on `non_profiled` requests only and is ignored on the others, which already have a user id. The SDKs have a helper (`patterna.AssignmentUnit(secret, userID)` in Go, `assignmentUnit(secret, userId)` in TypeScript).
- Patterna uses it **only** to draw the arm and as the ledger's unit. It is never stored raw (only the salted `unit_hash`), never joined to events and never used for ranking.
- Without your key it cannot be linked to an account, and rotating the key per experiment stops it from linking two experiments.
- The legal reasoning for your records: it serves measurement (keeping one person in one arm so the comparison is valid), not profiling. The request still ranks on context only.

## Deletion reaches every id you mapped

On an account deletion, look up every Patterna id you hold for that account (the minted id, and any hashed ids from connectors), call `DELETE /users/{user_id}` for each with your ingest key, then delete the mapping rows. Session ids need no deletion: no user row exists for them, and once the account is gone they point to nobody. See [Handle a deletion request](https://www.patterna.nl/docs/handle-a-deletion-request).

## Read next

- [Consent modes](https://www.patterna.nl/docs/consent-modes)
- [Data we never store](https://www.patterna.nl/docs/data-we-never-store)
