Littledata MonitorAI guide

Klaviyo flows — what the audits mean

These checks review live flows, triggers, exclusions, and revenue attribution so Littledata server-side events work cleanly next to native Klaviyo tracking.

How to read results

Outcome Meaning
Error Likely hurts revenue capture, doubles messaging, or hides a competitor issue.
Warning Worth fixing.
Info FYI.
Success Rule passed.
Unknown Missing data, no flows, or nothing to compare.

Littledata abandonment flows

What we look for: Live flows whose triggers use exactly these three Littledata metric names:

Rules:

Outcome Rule
Success All three have at least one live flow.
Warning One or two covered.
Error None covered.
Unknown No flows visible in the account.

Caveats: Renamed triggers are not detected. Email/SMS content is not reviewed.

Performance vs peer benchmark (enrichment): at value-compute time (customer and prospect paths), each trigger is also benchmarked against the size-aware peer expectation — the store’s own deepest live Littledata flow anchors the scale, multiplied by the cohort-median uplift ratio between stages. A live flow earning under 50% of its peer-expected uplift is flagged as underperforming with the dollar gap; a missing Littledata flow gets an “activate to add ~$X/mo” estimate. The result is appended to the check’s message/furtherInfo and stored in structured details (flowPerformanceVsBenchmark, potentialMonthlyUplift). The red/amber/green status is unchanged — it still reflects on/off coverage only. Requires the measured-uplift predicted tier; peer-modeled tiers are never used to judge per-flow performance.


Native abandonment flows (coverage by stage)

What we look for: For each funnel stage—browse, cart, checkout—whether there is either a live native Klaviyo flow or a live Littledata flow (same three pairs as above). Littledata-only setups should not be punished for “missing native” if the Littledata stage is live.

Rules:

Outcome Rule
Success Every stage has native or Littledata live.
Warning At least one stage has neither.

Caveats: Only Warning when a stage is empty (no Error here). Does not check send timing or creative.


Active SMS Flows

What we look for: Whether any flow actually sent on the SMS channel in the last 30 days. Channel activity comes from Klaviyo’s flow values report grouped by send_channel — the same single call that syncs flow revenue, so this costs no extra API requests. Klaviyo labels channels email, sms, whatsapp and push-notification.

Rules:

Outcome Rule
Success At least one flow sent SMS (recipients > 0).
Info Flows are sending, but none on SMS — an email-only store. The message frames SMS as an opportunity: around 1 in 3 Klaviyo accounts run SMS flows.
Unknown The report was unavailable, or no flow sent on any channel.

Caveats: This is sending, not configured — an SMS flow that is drafted, paused, or has an empty audience shows up as Info. Volume is not judged: a flow reaching 5 recipients still counts as Success. WhatsApp and push activity are recorded in the check details (channels) but do not change the status.


Active competitor flows

What we look for: Flows triggered by metrics that reference rival tracking tools. Detected names include (examples): Elevar, Blotout, Wunderkind, Black Crow, Addingwell, Wetracked, Aimerce, Fueled, Upstack, Stape, Triple Whale, Polar, ThoughtMetric, TrackBee, Tracklution, Venon, plus Retention.com’s Reclaim by product name (matched case-insensitively in the metric name or the metric’s integration label when the title alone is generic — see Detection below).

The bar is “would Littledata replace it as the trigger source?” — a detected tool becomes an “upgrade from {competitor}” pitch and anchors a predicted-revenue tier on its own flow revenue. Tools that fire the same abandonment metrics but run alongside Littledata are deliberately not detected: Customers.ai (builds purchase-intent audiences), Opensend and usemate.ai (identity resolution that surfaces shoppers in addition to the ones we track; usemate.ai tags its metrics [Checkmate]) and Digioh (popups).

What we compare: For live competitor flows we can map to the same funnel stage as Littledata, we compare Klaviyo-attributed Placed Order revenue in the same rolling window Littledata uses for its own flow revenue—then we factor in whether the flows exclude each other’s audiences so double counting is less likely.

Comparable stages: A row counts toward the aggregate only when the shop has a paired Littledata flow and the competitor’s revenue in that window is not below our low-volume floor (so noise does not dominate).

Rules:

Outcome Rule
Success No competitor flows found or (live + comparable stages exist and Littledata’s total attributed flow revenue is competitors’ total across those stages).
Info Competitor flows exist but are not live (draft/manual).
Warning Live competitor flows, but we have no comparable stages to score (for example, every pair is unmapped, missing a Littledata flow, or the competitor is low-volume).
Error Live + at least one comparable stage, and Littledata’s total is less than competitors’ total across those stages.

Per-row detail: The audit’s furtherInfo still shows one line per funnel stage (red/green bullets) so a single losing stage stays visible. Headline status is not “any one losing pair = Error”; it is the aggregate comparison above.

