Internal spec · share with CRM developer

ENCY — Server-Side Conversion Tracking (Meta CAPI + GA4 MP)

Status: Draft for CRM developer Owner: Andrei Kharatsidi · a.kh@encycam.com Created: 2026-05-24 Target EMQ: 9–10/10 (Meta), full attribution (GA4)


1. Goal

Every conversion in ENCY's product funnel must reach Meta and GA4 from the server side, deduplicated against the browser pixel, with the full user identity payload (email, phone, name, address, fbp/fbc, external_id, IP, UA). This is the difference between EMQ 4–5 (anonymous IP+UA only) and EMQ 9–10 (matched to a real Facebook/Google user with high confidence).

Today (after the 2026-05-24 sweep): - Browser-side dual-send works: encycam.com via Zaraz CAPI, own-it.encycam.com via custom /api/capi Function. - Both ship external_id (hashed handlID), fbp, client_ip, client_user_agent on every event. - No server-side conversion event is fired from the CRM backend yet. Lead, sign-up, trial download, purchase — none reach Meta/GA4 with the email/phone/name from the form. This spec closes that gap.


2. Architecture

                              ┌──────────────────────────┐
   Browser (visitor)          │ CRM Backend (this spec)  │
   ┌──────────────────┐       │                          │
   │ encyTrack(...)   │──────▶│ POST /forms/lead-form-…  │
   │ /api/capi (Pages)│       │                          │
   │ Zaraz CAPI       │       │  ┌────────────────────┐  │
   └──────────────────┘       │  │ event_id from URL  │  │
            │                 │  │ (fb_event_id param)│  │
            │                 │  └─────────┬──────────┘  │
            │                 │            │             │
            │  same event_id  │            ▼             │
            │   (dedup)       │  ┌────────────────────┐  │
            │                 │  │ User identity:     │  │
            ▼                 │  │  email, phone, name│  │
        Meta + GA4 (browser   │  │  + fbp/fbc/UTM     │  │
        attribution, anon)    │  └─────────┬──────────┘  │
                              │            │             │
                              │            ▼             │
                              │  ┌────────────────────┐  │
                              │  │ Meta CAPI POST     │──┼──▶ graph.facebook.com
                              │  │ (full PII)         │  │
                              │  └────────────────────┘  │
                              │  ┌────────────────────┐  │
                              │  │ GA4 MP POST        │──┼──▶ www.google-analytics.com
                              │  │ (user_id + user    │  │
                              │  │  properties)       │  │
                              │  └────────────────────┘  │
                              └──────────────────────────┘

Key principle: server is the source of truth for conversions. Browser fires for client-side modeling and quick attribution, server fires the canonical event with full PII. They share event_id so Meta and GA4 dedupe them as one conversion.


3. Event taxonomy

Every event MUST fire both browser (already wired) and server (this spec). Server is always more reliable; browser fills behavioral signal.

Funnel step Trigger on CRM Meta event_name GA4 event_name Browser pair Notes
Lead form submit POST /forms/lead-form-ds25 success Lead generate_lead Yes — InitiateCheckout (browser, on CTA click) The flagship server event.
Trial download Backend reach of /download thank-you flow Lead (sub-action) or Subscribe file_download Yes — Zaraz Pageview on /thank-you?download_product=… URL Already partially in Zaraz; CRM should mirror server-side.
Account sign-up After CRM creates ENCY account record CompleteRegistration sign_up No browser pair (server-only).
Trial start (first login) First login after account creation StartTrial (custom) start_trial (custom) Generate new event_id.
Subscription purchase Stripe webhook → CRM Purchase purchase If checkout is on encycam.com — browser pair; if Stripe-hosted — server-only. Include value + currency.
Plan upgrade / renewal Stripe webhook Subscribe (renewal) or Purchase (upgrade with value) purchase Server-only.
Demo/Conference request Conference form submit Lead with content_category: "conference" generate_lead with same param Yes — Zaraz Sqpy action
Email verification When user clicks verify link (custom) EmailVerified email_verified Server-only. Strengthens user identity.

