xavier-ramirez.com
← Consent, isolation & deletion

Your own activation platform · 05 · 24 Aug 2026 · 11 min read

Activation & match rates

Diff, don't replace — and watch the match rate like it's revenue.

The tempting way to sync an audience is to recompute it every night and push the whole list. One query, one loop, no state to keep, and obviously correct — tonight’s destination contents equal tonight’s membership. It is also the version that resets a destination’s learning phase, burns the quota you will need in December, scales with audience size instead of change, and leaves a marketer with no way to ask why a particular person started getting an email.
01 · Spine02 · Identity resolution03 · Profiles & propensity04 · Consent & deletion05 · Activation & match rates06 · Measurement

This post deepens the activation stage — the moment the platform stops being an analytics system and starts spending money.

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.

The diff

Send what changed, not what is

The same stable audience, the same nights, two modes. Night one is identical in both, because the list has to be loaded once. Every night after that is the argument.

rows sent per night

↳ Drag back to 1: on the first night the two modes are identical, because the list has to be loaded once.

Replace · total rows
1.01M
Diff · total rows
191k
Ratio
5.3×
Batches tonight
8 vs 1
Replacing is O(audience) and diffing is O(change), and only one of those numbers grows with your success. The cost you can see is the quota. The cost you cannot is that a destination reads a wholesale rewrite as a brand new population and re-enters its learning phase — so a campaign syncing in replace mode never gets past exploring, every single night, for as long as it runs.

So the sync engine writes a snapshot per run, diffs it against the previous run, and emits adds and removes.

sql/audience_snapshot.sql
-- One row per member per run. Not a replacing table: snapshots are history.
CREATE TABLE audience_snapshot (
  brand_id      LowCardinality(String),
  env           LowCardinality(String),
  audience_id   LowCardinality(String),
  snapshot_id   String,              -- "snap_2026_08_25"
  definition_v  UInt32,              -- the audience definition version compiled
  profile_id    String,
  entered_at    DateTime,            -- carried forward across runs, not recomputed
  reason        String,              -- "p_purchase_7d>=0.62 AND orders_lifetime>=1"
  captured_at   DateTime
) ENGINE = MergeTree
PARTITION BY (brand_id, env)
ORDER BY (brand_id, env, audience_id, snapshot_id, profile_id);
sql/audience_diff.sql
-- adds = in current, not in previous. removes = the mirror image.
SELECT
  coalesce(cur.profile_id, prev.profile_id) AS profile_id,
  if(prev.profile_id = '', 'add', 'remove')  AS op,
  cur.reason                                 AS reason,
  cur.definition_v                           AS definition_v
FROM (
  SELECT profile_id, reason, definition_v FROM audience_snapshot
  WHERE brand_id = {brand:String} AND env = {env:String}
    AND audience_id = {aud:String} AND snapshot_id = {cur:String}
) AS cur
FULL OUTER JOIN (
  SELECT profile_id FROM audience_snapshot
  WHERE brand_id = {brand:String} AND env = {env:String}
    AND audience_id = {aud:String} AND snapshot_id = {prev:String}
) AS prev USING (profile_id)
WHERE cur.profile_id = '' OR prev.profile_id = '';

The membership record carries the moment the person entered and the reason they qualified, and both survive re-entry: if someone leaves on the twelfth and returns on the nineteenth, the entry date is the nineteenth and the ledger still shows the exit. That is what turns “why is this person getting this email” from an archaeology project into a lookup.

Why snapshot membership instead of diffing on the fly?
Because audience definitions change, and a naive diff cannot tell a definition change from a behaviour change. Loosen a propensity threshold and forty thousand people appear overnight. Diffed on the fly, that is indistinguishable from a traffic surge — or from a broken trait job that pushed everyone’s score up. The snapshot pins the definition version alongside the membership, so the diff is computed within a version and a version bump is reported as its own event: “definition v7 to v8, plus forty thousand adds attributable to the threshold change”. Attribution of your own audience movements stops being optional the moment someone is spending against them.

The adapter

Every destination has its own shape, and none of it is your engine's business

The wrong shape here is a class per destination with the batching baked in — one syncer per platform, each with its own loop, its own retry, its own quiet divergence. Six months later one of them has a bug the others do not. Instead, every adapter declares a capability descriptor and one generic engine reads it.

src/activation/capabilities.ts
interface DestinationCapabilities {
  id: "meta" | "google" | "klaviyo" | "webhook";
  acceptedKeys: ("email_sha256" | "phone_sha256" | "madid")[];
  maxBatchSize: number;
  supportsRemovals: boolean;
  rateLimitPerMinute: number;
  requiresConsent: (keyof ConsentState)[];
}