Waterfall (multi-flow) setups: If the shop uses a cascade of triggers (for example, native “Added to Cart” → a competitor’s ATC → Added to Cart – Littledata), we still infer Littledata’s position from exclusion metadata (trigger filters, profile / metric conditions, conditional splits). That inference shapes per-row bullet tone in furtherInfo (for example, when Littledata is downstream, higher revenue on a competitor line may be expected). It no longer overrides the Error vs Success headline by itself; the headline follows aggregate revenue on comparable stages.

Head-to-head benchmark (background): When a shop runs both Littledata and a competitor flow at the same stage (dual-active), we also write a dated head-to-head snapshot (LD vs competitor revenue, keyed by waterfall position) and, when a previously-dual shop later drops to one source, a consolidation outcome (which source it kept). These accumulate over a rolling 12 months and feed the prospect-facing “upgrade from {competitor}” value estimate — see Value metrics. They don’t affect this check’s status.

Detection: We match the competitor’s brand anywhere in the trigger metric’s title or its Klaviyo integration name — plus, for vendors where the brand alone doesn’t settle it, the individual product. Retention.com is the worked example: it sells identity resolution (additive, like Opensend) and Reclaim, which supplies the abandonment triggers a store’s flows fire on — Littledata’s own job. So we match “Reclaim” and deliberately not the bare brand, which wouldn’t say which product a metric came from. A product match is still reported as the company, because that is what customer-facing copy names.

Caveats: Name-based detection misses white-label tools. Renamed triggers may disappear from detection. The team may get a Slack heads-up when competitors are first seen.

Competitor audience uplift (separate check)

What it is: A follow-on row that compares unique profile reach (audience) at each funnel stage when competitor flows or competitor metrics exist, using the same comparison window as revenue.

Stages compared:

  1. Live-flow stages — every funnel stage where a live competitor flow already drives the revenue comparison.
  2. Metric-only stages — funnel stages where the competitor metric is firing (e.g. via the competitor’s tracking integration) but no live competitor flow exists. These rows are tagged “(no live competitor flow)” in furtherInfo so they’re visually distinct, and they roll into the audience aggregate. They do not affect the revenue check.

Rules: We compare total Littledata vs total competitor unique profiles (same comparison window) across both row types—not “fail if any single stage loses”. Success when Littledata’s aggregate reach is the competitors’ aggregate; Info when the aggregate is behind or profile snapshots are not yet available. Per-row lines in furtherInfo still show each stage so individual gaps stay visible.


Flow exclusion rules (Littledata vs native)

Why it matters: If both Littledata and native Klaviyo triggers are live for the same action, both flows can fire unless one flow excludes people who already entered the other.

What we check: For each parallel pair we expect, one side should reference the other’s metric in a trigger filter or profile / metric style condition so the same shopper is not double enrolled. The exclusion is bidirectional—it counts whether the Littledata flow excludes the native metric or the native flow excludes the Littledata metric (we look for the metric name or ID Klaviyo exposes—not whether the English sentence is perfect). A pairing is only reported when neither direction fences it off.

Rules:

Outcome Rule
Success Every trigger with a live Littledata flow is safe — either fenced off on at least one side, or one side is paused, drafted, or no longer sending (so there is nothing to collide with).
Error At least one sending Littledata flow paired with a sending native flow where neither direction excludes the other.
Unknown No live Littledata flow sits on a trigger we can pair.

Every sending flow on each side counts, not just the first: an account can run several live flows on one trigger — a rebuilt flow next to the shell it replaced, or per-market variants. We evaluate all sending flows on both sides and report only the pairing that is genuinely unguarded. Judging the trigger on whichever flow happened to come back from Mongo first produced false Errors: UK Flooring Direct rebuilt its Littledata flows and left the originals at status: live with 0 recipients and no exclusion metadata, so the check read a flow that sends nothing while the flow actually sending (972 and 244 recipients) did exclude the native metric.

Caveats: We cannot read every creative nuance of filters; we flag missing references, not “wrong logic.” Flow-level exclusions are invisible to us: Klaviyo’s profile-not-in-flow condition (“has not been in flow X in the last N days”) names no flow in the definition API — all 1,479 occurrences across a 20,000-flow scan carry {type, timeframe_filter} and nothing else — so only metric-level exclusions can be verified.

Why pausing counts as Success: merchants resolve an Error here either by adding an exclusion filter or by pausing a flow. Both remove the double-trigger risk, so both must land in Success — a status of Unknown skips the resolvedAt stamp that the admin UI’s status badge uses to show “Resolved …”, which makes a real fix look like a check that stopped working. This applies to either side going quiet: pairsWithExclusion[].reason records which (exclusionRule, noActiveNativeFlow, noActiveLittledataFlow).

