Patterna Docs

Identity across connectors

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

Developers 5 min read View as Markdown

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#

SourceId it sendsWhat Patterna storesWhat you must do
Your backend (Backend API, SDKs)the Patterna id from your mapping table, or a session id in non_profiled modethe Patterna id, or the session id with no user rowlook up the id per request; never forward an id from the client
PostHog webhookPostHog's distinct_idnever the raw distinct id: an import-minted u_… is kept, anything else becomes a Patterna-minted id (see below) or is droppedmake the distinct id the Patterna id (posthog.identify(patternaId)) or a keyed hash; send only personalized users
PostHog batch exportdistinct_id, optionally hashed by the reader (id_mapping: "hmac")u_ + a Patterna-minted hash, never the raw valueif Patterna must not be able to derive the id, hash before the export with your own key
GA4GA4 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 idsset GA4 user_id to the Patterna id, or turn on hashing
Shopifyno 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, valueforward orders from your backend with the Patterna id, or link through the cart attribute
Quiz CSVthe id column you nametag affinities on users that already existexport the file with the Patterna id column, never your account id
One-off imports from your own databaseyour ids, replaced by Patterna-minted hashes during the importthe hashed ids; the mapping goes to you as a fileload 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.

esc
  • Type to search every page, heading and code sample.