xavier-ramirez.com
← How anonymous traffic becomes revenue

Your own activation platform · 01 · 20 Aug 2026 · 9 min read

Your own activation platform: the spine

Clone it and run it — anonymous click to synced audience, end to end.

The tempting way to build this is to buy the middle and wire the ends. A tag manager on the front, a vendor platform in the middle, an export job on the back — and three integrations later nobody can answer why a device that bought yesterday is not in today’s audience. The whole thing is opaque exactly where you need to debug it. This post builds the spine instead: a collector, a resolver, a profile table, an audience, and one destination sync. Thin, but real, and running on your laptop in five commands.
01 · Spine02 · Identity resolution03 · Profiles & propensity04 · Consent & deletion05 · Activation & match rates06 · Measurement

This post builds all five stages at their thinnest honest version, so the later posts have somewhere to land.

Worked exampleThis series uses a multi-brand customer data & activation platform as its running example — clickstream from direct-to-consumer storefronts, orders, ad spend, and the audiences the platform pushes back out to ad and email destinations. The pipeline itself is domain-agnostic; the data just happens to belong to commerce brands.

End to end

One device, five hops, and the hour that decides them

Before any of the code, watch the thing work — and watch it not work. The trace follows a single device through every stage of the spine. The only control is where the replay stops.

trace dev_31f0a
Replay the day

↳ Same device, same events. The only difference is whether the identify at 11:04 has been replayed yet.

Nine events, a real device, a real session — and no profile. Identity is not a property of the device; it is an event that has not happened yet. Everything downstream waits, and nothing errors, because there is nothing wrong.

Collection

An event is only trustworthy at the edge you control

Start at the edge, because everything downstream inherits its mistakes. The collector is a first-party server-side endpoint on a subdomain the brand controls — not a third-party pixel on someone else’s domain. It sets the device cookie itself, and because the server sets it, tracking prevention does not cap it at days the way it caps a cookie written from a page script.

src/collector/server.ts
import Fastify from "fastify";
import { randomDeviceId, readDeviceCookie } from "./cookie.js";
import { producer } from "../stream/producer.js";
import type { CollectedEvent } from "../contracts/events.js";

const app = Fastify({ logger: true });

app.post("/e", async (req, reply) => {
  const body = req.body as Partial<CollectedEvent>;
  const brandId = String(body.brandId ?? "");
  if (!brandId) return reply.code(400).send({ ok: false });

  // The device id is ours, not the page's: cookie first, mint one otherwise.
  const deviceId = readDeviceCookie(req) ?? randomDeviceId();
  reply.setCookie("_ap_did", deviceId, {
    httpOnly: true, secure: true, sameSite: "lax", path: "/",
    maxAge: 60 * 60 * 24 * 365,
  });

  const event: CollectedEvent = {
    brandId,
    env: (body.env ?? "prod") as "dev" | "prod",
    deviceId,
    sessionId: String(body.sessionId ?? "sess_9c22"),
    name: body.name as CollectedEvent["name"],
    ts: new Date().toISOString(),                        // server-stamped, authoritative
    props: { ...body.props, clientTs: body.ts ?? null }, // recorded, never trusted
    consent: body.consent!,                              // captured at collection time
  };

  await producer.send({
    topic: "events.raw",
    messages: [{ key: `${brandId}:${deviceId}`, value: JSON.stringify(event) }],
  });
  return reply.code(202).send({ ok: true });
});
src/contracts/events.ts
interface CollectedEvent {
  brandId: string;             // "northwind"
  env: "dev" | "prod";
  deviceId: string;            // "dev_31f0a"
  sessionId: string;           // "sess_9c22"
  name: "page_view" | "identify" | "add_to_cart" | "purchase";
  ts: string;                  // ISO 8601, server-stamped, never client-trusted
  props: Record<string, unknown>;
  consent: ConsentState;       // captured at the moment of collection
}
Why key on the device, not the profile?
Because at collection time you do not have a profile. Resolution has not run — that is two stages downstream, and the whole point of a device id is that it exists before anyone knows who they are. Keying on the device is also what preserves per-device ordering through the entire pipeline: one key, one partition, events in the order they happened. Ordering is what makes the resolver’s job tractable, because “identify then purchase” and “purchase then identify” produce different links. And keying on something you would have to look up first would put a database read in the hot path of every page view.

