Skip to main content

Tracking & CAPI System

The tracking system implements a server-side Conversions API (CAPI) pipeline. It ingests events from a lightweight client SDK, fans them out to multiple ad platform adapters, and builds customer profiles with quality scores.

Current behaviour (verified 2026-10-08), which supersedes older details on this page
  • Public ingest: POST /api/v1/tracking/ingest (single) and /api/v1/tracking/ingest/batch (100 events max). Snake-case body: pixel_id, api_key, event_name, event_id, event_time, source_url, user_data, custom_data. The api_key goes in the body, not an x-api-key header. It is mounted before the authenticated /tracking router, has its own CORS policy (any origin, no cookies), accepts text/plain JSON (sendBeacon), and enforces the workspace plan's tracking feature.
  • Authenticated ingest: POST /api/v1/tracking/events and /events/batch, with JWT plus X-Workspace-Id (camelCase body, pixelId = the Meta dataset id).
  • Deduplication: one event_id per logical event, shared unchanged by every destination and by the browser pixel. TrackingEvent is @@unique([destinationId, eventId]), and a replayed id creates nothing. Purchase ids are purchase:<platformOrderId> on both server and SDK.
  • Hashing: the Meta adapter normalises and SHA-256-hashes em ph fn ln ge db ct st zp country external_id. It passes existing 64-hex digests through unchanged, so nothing is double-hashed. fbp, fbc, client_ip_address and client_user_agent are sent clear, as Meta requires. The rules live in @omnisocial/shared (tracking-identity.ts) and are copied byte-for-byte into the SDK.
  • Meta adapter: capi-adapters/meta.adapter.ts, graph.facebook.com/v21.0/{dataset}/events, token in the body, test_event_code per destination. HTTP 200 counts as success only if events_received matches what was sent. HTTP 429 and Graph codes 4, 17, 32 and 613 return events to PENDING, and the worker schedules a delayed retry using Retry-After. Other errors become FAILED with code, subcode and fbtrace_id. POST /tracking/events/:id/retry resends with the same event_id.
  • Commerce bridge: a Purchase per real order (not cart_ rows, cancelled, refunded or zero-value orders), in the order's own currency, with hashed identifiers stored at rest. It is idempotent across order-sync runs.
  • Consent: POST /api/v1/tracking/ingest/consent is public and authenticated like ingest (pixel_id, api_key, visitor_id, and per-category granted/denied). The SDK's grant/revoke calls it. With blockBeforeConsent on, ingest accepts a grant recorded under either external_id or _fbp.
  • Attribution: a pixel event attributes to a post from the UTMs publishing writes: utm_content = publication id or pub_<id>, then utm_campaign/utm_content = post_<id>. Only an explicit utm_campaign=camp_<id> credits an ad campaign. A bare click id never does.
  • Multi-touch: ingest records one TrackingTouchpoint per journey step: a conversion, a landing with utm_* or a click id, or an external referrer (the SDK sends document.referrer). Internal navigation and replays are skipped.
  • Workflow step send_conversions_api_event: sends through a chosen META Tracking Destination, using that destination's encrypted token and the same pipeline, with event id auto:<runId>:<nodeId>.
  • One Purchase per order: prepaid orders go through the commerce bridge when placed. COD orders (isCashOnDelivery) go only through the offline-conversion path on delivery: a per-store sweep in order-sync.worker (60-day window) plus an immediate trigger on PATCH /commerce/orders/:id/status to DELIVERED.
  • Raw PII written before 2026-10-08: re-hash with apps/api/prisma/rehash-tracking-pii.ts (dry run by default, then APPLY=1). Duplicate customers are merged, and scores are recalculated.
  • Live verification: Pulse/docs/remediation/25-meta-e2e-release-gate.md and apps/api/prisma/diagnose-meta-capi.ts.

Architecture Overview​


Prisma Models​

TrackingPixel​

