Server-side tracking without leaking personal data: SHA-256 hashing for merchants

OpenAI accepts emails, phones, names and IDs only as SHA-256 hashes. The exact normalization rules, a Node.js snippet, and why a hash stays personal data.

Published 11 min read

Server-side tracking sends customer identifiers to an ad platform so it can match conversions to users. The OpenAI Conversions API accepts emails, phone numbers, external IDs and names only as normalized SHA-256 hashes, and a few fields such as IP address in clear. Hashing prevents the raw value from leaving your infrastructure, but a hash is still personal data under GDPR: consent and a processor contract remain required.

What advanced matching is, and why it involves personal data

A browser pixel identifies a visitor by what the browser carries: a click identifier in the URL, a first-party cookie. When that context is missing (the script was blocked, the cookie expired, the order was confirmed by a webhook rather than a page), the ad platform needs another way to connect the conversion to a user it knows. Advanced matching is that other way: the merchant sends identifiers the platform can compare against its own records.

The identifiers in question are the ones your store already holds on every order: email, phone number, customer ID, name, address. They are personal data. The engineering problem is to make them useful for matching without shipping them in clear to a third party; the legal problem is to process them lawfully even after they are hashed. This article covers both, using the OpenAI Conversions API as the concrete case. The overall two-layer setup is described in OAIQ pixel vs Conversions API.

Which fields OpenAI accepts hashed, and which in clear

The user object of a Conversions API event has two kinds of fields. Every field is optional; the more you send, the better the matching, and the more you owe in diligence.

FieldFormRule from the reference
emails_sha256List of SHA-256 hashes“SHA-256 hashes of normalized email addresses”: trim whitespace, lowercase
phone_numbers_sha256List of SHA-256 hashes“8-15 digits after removing a leading +, leading zeroes, whitespace, parentheses, periods, and hyphens”
external_ids_sha256List of SHA-256 hashes“SHA-256 hashes of stable, pseudonymous customer identifiers”
first_names_sha256List of SHA-256 hashes“Lowercase first names after removing whitespace and ASCII punctuation”
last_names_sha256List of SHA-256 hashes“Lowercase last names after removing whitespace and ASCII punctuation”
ip_addressClear“Valid IPv4 or IPv6 address”
user_agentClear“Non-empty user agent string from the client”
countries, regions, cities, postal_codesClear, listsRaw string values, up to 3 values each
obrefClear“Opaque browser reference from the Pixel’s __obref cookie”
android_advertising_idClear“Raw Android Google Advertising ID (GAID) in UUID format”

A hashed field is a list, so a customer with two known emails can be matched on either. Each hash must be the lowercase hexadecimal representation of the SHA-256 digest: 64 characters, 0-9a-f. Convrail validates that shape before any network call and rejects anything else.

Note what is absent: there is no email, phone, first_name or customer_id field in clear. A payload that contains one is malformed at best and a data leak at worst. The image tag reference states the same principle for the browser side: “Don’t put personal data, secrets, session IDs, customer identifiers, or order identifiers in any query parameter”.

Normalization rules, with before and after

Hashing is deterministic: the same input always gives the same output, and a one-character difference gives a completely different output. That is what makes matching possible, and it is also why normalization matters more than the hash function itself. If you hash [email protected] and OpenAI holds the hash of [email protected], the two 64-character strings share nothing and the match fails silently.

Email

Raw valueNormalizedSHA-256
[email protected][email protected]86e0b9e56c17cc4d12387e1949b85053fbe73bc3ce5a1188713a9d300cc6133d
[email protected][email protected]same as above

Trim, lowercase, hash. Do not strip dots or plus-suffixes from the local part; the reference does not ask for it and doing so would produce hashes OpenAI cannot match.

Phone number

Raw valueStepsNormalizedSHA-256
+33 6 12 34 56 78remove +, remove spaces336123456788a3e7886c9335e82e02299fa3e87b46e2de3b0c63d56003e30a5029394a47661
(+33) 6.12.34.56.78remove parentheses, +, periods33612345678same as above
06 12 34 56 78remove spaces, remove leading zero612345678d500e1b5b11d4a3049f3a6761ac1720f7d33ffc18160dc9ca1a68cbcd1948d9c

The third row is the trap. The documented rule removes leading zeros, so a French number written in national format loses its trunk zero and ends up as 9 digits without a country code. It is a valid 8-15 digit string, OpenAI will accept it, and it will never match the same customer’s 33612345678 from another source. The rule is a normalization of format, not a conversion to international format. Do the conversion yourself before applying it: store phone numbers in E.164 (+33612345678) at capture time, using the order’s country to resolve the prefix, and hash the rule’s output of that.

Names

Raw valueNormalizedSHA-256
Jean-Pierrejeanpierrefbcc8fa66b9a007dc3649ee989950d38f12ed91b9f18a3d215b04ff77546dca0
Doedoe799ef92a11af918e3fb741df42934f3b568ed2d93ac1df74f1b8d41a27932a6f
Zoëzoë2752b88686847fa5c86f47b94ce652b7b3f22a91c37617d451a4db9afa431450

