Pixelex.Destinations.Hash (Pixelex v0.1.0)

Copy Markdown View Source

SHA-256 of normalised personal data, per each platform's own rules.

Ad platforms match a server-side conversion to a user by comparing hashes, so the hash has to be byte-identical to the one they computed from their own copy of the same fact. That makes normalisation the whole job: " Ali@Ex.COM" and "ali@ex.com" are the same person and must produce the same digest, and a leading + on a phone number changes the answer.

The platforms disagree, and it matters

phone
Meta, Snapchat, Pinterest, Redditdigits only — +20 100 123 4567201001234567
TikTokE.164, the + kept+201001234567

This is exactly why the raw value travels to the worker and is hashed at the edge of each client, rather than hashed once at enqueue: one shared digest cannot satisfy both, and hashing early would silently break TikTok matching in a way no error would ever report.

Raw values never leave the box

email, phone, names, city, state, zip, country and external ids are hashed before egress. ip, user_agent and click ids are match keys the platforms want in the clear, and pass through untouched.

Summary

Functions

Drop nil and empty values. Platforms reject empty match keys.

SHA-256 hex of an already-normalised value; nil passes through.

Lower-cased, trimmed, SHA-256 hex.

Reddit's email rule, which is nobody else's.

Does this already look like a SHA-256 digest?

Lower-cased, trimmed, non-empty — or nil. Normalisation without hashing.

Hash with fun, unless the value already is a digest.

Digits only, SHA-256 hex. Meta, Snapchat, Pinterest and Reddit.

E.164 with the leading + preserved, SHA-256 hex. TikTok only.

Lower-cased, trimmed, SHA-256 hex. Names, cities, countries, external ids.

Functions

compact(map)

@spec compact(map()) :: map()

Drop nil and empty values. Platforms reject empty match keys.

digest(value)

@spec digest(String.t() | nil) :: String.t() | nil

SHA-256 hex of an already-normalised value; nil passes through.

email(value)

@spec email(String.t() | nil) :: String.t() | nil

Lower-cased, trimmed, SHA-256 hex.

email_reddit(value)

@spec email_reddit(String.t() | nil) :: String.t() | nil

Reddit's email rule, which is nobody else's.

Lower-case, then strip dots from the local part and drop everything after a +, then hash. Reddit's own published vector:

alice@example.com
Al.ice+Apple@Example.Com

both produce ff8d9819fc0e12bf…. Meta, Pinterest and TikTok do none of this — they hash the address as written, lower-cased. Using the wrong rule produces a valid request, a 200, and no matches, which is the failure this library keeps having to design around.

hashed?(value)

@spec hashed?(term()) :: boolean()

Does this already look like a SHA-256 digest?

Some hosts store a hashed email and pass it straight through. Hashing a 64-character hex string a second time produces a digest that matches nothing, and nothing anywhere reports an error — the conversions simply stop attributing, which is the most expensive kind of silence in this library.

normalise_text(value)

@spec normalise_text(term()) :: String.t() | nil

Lower-cased, trimmed, non-empty — or nil. Normalisation without hashing.

once(value, fun)

@spec once(term(), (String.t() -> String.t() | nil)) :: String.t() | nil

Hash with fun, unless the value already is a digest.

phone_digits(value)

@spec phone_digits(String.t() | nil) :: String.t() | nil

Digits only, SHA-256 hex. Meta, Snapchat, Pinterest and Reddit.

phone_e164(value)

@spec phone_e164(String.t() | nil) :: String.t() | nil

E.164 with the leading + preserved, SHA-256 hex. TikTok only.

text(value)

@spec text(String.t() | nil) :: String.t() | nil

Lower-cased, trimmed, SHA-256 hex. Names, cities, countries, external ids.