We recursively walk condition_groups in trigger filters, the flow-level profile_filter, and conditional split profile filters, and we collect metric IDs and string values (e.g. on event_name conditions) so exclusions are detected even when Klaviyo nests groups. The union of triggerMetricId values for a trigger (both the native and the Littledata side) also includes non-live flows (same metric name) so a parallel draft locale does not hide a metric id the opposing flow already excludes. Re-sync flows (Klaviyo audit) is required to refresh profileFilterMetricIds / filterTriggerValues in Mongo after logic changes or flow edits.


Pseudo market identifiers (locale)

What we look for: Flow splits, trigger filters, or flow-level profile filters that use locale, country, or related dimensions (for example Customer Locale on the event, or location['country'] on the profile)—often a stand-in for market in multi-market shops.

Rules: Success if none; Info if some flows use those signals. Informational only.

Caveats: The next flow sync refreshes stored dimensions; very old flow rows may not list dimensions until Klaviyo reports the flow as updated.


Klaviyo attribution windows

What we look at: The time gap between a Placed Order and the email/SMS touch Klaviyo credited it to, sampled from the last 7 days. Because Klaviyo does not expose account-level attribution settings via API, we infer a window only for channels we actually see in that sample (Email click, Email open, SMS click, SMS open — each omitted if absent). We snap the largest observed gap per channel to the nearest standard bucket — 12 hours, 24 hours, 5 days, or 7 days. Push, WhatsApp, and SMS delivery appear in the banner only when we have samples and the inferred window differs from Klaviyo’s documented new-account default.

Why it matters: Wider attribution windows (especially opens or clicks beyond 24 hours) inflate Klaviyo Attributed Value (KAV) by crediting orders that customers would likely have placed anyway. Switching to 1-day click attribution typically gives a more honest view of incremental email/SMS revenue.

Rules:

Outcome Rule
Success Every observed email/SMS click or open window listed in the banner is ≤ 1 day.
Info Any observed email/SMS click or open in the banner is wider than 1 day (informational only; not Warning/Error). When at least 25 sampled orders sit in the Littledata flow buckets we also estimate the reduction in revenue Klaviyo reports for Littledata flows under 1-day-click attribution with no open credit, phrased as an upper bound (“could reduce … by up to N%, based on M sampled orders”). Below that floor the banner states the window and nothing else — no impact figure.
Unknown Fewer than ~100 sampled Placed Orders across the 7-day window, or enough orders but no email/SMS click/open touches appeared in the sample (so we cannot list those windows).

Banner wording: Results read like Email 1-day click, 1-day open and SMS 12-hour click, 1-hour open (windows use N-day / N-hour; each channel is one clause).

Tooltip (Read more): A per-flow breakdown of Littledata-flow revenue under the current attribution model versus a 1-day-click model, one line per Littledata flow with the percentage delta and the sampled order count behind it. A flow with fewer than 5 sampled orders shows the count in place of a percentage (2 sampled orders — too few to compare). Assumes Klaviyo’s default cooperative last-touch model (linear is only available on Advanced KDP / Marketing Analytics plans).

Caveats: Channels with no matching touch in the sampled orders are left out of the banner (we do not substitute Klaviyo’s account defaults). Accounts that only send email may therefore show email lines only; that does not mean SMS is set to 1-day. The estimate uses sampled orders (capped at 200/day), not the full revenue picture, so the % reduction is directional rather than exact.

Why the estimate is an upper bound: The snapshots record only the last touch Klaviyo credited. An order the current model credits to an open may also have had a click within the day that would keep it attributed under a 1-day-click model, so the true reduction is at most the figure quoted. Two floors keep the figure from being read as precise: the 25-order account floor above (which also gates the “under 10%, so Success” tolerance — a thin sample cannot flip the verdict in either direction), and the 5-order per-flow floor in the tooltip. When the figure is withheld, details.kavReductionOrderSample and an insignificanceReason of count_too_small record why, while the raw kavReductionPct is still stored for internal use.


What we look at: For each sampled Shopify customer, their email and SMS marketing consent in Shopify versus the same identity’s consent in Klaviyo. This is the only Klaviyo check about compliance rather than performance: if Shopify records that someone unsubscribed and Klaviyo will still send to them, that person keeps receiving marketing they withdrew consent for.

Why it matters: Consent lives in two systems that sync imperfectly, and Klaviyo deduplicates identities imperfectly, so one person with several email addresses can end up as several profiles carrying different consent. Neither system flags the disagreement on its own.

Three states, deliberately:

State Meaning
In sync Shopify and Klaviyo agree, or disagree in a way with no consequence.
Definitely out of sync We are certain they disagree, in a direction that matters.
Not sure We could not confidently match the two systems, so we draw no conclusion.

The decision table

