Internal spec · share with CRM developer
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)
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.
┌──────────────────────────┐
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.
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.
POST https://graph.facebook.com/v22.0/1530042020969436/events
Pixel ID is 1530042020969436 (ENCYCAM market data — same as browser pixel).
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.
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;
}
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)
});
stripEmpty helperMeta 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;
}
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.
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:
crm-server-side → copy the valueGA4_API_SECRETconst 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).
client_id sourceGA4 needs a stable client_id per user. Pick in this order:
_ga cookie value (parse GA1.1.<client_id>.<timestamp> — take just <client_id>.<timestamp>)ga_client_id URL param (forwarded by landing — needs adding)Without a client_id, GA4 will discard the event.
For testing, swap mp/collect → debug/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.
Three identifiers, in order of strength:
em field. The strongest, persistent across devices/browsers. Always include if known.external_id. Use as canonical for logged-in users. Pass on every server event after sign-in.When a visitor signs in / creates an account:
account_id with the visitor's existing handlID and any lead_ids they've generated.account_id first in external_id, followed by handlID and lead_id (Meta accepts multiple external IDs and tries to match across all).user_id to the account_id — this enables cross-device reports.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:
handlID on early anonymous events (already done ✅).account_id + handlID on the sign-up event.Meta links the prior anonymous events to the new account via the shared external_id.
| 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.
In Meta Events Manager → Data Sources → Pixel 1530042020969436 → Test Events tab:
test_event_code (e.g., TEST46298).test_event_code set when an env var META_CAPI_TEST_CODE is present.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.
Use debug/mp/collect (§5.4) before flipping production. Iterate on validationMessages until empty.
After deploy, monitor for 24 hours:
generate_lead events appear within 1 minute of form submit.lead_source × user_id × event_count table to confirm attribution.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.
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.
Please respond before starting:
/forms/lead-form-ds25) or multiple? List them so we wrap each.account_id shape — UUID, integer, email-as-id? Pick once, document.End of spec. Ping Andrei with implementation questions.