All custom Meta events should be registered in Events Manager → Custom Events for proper Ads optimization.


4. Meta CAPI integration

4.1 Endpoint

POST https://graph.facebook.com/v22.0/1530042020969436/events

Pixel ID is 1530042020969436 (ENCYCAM market data — same as browser pixel).

4.2 Authorization

Access token in env var META_CAPI_TOKEN. Long-lived system user token (no rotation needed unless revoked). DevOps has it in vault — request from Andrei.

4.3 Required user_data fields (target: all of these on every event)

All PII fields MUST be SHA-256 hashed, lowercase, trimmed. IP/UA/fbp/fbc stay raw.

function hash(value) {
  if (value === undefined || value === null) return undefined;
  const normalized = String(value).trim().toLowerCase();
  if (!normalized) return undefined;
  return crypto.createHash('sha256').update(normalized).digest('hex');
}

const user_data = {
  em:                hash(form.email)             ? [hash(form.email)]               : undefined,
  ph:                hash(normalizePhone(form.phone)) ? [hash(normalizePhone(form.phone))] : undefined,
  fn:                hash(form.first_name)        ? [hash(form.first_name)]          : undefined,
  ln:                hash(form.last_name)         ? [hash(form.last_name)]           : undefined,
  ct:                hash(form.city)              ? [hash(form.city)]                : undefined,
  st:                hash(form.state)             ? [hash(form.state)]               : undefined,
  zp:                hash(form.postal_code)       ? [hash(form.postal_code)]         : undefined,
  country:           hash(form.country_code)      ? [hash(form.country_code)]        : undefined,  // 2-letter ISO
  db:                hash(form.date_of_birth)     ? [hash(form.date_of_birth)]       : undefined,  // YYYYMMDD
  ge:                hash(form.gender)            ? [hash(form.gender)]              : undefined,  // 'f' or 'm'

  // Identifiers shared with browser pixel — DO NOT HASH
  client_ip_address: req.headers['cf-connecting-ip'] || req.ip,
  client_user_agent: req.headers['user-agent'],
  fbp:               req.query.fbp || req.cookies._fbp,
  fbc:               req.query.fbc || buildFbcFromFbclid(req.query.fbclid),

  // Hashed external IDs (one or many)
  external_id:       buildExternalIds(form.handlID, lead.id, account.id)
};

normalizePhone should strip non-digits and add + country prefix if missing (E.164 — +14155551234).

buildFbcFromFbclid constructs fbc if missing:

function buildFbcFromFbclid(fbclid) {
  if (!fbclid) return undefined;
  return `fb.1.${Date.now()}.${fbclid}`;
}

buildExternalIds returns hashed array of all known stable identifiers:

function buildExternalIds(handlID, leadId, accountId) {
  const ids = [handlID, leadId, accountId].filter(Boolean).map(hash);
  return ids.length ? ids : undefined;
}

4.4 Event payload structure

const event = {
  event_name:        'Lead',
  event_time:        Math.floor(Date.now() / 1000),
  event_id:          req.query.fb_event_id || uuid(),  // ← shared with browser if available
  event_source_url:  req.headers.referer || `https://${req.headers.host}${req.path}`,
  action_source:     'website',
  user_data:         stripEmpty(user_data),
  custom_data: {
    currency:        'EUR',
    value:           leadValueEstimate || 0,
    content_name:    'lead-form-ds25',
    content_category: form.product || 'ency-cam',
    lead_source:     form.utm_source || 'direct',
    lead_medium:     form.utm_medium,
    lead_campaign:   form.utm_campaign
  }
};

const payload = {
  data: [event],
  // Comment out in production. Use only when verifying integration in Events Manager → Test Events.
  // test_event_code: 'TEST123'
};

await fetch(`https://graph.facebook.com/v22.0/1530042020969436/events?access_token=${encodeURIComponent(META_CAPI_TOKEN)}`, {
  method:  'POST',
  headers: { 'Content-Type': 'application/json' },
  body:    JSON.stringify(payload)
});

4.5 stripEmpty helper

