DEV Community

Aleksander Sekowski
Aleksander Sekowski

Posted on

Hash the email. Do not hash the IP. Both mistakes still return 200.

A Purchase reaches the Meta Conversions API with a 64-character hex string in every user_data field. Test Events shows the event. Match quality on the live dataset stays poor, and nobody can see which field missed.

The helper hashed the email, which Meta requires. It also hashed the IP address and the click cookie, which Meta requires you to leave alone. Both mistakes return HTTP 200.

Vendors match SHA-256 of a specific normalization. Not bcrypt. Not MD5. Not a digest of the display string as the CRM stored it. The 64-character hex is the payload. The algorithm is not a choice. Hashing PII is the split. The field list for Meta lives on the Conversions API pack.

Two allowlists, not one function

On Meta CAPI, user_data.em, ph, fn, ln, ge, db, ct, st, zp, country, and external_id are SHA-256 hex. Lists are the same contract, one digest per value. 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 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. A value that is not 64 hex characters on a hashed slot fails as user_data.em.invalid and the matching field ids.

What must stay plaintext: client_ip_address and client_user_agent on Meta, Snap, and Pinterest. context.ip and context.user_agent on TikTok. Those are request metadata, not customer information parameters. Hashing them makes the event unmatchable. 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 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. Send the _fbc cookie value as stored, or rebuild it in the documented fb.1.creationTime.fbclid shape. Do not put the raw fbclid in user_data.fbc and do not hash either one.

A 64-character hex string in an IP or user-agent slot is vendor.meta-conversions-api.body.hashed_plaintext_field, plus the TikTok, Snap, and Pinterest codes. The check is the pattern ^[A-Fa-f0-9]{64}$, scoped to the plaintext fields. It does not invent extra plaintext fields.

// identifiers hashed, request metadata left as seen
user_data.em = sha256hex(email.trim().toLowerCase());
user_data.ph = sha256hex(e164Phone);
user_data.client_ip_address = ip;
user_data.client_user_agent = ua;
user_data.fbc = fbcCookie;
Enter fullscreen mode Exit fullscreen mode
// the production bug: one walk of user_data
for (const key of Object.keys(user_data)) {
  user_data[key] = sha256hex(String(user_data[key]));
}
Enter fullscreen mode Exit fullscreen mode

The second loop produces a legal-looking digest in client_ip_address. Meta cannot reproduce the IP it saw on the request. The email may be fine. The event still 200s.

Normalize, then hash, then send

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 invent a requirement. 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.

Hashing first and then lower-casing the digest does not undo a mixed-case email that went into the hasher. Trim and lower-case the email, hash that, send the hex. 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 fails the SHA-256 slot. An MD5 hex is 32 characters and fails the same check. The HTTP 200 does not mention any of that.

Google Ads enhanced conversions hash email after a stricter Gmail normalize: dots and plus-tags. One hasher shared between Meta and Google Ads will miss one of them on Gmail-heavy lists. Build the normalize next to the vendor, not in a shared hashEmail() that both pipes call.

Reddit is the exception people copy backwards

Reddit CAPI v3 accepts email and phone unhashed or SHA-256 hashed. 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 a rule Reddit did not write. The inverse is worse: seeing that Reddit accepts a raw email, and then sending a raw email on Meta. unhashed_email is the Meta finding. Do not take Reddit's optional digest as a Meta policy.

Double hashing looks legal

The other production bug is hashing on the pixel and hashing again on the server. The browser sends a digest. The server runs SHA-256 on that digest. The result is still 64 hex characters. It 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.

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 look fine in Test Events and score badly in production because the IP never joined. Paste the JSON into pixellint validate json. hashed_plaintext_field is the IP bug. unhashed_email is the other direction.

Keep one 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.

$ pixellint validate json @purchase.json --rulepack vendor/meta-conversions-api
Enter fullscreen mode Exit fullscreen mode

The conversion API validator is the rest of the same body: clock, action_source, value, currency. Hashing is the part a generic privacy helper gets backwards while the status code stays green. Paste the redacted JSON into pixellint.org with the format set to JSON. I maintain Pixellint. It is independent of Meta, TikTok, Snap, Pinterest, and Reddit. The rule ids cite their customer-information docs because that is where the allowlists live, not because this is an official hasher. A model that "hashes PII before send" will hash the IP unless something checks the field list. The package is that check.

Top comments (0)