Snapchat and Nextdoor don't get as much attention as Meta and Google, but for local service businesses they can bring cheap bookings. Both also have server-side conversion APIs, and both have defaults that quietly hurt you.
Short answer: on Snapchat, a booking maps naturally to RESERVE, but web campaigns couldn't optimize for it in our setup. Send SIGN_UP alongside it and optimize toward Pixel Sign Up. On Nextdoor, send lead and purchase events with the ndclid click ID in customer.click_id and your data_source_id. On both, skip bookings that don't carry the platform's identifier.
This is part of the pipeline we built for a US salon using Boulevard that advertises on five platforms. The full build is in our complete server-side guide.
Last verified: November 2026 against Snapchat's Conversions API v3 and Nextdoor's Conversion API reference. Snapchat optimization behavior is from our own campaigns.
The shared rule: skip what isn't yours
A booking pipeline fans every booking out to every platform. Most bookings come from Meta, Google or organic search, and carry no Snapchat or Nextdoor identifier.
Sending those anyway doesn't help either platform credit its own ads. It adds noise, and on platforms with thin data it can blur what the algorithm learns. So both senders start with a skip-guard:
- Snapchat: send only if you have the click ID (
ScCid) or the pixel cookie (_scid). - Nextdoor: send only if you have the click ID (
ndclid).
Return a clear "skipped" result, and count skips separately from failures in your dashboard.
Snapchat
Capture the identifiers
When someone clicks or swipes up on a Snapchat ad, Snapchat appends &ScCid= to your landing URL. Capture it on landing, along with the _scid cookie set by the Snap Pixel if you run it. Send them as sc_click_id and sc_cookie1.
RESERVE vs SIGN_UP: the optimization trap
For appointments, the obvious mapping is:
- Booked →
RESERVE - Completed and paid →
PURCHASEwith the value
RESERVE is a valid Snapchat event and shows up in reporting. The problem appeared when we set up the ad squads: for web campaigns, we couldn't choose RESERVE as the optimization goal. The options were Pixel Sign Up, Page View, Add to Cart and Purchase.
Our fix: on every booking, send both RESERVE and SIGN_UP.
RESERVEkeeps reporting honest ("this is a reservation").SIGN_UPgives the ad squad something it can optimize toward. We optimize toward Pixel Sign Up as "booking made."
Completed visits send PURCHASE with the real revenue.
The request
Snapchat's v3 endpoint takes your pixel ID in the path and an access token. We send the token as a query parameter and as a Bearer header:
import { sha256Hex, normalizeEmail, digitsWithCountry } from './hash.js';
export async function sendSnap(c, env) {
const a = c.attribution || {};
if (!a.sc_click_id && !a.sc_cookie1) return { ok: true, skipped: 'no snap identifier' }; // skip-guard
const user_data = {};
if (c.client.email) user_data.em = [await sha256Hex(normalizeEmail(c.client.email))];
if (c.client.phone) user_data.ph = [await sha256Hex(digitsWithCountry(c.client.phone))];
if (a.sc_click_id) user_data.sc_click_id = a.sc_click_id; // from &ScCid=
if (a.sc_cookie1) user_data.sc_cookie1 = a.sc_cookie1; // _scid cookie
if (a.user_agent) user_data.client_user_agent = a.user_agent;
if (a.ip) user_data.client_ip_address = a.ip;
const apptKey = String(c.appointment_id).split(':').pop();
const make = (name) => ({
event_name: name,
action_source: 'website',
event_time: c.event_time, // unix seconds
event_id: `snap_${apptKey}_${name}`, // unique per appointment + event
event_source_url: a.landing_url || 'https://YOUR_SITE/',
user_data,
custom_data: { currency: 'USD', value: c.value || 0, order_id: apptKey },
});
const completed = c.event_type === 'APPOINTMENT_COMPLETED';
const events = completed ? [make('PURCHASE')] : [make('RESERVE'), make('SIGN_UP')];
// A pasted token can carry a space or quotes: Snap rejects it as "Invalid access token"
const token = String(env.SNAP_ACCESS_TOKEN).trim().replace(/^["']|["']$/g, '');
const path = env.SNAP_VALIDATE === '1' ? 'events/validate' : 'events'; // validate while testing
const res = await fetch(
`https://tr.snapchat.com/v3/${env.SNAP_PIXEL_ID.trim()}/${path}?access_token=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ data: events }),
});
const body = await res.json().catch(() => null);
return res.ok ? { ok: true, body } : { ok: false, status: res.status, error: JSON.stringify(body) };
}
Two details from our build:
- Use the validate endpoint while testing.
POST /v3/{pixel_id}/events/validatechecks your payload without recording a conversion. Switch it with a setting, not by editing code. - Trim the token. A secret pasted with a trailing space, newline or quotes came back as "Invalid access token", which sent us looking in the wrong place.
Nextdoor
Nextdoor is built around neighborhoods, which suits salons, clinics and home services. Its Conversion API works much like the others, with its own naming.
Before you start
- Request Conversion API access. It's separate from running ads, so request it early.
- Find your
data_source_id. Nextdoor deprecatedpixel_id,client_idandevent_time. The pixel ID is nowdata_source_id, and the timestamp isevent_time_epoch. Older examples online still use the old names. - Capture
ndclid. Nextdoor appends it to the landing URL on ad clicks. Send it ascustomer.click_id.
Events
Nextdoor's event names are lowercase. For appointments:
- Booked →
lead - Completed and paid →
purchase, with the value incustom.order_value
The request
export async function sendNextdoor(c, env) {
const a = c.attribution || {};
if (!a.ndclid) return { ok: true, skipped: 'no ndclid' }; // skip-guard
const completed = c.event_type === 'APPOINTMENT_COMPLETED';
const apptKey = String(c.appointment_id).split(':').pop();
const eventName = completed ? 'purchase' : 'lead';
const customer = { click_id: a.ndclid };
if (c.client.email) customer.email = await sha256Hex(normalizeEmail(c.client.email));
if (c.client.phone) customer.phone_number = await sha256Hex(digitsWithCountry(c.client.phone));
const res = await fetch('https://ads.nextdoor.com/v2/api/conversions/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.NEXTDOOR_API_TOKEN}` },
body: JSON.stringify({
event_name: eventName,
event_id: `nd_${apptKey}_${eventName}`, // deterministic, also usable by the pixel
action_source: 'website',
data_source_id: env.NEXTDOOR_DATA_SOURCE_ID, // formerly pixel_id
event_time_epoch: c.event_time,
customer,
...(completed && c.value > 0 ? { custom: { order_value: String(c.value), order_id: apptKey } } : {}),
}),
});
const body = await res.json().catch(() => null);
return res.ok ? { ok: true, body } : { ok: false, status: res.status, error: JSON.stringify(body) };
}
Check Nextdoor's current reference for the exact authorization header and how it wants email and phone normalized before hashing. The reference lists the fields but left those details to other pages when we checked.
Two Nextdoor pixel gotchas
- Leave URL-based events empty when booking happens in an iframe. Nextdoor's pixel setup can map events to URLs, like a thank-you page. A booking widget in an iframe has no thank-you URL on your site, so URL rules either never fire or fire on the wrong page. Let the Conversion API report bookings instead.
- Remove the pixel's
<noscript>image if your site is built with Vite. Many AI-built and React sites use Vite, and Vite builds reject a<noscript>inside<head>. The noscript image only matters for visitors with JavaScript off, so drop it.
Phone numbers: one format per platform
Each platform wants phone numbers normalized slightly differently before hashing. In our pipeline, TikTok gets E.164 with the +, while Meta and Snapchat get digits only, country code included. Keep a small normalizer per platform instead of one shared function, and test each with a known number.
// Digits only, with country code (assumes US when 10 digits: adjust for other markets)
export function digitsWithCountry(phone) {
const d = String(phone).replace(/\D/g, '');
return d.length === 10 ? '1' + d : d;
}
Common errors
- Snapchat "Invalid access token": trailing whitespace or quotes in the stored secret.
- Can't select RESERVE as the optimization goal: send SIGN_UP too and optimize for Pixel Sign Up.
- Snapchat or Nextdoor credited with Meta bookings: no skip-guard.
- Nextdoor rejects the payload: deprecated fields (
pixel_id,client_id,event_time) instead ofdata_source_idandevent_time_epoch. - Nextdoor events with no attribution:
ndclidmissing because it wasn't captured on landing. - Build fails after adding the Nextdoor pixel:
<noscript>in<head>with Vite.
Testing checklist
- Landing page captures
ScCid, the_scidcookie andndclid. - A booking without those identifiers is skipped for that platform.
- Snapchat test payload passes
events/validatebefore going live. - A booking sends both RESERVE and SIGN_UP; a completed visit sends PURCHASE with value.
- The Snapchat ad squad optimizes toward Pixel Sign Up.
- Nextdoor events use
data_source_idandevent_time_epoch, withclick_idinsidecustomer. - Event IDs are unique per appointment and event.
Related reading: TikTok Events API for bookings, and why your Facebook ads show 0 bookings when you use Boulevard.

