pixellint

Identity · engineers · marketers

Lowercase, trim, then hash

The digest is only useful if both sides hashed the same string. Mixed case, trailing spaces, and Gmail dots are how two correct SHA-256 implementations never match.

The common floor

Trim leading and trailing whitespace. Convert the whole address to lowercase. Then SHA-256 and send hex. Meta CAPI documents that order for user_data.em. TikTok's Events API fix hint is the same trim-and-lowercase path. Snap and Pinterest follow the same SHA-256 hex contract on em. Platforms generally hash the string you send after that trim and lowercase. They do not magically undo dots you left in.

Do not lowercase the digest and call it done. Do not trim after hashing. Do not keep the original around in another field of the same JSON body. Pixellint will flag a raw address as unhashed_email even if em is already a digest, because the leak is the other key.

Unicode emails exist. The ads contracts in these packs are SHA-256 of the UTF-8 bytes of the normalized string. Do not punycode unless the vendor example does. Do not NFC-normalize unless the vendor example does. Match the documented example, then freeze the helper.

Do not invent Gmail-dot collapsing for Meta

Gmail ignores dots in the local part. buyer@gmail.com and b.u.y.er@gmail.com are the same mailbox at Google. That is a Gmail receiving rule. It is not a Meta hashing rule. Meta's documented floor is trim and lowercase. If you strip dots before hashing for Meta CAPI, you hashed a string Meta's graph did not hash unless the user typed it that way.

Same for plus-tags. Gmail delivers buyer+camp@gmail.com to buyer@gmail.com. Meta hashes the string you send. If checkout collected buyer+camp@gmail.com, hash that. Do not strip +camp because a blog post about Google Ads did.

Google Ads is stricter on Gmail

Enhanced conversions for Google Ads document extra steps for gmail.com and googlemail.com: remove periods from the local part, and strip a plus-tag, then SHA-256. That is Google's recipe, in Google's docs. It is the reason a shared hasher across Meta CAPI and Google Ads will match one vendor and miss the other.

Use the vendor's own test vectors. A helper that only lowercases will look fine in Events Manager and fail enhanced conversions on Gmail-heavy lists. A helper that always strips dots will look fine in Google Ads and miss Meta on every dotted Gmail the user actually typed. Two functions, or one function with an explicit vendor argument. Not one silent default.

hashedEmail on Google Ads UploadClickConversions is SHA-256 hex, same 64-character shape. Pixellint flags a non-digest there as vendor.google-ads-click-conversions.body.userIdentifiers[].hashedEmail.invalid. It does not check whether you stripped Gmail dots. That check would be inventing a rule the pack does not have.

Where the raw address still lives

Checkout emails in thank-you query strings, in event_source_url, and in the Referer of the next hop. Hashing em does not erase those. Canonicalize the page URL before you put it on CAPI. A Purchase with a perfect em digest and ?email=Buyer@Example.com on event_source_url is still a leak, and still a matching miss if a downstream log scrapes the URL instead of user_data.

If external_id is the email, hashing em and sending the raw email as external_id is not anonymization. Pick a stable customer id that is not the address, or hash external_id too, the way Meta recommends. Pixellint requires SHA-256 hex on user_data.external_id for Meta CAPI.

Test vectors before CRM scale

Hash buyer@example.com after trim and lowercase and compare to a known SHA-256. Then hash Buyer@Example.com and confirm it is the same digest. Then hash buyer@example.com with a trailing space and confirm it is still the same, because you trimmed. Then hash b.uyer@gmail.com for Meta without stripping the dot, and a second digest for Google Ads enhanced conversions with the dot stripped. Store both expected hex strings in the test.

Run pixellint validate json on a payload that still has the raw address in custom_data or in event_source_url. The unhashed_email rule is how you catch the leak the hasher did not see.

// Common floor (Meta, TikTok, Snap, Pinterest)
function hashEmail(raw) {
  return sha256hex(String(raw).trim().toLowerCase());
}

// WRONG for Meta: Gmail-dot collapsing copied from Ads docs
function hashEmailAsIfGoogleAds(raw) {
  let s = String(raw).trim().toLowerCase();
  const [local, domain] = s.split('@');
  if (domain === 'gmail.com' || domain === 'googlemail.com') {
    s = local.replace(/\./g, '').replace(/\+.*$/, '') + '@' + domain;
  }
  return sha256hex(s);
}

Check the artifact

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