Enhanced conversions: normalization & hashing
Enhanced conversions are how Google matches a conversion to a user when click identifiers alone aren’t enough. You upload identity fields (email, phone, name, address) hashed with SHA-256; Google rehashes the same fields from logged-in user data and compares. If the hashes match, the conversion attributes to that user.
Two rules:
- The bytes must match exactly. Different normalization on browser and server produces different hashes. Google has no way to know they’re the same person.
- Some fields are hashed; some are plaintext. Get the wrong split and Google rejects the conversion.
Trackbridge does the normalization and hashing for you on both sides — identically — so you don’t have to worry about either rule. This page documents what it does, in case you need to verify or debug.
What you pass in
Section titled “What you pass in”Always raw values. Don’t normalize. Don’t hash. Don’t lowercase. Don’t trim. Don’t strip phone formatting.
await tracker.trackConversion({ label: 'purchase', transactionId: order.id, userData: { email: ' Alice@Example.COM ', phone: '+1 (555) 123-4567', firstName: 'Alice', lastName: 'O\'Brien', address: { street: '123 Elm Street, Apt 4', city: 'New York', region: 'NY', postalCode: '10001', country: 'us', }, },});That’s the call. Pass exactly what you have, exactly as you have it. The SDK takes it from there.
What gets hashed vs sent plaintext
Section titled “What gets hashed vs sent plaintext”| Field | Hashed | Why |
|---|---|---|
email | yes | PII; Google’s enhanced-conversions protocol requires SHA-256. |
phone | yes | Same reason. |
firstName | yes | Same. |
lastName | yes | Same. |
address.street | yes | Same. |
address.city | no — plaintext | Geographic; Google needs to read it for locality matching. |
address.region | no — plaintext | Same. |
address.postalCode | no — plaintext | Same. |
address.country | no — plaintext | Same. |
This split matches what Google’s enhanced conversions guide specifies. The SDK’s rules are not negotiable per call.
Normalization rules
Section titled “Normalization rules”These run before hashing on the fields that get hashed, and before transmission on the fields that don’t.
email → normalizeEmail
Section titled “email → normalizeEmail”trim → toLowerCase → unicodeNormalize('NFC')' Alice@Example.COM ' → 'alice@example.com' → SHA-256.
phone → normalizePhone
Section titled “phone → normalizePhone”strip non-digits, preserve leading '+' if present'+1 (555) 123-4567' → '+15551234567' → SHA-256.
The SDK does not infer a country code. Phones without a leading + are returned as digits only; the matcher will treat 5551234567 and +15551234567 differently. If you have customer phones in mixed formats, normalize them in your data layer first — picking a consistent representation (preferably E.164) is your responsibility, not the SDK’s.
firstName / lastName → normalizeName
Section titled “firstName / lastName → normalizeName”trim → toLowerCase → unicodeNormalize('NFC')Hyphens, apostrophes, internal spaces, and diacritics are preserved (in NFC form).
"O'Brien " → "o'brien" → SHA-256.
address.street → same as names
Section titled “address.street → same as names”trim → toLowerCase → unicodeNormalize('NFC')'123 Elm Street, Apt 4' → '123 elm street, apt 4' → SHA-256.
address.city / address.region → same, but plaintext
Section titled “address.city / address.region → same, but plaintext”trim → toLowerCase → unicodeNormalize('NFC')Sent as plaintext, not hashed.
address.postalCode → simpler
Section titled “address.postalCode → simpler”trim → toLowerCaseInternal spaces preserved (so UK codes like SW1A 1AA stay readable). Sent plaintext.
address.country → uppercased
Section titled “address.country → uppercased”trim → toUpperCase → unicodeNormalize('NFC')ISO 3166-1 alpha-2 by convention: US, GB, DE, BR. The SDK enforces uppercase but doesn’t validate that the value is a real country code.
Why “byte-identical” is the bar
Section titled “Why “byte-identical” is the bar”The browser SDK runs in the user’s browser. The server SDK runs in your runtime. They are physically different processes. They might use different JS engines, different Unicode tables, different default locales.
If they normalize differently — even by one byte — the SHA-256 hash differs. If the hashes differ, Google can’t tell the two events are about the same user, and the dedup logic (which is keyed on transactionId) carries the count, but the enhanced match silently degrades.
Trackbridge defends against this in two ways:
- Locale-independent normalization. The SDK explicitly avoids
toLocaleLowerCase(which depends on the runtime’s locale) and uses plaintoLowerCase. - Same code on both sides. Both
@trackbridge/browserand@trackbridge/serverimport the normalization functions from@trackbridge/core. There is one implementation, used twice.
The result: if you call trackConversion with the same userData on both sides, the hashes are byte-equal. You can verify with hashUserData from @trackbridge/core if you ever need to.
What “absent” means
Section titled “What “absent” means”Trackbridge omits any field that’s missing on input or normalizes to an empty string. Empty strings don’t get hashed; they get dropped.
userData: { email: 'alice@example.com', phone: '', // dropped firstName: ' ', // normalizes to '' → dropped}Result sent to Google: only email. The address object is dropped entirely if no sub-field survives normalization.
What this does NOT do
Section titled “What this does NOT do”- It does not look up missing fields. Pass empty
phone, get nophonehash. The SDK does not query your database. - It does not validate format. A
countryof'Mars'will be hashed (no, sorry, normalized) and sent. Google will reject it; the SDK won’t. - It does not support custom normalization. Your business cannot set its own rules — Google’s rules win, and that’s the point.
See also
Section titled “See also”@trackbridge/core—hashUserData,normalizeEmail, etc.tracker.trackConversion()serverTracker.trackConversion()- Google’s Enhanced conversions for the web — the source of truth.