Proof of concept · server-side Meta tracking for health advertisers

Health-Safe CAPI Gateway

Dry runNothing here calls Meta. All people, sites and IDs are fake. Hashing is real (WebCrypto SHA-256). Anything labeled Simulated or Heuristic models documented behavior; it is not measured.

The hard part isn't the API call. Sending an event to the Conversions API is one POST. The hard part is everything around it:

  • A distributed join. The ad click, the browsing, the booking and the payment live in different systems with no shared key.
  • Exactly-once delivery across the browser pixel, the server container and third-party webhooks that retry and arrive out of order.
  • A classifier where a false negative is a lawsuit and a false positive is lost revenue.
  • All of it fails silently. Meta answers 200 OK for an event that matches nobody.

Sources

  • Browser pixel
  • Server container (sGTM on Stape)
  • Webhooks: payments, scheduling, CRM

Gateway (this POC)

  • Identity join: click → session → booking → payment
  • Allowlist redaction with a reason per field
  • Normalize and hash to Meta's spec
  • Idempotency, dedup IDs, replay and dead-letter queue

Outputs

  • Meta Conversions API (dry run here)
  • Client audit log: sent, blocked, why
G1 · REDACTION

Redaction has to fail closed

The bug

A blocklist regex removes the words someone thought of. Health data reaches Meta through channels nobody wrote a rule for: misspellings, URL encoding, hyphen-split words, base64 parameters, product SKUs, form field names, and the custom event name itself.

The fix

An allowlist. Only fields proven safe pass. Custom event names map to standard events. The URL drops to its origin or a mapped neutral path. Anything unknown, including a field a developer adds next month, is dropped with a reason.

Raw event leaks marked

Regex blocklist output

Allowlist output CAPI payload

Every leak channel, checked

Allowlist decisions

Production adds a classifier for free text that a client decides to keep, such as Microsoft Presidio or Google Cloud Sensitive Data Protection (formerly Cloud DLP). The allowlist limits what a classifier is trusted with: only the few fields a client opts in ever reach it, and for this vertical the default is no free text at all. One leak remains that no payload filter can remove: the domain. Meta classifies health advertisers by domain and can restrict URL data, custom parameters and lower-funnel events whatever the payload says (Stape, March 2026).

G2 · TRADE-OFF

Privacy against match quality

Too little redaction

Legal exposure. The FTC fined GoodRx $1.5M in 2023, its first Health Breach Notification Rule case, over health data shared with Facebook and others for ads. BetterHelp paid $7.8M the same year for sharing emails, IPs and intake answers. Washington's My Health My Data Act (in force March 31, 2024) adds a private right of action.

Too much redaction

Meta can't tie conversions to people, campaigns optimize worse, cost per patient rises, and the client churns. The way out is that context and identity are separable: matching runs on identifiers, not on what condition someone has.

Presets

Identifiers weight in heuristic

Health context adds 0 to matching

Result Heuristic

Estimated match strength, 0 to 10
0

My own weighting, for illustration. This is not Meta's Event Match Quality score, which Meta computes and doesn't publish a formula for.