model TrackingPixel {
id String @id @default(cuid())
workspaceId String
name String
domain String?
apiKey String @unique
isActive Boolean @default(true)
workspace Workspace @relation(fields: [workspaceId], references: [id])
destinations TrackingDestination[]
events TrackingEvent[]
customers TrackingCustomer[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

TrackingDestination​

model TrackingDestination {
id String @id @default(cuid())
pixelId String
type String // "meta" | "google" | "tiktok" | "ga4" | "webhook"
credentials Json // Encrypted platform credentials
enabledEvents String[] // Event types to forward
isActive Boolean @default(true)
pixel TrackingPixel @relation(fields: [pixelId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

TrackingEvent​

model TrackingEvent {
id String @id @default(cuid())
pixelId String
customerId String?
eventName String // "PageView", "Purchase", "AddToCart", etc.
eventData Json? // Arbitrary event payload
sourceUrl String?
utmSource String?
utmMedium String?
utmCampaign String?
utmTerm String?
utmContent String?
fbclid String?
gclid String?
ttclid String?
ipAddress String?
userAgent String?
eventId String? // Dedup key, @@unique([destinationId, eventId])
pixel TrackingPixel @relation(fields: [pixelId], references: [id])
customer TrackingCustomer? @relation(fields: [customerId], references: [id])
createdAt DateTime @default(now())
}

TrackingCustomer​

model TrackingCustomer {
id String @id @default(cuid())
pixelId String
externalId String? // Hashed identifier from SDK
email String? // Hashed
phone String? // Hashed
score Int @default(0)
segment String? // "high_value" | "repeat" | "promising" | "at_risk" | "new" | "churned"
totalRevenue Float @default(0)
purchaseCount Int @default(0)
firstSeenAt DateTime @default(now())
lastSeenAt DateTime @default(now())
pixel TrackingPixel @relation(fields: [pixelId], references: [id])
events TrackingEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

CAPI Adapter Pattern​

All destination adapters implement a common interface:

interface TrackingAdapter {
type: string;
sendEvent(event: TrackingEvent, credentials: Record<string, string>): Promise<AdapterResponse>;
mapEventName(internalName: string): string;
validateCredentials(credentials: Record<string, string>): boolean;
}

interface AdapterResponse {
success: boolean;
platformEventId?: string;
error?: string;
}

Adapter Implementations​

AdapterFilePlatform APIAuthentication
Metameta.adapter.tsgraph.facebook.com/v21.0Dataset (pixel) ID + access token, in the request body
Googlegoogle-ads.adapter.tsgoogleads.googleapis.comCustomer ID + Developer Token + OAuth
TikToktiktok-events.adapter.tsbusiness-api.tiktok.com/open_api/v1.3Pixel Code + Access Token
GA4ga4-measurement.adapter.tswww.google-analytics.com/mp/collectMeasurement ID + API Secret
Webhookwebhook.adapter.tsCustom URLOptional HMAC signature header

Each adapter handles:

  • Event name mapping -- Translates internal event names to platform-specific names (e.g., purchase becomes Purchase for Meta, CompletePayment for TikTok).
  • Payload transformation -- Restructures the event data into the platform's expected schema.
  • Hashing -- The Meta adapter normalises and SHA-256-hashes customer identifiers (em, ph, fn, ln, ge, db, ct, st, zp, country, external_id) via utils/tracking-pii.ts. IP address and user agent are sent clear, as Meta requires.
  • Error handling -- Returns structured AdapterResponse with success/failure status and platform event ID.

Ingestion Pipeline​

The event ingestion pipeline processes each incoming request through these stages:

POST /tracking/ingest
│
├─ 1. Pixel Validation ─── Look up pixel by ID, verify isActive
├─ 2. API Key Check ────── Compare request header to pixel.apiKey
├─ 3. Deduplication ────── Check eventId uniqueness
├─ 4. Customer Resolution ─ Find or create TrackingCustomer by hashed identity
├─ 5. Attribution ──────── Parse UTMs, extract click IDs, match to posts/ads
├─ 6. Persist ──────────── Write TrackingEvent to database
├─ 7. Fan-Out ──────────── Send to all active destinations in parallel
├─ 8. SSE Publish ──────── Publish to Redis channel for real-time stream
└─ 9. Scoring Queue ────── Enqueue customer for score recalculation

Key Implementation Details​

  • Parallel fan-out -- Destination delivery uses Promise.allSettled() so a single adapter failure does not block others.
  • Deduplication -- The event_id (set by the SDK, or generated once per event) is shared unchanged across all destinations. @@unique([destinationId, eventId]) makes a replay a no-op, and Meta drops the copy when the browser pixel fired the same eventID.
  • API key validation -- Keys are compared using crypto.timingSafeEqual() to prevent timing attacks.

Customer Scoring​

Algorithm​

function calculateScore(customer: TrackingCustomer, events: TrackingEvent[]): number {
const purchaseScore = normalize(customer.purchaseCount, maxPurchases) * 0.30;
const revenueScore = normalize(customer.totalRevenue, maxRevenue) * 0.25;
const frequencyScore = normalize(sessionCount(events), maxSessions) * 0.20;
const recencyScore = normalize(daysSinceActive(customer), maxDays) * 0.15;
const cartRate = normalize(cartConversionRate(events), 1.0) * 0.10;

return Math.round((purchaseScore + revenueScore + frequencyScore + recencyScore + cartRate) * 100);
}

Segment Assignment Rules​

function assignSegment(customer: TrackingCustomer): string {
const daysSinceFirst = daysBetween(customer.firstSeenAt, now());
const daysSinceLast = daysBetween(customer.lastSeenAt, now());

if (daysSinceFirst < 30) return 'new';
if (daysSinceLast > 90) return 'churned';
if (customer.score > 80) return 'high_value';
if (customer.score > 60 && customer.purchaseCount >= 2) return 'repeat';
if (customer.score > 40) return 'promising';
return 'at_risk';
}

Weights Table​

SignalWeightNormalization
Purchase count0.30count / max(all customers)
Total revenue0.25revenue / max(all customers)
Visit frequency0.20sessions / max(all customers)
Recency0.151 - (daysSinceActive / 90), clamped to 0--1
Cart-to-purchase rate0.10purchases / addToCarts, capped at 1.0

Attribution Engine​

UTM Parsing​

The SDK reads UTM parameters from window.location.search on initialization and includes them with every event. The server also parses the Referer header as a fallback.

Post Attribution​

When a URL contains a post_id or pub_id parameter (added automatically by OmniSocial link shortener), the attribution engine looks up the corresponding Publication and links the conversion.

Ad Attribution​

Platform click identifiers are captured by the SDK:

ParameterPlatformStorage
fbclidMeta (Facebook/Instagram)First-party cookie + event field
gclidGoogle AdsFirst-party cookie + event field
ttclidTikTok AdsFirst-party cookie + event field

These identifiers are forwarded to the respective adapters so platforms can attribute the conversion to the correct ad.


SSE Streaming​

The real-time event stream uses Redis Pub/Sub to push events to connected clients.

Implementation​

  • Server -- After persisting an event, the ingest handler publishes to Redis channel tracking:{pixelId}.
  • Client -- The dashboard opens an EventSource connection to GET /tracking/pixels/:pixelId/stream.
  • Route handler -- A dedicated SSE route subscribes to the Redis channel and pipes messages to the HTTP response with Content-Type: text/event-stream.
  • Cleanup -- On client disconnect, the Redis subscription is removed to prevent memory leaks.

Commerce Bridge​

When an e-commerce store is connected, order events are automatically converted to tracking events:

Event Mapping​

Commerce EventTracking EventPayload
Order synced (not a cart, cancelled, refunded or zero-value order)Purchase, event_id = purchase:<platformOrderId>order_id (store order id), value, currency (the order's own)
Cart updatedadd_to_cartproduct_id, name, price, quantity
Checkout startedinitiate_checkoutcart_total, item_count

The bridge writes events directly for every active destination linked to an active pixel, with identifiers hashed before storage. Re-running it for the same order is a no-op.


SDK Package​

The tracking SDK is built as a standalone package at packages/tracking-sdk/.

Architecture​

ModuleFileResponsibility
Corecore.tsCommand queue, initialization, omni() function
Hashhash.tsSHA-256 hashing for PII fields
Cookiescookies.tsFirst-party cookie management for identity and click IDs
Transporttransport.tsBeacon API / XHR fallback for event delivery
Consentconsent.tsGDPR consent gate, event queuing

Build Output​

FormatFileSize Target
IIFEdist/omni.min.js< 4 KB gzipped
ESMdist/index.mjsTree-shakeable
CJSdist/index.cjsNode.js / SSR
Typesdist/index.d.tsTypeScript declarations

API Endpoints​

MethodPathDescription
POST/tracking/ingestPublic SDK ingest (pixel api_key in the body)
POST/tracking/ingest/batchPublic SDK batch ingest (100 events max)
POST/tracking/eventsAuthenticated server-side ingest (JWT + workspace)
GET/tracking/pixelsList all pixels for the workspace
POST/tracking/pixelsCreate a new pixel
GET/tracking/pixels/:idGet pixel details
PUT/tracking/pixels/:idUpdate pixel settings
DELETE/tracking/pixels/:idDelete a pixel
POST/tracking/pixels/:id/regenerate-keyRegenerate the pixel API key
GET/tracking/pixels/:id/destinationsList destinations for a pixel
POST/tracking/pixels/:id/destinationsAdd a destination
PUT/tracking/destinations/:idUpdate a destination
DELETE/tracking/destinations/:idRemove a destination
GET/tracking/pixels/:id/customersList tracked customers
GET/tracking/customers/:idGet customer profile and events
GET/tracking/pixels/:id/streamSSE real-time event stream
POST/tracking/customers/:id/exportGDPR data export request
DELETE/tracking/customers/:idGDPR data erasure request
Authentication

All management endpoints require a valid workspace session (JWT plus X-Workspace-Id) and the tracking plan feature. Only /tracking/ingest is public: it authenticates with the pixel api_key in the request body and applies the plan gate itself.


Queue & Worker​

Scoring Queue​

The tracking system uses a dedicated BullMQ queue named tracking-scoring:

Job TypeTriggerDescription
score-customerEvent ingestionRecalculate score for a single customer
batch-recalcCron (every 6h)Recalculate scores for all active customers in a workspace

Worker Configuration​

const scoringWorker = new Worker('tracking-scoring', async (job) => {
if (job.name === 'score-customer') {
const { customerId } = job.data;
await recalculateScore(customerId);
}
if (job.name === 'batch-recalc') {
const { workspaceId } = job.data;
await batchRecalculateScores(workspaceId);
}
}, {
concurrency: 10,
limiter: { max: 100, duration: 1000 },
});

The batch recalculation job is scheduled via a repeatable BullMQ job:

scoringQueue.add('batch-recalc', { workspaceId }, {
repeat: { every: 6 * 60 * 60 * 1000 }, // 6 hours
});

Cross-Reference