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.
- The host kept losing my token. Jobber's OAuth refresh with token rotation on, combined with the Space's ephemeral storage, meant the rotated refresh token vanished on every restart and auth broke. I turned rotation off and stored state somewhere that survives restarts.
- The runtime had no crypto. The self-hosted Code node had no Web Crypto global, so
crypto.subtlewas undefined and hashing failed. I dropped in a dependency-free, pure-JS SHA-256 to get past it. - Meta rejected the auth twice over. First the header had to be a real
Authorization: Bearer <token>, not a raw value; then the System User token needed the pixel assigned with full control andads_management. Once both were right, I got my firstevents_received: 1. - Meta's edge refused the host's IP. Outbound TLS to
graph.facebook.comdied withEPROTO / SSL alert number 0, a reset because the shared free-host IP was untrusted. I bought myself one more run by adding a Cloudflare Worker as a trusted relay in front of Meta. - Then the host started dropping its own connections.
ECONNRESETbefore the TLS handshake even finished, plus the editor session disconnecting. At that point the pattern was clear: the free Space itself was the weak link, not any one config.
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:
- Send-then-mark dedup. A request ID is written to the KV
seenset only after Meta confirmsevents_received >= 1. A failed send is never marked, so it simply retries on the next run instead of being lost. - A first-run baseline. The very first live run records the IDs that already exist and sends nothing, so switching the system on never blasts weeks of old bookings into Meta as fresh leads.
- A source filter. Only requests whose source starts with the embedded form are sent, so manual or phone entries in the CRM don't masquerade as web leads.
- Secrets out of the code. The Jobber refresh token lives in KV, the Meta token is a Worker secret, and nothing sensitive is ever committed. The Worker code is safe to read; the credentials are not in it.
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.