Lowercase, remove whitespace and ASCII punctuation (the hyphen in Jean-Pierre goes, the apostrophe in O’Brien goes). Accented letters are not ASCII punctuation and stay as they are; do not transliterate ë to e. Hash the UTF-8 bytes.

External ID

Any stable, pseudonymous identifier your system already uses for the customer, such as a Shopify customer ID. customer-7781 hashes to 91d8dedaef47ad2016875e1ecbb6f01c00bba531c0d4c3c5661ed3129ae381b0. Pick one format and never change it, or every past match breaks.

A Node.js implementation

The whole normalization fits in a few lines. The phone step assumes the number is already in E.164 for the reason given above.

import { createHash } from 'node:crypto';

const sha256 = (value) => createHash('sha256').update(value, 'utf8').digest('hex');

export function hashEmail(raw) {
  const normalized = raw.trim().toLowerCase();
  return normalized ? sha256(normalized) : null;
}

export function hashPhone(e164) {
  // Rule from the Conversions API reference: drop the leading +, leading zeros,
  // whitespace, parentheses, periods and hyphens; keep 8 to 15 digits.
  const digits = e164
    .replace(/^\+/, '')
    .replace(/[\s().-]/g, '')
    .replace(/^0+/, '');
  if (!/^\d{8,15}$/.test(digits)) return null;
  return sha256(digits);
}

