Experience · Tracking & Automation

Recovering form leads from a sealed iframe, and getting them into Meta

The booking form was a cross-origin iframe the browser could not see into, so a submit event was flat-out impossible. Here is how I fired a Meta Lead for every submission anyway, by going around the browser, and why my first build had to be thrown away.

September 2026 · ~8 min read · Cloudflare Workers, Jobber API, Meta CAPI

The whole problem in one picture: the form lives in another origin, so the submit never reaches the page's pixel.

Most conversion tracking assumes the browser can see the thing you want to measure. Sometimes it simply cannot. On this project the booking form was embedded from a field-service CRM as a cross-origin iframe, and a same-origin page has no way to read inside another origin's frame. No click, no submit, no event. The ads account was optimising against a conversion that, from the browser's point of view, never happened. This is the story of getting that Lead event to fire anyway, and of the first architecture I built, watched fail, and deliberately replaced.

The goal

For a home-services website running Meta ads, every real inquiry came through one embedded booking form. The ads team needed a Lead conversion to fire on each submission so the campaigns could optimise toward actual inquiries instead of proxy signals. One hard constraint set the tone for the whole build: no paid tooling until the leads proved themselves. It had to work on free infrastructure first.

The wall: a form the browser can't see

The booking widget was served inside an iframe from the CRM, Jobber, on a different origin from the site. I confirmed the important part by live inspection: the frame emits no postMessage on submit. That matters because a cross-origin iframe is a sealed box by design. The browser's same-origin policy stops the parent page reading the frame's DOM or its events, and without a postMessage handshake there is nothing to listen for. GTM triggers, the pixel, a custom listener, none of them can observe a submit that happens inside another origin. Client-side tracking here was not hard, it was impossible. That is a useful thing to recognise early, because the honest move is to stop trying to force the browser and go find the data somewhere it actually exists.

The way around it, and why it works

The data did exist, just not in the page. Every submission lands in Jobber as a work request. So instead of watching the browser, I watched the source of truth: poll Jobber's API on a schedule, take each new request, hash the personal fields, and send a Lead to Meta's Conversions API server-to-server. The reason this works is the same reason server-side tracking works in general: Meta's Conversions API accepts events from a trusted server, not only from a browser pixel, so the browser never needs to witness the conversion at all. If Jobber knows a booking happened, I can tell Meta a booking happened, and the sealed iframe becomes irrelevant.

The first build, and why I moved off it

My first instinct was to keep it visual and low-code, so I self-hosted n8n on a free Hugging Face Space: a scheduled workflow that hit Jobber's GraphQL API, filtered and hashed in a Code node, and posted to Meta. It ran. But the free host turned into a fight, and the fights taught me exactly where a build like this actually breaks:

The move that fixed it: retire the fragile free host and collapse every moving part into one Cloudflare Worker.

That last failure was the useful one. I had already put a Cloudflare Worker in the path as a relay, so the honest question was: why keep the fragile host at all? The decision was to retire n8n and Hugging Face entirely and collapse everything into a single Cloudflare Worker. The trade-off was giving up n8n's visual workflow for hand-written code, but in return I got a runtime with built-in Web Crypto, a trusted egress IP Meta accepts, persistent storage, and a real scheduler, the exact four things the free host kept taking away.

The finished pipeline

One Worker, one schedule, one store. Nothing to keep warm and nothing to babysit.

The final system is a single Cloudflare Worker on a ten-minute cron. On each run it refreshes the Jobber OAuth token, queries Jobber's GraphQL API for new work requests, hashes the personal fields with the Worker's native Web Crypto SHA-256, and posts a Lead to the Meta Conversions API. All state lives in Cloudflare KV: the Jobber refresh token, and the set of request IDs already sent. Connecting it is a one-time step, an /auth/start endpoint kicks off the Jobber OAuth flow and the callback stores the refresh token in KV, after which the cron runs unattended. There is also a test endpoint that sends a single event with Meta's test_event_code so I can prove the path without touching live dedup.