// The numbers below are placeholders. Read the real ones off each destination's
// current published limits and your own account tier — never hard-code a guess.
export const AD_PLATFORM: DestinationCapabilities = {
  id: "meta",
  acceptedKeys: ["email_sha256", "phone_sha256", "madid"],
  maxBatchSize: 10_000,             // batches in the low tens of thousands
  supportsRemovals: true,
  rateLimitPerMinute: 60,
  requiresConsent: ["marketing", "saleShare"],
};

export const EMAIL_PLATFORM: DestinationCapabilities = {
  id: "klaviyo",
  acceptedKeys: ["email_sha256"],   // email-only list semantics
  maxBatchSize: 1_000,              // smaller batches than the ad platforms
  supportsRemovals: true,
  rateLimitPerMinute: 600,
  requiresConsent: ["marketing"],
};
src/activation/engine.ts
import type { DestinationCapabilities } from "./capabilities.js";
import type { MemberRow, BatchResult } from "./types.js";
import { saveCheckpoint, lastCheckpoint } from "./checkpoint.js";
import { deadLetter } from "./deadletter.js";
import { suppress } from "./suppression.js";

export async function syncBatches(
  caps: DestinationCapabilities,
  syncId: string,
  rows: MemberRow[],
  op: "add" | "remove",
  send: (batch: MemberRow[]) => Promise<BatchResult>,
): Promise<void> {
  if (op === "remove" && !caps.supportsRemovals) return suppress(syncId, rows);

  const resume = (await lastCheckpoint(syncId, op))?.batchIndex ?? -1;
  for (let i = 0; i * caps.maxBatchSize < rows.length; i++) {
    if (i <= resume) continue;                       // already durably accepted
    const batch = rows.slice(i * caps.maxBatchSize, (i + 1) * caps.maxBatchSize);
    const res = await send(batch);                   // paced by rateLimitPerMinute
    for (const r of res.rejected) await deadLetter(syncId, r.profileId, r.reason);
    await saveCheckpoint({ syncId, op, batchIndex: i, accepted: res.accepted,
                           rejected: res.rejected.length, at: new Date().toISOString() });
  }
}

The checkpoint is written after a batch is accepted, so a crash between batch 41 and 42 resumes at 42 rather than replaying four hundred thousand rows. Idempotence is the destination’s job at the row level and the checkpoint’s job at the batch level; you need both.

Partial failure is the normal case, not the exception. A ten-thousand-row batch comes back accepted with thirty-seven rows rejected — a malformed hash, a key type the destination will not take today, a validation you will never see documented. There are two tempting responses and both are wrong. Failing the sync throws away 9,963 good rows over 37 bad ones. Dropping the 37 makes the dashboard green and the number wrong. You dead-letter each row with its reason, and you surface the dead-letter rate as a first-class metric next to the sync’s success flag.

src/activation/deadletter.ts
export interface DeadLetterRow {
  syncId: string;          // "sync_7f31"
  brandId: string;         // "northwind"
  audienceId: string;      // "aud_high_intent_30d"
  destination: string;
  profileId: string;       // "prof_8812"
  keyType: "email_sha256" | "phone_sha256" | "madid";
  reason: string;          // destination-reported, verbatim, never rewritten
  batchIndex: number;
  at: string;
}

A sync that reports success while dropping rows is worse than one that fails, because it does not wake anyone up.

Match rate

The one number that tells you whether any of this worked

Match rate is keys submitted versus keys matched on the far side. It is the product metric, and it is also the series’ scoreboard: every earlier post shows up here as a line item. Read one night as a funnel.

one audience, one nightmatch rate 67.1%
Where did the members go?
Matched by the destination · -3,007
67.1% match rate. Normalisation drift, dead accounts, and over-merged components submitting one key for three people.

Report key coverage separately from match rate. Losing ten and a half thousand members for having no email at all is a collection problem; losing three thousand at the destination is a normalisation problem. One alert for both tells you nothing about which one moved.

A sloppy normaliser from post 2 costs you match rate. An over-merged component from post 2 costs you match rate — one profile, three people, one key submitted. A stale trait from post 3 puts the wrong people in the audience entirely. An unconsented row from post 4 is not a loss at all, it is a correct rejection, and confusing the two is how teams end up “fixing” their consent gate.

src/activation/matchrate.ts
import { client } from "../warehouse/client.js";

export async function checkMatchRate(
  brandId: string, audienceId: string, destination: string, observed: number,
): Promise<{ baseline: number; dropPct: number; alert: boolean }> {
  const rs = await client.query({
    query: `SELECT quantileExact(0.5)(match_rate) AS baseline
            FROM sync_audit
            WHERE brand_id = {brand:String} AND audience_id = {aud:String}
              AND destination = {dest:String}
              AND ts >= now() - INTERVAL 14 DAY`,
    query_params: { brand: brandId, aud: audienceId, dest: destination },
    format: "JSONEachRow",
  });
  const [{ baseline }] = await rs.json<{ baseline: number }>();
  const dropPct = baseline > 0 ? (baseline - observed) / baseline : 0;
  return { baseline, dropPct, alert: dropPct >= 0.15 };   // relative, never absolute
}

