If you send bookings to TikTok's Events API and Ads Manager shows fewer conversions than your calendar, check two things first: the event names you send, and how you read TikTok's response.
Short answer: for appointments, send a Lead when someone books and a Purchase with the real value when the visit is completed and paid. TikTok renamed these events from SubmitForm and CompletePayment. The old names still work, but new setups should use the new ones. Then check the response body: code must be 0. TikTok returns HTTP 200 even when it rejects the event.
We send every booking to TikTok server-side for a US salon using Boulevard, along with Meta, Google Ads, Snapchat and Nextdoor. The full pipeline is in our complete server-side guide.
Last verified: November 2026 against TikTok's Events API and standard events documentation. Our own pipeline was built earlier with the legacy event names, which TikTok still accepts.
Which events to send for appointments
An appointment business has two moments that matter to TikTok:
| Moment | Event (current name) | Legacy name | Value |
|---|---|---|---|
| Client books | Lead |
SubmitForm |
0, or none |
| Visit completed and paid | Purchase |
CompletePayment |
Real revenue |
The booking is the optimization signal. It happens often and soon after the click, so TikTok can learn from it. The completed visit is the revenue signal, the one that turns ROAS into a real number.
The rename, and what to do about it
TikTok renamed SubmitForm to Lead and CompletePayment to Purchase. According to TikTok:
- Existing setups keep working. You don't have to rename anything in code.
- The legacy names are supported indefinitely.
- New setups should use
LeadandPurchase. - In Events Manager, your old
SubmitFormandCompletePaymentevents now appear asLeadandPurchase.
Our pipeline was built with the legacy names and still runs fine. For a new build, start with Lead and Purchase. If you migrate an existing one, write down the date you switched, so nobody gets confused reading the reports later.
TikTok also lists Schedule as a standard event. It describes a booking literally, but check which events your campaign objective can optimize toward in Ads Manager before choosing it over Lead.
The request
The Events API takes one endpoint for web, app and offline events:
// Send a booking or completed visit to TikTok's Events API (server-side)
import { sha256Hex, normalizeEmail, toE164 } from './hash.js';
export async function sendTikTok(c, env) {
const a = c.attribution || {};
const eventName = c.event_type === 'APPOINTMENT_COMPLETED' ? 'Purchase' : 'Lead';
const user = {};
if (c.client.email) user.email = await sha256Hex(normalizeEmail(c.client.email));
if (c.client.phone) user.phone = await sha256Hex(toE164(c.client.phone)); // "+1..." then hash
if (c.client.id) user.external_id = await sha256Hex(String(c.client.id).trim().toLowerCase());
if (a.ttclid) user.ttclid = a.ttclid; // from the landing URL
if (a.ttp) user.ttp = a.ttp; // _ttp cookie, if captured
if (a.user_agent) user.user_agent = a.user_agent;
if (a.ip) user.ip = a.ip;
const apptKey = String(c.appointment_id).split(':').pop();
const body = {
event_source: 'web',
event_source_id: env.TIKTOK_PIXEL_ID,
data: [{
event: eventName,
event_time: c.event_time, // unix seconds
event_id: `tt_${apptKey}_${eventName}`, // unique per appointment + event, stable on retries
user,
properties: {
currency: 'USD',
value: c.value || 0,
content_type: 'product',
content_id: 'salon_appointment',
order_id: apptKey,
},
page: { url: a.landing_url || 'https://YOUR_SITE/' },
}],
};
if (env.TIKTOK_TEST_EVENT_CODE) body.test_event_code = env.TIKTOK_TEST_EVENT_CODE; // remove after testing
const res = await fetch('https://business-api.tiktok.com/open_api/v1.3/event/track/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Access-Token': env.TIKTOK_ACCESS_TOKEN },
body: JSON.stringify(body),
});
let json; try { json = await res.json(); } catch { json = null; }
if (!res.ok || !json || json.code !== 0) { // 200 is not enough
return { ok: false, status: res.status, error: `tiktok code ${json?.code}: ${json?.message}` };
}
return { ok: true };
}
The access token is generated in Events Manager, in your pixel's settings. Keep it as a server secret.
HTTP 200 doesn't mean accepted
This is the most common silent failure. TikTok returns HTTP 200 for requests it rejects. The real result is in the body:
code: 0withmessage: "OK"means the event was accepted.- Any other
codemeans it wasn't, andmessagesays why.
If your code only checks res.ok, rejected events look like successes. We log the TikTok result on every booking row in our dashboard, so a column of failures would be obvious. Check code on every call.
Hashing: email, phone and external ID
TikTok matches people using hashed identifiers. Get the normalization wrong and the hash matches nobody, without any error.
- Email: trim spaces, lowercase, then SHA-256.
- Phone: E.164 format with the leading
+and country code, for example+15551234567, then SHA-256. - External ID: your stable customer ID (we use the booking platform's client ID), trimmed and lowercased, then hashed. It helps TikTok connect repeat bookings from the same person.
One trap if you send to several platforms: phone formats differ. For TikTok we hash the number with the +. For Meta we hash digits only, country code included, no +. Use one normalization function per platform, not one for all.
// hash.js
export async function sha256Hex(str) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
export const normalizeEmail = (e) => String(e).trim().toLowerCase();
// E.164 for TikTok. Assumes US numbers when no country code is present: adjust for other markets.
export function toE164(phone) {
let d = String(phone).replace(/\D/g, '');
if (d.length === 10) d = '1' + d;
return '+' + d;
}
Also send ttclid (from the landing URL), plus the visitor's user agent and IP when you have them. A click ID is the strongest match signal, and TikTok's click ID is only valid for a limited window (7 days, per TikTok's integration docs), so capture it on landing.
event_id: per booking, not per visitor
TikTok deduplicates events that share an event_id. That's what you want for retries, and dangerous if the ID is too broad.
We first reused a visitor-level event ID. That works for a single booking. But if the same person books twice within TikTok's deduplication window, the second booking carries the same ID and gets dropped as a duplicate.
The fix: build the ID from the appointment and the event name, for example tt_{appointmentId}_Lead. It's unique per booking, stable when the webhook retries, and the Lead and Purchase for the same appointment don't collide.
If you also fire the same event from the browser pixel, pass that same ID to ttq.track(…, { event_id }). In our setup, Lead and Purchase come only from the server (the booking happens in an iframe the pixel can't see), so there's no browser twin to match.
Testing without polluting your data
- In Events Manager, open Test events and copy the test event code.
- Set it as
TIKTOK_TEST_EVENT_CODEon your server and make a test booking with?ttclid=test_…in the URL. - The event should appear in the test tool with code 0.
- Remove the test code afterwards. Events sent with it don't count in Ads Manager. Test codes also change, so fetch a fresh one each time you re-test.
"0 conversions on day 1"
Before you blame the tracking, check whether anyone actually booked from a TikTok click. A new campaign often has no bookings on its first day. Run a controlled test booking, confirm it in the test tool, then give the campaign time.
Two diagnostics that look scarier than they are:
- "Pixel missing vertical funnel events" in Events Manager diagnostics refers to the browser pixel not firing events like
ViewContentorAddToCart. It doesn't mean your server events failed. If you want to clear it, fireViewContenton landing andInitiateCheckouton the Book Now click, with the browser pixel. - Low match quality usually means no
ttclid, no email and no phone on the event. Check your normalization first.
Common errors
- HTTP 200, nothing in Ads Manager:
codeisn't 0. Log and readmessage. - Events only visible in the test tool: the test event code is still set.
- Second booking from the same person missing: a visitor-level
event_id. Make it per appointment and event. - Good click volume, poor match rate: phone hashed without
+or country code, email not lowercased, or nottclidcaptured. - Legacy event names in a new build: use
LeadandPurchaseinstead ofSubmitFormandCompletePayment.
Testing checklist
- Landing page captures
ttclid(and_ttpif you can). - A test booking appears in the test events tool with code 0.
- Your code treats any
code ≠ 0as a failure. - The
event_idis unique per appointment and event. - Phone is E.164 with
+before hashing, email trimmed and lowercased. - A completed visit sends
Purchasewith the real value. - The test event code is removed before going live.
Related reading: why our "match by IP" attribution failed (it once made TikTok look like it under-performed), and Google Ads offline click conversions: gbraid errors and silent partial failures.