The decisions that made it reliable

Getting an event to send once is easy. Getting it to send exactly once, forever, unattended, is the actual work. Four decisions carried that weight:

The core loop is small enough to hold in your head, which is exactly what you want from something that runs on its own:

async function runJob(env) {
  const token = await refreshToken(env);            // Jobber OAuth, refreshed from KV
  const requests = await fetchRequests(env, token); // Jobber GraphQL: new work requests
  const state = await env.KV.get("state", "json");
  const seen = new Set(state.seen);

  for (const r of requests) {
    if (seen.has(r.id)) continue;                   // already sent, skip
    if (!r.source.startsWith("embedded")) continue; // form leads only

    const event = await buildEvent(r);              // PII hashed with SHA-256
    const meta  = await sendToMeta(env, [event]);   // Meta Conversions API

    if (meta.events_received >= 1) seen.add(r.id);  // mark only on a confirmed send
  }

  await env.KV.put("state", JSON.stringify({ ...state, seen: [...seen] }));
}

And the hashing, the one piece that had no home on the free host, is native here:

// on the free host crypto.subtle was undefined, so this line failed there.
// on Cloudflare Workers the Web Crypto API is built in, so this is native.
async function sha256(value) {
  const data = new TextEncoder().encode(String(value).trim().toLowerCase());
  const digest = await crypto.subtle.digest('SHA-256', data);
  return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('');
}

How I proved it worked

I did not trust it until every layer showed its work. The test endpoint returned { ok:true, mode:"live", sent:1, results:[{ ok:true, status:200, events_received:1 }] }. In Meta Events Manager the Lead event showed as Active with the integration listed as Conversions API. Then a real form submission on the site flowed all the way through on its own, no clicking, no manual push. That end-to-end pass, from a booking the browser could never see to a Lead landing in Meta, was the moment it was actually done.

What it changed

The payoff here is blunt: a conversion that was impossible to track went to fully tracked. Before, not a single booking-form submission could fire a Lead, because the browser had no way to observe the sealed iframe. Now every embedded-form submission produces a server-side Lead automatically, on a ten-minute cadence, across every page of the site, at zero hosting cost, on infrastructure that never sleeps. Meta went from optimising against a conversion it never received to optimising against real inquiries. Going from nothing to something reliable is a bigger jump than any tuning that comes after it.

What this approach won't fix

An honest limit, because server-side is often sold as a finish line and it is not. A purely server-side Lead carries no browser click identifiers, no fbc or fbp, so Meta has less to match on and event match quality starts low with a real ceiling. It climbs as each lead arrives with more real detail, email, phone, name, city, but it will not reach what a browser event with click IDs would. The clean way to lift it further is a phase two: a native, same-origin form on the site that captures the click IDs in the browser and feeds them alongside the server event. That was scoped as optional, later work. Two smaller caveats: the OAuth connection needs occasional care, and the pipeline is near-real-time at about ten minutes, not instant. For ad optimisation that is fine; for anything needing a sub-minute reaction it would not be.

What I'd carry into the next build

The biggest lesson had nothing to do with tracking. A free tier that resets your state and rotates your egress IP will cost you more time than the money it saves, and the failures show up as someone else's TLS errors, which is the worst kind to debug. The second lesson: when you find yourself adding a relay to prop up a shaky component, that is usually a sign the component should go, not get propped. Collapsing four fragile pieces into one Worker made the system easier to reason about and more reliable at the same time, which does not happen often. And the send-then-mark plus baseline pattern is now my default for any unattended job that must never double-fire and must never replay history.

Stacks involved

Jobber GraphQL APIOAuth 2.0n8n (first attempt)Hugging Face Spaces (first attempt)Cloudflare WorkersCloudflare KVCron TriggersMeta Conversions APISHA-256server-side tracking
← more of my work