Low TikTok Events API match quality is almost always an identifier problem, not a delivery problem. The events are arriving — TikTok returns success — and they carry too little about the person for TikTok to resolve them to a user. The fix is to widen identifier coverage on every event you send, and to normalise each value correctly before hashing it.
Those are two different jobs, and most integrations do the first badly because they never audited the second.
What does match quality actually mean on TikTok?
Match quality is TikTok’s ability to tie your server-side event to a real TikTok account. It is measured on identifiers, not on volume. An event can be accepted with an HTTP 200, appear in your logs as a clean send, and match nobody — because everything TikTok needed to recognise the person was missing, malformed, or hashed wrong.
Two numbers get confused constantly. Delivery — did the API accept the event? That is your payload’s shape and your credentials, and you control it entirely. Matching — did the platform resolve that event to a user? That depends on the identifiers you sent and on the platform’s own graph. You control one half.
The same distinction exists on every platform running the Conversions API pattern. Meta made it legible by publishing a 1–10 score — event match quality — giving advertisers something concrete to chase. TikTok’s reporting is less granular, so run the diagnostic yourself: for each event you send, count the populated identifier fields. If the answer is two, IP and user agent, you have found your problem without the platform telling you. Worth knowing what a good match rate looks like before deciding yours is broken.
Which identifiers does the TikTok Events API accept?
Events API 2.0 posts to the v1.3 endpoint at business-api.tiktok.com/open_api/v1.3/event/track/, authenticated with an Access-Token header. The identifiers live in a user object on each event.
| Field | What it is | Hashed? |
|---|---|---|
email |
The lead’s email address | Yes — SHA-256 |
phone |
The lead’s phone number in E.164 | Yes — SHA-256 |
external_id |
Your own stable ID for the person | Yes — SHA-256 |
ttclid |
TikTok’s click identifier from the ad click | No |
ttp |
The value of TikTok’s _ttp browser cookie |
No |
ip |
The visitor’s IP address | No |
user_agent |
The visitor’s browser user-agent string | No |
A minimal, correctly-shaped event body:
{
"event_source": "web",
"event_source_id": "<your pixel code>",
"data": [
{
"event": "CompletePayment",
"event_time": 1757500000,
"event_id": "a3f1c8...",
"user": {
"email": "2a4b6c...",
"phone": "9d1e3f...",
"external_id": "5e7a20...",
"ttclid": "E.C.P...",
"ttp": "01H8X...",
"ip": "203.0.113.9",
"user_agent": "Mozilla/5.0 ..."
},
"properties": {
"currency": "USD",
"value": 72.39,
"order_id": "10482"
},
"page": { "url": "https://yoursite.com/thank-you" }
}
]
}
Two fields do the heavy lifting. Hashed email and hashed phone are what resolve a person; everything else is a click-scoped token or a weak signal. IP and user agent describe a device on a network at a moment in time, which is why an event carrying only those two is, in practice, an anonymous server event.
The unhashed fields are unhashed for a reason. ttclid and ttp are opaque tokens the platform looks up directly; hashing them destroys them silently, because a hash is still a well-formed string and the API has no way to tell you that you ruined it.
Why do correctly hashed identifiers still fail to match?
Because hashing is the easy half. The value you hash has to be identical to the value the platform hashed on its side, and a hash gives you no feedback when it isn’t. Six failures produce this, and all six look fine in your logs:
Case and whitespace. Ava.Chen@Example.com and ava.chen@example.com produce completely different hashes. Lowercase and trim before hashing, always.
Phone numbers that aren’t E.164. (04) 0000 0000 is a phone number to a human and noise to a hashing function. Strip formatting, resolve the country code, hash the digits. The country code is the part people get wrong: a form collecting local-format numbers has no country in it, so it has to come from elsewhere — the visitor’s geo, the site’s market, or a country selector on the field.
Double hashing. A library hashes the email, then a wrapper hashes it again on the way out. The result is a valid SHA-256 of a SHA-256 — indistinguishable from a correct value at the API boundary, and matching nothing.
Hashing an empty string. The quietest one. If the field is missing and your code hashes whatever it has, you send the SHA-256 of "" — a fixed, well-known constant. It is a perfectly formed identifier for a person who does not exist, and it will sit in your payloads forever looking correct. Skip the field instead.
Uppercase hex. Emit hex digests in lowercase.
Inconsistent paths. The worst version of all of these: two code paths that build the payload differently — a real-time send and a retry worker, or a webhook and a manual re-send. Both look right in isolation; only one matches. Normalisation belongs in exactly one function that every dispatch path calls.