“Will Klaviyo send?” comes from Klaviyo’s own can_receive_email_marketing / can_receive_sms_marketing, which accounts for suppressions — a bounced profile can read SUBSCRIBED and never be sent to.

Shopify Klaviyo Verdict
UNSUBSCRIBED (and more recent) will send Definitely out of sync — critical. Marketing without consent.
UNSUBSCRIBED, but Klaviyo consent is newer will send Not sure — they re-subscribed in Klaviyo after opting out; Shopify is the stale side
UNSUBSCRIBED won’t send In sync
PENDING (double opt-in never confirmed) will send Unconfirmed opt-in — reported, never scored. See below.
PENDING, but Klaviyo holds its own confirmation will send Not sure — see below
PENDING won’t send In sync
SUBSCRIBED will send In sync
SUBSCRIBED won’t send Out of sync (reverse) — lost reach, not a breach. When this is email, the check also estimates missed revenue (see below).
NOT_SUBSCRIBED will send Not sure — usually a legitimate Klaviyo popup signup Shopify never saw
NOT_SUBSCRIBED won’t send In sync
INVALID / REDACTED any Not sure — Shopify’s own state says nothing

PENDING is a consent answer, not a missing one — but it is reported, never scored. Shopify is stating that the person entered an address and never confirmed it, so consent was not completed, and on a store running Shopify’s double opt-in this is often the largest single population (Revendo: 1,012 of 4,993 sampled). Filing it under “not sure” hid a real question; scoring it would be worse. On most stores Klaviyo, not Shopify, is where consent is actually collected — Shopify’s PENDING is then a stale artefact of a confirmation email the merchant never intended to rely on, and turning the check red on it would redden stores doing nothing wrong. So it gets its own verdict, divergedUnconfirmed, surfaced with the system-of-record context that says how much weight to give it, and kept out of every threshold.

Two escape hatches keep it honest, since Klaviyo can legitimately hold a confirmation Shopify never saw:

Consent recency matters. A customer can opt out in Shopify and then opt back in through a Klaviyo form. Comparing values alone would call that a breach. We compare Klaviyo’s consent_timestamp against Shopify’s marketingUpdatedAt and only count it as a breach when Shopify’s opt-out is the more recent event. When Klaviyo has no timestamp we take the conservative reading and count it.

Why “not sure” happens (each points at a different fix):

Never compared at all: customers with no email or phone on that channel. This is neither a sync problem nor a consent problem — there was nothing to compare — and it is usually the biggest number on the page, since two thirds of customers typically have no phone. Those rows never enter compared, so they cannot dilute a rate or inflate the “not sure” headline; they are carried only as noIdentityOnChannel so a run’s arithmetic against sampled still adds up. (Cocopup’s SMS channel first reported 1,365 “not sure” of which 1,328 were simply customers with no phone number.)

consentSystemOfRecord is inferred from which side holds opt-ins the other never saw, read on email only — SMS reverse divergence is dominated by stores that simply never collected SMS consent in Klaviyo and would swamp the signal (Brecks: 893 reverse SMS against 85 reverse email).

Signal Meaning
klaviyoOnlyOptIn Klaviyo subscribed, Shopify has no record → signup happened in Klaviyo
divergedReverse Shopify subscribed, Klaviyo not → consent captured at Shopify, never reached Klaviyo

A verdict needs at least 20 unmatched opt-ins and a 3:1 lean; otherwise unclear. Measured live:

Store Klaviyo-only Shopify-only Verdict
Revendo 1,204 15 klaviyo
Cocopup 302 37 klaviyo
Brecks 3 85 shopify

This never changes the status. It exists so the rest of the snapshot reads correctly: on a Klaviyo-led store a large Shopify PENDING or NOT_SUBSCRIBED population is the design, not a fault.

⚠️ It measures where consent is collected, not where email is sent. Detecting whether Shopify Email is actively sending would settle it, and there is currently no way to do so: marketingActivities needs read_marketing_events and appInstallations needs read_apps (both denied on our token); openTrackingLevel reads UNKNOWN for 100% of customers on every store tested, with no positive control available; and BuiltWith’s “Shopify Email Marketing” tag appears on 75.5% of all Shopify stores, so it detects the capability rather than its use.

Missed revenue from unsynced Shopify opt-ins

When Shopify records that someone granted email consent but Klaviyo will not send to them, the check prices the gap as an uplift:

(unsynced email opt-ins ÷ email profiles compared) × everything Klaviyo earned this account in the last 30 days.

If 2% of the profiles Klaviyo could be sending to are missing from it, Klaviyo is making at most 2% less than it would with them. The denominator is flows + campaigns: flow revenue is already stored per flow in USD by the revenue sync, and campaign revenue is fetched as one account-level total from campaign-values-reports and converted (klaviyoCampaignRevenue30d is null, not zero, when that report cannot be read, so a partial denominator is visible in the details).