export function hashName(raw) {
  const normalized = raw
    .toLowerCase()
    .replace(/\s/g, '')
    .replace(/[!-\/:-@\[-`{-~]/g, ''); // ASCII punctuation only, accents survive
  return normalized ? sha256(normalized) : null;
}

export function buildUser(order) {
  const user = {
    emails_sha256: [hashEmail(order.email)].filter(Boolean),
    phone_numbers_sha256: [hashPhone(order.phoneE164)].filter(Boolean),
    external_ids_sha256: [sha256(String(order.customerId))],
    ip_address: order.clientIp,
    user_agent: order.userAgent,
    countries: [order.shippingCountry].filter(Boolean),
  };
  return Object.fromEntries(Object.entries(user).filter(([, v]) => v && v.length));
}

Returning null for an empty or malformed input, then filtering it out, matters: hashing an empty string produces a perfectly valid-looking 64-character digest that matches every other empty field in the world. Never send the hash of nothing.

Why hashing is not anonymization

This is the part merchants most often get wrong, usually because a vendor told them “we only send hashed data, so GDPR does not apply”. It does.

The GDPR defines personal data in Article 4(1) as “any information relating to an identified or identifiable natural person”. Article 4(5) defines pseudonymisation as “the processing of personal data in such a manner that the personal data can no longer be attributed to a specific data subject without the use of additional information”. A SHA-256 hash of an email is exactly that: on its own it points to nobody, but anyone who holds the email (you, OpenAI if the person is a ChatGPT user with that email, anyone with a list of addresses to hash and compare) can attribute it to the person again. Recital 26 draws the line: pseudonymised data that could be attributed to a natural person by the use of additional information “should be considered to be information on an identifiable natural person”, while the regulation “does not therefore concern the processing of such anonymous information”.

Consequences for a store sending hashed identifiers to an ad platform:

  • You still need a lawful basis. For advertising measurement involving a third party, that is in practice consent collected through your consent banner, or another basis your counsel has documented. Hashing changes the risk profile, not the legal category. Convrail records events from visitors who declined tracking as skipped_consent and never sends them.
  • You still need to inform. Your privacy policy has to name the processing (conversion measurement with OpenAI), the data involved (hashed email and phone, IP address, user agent), and the recipient.
  • Deletion and access rights still apply. Which is why the deletion mechanics below operate on hashes.

Where hashing does help, and it helps a lot: a leaked payload, a compromised log, an intercepted request or a misconfigured integration exposes digests, not addresses. The blast radius of a mistake is far smaller. That is a strong reason to hash; it is not a reason to skip consent.

Controller, processor, and what each owes

Article 4(7) defines the controller as the body which “determines the purposes and means of the processing of personal data”, and Article 4(8) the processor as the body which “processes personal data on behalf of the controller”. When you connect your store to a tracking connector:

  • The merchant is the controller. You decide that order data will be used to measure ChatGPT Ads conversions, and you decide to use a given tool to do it.
  • The connector is a processor. Convrail receives order webhooks, hashes identifiers, batches events and sends them to OpenAI on your instructions. Article 28(3) requires that processing to be “governed by a contract or other legal act”, the data processing agreement you should read before installing any tracking app, ours included.
  • OpenAI’s role for the matching data is defined in its own advertising terms, which you accept when you open an Ads account. Read them; we do not restate them here.

What a processor owes you in practice, and what to check in any connector: hash on receipt so clear values are never stored (Convrail hashes emails and phone numbers the moment a webhook arrives and never persists the clear value), encrypt credentials at rest (AES-256-GCM in Convrail’s case), and delete on request.

Deletion requests, applied by hash

Shopify makes the deletion flow concrete with three mandatory webhooks described in its privacy law compliance documentation:

WebhookWhen Shopify sends itWhat the app must do
customers/data_requestWhen a customer requests their data from the store ownerProvide the customer’s data to the store owner, using the resource IDs in the payload
customers/redact“If a customer hasn’t placed an order in the past six months, then Shopify sends the payload 10 days after the deletion request. Otherwise, the request is withheld until six months have passed”Redact or delete the customer’s data
shop/redact“48 hours after a store owner uninstalls your app”“erase data for that store from your database”

For each, the app must “complete the action within 30 days of receiving the request” and acknowledge with a 200-series status.

The catch for a connector that never stores clear values: the customers/redact payload contains the customer’s email and ID in clear, and the connector holds only hashes. The answer is to hash the incoming identifiers with the exact same normalization used at capture time and delete every event whose emails_sha256 or external_ids_sha256 matches. This is one more reason the normalization must be deterministic and never change: a store that silently switched from trimming to not trimming emails would be unable to find, and therefore unable to delete, the records it created before the switch. Convrail applies deletion this way and purges the entire store on shop/redact.

How Convrail’s guard blocks clear personal data

Hashing rules are only as good as their enforcement. A refactor, a new field mapped from a webhook, a debugging log left on: any of these can put a clear email back into a payload months after the original code was reviewed. Convrail puts a guard between the batch builder and the HTTP client, and it runs on every outgoing payload without exception.

The guard refuses to send a batch when any of the following is found anywhere in the serialized payload:

  • a string matching an email pattern (a local part, an @, a domain);
  • a forbidden key such as email, phone or first_name, regardless of where it appears in the structure;
  • a hashed field whose value is not exactly 64 lowercase hexadecimal characters.

A refused payload never reaches the network. The guard itself is covered by an automated test that tries to smuggle clear personal data through and must fail; if a future change weakens the guard, the test fails and the change does not ship.

This is the property to demand from any server-side tracking tool: not “we hash”, which is a statement about intent, but “clear personal data cannot leave, and here is the mechanism that enforces it”.

Common mistakes

  • Hashing without normalizing. Uppercase letters, stray whitespace and a national phone format each produce a hash OpenAI cannot match. The hash is fine; the input was wrong.
  • Hashing empty strings. A valid-looking digest of nothing, sent as a match key.
  • Applying the leading-zero rule to national numbers. 06 12 34 56 78 becomes 612345678 and never matches 33612345678. Convert to E.164 first.
  • Transliterating accents in names. The rule removes ASCII punctuation only; zoë stays zoë.
  • Salting. A salted hash cannot be matched by anyone who does not know the salt, which defeats the purpose. Advanced matching uses unsalted SHA-256 by design; the privacy protection comes from consent and minimization, not from the salt.
  • Treating hashed data as out of GDPR scope. It is pseudonymised personal data. Consent, information and deletion obligations remain.
  • Putting identifiers in URLs. The image tag reference forbids personal data and order identifiers in query parameters; the same goes for your own relay endpoints and logs.
  • Changing the normalization later. Every past hash becomes unmatchable and, worse, undeletable.

What to do next

Check what your current tracking setup sends by opening one real payload and searching for @; if you would rather have a guard do that on every batch, the Convrail tracking module hashes on receipt, blocks clear personal data automatically and applies deletion webhooks by hash.

Sources

Frequently asked questions

Does hashing a customer email make it anonymous under GDPR?

No. A SHA-256 hash of an email is pseudonymised data: it can be linked back to the person by anyone who holds the email, so it stays personal data and the GDPR still applies, including the need for a lawful basis such as consent.

Which customer fields does the OpenAI Conversions API accept in clear text?

IP address, user agent, countries, regions, cities, postal codes, the obref browser reference and the Android advertising ID. Emails, phone numbers, external IDs, first names and last names are accepted only as SHA-256 hashes.

How do I normalize a phone number before hashing it for the Conversions API?

Remove the leading plus sign, leading zeros, whitespace, parentheses, periods and hyphens, and keep 8 to 15 digits. Store numbers in international format first so that a national number and its international form hash to the same value.

Who is the data controller when a tracking connector sends hashed data to OpenAI?

The merchant. The store decides why and how customer data is processed for advertising measurement; a connector such as Convrail processes it on the merchant's behalf under a contract, which is the processor role defined in GDPR Article 4(8).

What happens to hashed data when a customer asks to be deleted?

The deletion request is applied by hashed identifier. Convrail purges every event matching the customer's hashed identifiers when the Shopify customers/redact webhook arrives, and a shop/redact webhook purges the whole store.

Measure the ChatGPT channel this week

Free during early access while we onboard the first stores. Leave your email and we send the install link when your spot opens.

No newsletter. One email with the install link, nothing else.