Carepatron · Growth
Callum — this is everything needed to wire cp-playbook-poc.vercel.app into Customer.io so the leads actually land somewhere and the nurture can fire. Tick things off as you go, it saves in your browser.
#leadform collects five fieldsname, email, profession, size, phone (optional). Client-side validation on the first four./api/lead already existspreventDefault(), fires fetch('/api/lead') with .catch(function(){}), then opens the PDF regardless. If the route breaks you lose leads silently and the user sees a success state./playbook/the-practice-growth-playbook.pdf returns 200 (286 KB) to anyone. The form is a speed bump, not a gate, so nothing forces a real email address.custom_forms is empty. Nothing to collide with, clean slate.preventDefault(), so the snippet is a fight. It also puts lead capture client-side, where it is spoofable and invisible when it breaks./api/lead to the Track APICIO_SITE_ID and CIO_TRACK_API_KEY. These are Track API credentials, not the App API key. Carlos will generate them, do not commit them./api/lead to identify the person and fire an eventexport default async function handler(req, res) { if (req.method !== 'POST') return res.status(405).end(); const { name, email, profession, practice_size, phone, page, utm } = req.body || {}; if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) { return res.status(400).json({ error: 'invalid email' }); } const auth = Buffer.from( `${process.env.CIO_SITE_ID}:${process.env.CIO_TRACK_API_KEY}` ).toString('base64'); // identity strategy is still open — see "Decisions" below const id = `lead_${email.trim().toLowerCase()}`; const call = (path, body, method) => fetch(`https://track.customer.io/api/v1/${path}`, { method, headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const identify = await call(`customers/${encodeURIComponent(id)}`, { email: email.trim().toLowerCase(), name, profession, practice_size, phone: phone || undefined, lead_source: 'practice-growth-playbook', lead_created_at: Math.floor(Date.now() / 1000), }, 'PUT'); if (!identify.ok) { console.error('cio identify failed', identify.status, await identify.text()); return res.status(502).json({ error: 'identify failed' }); } await call(`customers/${encodeURIComponent(id)}/events`, { name: 'playbook_requested', data: { asset: 'practice-growth-playbook', page, ...utm }, }, 'POST'); return res.status(200).json({ ok: true }); }
window.open so the popup blocker stays happy, but await the response and surface a failure. A lost lead should be loud.// open first, inside the trusted click — this part stays window.open(PDF, '_blank', 'noopener'); panel.classList.add('ok'); fetch('/api/lead', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(lead), keepalive: true, }) .then(function (r) { if (!r.ok) throw new Error('lead capture failed: ' + r.status); if (window.posthog) posthog.capture('playbook_form_submitted', lead); }) .catch(function (err) { console.error(err); if (window.posthog) posthog.capture('playbook_form_failed', { message: String(err) }); });
page: location.href. Parse utm_source, utm_medium, utm_campaign, utm_content off the query string and send them as a utm object, plus document.referrer. Without this we cannot tell which channel the leads came from.phc_VdUkyfC2Uae7em1DPbDNB9y1ZrmLbHSsu44I8fhnXvp, host us.posthog.com. Autocapture covers the pageview; the two custom events in the snippet above cover the rest. Submit rate is the number we will judge this page on.window.open is a bonus. That is what makes a valid address worth giving. Carlos builds the campaign off playbook_requested.id, with email as a plain attribute. These leads have no Carepatron user id, so lead_<email> creates a second profile that will not merge when they sign up later. That is exactly the orphaned-profile cleanup we did in June. Either mint the lead id and add a merge on signup, or look up by email first and update the existing profile. Carlos calls it before this goes live.profession and practice_size are placeholders. If the workspace already has equivalents from signup, use those instead so segments do not fragment across two spellings of the same thing.https://track.customer.io/api/v1/ — HTTP Basic auth, username is the Site ID, password is the Track API key.PUT /customers/{id} — body is a flat object of attributes. Creates on first call, merges on later ones.POST /customers/{id}/events — { name, data }. The name is what a campaign trigger matches on.