Meta API rejects events where user_data.em is an empty array. Drop any key whose value is undefined, null, '', or empty array.

function stripEmpty(obj) {
  const out = {};
  for (const k of Object.keys(obj)) {
    const v = obj[k];
    if (v === undefined || v === null) continue;
    if (typeof v === 'string' && !v) continue;
    if (Array.isArray(v) && v.length === 0) continue;
    out[k] = v;
  }
  return out;
}

4.6 Forwarded params from landing pages

The browser-side click listener on own-it.encycam.com (and equivalent on encycam.com via Zaraz) appends these query params to the lead-form URL when user clicks a CTA:

Param Source Purpose
fb_event_id crypto.randomUUID() Shared event_id for browser/server dedup
fbp document.cookie _fbp Meta browser identifier
fbc document.cookie _fbc Meta click identifier
fb_source landing host (e.g., own-it) Internal source label
fb_loc CTA location on page (e.g., hero, final-cta) A/B insight
utm_source, utm_medium, utm_campaign, utm_content, utm_term Inbound traffic Attribution

CRM should capture all of these and include in CAPI event_id, user_data, and custom_data respectively.

4.7 Failure handling


5. GA4 Measurement Protocol integration

5.1 Endpoint

POST https://www.google-analytics.com/mp/collect?measurement_id=G-XLHMXGR17M&api_secret=<GA4_API_SECRET>

Measurement ID G-XLHMXGR17M is the production stream. GA4_API_SECRET must be created:

  1. GA4 → Admin → Data Streams → click the encycam.com stream
  2. Scroll to Measurement Protocol API secrets
  3. Create → name it crm-server-side → copy the value
  4. Store in env var GA4_API_SECRET

5.2 Required payload

const ga4Payload = {
  client_id:    ga4ClientId,  // see §5.3 below
  user_id:      account?.id || lead?.id || undefined,  // optional, strong identity
  timestamp_micros: Date.now() * 1000,
  user_properties: {
    user_email_hash: { value: hash(form.email) },
    user_phone_hash: { value: hash(normalizePhone(form.phone)) },
    user_country:    { value: form.country_code },
    handlID:         { value: form.handlID },
    lead_source:     { value: form.utm_source }
  },
  events: [{
    name: 'generate_lead',
    params: {
      value:    leadValueEstimate || 0,
      currency: 'EUR',
      method:   'lead-form-ds25',
      product:  form.product || 'ency-cam',
      campaign: form.utm_campaign,
      session_id:           form.ga_session_id  || undefined,
      engagement_time_msec: 1,  // required for sessions
      // Use the SAME event_id Meta uses for cross-tool join in BigQuery export.
      ga_event_id: req.query.fb_event_id
    }
  }]
};

POST with Content-Type: application/json. Response 204 = accepted (GA4 does NOT validate event correctness — use the debug endpoint to verify).

5.3 client_id source

GA4 needs a stable client_id per user. Pick in this order:

  1. _ga cookie value (parse GA1.1.<client_id>.<timestamp> — take just <client_id>.<timestamp>)
  2. If not present: ga_client_id URL param (forwarded by landing — needs adding)
  3. If neither: synthesize a UUID and store it in CRM account (for future events on this user)

Without a client_id, GA4 will discard the event.

5.4 Debug / validation endpoint

For testing, swap mp/collectdebug/mp/collect:

curl -sS -X POST \
  "https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XLHMXGR17M&api_secret=$GA4_API_SECRET" \
  -H "Content-Type: application/json" \
  -d @event.json

Response shape:

{
  "validationMessages": [
    { "fieldPath": "events[0].params", "description": "Missing 'engagement_time_msec' …" }
  ]
}

Empty validationMessages = event is valid for ingestion.


6. Cross-device identity

Three identifiers, in order of strength:

  1. Email (hashed)em field. The strongest, persistent across devices/browsers. Always include if known.
  2. CRM account ID (hashed)external_id. Use as canonical for logged-in users. Pass on every server event after sign-in.
  3. handlID — fallback for anonymous-but-known visitors. UTM-Grabber cookie, stable within a browser.

