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.
- 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 anx-api-keyheader. It is mounted before the authenticated/trackingrouter, has its own CORS policy (any origin, no cookies), acceptstext/plainJSON (sendBeacon), and enforces the workspace plan'strackingfeature. - Authenticated ingest:
POST /api/v1/tracking/eventsand/events/batch, with JWT plusX-Workspace-Id(camelCase body,pixelId= the Meta dataset id). - Deduplication: one
event_idper logical event, shared unchanged by every destination and by the browser pixel.TrackingEventis@@unique([destinationId, eventId]), and a replayed id creates nothing. Purchase ids arepurchase:<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_addressandclient_user_agentare 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_codeper destination. HTTP 200 counts as success only ifevents_receivedmatches what was sent. HTTP 429 and Graph codes 4, 17, 32 and 613 return events toPENDING, and the worker schedules a delayed retry usingRetry-After. Other errors becomeFAILEDwith code, subcode andfbtrace_id.POST /tracking/events/:id/retryresends with the sameevent_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/consentis public and authenticated like ingest (pixel_id,api_key,visitor_id, and per-categorygranted/denied). The SDK's grant/revoke calls it. WithblockBeforeConsenton, ingest accepts a grant recorded under eitherexternal_idor_fbp. - Attribution: a pixel event attributes to a post from the UTMs publishing writes:
utm_content= publication id orpub_<id>, thenutm_campaign/utm_content=post_<id>. Only an explicitutm_campaign=camp_<id>credits an ad campaign. A bare click id never does. - Multi-touch: ingest records one
TrackingTouchpointper journey step: a conversion, a landing withutm_*or a click id, or an externalreferrer(the SDK sendsdocument.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 idauto:<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 inorder-sync.worker(60-day window) plus an immediate trigger onPATCH /commerce/orders/:id/statustoDELIVERED. - Raw PII written before 2026-10-08: re-hash with
apps/api/prisma/rehash-tracking-pii.ts(dry run by default, thenAPPLY=1). Duplicate customers are merged, and scores are recalculated. - Live verification:
Pulse/docs/remediation/25-meta-e2e-release-gate.mdandapps/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
| Adapter | File | Platform API | Authentication |
|---|---|---|---|
| Meta | meta.adapter.ts | graph.facebook.com/v21.0 | Dataset (pixel) ID + access token, in the request body |
google-ads.adapter.ts | googleads.googleapis.com | Customer ID + Developer Token + OAuth | |
| TikTok | tiktok-events.adapter.ts | business-api.tiktok.com/open_api/v1.3 | Pixel Code + Access Token |
| GA4 | ga4-measurement.adapter.ts | www.google-analytics.com/mp/collect | Measurement ID + API Secret |
| Webhook | webhook.adapter.ts | Custom URL | Optional HMAC signature header |
Each adapter handles:
- Event name mapping -- Translates internal event names to platform-specific names (e.g.,
purchasebecomesPurchasefor Meta,CompletePaymentfor 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) viautils/tracking-pii.ts. IP address and user agent are sent clear, as Meta requires. - Error handling -- Returns structured
AdapterResponsewith 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 sameeventID. - 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
| Signal | Weight | Normalization |
|---|---|---|
| Purchase count | 0.30 | count / max(all customers) |
| Total revenue | 0.25 | revenue / max(all customers) |
| Visit frequency | 0.20 | sessions / max(all customers) |
| Recency | 0.15 | 1 - (daysSinceActive / 90), clamped to 0--1 |
| Cart-to-purchase rate | 0.10 | purchases / 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:
| Parameter | Platform | Storage |
|---|---|---|
fbclid | Meta (Facebook/Instagram) | First-party cookie + event field |
gclid | Google Ads | First-party cookie + event field |
ttclid | TikTok Ads | First-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
EventSourceconnection toGET /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 Event | Tracking Event | Payload |
|---|---|---|
| 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 updated | add_to_cart | product_id, name, price, quantity |
| Checkout started | initiate_checkout | cart_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
| Module | File | Responsibility |
|---|---|---|
| Core | core.ts | Command queue, initialization, omni() function |
| Hash | hash.ts | SHA-256 hashing for PII fields |
| Cookies | cookies.ts | First-party cookie management for identity and click IDs |
| Transport | transport.ts | Beacon API / XHR fallback for event delivery |
| Consent | consent.ts | GDPR consent gate, event queuing |
Build Output
| Format | File | Size Target |
|---|---|---|
| IIFE | dist/omni.min.js | < 4 KB gzipped |
| ESM | dist/index.mjs | Tree-shakeable |
| CJS | dist/index.cjs | Node.js / SSR |
| Types | dist/index.d.ts | TypeScript declarations |
API Endpoints
| Method | Path | Description |
|---|---|---|
POST | /tracking/ingest | Public SDK ingest (pixel api_key in the body) |
POST | /tracking/ingest/batch | Public SDK batch ingest (100 events max) |
POST | /tracking/events | Authenticated server-side ingest (JWT + workspace) |
GET | /tracking/pixels | List all pixels for the workspace |
POST | /tracking/pixels | Create a new pixel |
GET | /tracking/pixels/:id | Get pixel details |
PUT | /tracking/pixels/:id | Update pixel settings |
DELETE | /tracking/pixels/:id | Delete a pixel |
POST | /tracking/pixels/:id/regenerate-key | Regenerate the pixel API key |
GET | /tracking/pixels/:id/destinations | List destinations for a pixel |
POST | /tracking/pixels/:id/destinations | Add a destination |
PUT | /tracking/destinations/:id | Update a destination |
DELETE | /tracking/destinations/:id | Remove a destination |
GET | /tracking/pixels/:id/customers | List tracked customers |
GET | /tracking/customers/:id | Get customer profile and events |
GET | /tracking/pixels/:id/stream | SSE real-time event stream |
POST | /tracking/customers/:id/export | GDPR data export request |
DELETE | /tracking/customers/:id | GDPR data erasure request |
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 Type | Trigger | Description |
|---|---|---|
score-customer | Event ingestion | Recalculate score for a single customer |
batch-recalc | Cron (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
});
- Services -- Full service layer overview
- Queue System -- BullMQ architecture and patterns
- Tracking SDK -- Client-side SDK details
- E-Commerce APIs -- Store integration internals
- Ads Platforms -- Ad platform API details