← blog/5-pitfalls-meta-crm-attributionPT
    // blog/5-pitfalls-meta-crm-attribution.md

    5 Pitfalls of Meta Ads → CRM Attribution That Cost Us Real Leads

    Vexo lost 83.6% of its leadgens silently for 3 days. Here's that story plus four more pitfalls from running PostepTrack across 4 clients.

    2026-06-03·9 min read·engineering·POSTEP Digital

    Sleep 10/15s · HTTP 204 trap · try/catch in waitUntil · System User Token · utm_campaign has an owner

    // 00

    A war story

    Client: Vexo. Branch: leadgen (Facebook/Instagram Lead Ads). Window: three days before we noticed. Result: 83.6% of leads went through the Edge Function, were processed “successfully” for Meta, and simply vanished before reaching Kommo. No error in the log. No alert. Just leads that never came back.

    When we found the cause, it was two bugs nested inside a single try block. Easy to fix, devastating to miss. This post combines that case and four more we learned running PostepTrack across four different clients.

    If you're building (or hiring) Meta Ads → CRM attribution, it's worth reading to the end. Each trap cost us production time. None of them are in official documentation.

    // 01

    #1 — The CRM is slow to index new leads

    Meta delivers the webhook in seconds. The CRM (Kommo, in our case) creates the lead from the WhatsApp message or Lead Ad a few extra seconds later — and takes even longer to make that lead appear in GET /leads. If you do the lookup immediately, you get empty. And empty here means “lead does not exist”.

    // sleep before lookup — numbers that actually work
    CTWA (Click-to-WhatsApp) 10s
    Leadgen (Lead Ads) 15s
    // less than that and the “lead not found” rate spikes

    These numbers aren't guesses. We measured success rate in production — 5s gives plenty of not-founds, 10s stabilizes for CTWA, 15s for leadgen (because the Lead Ad passes through Facebook's crawler first). Supabase Edge Functions need EdgeRuntime.waitUntil so the webhook response isn't blocked by that sleep.

    Rule: measure the time until the lead is searchable in YOUR CRM. Don't use defaults. And never sleep inside the webhook handler without fire-and-forget — Meta will retry assuming you crashed.

    // 02

    #2 — HTTP 204 is not “success”

    When you query Kommo searching for a lead by phone and it doesn't exist, the API responds with HTTP 204 (No Content). Not 404. Not 200 with an empty array. Just 204 with no body.

    // wrong — fetch.ok is true on 204
    const r = await fetch(url);
    if (!r.ok) throw ... // does NOT enter here
    const data = await r.json(); // silent SyntaxError
    unhandled rejection · lead enriched = false
    // right — check 204 explicitly
    if (r.status === 204) return null;
    ✓ missing lead becomes a known flow

    This was the second of the two nested bugs at Vexo. r.ok is true for any 2xx status, including 204. When you try to parse the empty body, you get SyntaxError. Without an outer try/catch, it becomes silent fail. Times 83.6% of leadgens over three days.

    Rule: handle every status code your CRM actually returns. 204 isn't an exception — it's a legitimate “not found” response, and your code has to know how to deal with it.

    // 03

    #3 — Fire-and-forget without an outer try/catch

    Meta expects a 200 response within seconds. If you delay (because you're waiting on the CRM sleep, doing a lookup, doing a PATCH), Meta retries — and your lead becomes a duplicate, or worse, falls into an infinite webhook loop.

    The fix is fire-and-forget: you return 200 immediately and process the rest in the background. On Supabase, that's EdgeRuntime.waitUntil(asyncTask()). It works — but there's a detail nobody warns you about.

    // wrong — exceptions vanish inside waitUntil
    EdgeRuntime.waitUntil(processLead(payload));
    return new Response("ok", { status: 200 });
    any throw inside processLead → nothing shows up in logs
    // right — outer try/catch persists the failure
    EdgeRuntime.waitUntil((async () => {
    try { await processLead(payload); }
    catch (e) { await logToTable(e); }
    })());
    ✓ exceptions become visible log rows

    Without the outer try/catch, any exception inside waitUntil becomes silent fail. The 200 response already went out, Meta is happy, but processing died in silence. Combine that with the 204 bug and you have the Vexo story.

    Rule: anything running in fire-and-forget needs an outer catch that writes to a table. Without it, you're trusting nothing will go wrong — and everything goes wrong in production.

    // 04

    #4 — 90-day token vs System User Token

    Meta's docs push you toward the user token, which expires in 90 days. Every renewal is manual — someone has to remember to log into Business Manager, generate a new token, update the env, redeploy. Across four clients, that's a guaranteed chronological trap.

    // two types of Meta token
    User Access Token
    ·validity: 90 days
    ·manual renewal
    ·tied to a human user
    ·breaks if the person leaves
    ·great for dev and testing
    System User Token
    validity: indefinite
    created once in Business Manager
    tied to the Business, not a person
    survives team turnover
    mandatory in production

    System User Tokens need a specific permission to read leadgen fields — leads_retrieval. Without it, you query the lead by leadgen_id, receive 200 with an empty payload, and spend hours debugging code that was right from the start.

    Rule: when going to production, create a System User in the client's Business Manager, grant leads_retrieval explicitly, and use that token. Forget the 90-day one — it's for prototypes.

    // 05

    #5 — utm_campaign almost always has an owner

    You implemented everything cleanly, the lead lands in Kommo, and when you populate utm_campaign with the campaign_name coming from Meta… someone in marketing complains that their dashboard filter stopped working. Because utm_campaign was already being filled by another upstream system — the landing, the form, an N8N.

    // pattern observed across 4 PostepTrack clients
    utm_source → PostepTrack writes "meta"
    utm_medium → PostepTrack writes "cpc"
    utm_campaign → 2 of 4 clients: PRESERVE
    utm_content → PostepTrack writes ad_name
    utm_term → PostepTrack writes adset_name
    // ASK before including utm_campaign in the PATCH

    In 2 of the 4 clients we run, utm_campaign already had an upstream owner. PostepTrack now skips that field in those cases and maps campaign_name to a dedicated field. Small detail, but it avoids a hard conversation with marketing three months later.

    Rule: for every UTM you plan to write into the CRM, ask whether it already has an owner. Attribution is collaborative — several systems write to the same lead. Overwriting without asking breaks the work of whoever was there first.

    // 06

    Recap: all five in one page

    #1 sleep

    Measure the time until the lead is searchable in the CRM. 10s for CTWA, 15s for leadgen. Always fire-and-forget so Meta doesn't retry.

    #2 HTTP 204

    204 is a legitimate “not found” response. r.ok is true on 204. Handle it explicitly before parsing the body.

    #3 waitUntil

    Everything in fire-and-forget needs an outer try/catch that persists the exception to a table. Without it, exceptions equal silent fail.

    #4 token

    Production wants a System User Token (no expiry) + leads_retrieval permission for Lead Ads. The 90-day one is for prototypes only.

    #5 utm_campaign

    Ask whether an upstream owner already exists. Overwriting a UTM field breaks someone else's dashboard — and nobody catches it in real time.

    // main rule

    Meta → CRM attribution has five holes where leads vanish silently. Each one costs real paid traffic before you notice. Test with real production leads and a log that captures exceptions — there's no other way.

    written by
    POSTEP Digital
    ← all posts