Our first version of server-side booking attribution passed every test we ran. Then, at real traffic, it credited 35 of 43 appointments to the wrong channel.
Short answer: we matched each booking to "the most recent landing," believing it was filtered by the visitor's IP. It wasn't. The IP available when the booking webhook arrives belongs to the booking platform's server, not the visitor. So every booking went to whoever had landed last, usually from the biggest Meta campaign. The fix is to match on a key that exists on both sides of the booking, not on timing.
This happened on a US salon using Boulevard, running ads on Meta, Google Ads, TikTok, Snapchat and Nextdoor. If you're building server-side attribution for any embedded booking tool, this is the mistake to avoid.
The setup
The booking happens inside Boulevard's widget, an iframe on Boulevard's domain, so browser pixels can't see it (we explain why in the iframe problem). So we built it server-side:
- A script on the landing page captured click IDs (
fbclid,gclid,ttclidand others) and sent them to a Cloudflare Worker, which stored them in KV with a 60-minute expiry. - Boulevard sent a webhook to the Worker when an appointment was created or completed.
- The Worker looked up "the visitor's landing," attached its click IDs, and sent the conversion to Meta, Google Ads, TikTok, Snapchat and Nextdoor.
Step 3 is where it broke.
The bug
The lookup was meant to find the landing from the same visitor, matched by IP. Two things were wrong:
- The webhook doesn't carry the visitor's IP. It's a server-to-server call. The IP the Worker sees is Boulevard's own server.
- So the function ignored IP in practice and returned the freshest landing site-wide: the last person to arrive from any ad.
Simplified, the logic did this:
// What the matching effectively did (simplified)
async function findLanding(env) {
const list = await env.KV.list({ prefix: 'track:' });
// "most recent wins", across ALL visitors
const latest = list.keys.sort((a, b) => b.name.localeCompare(a.name))[0];
return latest ? JSON.parse(await env.KV.get(latest.name)) : null;
}
Why every test passed
We tested alone. One person lands with a test click ID, books, and the most recent landing is that person's, so the test passes every time.
Real traffic is different. The salon had roughly 30 ad visitors an hour. During one live test, real visitors were landing every 10 to 40 seconds. Our test booking was credited to an unrelated Instagram visitor who had landed about 20 seconds before the booking went through.
What the data showed
Once we looked properly, the pattern was obvious:
- Three separate test bookings came back with the same
event_idand the samefbclid. Three people, one "click." - 35 of 43 appointments had contaminated channel data.
- Most bookings were credited to whichever campaign had the most traffic at that moment, usually the biggest Meta campaign.
Why this is worse than no tracking
Bad attribution doesn't only mess up a report. It feeds the ad platforms' algorithms with wrong conversions:
- Meta was told one campaign drove almost everything, so it would keep pushing budget and learning toward it.
- TikTok looked like it under-performed, because its real bookings were credited to Meta clicks. That's how a working channel gets switched off.
With no tracking, you know you're blind. With wrong tracking, you make confident decisions on false data.
Fix 1: a narrower signal (better, not enough)
Our first fix added a tighter signal. When a visitor clicked Book Now, the page sent an "intent" signal, refreshed every 30 seconds while the widget stayed open, and the Worker tried those first before falling back to landings.
That shrank the pool of candidates, but it's still a timing guess. A clean two-device test (a laptop on WiFi and a phone on 5G) proved it: the phone's booking was credited to the laptop's click, because the laptop's heartbeat was "fresher." Two people in checkout at the same time will always collide. The heartbeat also wrote to storage every 30 seconds per visitor, and used up the free tier's daily write limit during testing.
Fix 2: match on identity
The only data that exists on both sides of the iframe is the client's email and phone. The visitor types them into the booking, and the webhook's appointment record has them.
So the deterministic version works like this:
- Capture the visitor's email or phone on your own page, before they open the widget (for example a short "where should we send your confirmation?" step).
- Store the click IDs under a SHA-256 hash of the normalized email and phone.
- When the webhook arrives, hash the booked client's email and phone and look them up. Exact match or nothing.
// Normalize + hash, the same way on both sides
async function sha256(text) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
const normEmail = (e) => e.trim().toLowerCase();
const normPhone = (p) => '+' + p.replace(/\D/g, ''); // E.164, include the country code
// On the webhook: exact lookup, no timing
async function findByIdentity(env, client) {
for (const key of [
client.email && `id:email:${await sha256(normEmail(client.email))}`,
client.mobilePhone && `id:phone:${await sha256(normPhone(client.mobilePhone))}`,
].filter(Boolean)) {
const hit = await env.KV.get(key);
if (hit) return { ...JSON.parse(hit), match_tier: 'identity' };
}
return null; // fall back, and log that it was a fallback
}
We kept the timing match only as a labeled fallback, and logged a match_tier column for every booking. That way the dashboard shows what share of bookings are matched deterministically, instead of hiding the guesses.
Fix 3: own the booking flow (the 10/10 version)
The fully deterministic option is to stop using the iframe. Boulevard has a client-side booking SDK. If you build the booking flow yourself, checkout returns the appointment ID in your own page, where the click IDs already are.
In our proof of concept, the appointment ID returned at checkout was byte-identical to the ID in the webhook. Store appointmentId → click IDs at checkout, look it up when the webhook arrives, and there's nothing left to guess at any traffic volume.
The trade-off is real: you rebuild the booking UX (services, staff, dates, client details, possibly payment) on a page that makes the business money. Roll it out behind a flag, test against Boulevard's sandbox, and keep the old widget one deploy away.
What hashing does and doesn't fix
We also send hashed email and phone to Meta, TikTok and Google. That improves match quality: the platform can recognize the person. But it doesn't fix campaign attribution in your own reporting. If your Worker attached the wrong click ID, the platform still gets credited for the wrong campaign. Matching has to be right first.
Lessons
- Test attribution under concurrent traffic, never solo. Two devices, two networks, booking at the same time.
- Know what data each side really has. A webhook is server-to-server, so the visitor's IP isn't there.
- Prefer a shared key over timing. Email/phone hash or an appointment ID captured in the browser.
- Label your guesses. Log the match method per booking, so fallbacks are visible.
- Lock the channel at booking time. Store attribution when the appointment is created and reuse it when it's completed, instead of guessing twice.
If your platform numbers and your calendar disagree, start with why Facebook ads show 0 bookings with Boulevard.
For the full build, step by step, read How to track Boulevard bookings in Meta, Google Ads & TikTok: the complete server-side guide.