Why not revenue per recipient. The first version multiplied the opt-in backlog by last month’s attributed revenue per email recipient — but a recipient is one message delivered, not one person. Measured across 331 live accounts, that produced a figure larger than the account’s entire measured Klaviyo revenue on 40 of them, topping out at 7,988% (strong-fitness-de: $18,335 “missed” against $230 of flow revenue). The uplift model is bounded by construction — missed dollars can never exceed missed profiles as a share of the list — and zero accounts now exceed 100%.

It appears in the check message as “you’re missing about $X a month of revenue by not syncing that consent back to Klaviyo”. It does not change the status. We omit it when there are fewer than 3 unsynced opt-ins, fewer than 50 email profiles compared, the account has no measured Klaviyo revenue, or the figure is under $1.

Materiality is one threshold, not two: 0.5%. Because the dollars are the profile share, clearing 0.5% of revenue is clearing 0.5% of the list. Under that, a merchant is being asked to fix a sync over less than half a percent more sending. Persisted as unsyncedShareOfProfiles. When it is suppressed but unsynced opt-ins exist, the message is “No one is being marketed to without consent.” — never the stronger “both systems agree on every customer”, which would be untrue.

Across the estate (459 accounts checked, 331 with a computable figure): 304 clear the 0.5% floor, median 3.4% of profiles missing, median $234/month flows-only.

⚠️ Klaviyo does not set consent: "UNSUBSCRIBED" when someone clicks the unsubscribe link. Consent stays exactly where it was, can_receive_email_marketing flips to false, and an entry appears in subscriptions.email.marketing.suppression[] with a reason. Hard bounces and spam complaints behave the same way. A real profile from Love Brand & Co:

"consent": "SUBSCRIBED",                    // unchanged
"can_receive_email_marketing": false,       // will not be sent to
"consent_timestamp": "2026-08-15T16:24",    // signed up
"suppression": [{ "reason": "USER_SUPPRESSED", "timestamp": "2026-08-15T17:37" }]  // left, an hour later

Reading consent alone — or can_receive_* without the reason — cannot tell a dead address from a withdrawal from an opt-in Klaviyo never received. All three look like “Shopify opted in, Klaviyo did not get it”. Estate-wide, 91,182 of 158,895 reverse divergences (57%, across 421 shops) were profiles Klaviyo already held consent for. On the account that prompted the investigation, 2,786 of 2,794.

DIVERGED_SUPPRESSED keeps them out of divergedReverse and out of the missed-revenue estimate, and tallies Klaviyo’s reasons in suppressionReasons so the summary can say which problem it is:

Suppression Meaning Beats a newer Shopify opt-in?
HARD_BOUNCE, INVALID_EMAIL, SPAM_COMPLAINT The address is the problem Yes — no consent record makes a dead mailbox deliver
USER_SUPPRESSED, MANUAL_SUPPRESSION The person left No — a later Shopify opt-in is them coming back, and is genuine lost reach

Neither is a consent-sync fault, and neither is repaired by pushing consent again. A wall of hard bounces is a list-hygiene finding; a wall of user suppressions is people leaving.

When Klaviyo holds the newer opt-out

Shopify SUBSCRIBED + Klaviyo not-subscribed has two causes, and the check used to treat them as one:

Klaviyo’s record Meaning Verdict
Older than Shopify’s opt-in, or no timestamp Consent captured at Shopify never reached Klaviyo divergedReverse — lost reach, feeds the estimate
UNSUBSCRIBED, newer than Shopify’s opt-in The person withdrew in Klaviyo; Shopify never heard divergedKlaviyoOptOut — never lost reach, never in the estimate

The critical direction has always had this rule (“a Klaviyo opt-in after Shopify’s opt-out means the person came back”); the reverse direction did not, so every Klaviyo unsubscribe against an older Shopify opt-in was counted as revenue we were telling the merchant to go and collect. Syncing that consent back would re-subscribe someone who unsubscribed.

It is still a finding: Shopify holds the stale record, so anything reading Shopify’s consent — Shopify Email, another app — can market to them. Reported in the channel summary and klaviyoOptOutsNotInShopify, not in the status.

⚠️ Only an explicit UNSUBSCRIBED counts. NEVER_SUBSCRIBED is the absence of a consent event and its timestamp falls back to last_updated, which moves on any profile edit — without that gate, every recently-touched profile would read as a withdrawal and the reverse count would empty out.

Found counts vs implied scale

Every count in a message is found, not estimated: we compared N customers and this many diverged. The message says so — “66 customers from the sample…”, “we found 500 customers…” — because the list is far larger than any one run, so a bare count reads as the whole problem when it is a floor.