The pipeline

Five stages, one streaming backbone

Each stage reads a topic and writes a topic. That is the entire architecture, and it is deliberate: any stage can be stopped, rewritten and restarted from its own position without coordinating with the others.

TopicKeyCompactedContents
events.rawbrandId:deviceIdnoevery collected event
identity.signalsbrandId:deviceIdnoextracted match keys
identity.resolvedbrandId:profileIdyescurrent profile ↔ device/key set
identity.mergesbrandId:profileIdnomerge and un-merge audit records
identity.deletionsbrandId:profileIdnodeletion and revocation tombstones
audience.membershipbrandId:audienceIdnoadds and removes per sync
sql/schema/events.sql
CREATE TABLE events
(
    brand_id   LowCardinality(String),
    env        LowCardinality(String),
    device_id  String,
    session_id String,
    profile_id String DEFAULT '',       -- filled in after resolution
    name       LowCardinality(String),
    ts         DateTime64(3, 'UTC'),
    props      String,                   -- JSON
    consent    String                    -- JSON snapshot at collection time
)
ENGINE = MergeTree
PARTITION BY (brand_id, toYYYYMM(ts))
ORDER BY (brand_id, env, device_id, ts);

What the rest of the series does with this spine, briefly. Post 2 replaces the toy resolver with a real identity graph. Post 3 turns resolved profiles into traits and a propensity score. Post 4 takes the consent record seriously. Post 5 replaces the naive push below with diffing, batching, dead-lettering and match-rate instrumentation. Post 6 asks whether any of it caused revenue that would not have happened anyway. Each owns its stage; none of them re-derive this one.

Isolation

Brand and environment are partition keys, not afterthoughts

Two brands on the same cluster are two different companies, and a test environment must never leak into production. So scope is not a filter someone remembers to apply — it is in the key, the partition, and the namespace.

src/contracts/scope.ts
export interface BrandScope {
  brandId: string;   // "northwind"
  env: string;       // "prod" | "dev"
}

/** The one place a scope becomes a string. Index prefixes, topic keys, cache keys. */
export function namespace(scope: BrandScope): string {
  return `${scope.brandId}__${scope.env}`;
}

export function eventKey(scope: BrandScope, deviceId: string): string {
  return `${scope.brandId}:${deviceId}`;
}

Partitioning keeps brands apart; it does not keep people safe. Consent as a join key, row policies bound to the executing role, and the external agency login that makes both necessary are post 4’s job.

The sync

The thin end: one audience, one destination

An audience is a document. The compiler turns it into warehouse SQL, the SQL returns hashed keys, and the adapter pushes them at a local mock destination. That is the minimum viable activation, and it is enough to prove the spine end to end.

src/audiences/definition.ts
// A document a marketer edits — data, not code.
export interface AudienceDefinition {
  _id: string;                 // "aud_high_intent_30d"
  brandId: string;             // "northwind"
  env: "dev" | "prod";
  rules: {
    sessions30dAtLeast?: number;   // 3
    ordersLifetimeAtMost?: number; // 0  -> high intent, never purchased
    daysSinceLastOrderAtMost?: number;
  };
  destination: "meta" | "google" | "klaviyo" | "webhook";
  key: "email_sha256" | "phone_sha256";
}
src/audiences/compile.ts
import type { AudienceDefinition } from "./definition.js";

export interface CompiledAudience { sql: string; params: Record<string, unknown>; }