Where does ttclid go missing?
ttclid is TikTok’s click identifier: it lands in the landing-page URL as a query parameter on an ad click, and TikTok’s pixel stores it alongside the _ttp cookie. It is the strongest evidence you have that a specific ad click happened, and it breaks in four ways.
Redirects strip it. A link shortener, geo-redirect, or marketing redirect that rebuilds the destination URL without forwarding query parameters loses the click ID before your page loads. Most common cause, easiest to miss — the ad works and the page renders.
Iframes hide it. If your form lives in an embedded widget, the frame’s URL has no click ID and no UTMs; the parent page has them. Anything reading the URL from inside the frame sees nothing.
Time separates it. The click ID belongs to a click; the conversion can be a week later, in a different session, on a different device. Unless the ID was stored against the person rather than the pageview, it is gone by then.
Cookie loss removes the fallback. _ttp is a browser cookie, subject to every policy that shortens cookie lifetimes and every blocker that stops it being written.
The rule across platforms: read the click ID from the URL first, fall back to the cookie, store it against the visitor rather than the page. The differences between gclid, fbclid and UTM parameters apply to ttclid almost unchanged.
Does deduplication affect match quality?
Indirectly — and it’s the reason “just turn off the pixel” is bad advice.
The Events API and the TikTok Pixel are meant to run together: the same conversion sent twice, once from the browser and once from your server, collapsed on a shared event_id. The browser copy contributes the cookie-scoped identifiers. The server copy contributes the hashed PII and fires whether or not the browser cooperated.
Remove the pixel to avoid double counting and you don’t lose a redundant copy — you lose the ttp cookie and the client-side click ID capture, exactly the identifiers your server can’t produce on its own. Match quality falls while your event count looks unchanged.
Make the event_id deterministic instead: derive it from something stable about the conversion, like the order ID hashed with the event name and the pixel, rather than generating a random value. Browser and server then produce the same ID for the same conversion, so the platform can collapse them — and a retried webhook produces the ID it produced the first time, so a retry cannot become a second conversion.
Don’t treat platform-side deduplication as your only protection. Enforce uniqueness on your own side too, because two of your own code paths deriving the ID differently is a bug the platform cannot see.
Why doesn’t sending more events fix a low match rate?
Because match quality is a per-event property, and adding events with poor identifier coverage moves the average the wrong way.
The instinct when a number is low is to send more: every page view, every button click, every scroll depth. But a ViewContent fired for an anonymous visitor carries an IP and a user agent, which is precisely the payload that doesn’t match. Ten thousand of them do not add up to one resolvable person.
Invert it. Pick the events that carry business meaning — a form submission, a purchase — and make sure those carry the full identifier set, then extend outward only where the identifiers actually exist.
This also protects the bidding algorithm. Conversion events are training data: feed a campaign a high volume of unmatched, low-value events and it learns to find people who generate events, not people who buy.
What actually raises identifier coverage?
You can only transmit what you collect. That is the whole ceiling, and no server-side integration raises it — a server can only hash what reached it. Three levers, in descending order of effect.
Collect a phone number as well as an email. Hashed phone is the second-strongest identifier after hashed email, and most lead forms don’t ask for one. This is a genuine trade-off, not free upside: a required phone field costs some form completion rate. Worth knowing which of your fields are expensive before you add another.
Capture identity earlier than the submit button. Most people who start a form never finish it, so the identifiers you’d want to send exist — they were typed — and are discarded because the submit event never fired. A partial lead is exactly that: contact details entered but never submitted.
Fix the normalisation once. Covered above, and the cheapest of the three: a code change rather than a change to your form or your traffic.
For the same argument with Meta’s scoring model, where the arithmetic is visible in a published number, getting to a 9+ event match quality score walks through which fields carry which weight.
How does PartialLeads improve TikTok match quality?
By attacking the identifier-coverage half rather than the delivery half — the tag captures more identifiers per person, and one normalisation path builds every payload.
Email and phone are captured as they’re typed, before submit. The tag captures form fields on input with a short debounce and flushes anything pending when the page hides, so a visitor who types an email and a phone number and then leaves still produces a lead carrying both — the two highest-weight identifiers in any conversion payload, and on a normal form both would have been lost.
Normalisation happens in one canonical builder per platform. Email lowercased and trimmed, phone normalised to E.164 with the country code resolved from the session’s geo when the form didn’t collect one, and IP, user agent and click identifiers left unhashed. Every dispatch path calls the same builder — the fix for the two-code-paths failure above.
ttclid is stored against the visitor, not the pageview. PartialLeads’ identity layer unions a person’s sessions by visitor ID, email, phone, IP and user agent, device fingerprint, and click ID. A conversion days later, in a session that arrived with no click ID at all, is still dispatched with the identifier from the session that started the journey.
Event IDs are deterministic and deduplicated on our side. Each is derived by hashing the pixel, event name and record together, with a uniqueness constraint on configuration plus event ID — so a redelivered webhook physically cannot double-fire, and the browser and server copies of a conversion collapse rather than double-count.
Failures are classified rather than assumed. Authentication errors flag the integration for reconnection, transient failures retry on the next worker cycle, permanent payload failures don’t retry. An HTTP 200 reporting a semantic failure in its body is recorded as a failure, not a delivery.
Honest constraints, three. Keep the TikTok Pixel installed — cookie-scoped identifiers are captured in the browser, and PartialLeads does not synthesise platform cookies that were never set. No numeric match-quality promise is made for TikTok; the conditional EMQ 9+ evidence we publish is Meta-specific and depends on collecting email and phone. And the split at the top cuts both ways: dispatch is ours to guarantee, matching is the platform’s — an audience TikTok cannot resolve matches lower no matter how complete the payload.