The reverse-divergence message then projects: “we found 500 customers (12% of those checked)… you’re missing about $100,000 of revenue… for ~230,000 customers” (impliedAcrossStore, two significant figures, only when the run sampled and Shopify gave a store total). Treat the projection as an upper-ish bound: recency ordering puts the customers most likely to have changed consent at the front of the sample, so the sampled rate runs ahead of the list’s.

The dollars and the population are projected together or not at all. The projection uses the email reverse rate, because the rate it is multiplied by is per email recipient. Quoting the sample’s $227 beside a store-wide 230,000 would understate by the entire sampling factor. When there is nothing to project onto — the whole store was checked, or Shopify gave no total — the sentence falls back to the sample’s own dollars and names no population. Every other branch stays sample-only: it names a sampled count, so a sampled dollar figure matches it.

What goes in the message and what goes in the tooltip

The message carries the finding only. Coverage moved to furtherInfo, because it qualifies every number in the check and nobody needs it before the finding: “We compare each customer’s email and SMS marketing consent in Shopify against the same person’s consent in Klaviyo. Based on a sample of 8,504 recently updated customers out of 1,929,539 total profiles.” The sample figure is the cumulative distinct identities over the trailing 30 days, not one run’s 5,000. A store checked in full gets “Based on all 900 customers in the store.”; a failed run gets the first sentence alone.

Three gates before the check goes red

A critical divergence must pass all three:

  1. Stable — Shopify’s opt-out is more than 24 hours old, so an in-flight sync cannot explain it.
  2. Confirmed — the same identity was already critical at its previous check, so Klaviyo’s sync has had a full check cycle to catch up. A store’s first run can never report Info; it resolves to Success with a message saying so.
  3. Material — clears the tolerance for its jurisdiction, and there are at least 3 of them.

The check never goes above Info: a consent mismatch is between the merchant’s Shopify and Klaviyo accounts, not something Littledata can fix for them today, so it is surfaced without alarming.

Outcome Rule
Info ≥3 confirmed criticals and either >1% of all matched identities, or >0.2% of matched identities in strict-consent countries. Also >5% reverse divergence on a channel.
(never) Unconfirmed double opt-ins, however many. Reported in the summary only.
Success Everything below those thresholds, including within-tolerance stable criticals (still described in the message).
Unknown Fewer than 50 matched identities on both channels, no Shopify token, or the app lacks read_customers.

Why a tolerance at all: the comparison is imperfect at the margin — a customer merged in Shopify, an address changed on one side, a profile updated between our two API calls. A check that flags a single ambiguous row trains people to ignore it. Everything under the tolerance is still reported in the message, as success rather than silence.

Why Europe is five times tighter: in Germany, under the UWG, an unsolicited commercial email is an actionable unfair-competition tort that any competitor or consumer association can pursue by Abmahnung — no regulator, no volume threshold, one message is enough. The rest of the EU/EEA, the UK and Switzerland carry the same duty under GDPR Art. 7(3) and ePrivacy/PECR; Canada’s CASL is comparable. The US is excluded: CAN-SPAM gives a 10-business-day window to process an opt-out and requires no prior consent, so a short sync gap there is a different kind of problem.

Sampling

5,000 distinct customers per run, most-recently-updated first, deduplicated by customer ID, allocated in four passes:

  1. Store-wide, 1,000 — the general health signal.
  2. Germany — takes as much of the remaining 4,000 as it can fill. A US-heavy store would otherwise contribute a handful of German rows, far too few to tell a pattern from noise.
  3. Rest of the EU/EEA/UK/CH/CA — whatever Germany left unfilled.
  4. Store-wide again — spends any budget the jurisdiction passes could not use, so a store selling mostly outside Europe still gets a full 5,000 rather than a truncated sample. (Brecks has 3M customers but only ~220 in strict-consent countries; without this pass it sampled 1,345.)

The 5,000 is a ceiling on distinct customers, not a per-pass limit — later passes fetch more rows than they need because most of what they return is already in the sample and dedups away.

⚠️ Shopify’s country: filter matches the country name, not the ISO code, despite the docs saying either works. country:DE returns zero rows — silently, looking exactly like “this store has no German customers”. Verified against a live store on 2026-08-13. The match is also fuzzy (country:Germany returned a Swiss customer), so the filter only biases the sample; every row’s real country is read from defaultAddress.countryCodeV2 afterwards, and a unit test asserts no query term is ever an ISO code.

Recency ordering is a biased sample by design: a consent change bumps updatedAt, so the customers most likely to have diverged are seen first. Rates therefore describe recently active customers, not the whole list, and the check’s wording says so. The strict-jurisdiction rate is measured against its own denominator, not the global one. Coverage of the older tail accumulates across daily runs, reported as cumulative distinct identities checked over the trailing 30 days.

Identity quality (informational)