6.1 Identity merge on sign-in

When a visitor signs in / creates an account:

  1. CRM links the new account_id with the visitor's existing handlID and any lead_ids they've generated.
  2. From this point forward, ALL server-side events include the user's account_id first in external_id, followed by handlID and lead_id (Meta accepts multiple external IDs and tries to match across all).
  3. In GA4, set user_id to the account_id — this enables cross-device reports.

6.2 Backfill

If a visitor was anonymous when they generated leads (sub-events: PageView, InitiateCheckout), then signed up, Meta CAPI can do retroactive matching via shared external_id. To enable:

Meta links the prior anonymous events to the new account via the shared external_id.


7. Credentials & environment variables

Var Source Where to store
META_CAPI_TOKEN Andrei has it. Get via one-time cloudflared tunnel from ~/.openclaw/secrets/meta-capi.env. Never commit. CRM deployment secrets store (Vault / AWS Secrets Manager / etc.)
GA4_API_SECRET Create via GA4 UI (§5.1). Same secrets store.
Pixel ID 1530042020969436 Hardcode in code (not a secret).
GA4 Measurement ID G-XLHMXGR17M Hardcode in code (not a secret).

Token rotation: schedule a calendar reminder for 2027-05-24 to verify both tokens are still active.


8. Testing & validation

8.1 Meta CAPI Test Events

In Meta Events Manager → Data Sources → Pixel 1530042020969436Test Events tab:

  1. Open the Test Events tab.
  2. Note the displayed test_event_code (e.g., TEST46298).
  3. Configure CRM to send events with test_event_code set when an env var META_CAPI_TEST_CODE is present.
  4. Submit a form. Expected: event appears within ~10 seconds in the Test Events list with all user_data fields visible (Meta shows them as ✅ / ❌ per field).

EMQ score for a single test event is shown in the test panel. Target ≥ 9.0 / 10.0.

8.2 GA4 debug endpoint

Use debug/mp/collect (§5.4) before flipping production. Iterate on validationMessages until empty.

8.3 Production validation

After deploy, monitor for 24 hours:

A daily Telegram report on EMQ is already scheduled at 12:30 Cyprus time (ency_meta_emq_daily_check cron, ID cfd34f08-2ac1-45a5-b9ee-28e6b6d2e2fa). It will track the day-over-day jump after this spec ships.


9. Rollout plan

Phase 1 — Lead event (1–2 days): - Implement /forms/lead-form-ds25 server-side CAPI + MP integration. Test with test_event_code. Deploy behind feature flag.

Phase 2 — Sign-up + Trial start (2–3 days): - Wire CompleteRegistration (Meta) / sign_up (GA4) on account creation. - Wire StartTrial on first login.

Phase 3 — Purchase events (3–5 days): - Stripe webhook handler fires Purchase (Meta) / purchase (GA4) on successful subscription / one-off.

Phase 4 — Identity stitching (1 day): - On every authenticated server event, attach account_id to external_id and user_id.

Phase 5 — Backfill conference / dealer / quote events (1 day): - Same as Phase 1 but for the secondary lead types — they already have browser triggers in Zaraz.

After Phase 1+4, expect EMQ to jump from current ~4–5 to ~8.

After Phase 3+5, target EMQ ≥ 9 with full multi-touch attribution in GA4 Acquisition reports.


10. References


11. Open questions for the CRM dev

Please respond before starting:

  1. Stack: which runtime/framework is the CRM in? (Node.js? Python/Django? Ruby? PHP?) — code samples here are JS but trivially portable.
  2. Form handlers: are all lead forms processed by one endpoint (/forms/lead-form-ds25) or multiple? List them so we wrap each.
  3. Stripe integration: do we have a Stripe webhook handler already? If yes, what URL? If no, we need one.
  4. User identity: what's the canonical account_id shape — UUID, integer, email-as-id? Pick once, document.
  5. Test environment: can we point a staging CRM at the same Pixel/Measurement ID for testing, or do we need a separate Meta test pixel?

End of spec. Ping Andrei with implementation questions.