Health-data exposure
LowMediumHigh

    G3 · HASHING

    Normalize, then hash, or match nothing

    The bug

    Hashing " Jane.Doe@Gmail.com " as typed produces a valid SHA-256 that matches no one. Meta still returns 200 OK and counts the event as received. Nothing errors. Match rates just sit low, and nobody knows why.

    The fix

    Normalize each field to Meta's published rules before hashing. Detect values that arrive already hashed so they aren't hashed twice. Never hash fbc, fbp, client_ip_address or client_user_agent: Meta says to send those as is.

    Input as the form captured it

    What Meta receives Real SHA-256

    Meta's response to the naive payload Simulated
    
            

    Rules from Meta's customer information parameters page: email trimmed and lowercased; phone with symbols, letters and leading zeros removed and the country code included; names lowercase with no punctuation. Treating a number without + or 00 as national and prefixing the chosen country code is my assumption about the form's data, not a Meta rule.

    G4 · CLICK ID

    fbc is case-sensitive and timestamped in milliseconds

    The bug

    A URL-normalizing middleware lowercases the landing URL, or the server stamps fbc with the purchase time in seconds. The string still looks like a click ID. Meta can't tie it to the click, and the sale goes unattributed.

    The fix

    fb.1.{creationTime}.{fbclid}, where creationTime is Unix milliseconds when the click ID was first seen and fbclid is copied byte for byte. Meta's docs: "ClickID value is case sensitive - do not apply any modifications before using."

    Landing

    Click captured . Purchase on day 3.

    Inject a bug

    Built values

    fbc
    fbp (from the browser cookie, created on an earlier visit)

    Format and the case rule are from Meta's fbp and fbc page. Subdomain index 1 means the cookie sits on example.com-level domain, which is also what Meta says to use when the server builds fbc without a cookie.

    G5 · COOKIE LIFETIME

    Safari deletes the click before the sale

    The bug

    A patient clicks the ad on day 0 and pays on day 3 in Safari. The pixel stored _fbc with JavaScript on a landing page reached through a link with a click ID, so WebKit capped it at 24 hours. At checkout there's no fbc, and the sale is unattributed.

    The fix

    Set the cookie from the server, on the brand's own domain, from an IP that Safari considers first party. Better still, store fbc against the person when they identify themselves (intake form), so the cookie is a cache and not the source of truth.

    Click on day 0, purchase on day 3 Simulated

    Simplified: assumes the visitor doesn't return between click and purchase, and ignores Safari's "Hide IP address" setting. Caps: WebKit's tracking prevention page documents the 24-hour cap for script-set cookies after a link with a click ID, the 7-day cap for script-writeable storage, and the 7-day cap for server-set cookies behind CNAME cloaking. The Safari 16.4 rule that also caps server-set cookies from an IP that doesn't share its first 16 bits (IPv4) with the site's IP comes from third-party analyses (Stape, Addingwell), not WebKit's own docs. Separately, Link Tracking Protection strips fbclid in Private Browsing and in links opened from Mail and Messages (Croud), and no cookie strategy recovers a click ID that never arrived.

    G6 · DEDUPLICATION

    One purchase, counted twice

    The bug

    The pixel fires Purchase with a random eventID. The payment webhook fires Purchase from the server and can't know that random ID. Meta only deduplicates when event_name and event_id match within 48 hours, so it keeps both. Reported revenue doubles and the bidder optimizes toward phantom sales.

    The fix

    Create the ID once, at checkout, from the checkout ID. Hand it to the pixel and write it into the payment's metadata so the webhook reuses it. The gateway also remembers what it sent, which covers webhooks that land after Meta's 48-hour window.

    What reaches Meta Simulated dedup

    The fix in code

    // 1. Server, when the checkout is created
    const eventId = `purchase_${checkoutId}`;
    await stripe.checkout.sessions.create({
      /* line items, urls ... */
      metadata: { meta_event_id: eventId },
    });
    
    // 2. Browser, on the confirmation page
    fbq('track', 'Purchase',
      { value: 299, currency: 'USD' },
      { eventID: eventId });
    
    // 3. Webhook: checkout.session.completed
    const id = session.metadata.meta_event_id;
    if (await sentStore.has(id)) return; // gateway guard
    await capi.send({ event_name: 'Purchase', event_id: id, ... });

    Rule source: Meta's deduplication page (event name and ID must match; events are only deduplicated within 48 hours).

    G7 · EXACTLY ONCE

    Retries and out-of-order webhooks

    The bug

    The handler sends to Meta, then times out before answering. The payment provider retries, so the same payment goes out three times, each stamped with its delivery time. The payment also lands before the booking that carries the patient's identity, so the first Purchase goes out with no match keys.

    The fix

    Answer fast and key every delivery by source:event_id. Duplicates are acknowledged and skipped. event_time comes from the source event, not the clock. A payment that can't be joined to a person yet is parked until the booking arrives, with a deadline.

    Delivery log Simulated

    Sent to Meta Dry run

    Why "exactly once" is two layers

    Exactly-once delivery over a network isn't achievable; effectively-once is. Layer one is the idempotency store: mark the key as in flight, send, mark as sent. A crash between send and mark can still cause a resend. Layer two catches that: the resend carries the same deterministic event_id, so Meta's own dedup drops it. Each layer covers the other's gap.

    Stripe's webhook docs confirm both behaviors modeled here: endpoints may receive the same event more than once, and Stripe doesn't guarantee delivery in the order events were generated. Live-mode retries continue for up to three days.

    G8 · REPLAY

    Replay inside the 7-day window

    The bug

    After an outage, someone replays the backlog in one batch. Meta accepts event_time up to 7 days old, and if any event in a request is older, the whole request is rejected. One stale event sinks the fresh ones. Or the old events are quietly discarded and the client never hears about it.

    The fix

    Partition before batching. Events inside the window, minus a safety margin for queue time, are replayed with their original event_time. Older ones go to a dead-letter queue the client can see. Each event also needs the right action_source and the fields that value requires.

    Backlog after a 9-day outage Simulated

    action_source check

    Valid values per Meta's server event parameters: email, website, app, phone_call, chat, physical_store, system_generated, business_messaging, other. crm is not one of them; Meta's CRM lead spec uses system_generated with custom_data.event_source: "crm". Website events require event_source_url and client_user_agent.

    G9 · CLIENT VIEW

    The client's audit log

    The problem

    When a client's lawyer asks "what did you send Meta about our patients?", "we strip PHI" is not an answer. Neither is a dashboard that only shows what was sent.

    The product

    For each event: the exact payload sent, every field that was blocked or rewritten, and the rule that did it. Below is one patient's funnel through every gotcha above, including the parts that were held back.

    How the events were joined to one person

    ad clickfbclid → fbc→ browser_fbp cookie→ intake formemail, phone, user id→ booking webhooksame email→ payment webhookmetadata.booking_id

    No system shares a key with all the others. The gateway stores fbc and fbp with the user record at intake, so later server-side events inherit click attribution even after Safari clears the cookie (G5).

    TESTS

    Gotcha tests

    Assertions that run the same functions the panels above use. They run on load; run them again after changing inputs to confirm the defaults still hold.

    …
    SCOPE

    What I'd build next, and what this leaves out

    Next

    • Stape container setup by API: provision the server container, custom domain and same-origin path per client, so the cookie fix in G5 isn't a manual step.
    • Integrations for Shopify, Stripe, Calendly and GoHighLevel, each mapped onto the same allowlist and idempotency keys.
    • Monitoring for drops in Meta's Event Match Quality and in dedup rate, alerting before the client notices weaker campaigns.
    • Consent mode and CCPA opt-out handling, including Meta's Limited Data Use flag.

    Deliberately left out

    • A cross-device identity graph. Here, one person on one device is joined by email and booking ID.
    • Any network call. Meta's responses are simulated from its docs.
    • The free-text classifier. It's named, not built.
    • Meta's domain-level health restrictions. A clean payload can't lift them; only Meta's review can.
    • Legal advice. Whether data counts as health data under HIPAA, the FTC Act or MHMDA is a question for the client's counsel.