Reported in details.identityQuality, never affecting the status. It sizes how badly Klaviyo’s identity resolution behaves — the thing that makes the “not sure” bucket large — and aggregates across accounts to size the problem estate-wide:

Metric Failure mode
emailsWithMultipleKlaviyoProfiles Splitting — consent set on one profile does not apply to the others
externalIdsWithMultipleKlaviyoProfiles Splitting, by Shopify customer ID
klaviyoProfilesMatchedByMultipleCustomers Merging — several addresses flattened onto one profile
customersWithConflictingProfiles Email and external_id point at entirely different profiles
shopifyCustomersSharingEmail Shopify’s own duplicates, before Klaviyo is involved
cleanMatchRate Headline: share resolving to exactly one profile nothing else claims

Drill-down

details.affectedKlaviyoProfileUrls gives up to 50 direct klaviyo.com/profile/{id} links. Klaviyo has no URL for an ad-hoc set of profile IDs, and its segment builder cannot reference Shopify, so no segment can express “diverged from Shopify’s record” — the divergence only exists once the two systems are compared outside Klaviyo. The alternative would be writing a littledata_consent_divergence property onto the affected profiles so a segment can target them; that is a write into the merchant’s account and needs their explicit agreement first.

Privacy

No email address or phone number is ever written to the database. Identities are stored only as sha256(pepper + shopName + channel + identity), shop-scoped (the same address at two stores cannot be correlated) and peppered via CONSENT_HASH_PEPPER (not reversible from a list of known addresses). The cache exists to confirm divergences across runs and to track cumulative coverage, and expires after 30 days. The only person-level pointer persisted is the Klaviyo profile ID of a diverged profile.

Database cost

The check touches two collections, klaviyoConsentDivergenceSnapshots (one counts-only document per shop per UTC day) and klaviyoConsentIdentityCache (one hashed row per compared identity, up to 2 × 5,000 per shop). Three things keep that from hurting the database:

Caveats

Reads Shopify’s defaultEmailAddress — a customer has one default address, so one person with several emails appears as several customer records, compared separately (which is also how consent works legally). SMS consent reads Shopify’s deprecated defaultPhoneNumber.marketingState, currently the only field exposing it. Country comes from defaultAddress.countryCodeV2; customers with no address are bucketed unknown and never treated as strict, so a missing address never fires the check. Consent held in Klaviyo but not Shopify is never a breach, so a store collecting consent purely through Klaviyo forms shows a large klaviyoOnlyOptIn count and no error. Requires read_customers; without it the check is Unknown, never Success.

Preview: npx tsx scripts/preview-consent-divergence.ts <shop.myshopify.com> dry-runs a store and prints the full breakdown without writing anything. A dry run behaves like a first run, so it never shows an escalation the live check would not yet produce.


Spam profiles created

What we look for: whether someone is injecting machine-generated profiles into the account. This is a data-quality check, not a deliverability one: the invented addresses do not exist, so the first flow email to reach them hard-bounces and the sending domain wears the damage. On the account this was built from, one flow message took 2,373 hard bounces in five days and its 30-day bounce rate read 61%.

What we read: the 100 newest profiles, in one API request, never paginated. Injection is contiguous in time — a generator works through a queue — so a newest-first page either contains the flood or the account is quiet. The cost is therefore identical for a five-million-profile account and a five-thousand-profile one.

Signals

Two tiers. Primary signals are hard to explain innocently; corroborating ones only support them.

Signal Tier Measured separation
Share of profiles on cloud-server IPs (AWS + GCP published ranges) Primary ≥25% only alongside address evidence (or ≥50% alone), corroborating ≥10% 0% of profiles that placed an order, 4.9% of ordinary bouncers, 45.7% of a flood cohort
Addresses following a generated name template (firstname.lastname4137@) Primary ≥40%, corroborating ≥20% 0.5% of real buyers, ~100% inside a flood
Addresses spread too evenly over a few free providers Primary 7 providers at near-equal share, gmail under-represented; organic traffic is dominated by one provider in every market sampled
A block of ≥25 generated addresses that on their own look drawn from a pool, or run from cloud servers Primary catches a flood diluted by real signups, which pushes every whole-page share below its threshold
Never opted in to marketing Corroborating ≥60%  
Created inside one clock hour Corroborating ≥25% only counted when the page spans ≥6 hours

The provider-pool clause says what to expect instead. “Addresses spread evenly over 9 free providers” left the reader to work out why that was bad; it now reads “spread too evenly … none holding more than 40% — a real list is dominated by one provider, usually Gmail”. The abnormality is the flatness: a generator draws from its pool uniformly, where a real list skews hard to one provider. Gmail is named as the usual case rather than the rule, because the threshold is deliberately country-agnostic — absolute Gmail share moves with the market, but “no provider dominates” holds nowhere organically.

