Carepatron · Growth

Playbook lead form → Customer.io

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.

CIO workspace 199348 Vercel 2 decisions blocked on Carlos

What's on the page today

Read from the deployed HTML, so this is the live state, not a guess.
Single static HTML file, inline vanilla JS
No framework, no build step. About 17 KB. Easy to change.
Form #leadform collects five fields
name, email, profession, size, phone (optional). Client-side validation on the first four.
/api/lead already exists
A junk POST returns 400, so the route is live and validating. That is the hook point — everything below goes in there.
Every error is swallowed
Submit does preventDefault(), 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.
The PDF is public and ungated
/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.
No analytics on the page at all
No PostHog, no CIO snippet, no GA. There is currently no way to see the view-to-submit rate, which is the only number that says whether the page works.
No custom forms configured in Customer.io
Checked the workspace: custom_forms is empty. Nothing to collide with, clean slate.

The approach, and why

Customer.io has no embeddable form builder. Three ways in, we want the third.
Not the connected-forms snippet
CIO's JS snippet scans real HTML form submissions. This form calls preventDefault(), so the snippet is a fight. It also puts lead capture client-side, where it is spoofable and invisible when it breaks.
Not the in-app Lead Form component
CIO does have a native lead form, but it only renders inside in-app messages, not on a marketing page. Wrong tool.
Server-side from /api/lead to the Track API
Keys stay on the server, failures are visible, and you get a real event to trigger the campaign off. The route already exists, so this is a rewrite rather than new plumbing.

The build 7 items

Add the CIO credentials to Vercel env
CIO_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.
Rewrite /api/lead to identify the person and fire an event
Identify writes the profile, the event is what the campaign triggers on. Both calls, in that order. Code below.
api/lead.js
export 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 });
}
Stop swallowing errors on the client
Keep the synchronous window.open so the popup blocker stays happy, but await the response and surface a failure. A lost lead should be loud.
inline script, submit handler
// 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) });
  });
Capture UTMs and referrer into the payload
Right now the lead object only carries 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.
Add the PostHog snippet
Project token 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.
Let Customer.io deliver the PDF by email
Nothing to build on your side, but it changes the design: the email becomes the real delivery mechanism and the instant window.open is a bonus. That is what makes a valid address worth giving. Carlos builds the campaign off playbook_requested.
Optional: stop the PDF being crawlable
Low priority for a POC. If it matters later, serve it from the API route with a short-lived token rather than a static path. Do not block the rest of the work on this.

Waiting on Carlos 4 items

Do not guess on the first one, it is the expensive mistake.
Identity strategy: how does a lead become a user?
The CIO workspace keys profiles by 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.
Confirm the attribute names
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.
Phone consent
The privacy line covers email only. If that phone number is ever used for SMS it needs explicit A2P 10DLC consent copy at the point of capture, same as the signup form. Until that is settled, collect it but do not message it.
Campaign ownership
Carlos builds the delivery email and the nurture as drafts in CIO and enables them. You do not need CIO access to finish your half — just fire the event.

Reference

Track API base
https://track.customer.io/api/v1/ — HTTP Basic auth, username is the Site ID, password is the Track API key.
Identify or update a person
PUT /customers/{id} — body is a flat object of attributes. Creates on first call, merges on later ones.
Send an event
POST /customers/{id}/events{ name, data }. The name is what a campaign trigger matches on.
Docs
Track API reference  ·  Connected forms (the approach we are not using, for context)
Prepared for Callum · 28 July 2026