| What breaks | The mechanism | Where you see it in the dashboard |
|---|---|---|
| Events carry only IP and user agent | Email and phone captured on input, before submit | Partial badges on the Leads list |
| Phone sent in local format, matches nothing | E.164 normalisation with country code from session geo | Phone column on the lead record |
| Two code paths normalise differently | One canonical payload builder per platform, called by every dispatch path | Retained payload per send in the activity log |
ttclid lost between click and conversion |
Click ID stored against the visitor and resolved across the session cluster | Journey timeline on the lead |
| Retried webhook fires the conversion twice | Deterministic event ID plus a uniqueness constraint | One row per conversion in the activity log |
| HTTP 200 recorded as a successful send | Response parsed and failures classified before success is declared | Delivery status in the activity log |
| No idea which platforms a lead reached | Per-lead dispatch record | API column on the Leads list |
Tell us what's broken. We'll fix your tracking — free.
Describe the tracking/attribution problem you're stuck on and we'll map it to a fix: server-side conversions to Meta, Google, TikTok and Pinterest, plus first-party tracking that survives Safari. No code required.
Sources
- TikTok for Business — Marketing API developer documentation portal: https://business-api.tiktok.com/portal/docs
- Meta for Developers — Conversions API customer information parameters: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters
- Meta for Developers — Deduplicate Pixel and server events: https://developers.facebook.com/docs/marketing-api/conversions-api/deduplicate-pixel-and-server-events
- Wikipedia — E.164 international telephone numbering plan: https://en.wikipedia.org/wiki/E.164
- Wikipedia — SHA-2 hash family: https://en.wikipedia.org/wiki/SHA-2