The silent killer

An audience that stopped refreshing three weeks ago

The access token expires on a Tuesday. The sync is rejected. The error is caught, logged at warning level, and the job exits successfully — because it did run, it just did not accomplish anything. The nightly dashboard is green. The audience sits frozen at whatever it was on the fourth while spend continues against a three-week-old list, and the only symptom is performance getting slowly, unremarkably worse. Somebody notices in a month, and by then the campaign’s optimisation has been trained against a stale population.

The fix is not a better try-catch. It is structural: every audience-and-destination pair carries a freshness target and a last-successful-sync timestamp, and the alert fires on the absence of a success rather than the presence of an error. A caught exception can be swallowed. A timestamp that fails to advance cannot be.

src/activation/freshness.ts
import { db } from "../config/store.js";

interface Binding {
  brandId: string; audienceId: string; destination: string;
  freshnessSlaMinutes: number;           // e.g. 1440 for a nightly audience
  lastSuccessfulSyncAt: string | null;   // advanced ONLY on a completed sync
}

export async function staleBindings(now = Date.now()): Promise<Binding[]> {
  const bindings = await db.collection<Binding>("destination_bindings").find({}).toArray();
  return bindings.filter((b) => {
    // Never-synced is stale, not absent. A null must page someone.
    const last = b.lastSuccessfulSyncAt ? Date.parse(b.lastSuccessfulSyncAt) : 0;
    return now - last > b.freshnessSlaMinutes * 60_000;
  });
}

Monitor for the thing that should have happened, not just the thing that went wrong. The same table gives you the sync ledger — every profile, every destination, every sync id, appended as batches are accepted. It costs one table and a little write volume, and it is the thing that makes post 4’s deletion fan-out possible at all.

Run it yourself · open-activation-platform @ v5-activation
Sync the same audience twice against the local mock destination and read the quota meter, then break the match rate down into its losses.
terminal
git clone https://github.com/xaviramirezcom/open-activation-platform
cd open-activation-platform
git checkout v5-activation
pnpm install
pnpm activate up            # streaming + warehouse + config store, via docker compose
pnpm activate seed          # synthetic multi-brand traffic, orders and spend

# The naive nightly sync: recompute and push everything.
pnpm activate sync aud_high_intent_30d --destination meta --mode replace

# The same audience, same night, diffed against the previous snapshot.
pnpm activate sync aud_high_intent_30d --destination meta --mode diff

# Where the members went.
pnpm activate matchrate aud_high_intent_30d --destination meta
The replace run sends 71,900 rows in eight batches and reports 71,900 units of quota consumed. The diff run sends 9,140 — 8,206 adds and 934 removes — in one batch, and the mock destination’s learning-phase flag stays stable instead of flipping. Same audience, same membership, same night: an eight-to-one difference in cost, and a difference in kind in what the destination believes happened to its population. Then the funnel prints, and the number worth staring at is not the 67.1% — it is the 10,500 members who had no accepted key at all. That loss was decided four posts ago, at collection.

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 marketer reports that an audience “isn’t working” — spend is up week over week, return on ad spend is down, and the sync dashboard is entirely green: every nightly job completed, no errors, no failed batches. They want to know whether the audience definition is wrong. Where do you look first, and in what order?
Reveal a model answer

Not at the definition. A green dashboard reporting completion is exactly the signature of the silent failures, so start with the two metrics a “completed” job cannot fake. First, the last-successful-sync timestamp for that binding: if it is three weeks old, the job has been catching an auth rejection and exiting zero, and spend has been running against a frozen list — that alone explains the curve. Second, the match-rate trend against the audience’s own trailing median: a drop with a normalisation or over-merge cause behind it means you are submitting keys the destination cannot resolve, and the campaign is optimising against whoever it did match. Third, the dead-letter rate — a sync that accepts ten thousand and quietly discards three thousand reports success. Only then look at the definition-version history, and only to correlate a version bump with the day the curve turned.

The bonus consequence is the one people miss: if you have been running in replace mode, the destination re-entered its learning phase every night for weeks, and some of that decline is a campaign that was never allowed to finish exploring. That is a cost you paid with your sync architecture, not your targeting.

Next in the series · 06
Measurement & incrementality

The audience is synced and the spend is real — now the harder question of whether any of those conversions would have happened anyway.

Compared to what?