Every clause names a count, not a share — “35 of the 100 newest profiles use formulaic-looking addresses” is something a merchant can go and look at, where a percentage of a page they cannot see is not. Thresholds stay as shares; only the copy changed.

Rules:

Outcome Rule
Error ≥2 primary signals and ≥2 corroborating — a proven injection.
Warning Any single primary signal.
Info A firing verdict on a store whose klaviyo connection has bot filtering (Connection.settings.filterUnidentifiedEvents) enabled — bot profiles are still landing in the account (Klaviyo’s own Shopify sync creates them), but Littledata’s triggers filter them, so Littledata-powered flows never email them.
Success No primary signal.
Unknown Fewer than 50 profiles in the account, or Klaviyo’s profiles API could not be read.

Corroborators alone never flag (a fleet sweep showed every corroborator-only flag was a false positive); a diluted flood is caught by the generated-address sub-cohort primary instead. The Shopify-side sweep that calibrated this grading is described in Shopify audit checks.

No single threshold can move this check — independent signals have to agree. That is deliberate: the retired klaviyoFlowDeliverability check fired on one noisy number, and Klaviyo deliverability is consistently good, so a bare rate told nobody anything.

Error and Warning are correct severities here because Littledata can actually fix it — Bot Protection filters these profiles out of Littledata’s triggers, and both checks link to its help article. The usual rule of capping third-party findings at Info does not apply to a finding the product resolves.

The message states the scale; furtherInfo carries the forensics. The message says how many of the newest profiles look suspicious and offers Bot Protection — nothing more. It used to open with the primary signal clauses, so a merchant met “58% of addresses follow a generated name pattern and 41 of 96 newest profiles with a known IP came from cloud servers” before being told what was happening, and there can be five such clauses (#644 made the same change to the Shopify check). furtherInfo lists every signal as a bullet — red for the primary ones that carry the verdict, orange for the corroborating ones — so a reader can see the working rather than taking the message on trust.

The message does not name a route — “Something is passing fake profiles into this Klaviyo account”. Deliberate: nothing in a Klaviyo profile records where it came from, so this check has no source evidence of its own. Every instance measured so far arrived through Shopify checkout (a bot creates checkouts, Klaviyo’s own Shopify sync turns them into profiles), but a flood arriving through a signup form or the Klaviyo API would look identical here, so the message says what the check can see and no more. The remedy sentence still names checkout bots — that is a description of what Bot Protection filters, not a claim about where these particular profiles came from. If we ever want the route asserted, condition it on the sibling shopifyCheckoutSpam row having fired for the same shop.

How the message counts “suspicious”. Per profile: a formulaic address or a cloud-server IP nominates one. Neither is proof alone — real people own first.last12@ addresses, and link-scanning proxies put a trickle of genuine subscribers on cloud IPs — which is why the copy says look suspicious and the verdict still needs signals to agree. When the whole page’s provider mix reads as drawn from a pool, the page itself is the cohort: a generator that does not use name templates shows up there and nowhere else, so counting templated addresses alone would report zero on a firing verdict.

No unconditional provider-pool line. furtherInfo used to append one whatever the verdict, which failed in both directions: on a healthy account it rendered “Addresses sit on 4 email providers, the largest holding 78% of them” under a warning bullet — an ordinary distribution dressed as evidence, the same fault that retired the billing-regions line on the Shopify check — and on a firing one it restated the pool signal that had just convicted in the bullet above it. The top-provider share now sits inside the clause that convicts on it.

Guards against false positives

All were found by measuring real accounts, not by reasoning:

Caveats

Klaviyo overwrites location.ip as later events arrive, so the cloud-IP share is a floor in one direction and inflated in the other: a scanned mail click files a genuine subscriber under AWS. That is why the whole-page share needs address evidence beside it (see above) — on this data source it measures where a profile was last seen, not where it signed up. Only AWS and GCP publish usable prefix lists — Azure has no stable unauthenticated JSON endpoint, and budget hosts favoured by cheaper bots are only identifiable by ASN — so a low cloud share is not evidence of clean traffic, which is why nothing fires on it alone. When no range snapshot is available the check still runs on the identity signals and records datacentreRangesAvailable: false.

Runs in the shared audit phase, so prospects get it too — it is the evidence behind telling a prospect their list is being poisoned, computed from their own account.

No Linear issues. This check and shopifyCheckoutSpam describe the same incident from two sides; only the Shopify check opens the Linear issue (it covers every customer with the orders scope, whatever ESP they use), so this one is in linearSuppressedChecks. It stays fully visible to customers, prospects, Slack and marketing automations — it just never doubles the investigation.

Its Shopify counterpart (Bot checkouts) sees the same problem at the source and fires whatever the merchant sends email with — see the Shopify audit checks.