Tracking & Attribution

How Do You Fix TikTok Events API Match Quality?

TikTok server events that arrive but match nobody are an identifier problem. The fields, the normalisation rules, and the fixes that actually move it.

Quick answer

Low TikTok Events API match quality is an identifier problem, not a delivery problem. Your events are arriving; they arrive without enough about the person for TikTok to resolve them to a user. Fix it by widening identifier coverage — hashed email, hashed phone, a stable external ID, `ttclid` — and by normalising every value before hashing. Volume of events does not help; coverage per event does.

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.

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.

Dashboard panel listing a TikTok server event's identifier fields with present and missing indicators, showing email and phone populated, ttclid present, and the resulting coverage count


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.

CAPI activity log rows showing TikTok dispatch status, event names, and a per-lead API column confirming which conversion APIs each lead was sent to

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

  1. TikTok for Business — Marketing API developer documentation portal: https://business-api.tiktok.com/portal/docs
  2. Meta for Developers — Conversions API customer information parameters: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters
  3. Meta for Developers — Deduplicate Pixel and server events: https://developers.facebook.com/docs/marketing-api/conversions-api/deduplicate-pixel-and-server-events
  4. Wikipedia — E.164 international telephone numbering plan: https://en.wikipedia.org/wiki/E.164
  5. Wikipedia — SHA-2 hash family: https://en.wikipedia.org/wiki/SHA-2

Frequently asked questions

QDoes TikTok have an event match quality score like Meta's?
TikTok's reporting on match quality is less granular than Meta's published 1–10 score, so there is no single number to chase. The diagnostic you can always run yourself is identifier coverage: for each event you send, count the populated identifier fields. An event carrying only an IP address and a user agent is an anonymous server event regardless of what any dashboard says about it.
QDo I still need the TikTok Pixel if I'm sending server events?
Yes. The pixel captures the cookie-scoped identifiers — the `_ttp` value and the click ID as it lands in the browser — which your server cannot produce on its own. Removing the pixel to avoid double counting loses those identifiers and lowers match quality while leaving your event count unchanged. Run both and deduplicate on a shared, deterministic `event_id`.
QShould I hash ttclid before sending it?
No. `ttclid` and `ttp` are opaque tokens the platform looks up directly, and hashing them destroys them. The API cannot tell you this, because a hash is still a well-formed string — the event is accepted and the identifier resolves to nothing. Hash email, phone and external ID; send click identifiers, IP and user agent as they are.
QWhy does my match quality not improve after I start sending more events?
Because match quality is a per-event property. Adding page views and button clicks for anonymous visitors adds events that carry an IP and a user agent and nothing else, which drags the average down rather than up. Concentrate on making your form submissions and purchases carry the full identifier set before you extend coverage to lower-intent events.
QWhat is the SHA-256 of an empty string doing in my payloads?
Being sent by code that hashes whatever it was given, including nothing. It produces a fixed, well-known constant that looks like a valid identifier and matches no one, and it will sit in your payloads indefinitely because nothing rejects it. Check for a missing value before hashing, and omit the field entirely rather than sending a hash of an empty string.
QCan I fix low match quality without changing my form?
Partly. Normalisation fixes — lowercasing emails, converting phone numbers to E.164, removing double hashing, consolidating code paths — cost nothing and often recover a meaningful amount. But if your form only collects an email, there is a ceiling on what any integration can transmit, because a server can only hash what reached it. Beyond the normalisation fixes, coverage is a collection problem.
QMy ad clicks work but ttclid never reaches my server. Why?
Usually a redirect. Link shorteners, geo-redirects and marketing redirects that rebuild the destination URL often drop query parameters, so the click ID is gone before your page loads — and everything looks fine because the ad works and the page renders. Embedded iframe forms produce the same symptom, because the frame's URL carries neither the click ID nor the UTMs.

Find the qualified leads your forms are currently throwing away.

Install PartialLeads on one landing page, send traffic, and compare what your CRM captured against what PartialLeads recovered and qualified.