export function compile(def: AudienceDefinition): CompiledAudience {
  const where: string[] = ["t.brand_id = {brandId:String}"];
  const params: Record<string, unknown> = { brandId: def.brandId };

  if (def.rules.sessions30dAtLeast !== undefined) {
    where.push("t.sessions_30d >= {minSessions:UInt32}");
    params.minSessions = def.rules.sessions30dAtLeast;
  }
  if (def.rules.ordersLifetimeAtMost !== undefined) {
    where.push("t.orders_lifetime <= {maxOrders:UInt32}");
    params.maxOrders = def.rules.ordersLifetimeAtMost;
  }

  // Parameters, never interpolated values: an audience definition is user input.
  return {
    sql: `SELECT t.profile_id, i.email_sha256
            FROM profile_traits AS t FINAL
            JOIN identity_keys AS i USING (brand_id, profile_id)
           WHERE ${where.join(" AND ")}
             AND i.email_sha256 != ''`,
    params,
  };
}

Diffing against the last snapshot, batching to a destination’s limits, dead-lettering the rows a success response quietly rejected, and instrumenting match rate all land in post 5. Here, the mock accepts everything and reports nothing — which is exactly the failure mode post 5 exists to fix.

Run it yourself · open-activation-platform @ v1-spine
Bring up the stack, seed a synthetic day of multi-brand traffic, then watch one device become a profile and land in an audience — twice, with one hour of difference.
terminal
git clone https://github.com/xaviramirezcom/open-activation-platform
cd open-activation-platform
git checkout v1-spine
pnpm install
pnpm activate up            # streaming + warehouse + config store, via docker compose
pnpm activate seed          # synthetic multi-brand traffic, orders and spend

# Replay a synthetic day through the whole spine.
pnpm activate replay --brand northwind --day 2026-08-19

# Follow one device across every hop.
pnpm activate trace dev_31f0a --brand northwind

# Same trace, before the identify event is replayed.
pnpm activate replay --brand northwind --day 2026-08-19 --until 11:00
pnpm activate trace dev_31f0a --brand northwind
The full-day trace prints five hops: fourteen raw events, two match keys, a resolved profile with three devices, a trait row, and an audience verdict of not a member (the audience excludes purchasers). The truncated run stops at hop two: the device has signals but no profile, because the identify at 11:04 is what created the first trustworthy link. Same device, same events, one hour of ordering apart — and that is the entire argument for keying on the device and letting the log decide when identity happens.

Teaching-grade reference implementation, not a production customer data platform. It reproduces the ideas and the streaming/warehouse integration shape; bring your own data and destination credentials. Destination adapters run against a local mock by default. MIT-licensed. View the repo →

Explain it back

A team ships the collector with the resolver called inline — the handler looks up the identity graph, merges the new key, and returns the resolved profile in the response, so profiles are “resolved instantly” and the storefront can personalise on the next render. It works beautifully for six months. On the busiest shopping day of the year the resolver’s graph store slows under merge contention, collector latency climbs past the storefront’s fetch timeout, and the brand’s tracking goes down during the highest-revenue four hours it has. What went wrong architecturally, and what should the collector’s only job have been?
Reveal a model answer

They put an unbounded, stateful operation in the hot path of every page view. Identity resolution is a graph mutation whose contention grows with traffic — exactly the workload you must not synchronise with request handling. The collector’s one job is to accept and publish: validate the envelope, stamp the time, write to the raw topic, return. Anything that can be slow, fail, or need a database belongs behind the log, where backpressure shows up as consumer lag instead of as a broken storefront. Lag is a metric you alert on; a timeout at the edge is lost revenue you never recover, because the events were never durably written.

The bonus consequence is worse and quieter. An inline resolver couples your ability to replay to your ability to collect — the resolution decision lives only in the response you already sent. Fix a normalisation bug six months later and there is nothing to re-run. With the resolver as a consumer, you reset its position and rebuild the graph from raw.

Next in the series · 02
Identity resolution: a profile is a connected component

The toy resolver becomes a real graph — links with provenance, un-merging as an ordinary operation, and the shared kiosk that quietly swallows four households into one customer.

See the merge storm