Adstack Activate
An advertiser uploads their customer list to Google and Meta so they can target the people they already know. The platforms hand back a match rate — say 58% — and the instinctive conclusion is that the platform's graph is thin.
Usually it isn't. Most of that gap is formatting. Both platforms want SHA-256 hashes of normalized identifiers, both publish what “normalized” means, and they don't agree with each other. Get it wrong and nothing errors — the number is just quietly lower, and the platform takes the blame.
This measures the difference. It's also the second half of a round trip: Adstack Sync pulls reporting data out of the platforms into a warehouse; this pushes an audience back into them. Same database, same tenant, opposite direction.
Everything on this page runs in your browser. Records are generated or parsed locally, normalized locally, hashed locally with Web Crypto, and matched locally. No identifier — raw or hashed — is transmitted. The server only ever receives counts. A SHA-256 of an email is still personal data under GDPR, so a public demo has no business storing one.
Onboard a customer list
Normalize it to a platform's documented rules, hash it, and see how much of it the platform can actually find.
Source
Target platform
Segment
LTV above $250 and no order in 90 days — the classic Customer Match use case.
Why normalization is not a detail
One customer, two hashes
Google's Customer Match spec requires stripping periods and +tags from Gmail addresses before hashing. Meta's documented normalization is trim and lowercase, and says nothing about either. Same person, same source row, different SHA-256.
The round trip
Ingestion and activation, same warehouse
Nothing in the warehouse yet. This page shares a tenant with Adstack Sync — run a sync there and come back, and the spend and conversions that paid for these customers will appear here alongside them.
Open Adstack Sync →Match rate is a hygiene problem wearing a platform problem's clothes
When an advertiser uploads a customer list and gets back a 58% match rate, the instinctive conclusion is that the platform's graph is thin. Usually it isn't. Most of the gap is formatting — and formatting is fixable in an afternoon, for free, without buying any more media.
The rules genuinely differ. Both platforms want SHA-256 hashes of normalized identifiers, and both publish what “normalized” means. They do not agree. Google strips periods and +tags from Gmail addresses; Meta does not. Google wants phone numbers in E.164 with the leading plus; Meta wants bare digits. Google wants postal code and country in the clear; Meta wants them hashed.
None of it errors. Every one of those mistakes produces a valid-looking upload and a quietly lower match rate, which is why the platform ends up taking the blame. This page measures the difference: the same segment, hashed two ways, matched against the same graph.
It is the second half of a round trip. Adstack Sync pulls reporting data out of the platforms into a warehouse; this pushes an audience back. Same D1, same tenant, opposite direction.
Key design decisions
- Google Ads Customer Match requires stripping periods and +tags from Gmail addresses before hashing. Meta's documented normalization is trim and lowercase and instructs neither. So one customer produces two different SHA-256 values, and a pipeline that hashes once and fans out to both platforms is silently wrong on one of them for every Gmail address in the list — routinely 30-40% of a consumer CRM.
- Normalization rules are per-platform data, not shared code. The same divergence shows up in fields nobody thinks about: Google requires country code and postal code UNHASHED, Meta requires them hashed. Hashing a zip for Google because it feels safer doesn't improve privacy, it just fails to match.
- Match loss is attributed, not reported as one number. 'Your match rate is 58%' is a shrug; 'these 1,840 records are unreachable because of periods in Gmail addresses, and they're customers you already have' is a to-do list. Separating recoverable loss from genuine non-overlap is the entire value of the exercise.
- Consent is evaluated before hashing, not after matching. A record without marketing consent is not a match problem, it's a legal one, and counting it as a match failure would hide the distinction that matters most.
- Phone numbers without a country code cannot be resolved to E.164 and are not guessed — but the country column in the same row usually can resolve them, so the pipeline infers and flags rather than discarding. That single recovery is where most of Meta's uplift comes from.
- Everything runs client-side: generation, normalization, hashing, matching. That is a demo-safety decision, not a production pattern — in production the CRM sits in the warehouse and hashing happens server-side or in a clean room. Stating the difference is more useful than pretending the demo is the architecture.
- The D1 schema for this phase holds no identifier of any kind, and its header comment explains why: a SHA-256 of an email is pseudonymised, not anonymised, and is still personal data under GDPR. The privacy claim on the page is only true while that stays true.
- Match rates for a pasted list are simulated locally and labelled as such. You cannot both keep identifiers on-device and have a third party match them — that constraint is exactly why clean rooms and hashed-upload models exist, so the limitation is the lesson rather than a gap.
Tech stack
- Web Crypto — SHA-256 in the browser; no identifier is transmitted
- TypeScript — Platform normalization rules as typed, testable data
- Cloudflare D1 — Aggregate-only activation history, shared with Adstack Sync
- Cloudflare Workers — Counts in, mart out — the round-trip join
- Next.js + Cloudflare Pages — This site and this page