pixellint

Identity · engineers · marketers

Hash email and phone. Leave the algorithm as SHA-256.

Vendors match SHA-256 of a specific normalization, not bcrypt, not MD5, not a digest of the raw display string. The 64-character hex is the payload. The algorithm is not a choice, and hashing the wrong fields is a matching miss that still returns HTTP 200.

What gets hashed

On Meta CAPI, user_data.em, ph, fn, ln, ge, db, ct, st, zp, country, and external_id are SHA-256 hex. Lists of those fields are the same contract, one digest per value. Meta's own customer information parameters document that split. TikTok Events API hashes context.user.email, phone_number, and external_id. Snap uses the Meta-shaped em and ph fields. Pinterest CAPI hashes em, ph, external_id, and hashed_maids.

A raw email in those slots is both a matching miss and a log leak. A digest of the wrong input is a 64-character string that never joins a person. The collector still returns 200. Pixellint flags a raw address as vendor.meta-conversions-api.body.unhashed_email (and the TikTok, Snap, and Pinterest twins). It flags a non-hex value on the hashed slots as user_data.em.invalid and friends. Pixellint is not affiliated with Meta, TikTok, Snap, or Pinterest. The packs cite their published field lists.

Reddit CAPI v3 is the exception people copy incorrectly. Reddit accepts email and phone unhashed or SHA-256 hashed, so that pack does not require a digest on those fields. Copying the Meta hasher onto Reddit is allowed. Requiring a digest because Meta required one is inventing a rule Reddit did not write.

What must stay plaintext

client_ip_address and client_user_agent on Meta, Snap, and Pinterest, context.ip and context.user_agent on TikTok: sent in the clear. Hashing them makes the event unmatchable. They are request metadata, not customer information parameters. A helper that maps every string in user_data through SHA-256 will hash them by accident.

Click ids and cookies are plaintext too. fbc, fbp, fbclid, ttclid, gclid, msclkid, li_fat_id, and twclid are not SHA-256 inputs. If you hashed them because a privacy pass hashed every string, you deleted the strongest identifiers you had. The cookie values are already opaque tokens. Digesting them a second time is not anonymization. It is a join key you threw away.

Pixellint flags a 64-character hex string in those IP and user-agent slots as hashed_plaintext_field: vendor.meta-conversions-api.body.hashed_plaintext_field, plus the TikTok, Snap, and Pinterest codes. The check is a pattern, ^[A-Fa-f0-9]{64}$, scoped to the plaintext fields. It does not invent extra plaintext fields. Run pixellint validate json on the body before you ship the helper.

Hex, not a fancy encoding

Send the 64-character hex digest. Upper-case hex still matches. Meta documents lower-casing the input, not the digest, so rejecting an upper-case digest would be inventing a requirement. Pixellint accepts [A-Fa-f0-9]{64}. Do not Base64 the hash. Do not prefix sha256:. Do not HMAC with a secret the vendor does not have. HMAC-SHA256 with your API secret produces a digest the graph cannot reproduce.

Normalize, then hash, then send. Hashing first and lowercasing the digest does not undo a mixed-case email that went into the hasher. Test with a known address and the vendor's own example digest before you ship the helper. If your digest disagrees with the documented example, stop. Do not A/B the algorithm in production.

MD5, SHA-1, and bcrypt show up in CRM exports. None of those are the ads contract. A bcrypt string is not 64 hex characters and will fail the SHA-256 slot. An MD5 hex is 32 characters and fails the same check. The HTTP 200 does not mention any of that.

One helper, two allowlists

The usual production bug is a single hashUserData(obj) that walks every key. Identifiers go through SHA-256. IP, user-agent, fbp, fbc, and click ids do not. Split the list in code, not in a comment. The second usual bug is hashing on the pixel and hashing again on the server, which produces SHA-256 of a SHA-256. That 64-character string looks legal and matches nobody.

If the pixel already hashed advanced matching, the server must hash the raw value it has, not the digest the browser sent. Two pipes, one normalization, one hash, same hex. Dedup is event_id, not a second hash.

Check the digest, not the dashboard

Events Manager match quality will not tell you which field missed. A Purchase with hashed email, hashed phone, hashed IP, and a real fbc can still look fine in Test Events and score badly in production because the IP never joined. Paste the JSON into pixellint validate json. The hashed_plaintext_field code is the IP bug. The unhashed_email code is the other direction.

Keep a fixture with one email, one E.164 phone, one IP, one user-agent, one fbp, one fbc. Assert the email and phone are 64 hex. Assert the IP still looks like an address. Assert fbp still starts with fb. That fixture is cheaper than a week of ROAS archaeology.

// WRONG: hashed everything in user_data, including IP
user_data.em = sha256hex(email.trim().toLowerCase());
user_data.client_ip_address = sha256hex(ip); // 64 hex, unmatchable

// RIGHT: identifiers hashed, request metadata plaintext
user_data.em = sha256hex(email.trim().toLowerCase());
user_data.client_ip_address = ip; // dotted IPv4 or IPv6, as seen

Check the artifact

Paste the pixel URL or JSON body into the playground. Same engine as pixellint validate. Nothing leaves the tab.