@dxv-systems/measurement
One consent gate, one fan-out, and a Metis leg that always reports.
- Consent: Google Consent Mode v2 with four categories, a banner, and the cookie mirror the server reads.
- Fan-out: one typed
track()to GA4, Meta Pixel and PostHog, each gated by the shop's policy. The TikTok Pixel is loaded and sends page views under the same gate, but receives notrack()events. - Metis:
reportPurchase/reportLeadsend every conversion to the Metis relay, carrying the visitor's consent.
Import this package rather than @dxv-systems/metis-client directly: everything an app needs from metis-client is re-exported here, so no code path can send to Metis without passing the consent mapping.
npm i @dxv-systems/measurement
| Subpath | Runs in | Contents |
|---|---|---|
@dxv-systems/measurement | anywhere (browser-safe) | consent state, read/write/parse, pushConsentUpdate, toMetisConsent, policy, inline script builders, browser click-id capture |
@dxv-systems/measurement/react | browser | ConsentBootstrap, ConsentBanner, useConsent, useBannerCategories, ClickIdCapture, createTracker |
@dxv-systems/measurement/next | Next proxy / middleware | persistClickIds |
@dxv-systems/measurement/server | Node | reportPurchase, reportLead, readMeasurementCookies |
The rule
Always report to Metis. Consent decides forwarding; Metis decides nothing about storage.
Every purchase and lead goes to Metis whatever the visitor answered. Consent decides two things only:
- What the event carries. Hashed email/phone and click ids are sent only when the visitor granted both
ad_user_dataandad_personalization. Otherwise the event is bare: the order or lead, its value, and the consent flags. - Whether Metis may forward it to Meta and Google. Metis forwards only when both flags are true and the visitor decided.
Metis stores every event it receives. That is what lets it count how many conversions the consent gate drops.
Policy
Each shop decides, per destination, whether it fires only with consent or always. The package encodes that decision; it never makes it.
import { definePolicy } from "@dxv-systems/measurement";
// Anything not named stays strict (gated on consent).
export const policy = definePolicy({ meta: "always" });
| Destination | Strict (default) | "always" |
|---|---|---|
ga4 | events only with analytics_storage | events always; gtag.js still applies Consent Mode (cookieless pings when denied) |
posthog | loads and captures only with analytics_storage | measures every visitor: with analytics_storage normally (cookies, identify, replay), without it cookieless (cookieless_mode: "on_reject", no cookies or browser storage, only a flag that remembers the refusal) |
sessionReplay | only with analytics_storage (and only when PostHog runs) | always |
meta | fbq('consent','revoke') and no events without ad_storage | fbq('consent','grant') whatever the answer |
tiktok | pixel loads only with ad_storage | loads immediately |
STRICT_POLICY is every destination gated. A common posture is definePolicy({ ga4: "always", posthog: "always" }): GA4 and PostHog measure every visitor, while Meta, TikTok and session replay follow the visitor's answer. Which posture a shop uses is the shop owner's decision.
posthog: "always" needs the PostHog project setting "Cookieless server hash mode" (project settings). Without it PostHog drops the cookieless events, so visitors who refuse or never answer stay invisible. With the policy, createTracker opts a visitor without analytics_storage out explicitly (on_reject treats "never answered" as opted out), opts in when they agree, starts replay only with analytics_storage, and identify (which sends traits such as an e-mail address) also needs analytics_storage.
The Meta and TikTok pixels are gated on ad_storage alone (hasPixelConsent): that is the cookie consent for a non-Google pixel. ad_user_data and ad_personalization are Google Consent Mode signals about sending data to Google, and gtag.js applies them itself. hasAdConsent (ad_storage + ad_user_data) is kept for Google-ads-specific checks and is not the pixel gate.
Banner copy must match this gate. The options release different things. ad_storage alone opens the browser pixels and cookies (Google Ads, Meta, TikTok), so its option must name those pixels and say what they receive. ad_user_data and ad_personalization together allow the server-side sharing: hashed e-mail address and phone number plus click ids, sent through Metis, which may forward the conversion to Google and Meta (and they are Google's own Consent Mode signals). Those two options must therefore name Google and Meta, and say the sending happens only when both are on; wording them as Google-only under-describes it. DUTCH_BANNER_COPY does this and a test pins it. It also says the e-mail address and phone number go to Google through the Google tag; a site that loads no Google Ads tag (no shareUserDataWithGoogle) passes copy={dutchBannerCopy({ googleTag: false })} (0.5.3) so the banner does not promise more than happens. Since 0.5.4 the text is built from what the site does: dutchBannerCopy({ pixels, forwardsTo, contactTags }) names the pixels ad_storage opens, every platform Metis forwards to (Google, Meta, Pinterest) and every browser tag that gets hashed contact details. Declare the lists once per site and keep them equal to what Metis has switched on: a platform that is not named is not consented to. Since 0.6.0 the banner can take the platform list from Metis instead (platformsEndpoint, see "Which platforms the banner names"): it then stores the named platforms with the answer and asks again when Metis adds one. Without platformsEndpoint the banner does not ask again by itself when the text changes: a stored answer still counts, so before switching a new platform on, ask again (a new consent storage key) or forward to it only for answers given after the text changed. A shop passing its own copy must keep the same promise; otherwise a visitor who declines "sharing data" still has pixel events or server-side conversions sent, which is not informed consent.
The Consent Mode update to gtag is sent on every answer regardless of policy: it is the signal itself, not a destination. Click ids are persisted only with ad_storage, regardless of policy.
Next.js (App Router)
lib/measurement.ts, shared by the layout and client components:
import { definePolicy } from "@dxv-systems/measurement";
import { createTracker } from "@dxv-systems/measurement/react";
export const policy = definePolicy({});
export const tracker = createTracker({
policy,
ga4MeasurementId: process.env.NEXT_PUBLIC_GA4_ID,
posthog: process.env.NEXT_PUBLIC_POSTHOG_KEY
? {
key: process.env.NEXT_PUBLIC_POSTHOG_KEY,
load: () => import("posthog-js").then((m) => m.default),
options: { api_host: "/ingest", ui_host: "https://eu.posthog.com" },
}
: undefined,
});
Root layout. The bootstrap must come before any tag:
import Script from "next/script";
import { ga4ScriptSrc, metaPixelScript, tiktokPixelScript } from "@dxv-systems/measurement";
import { ClickIdCapture, ConsentBanner, ConsentBootstrap } from "@dxv-systems/measurement/react";
import { policy } from "@/lib/measurement";
<html>
<head>
<ConsentBootstrap ga4MeasurementId={GA4_ID} />
</head>
<body>
{children}
<Script src={ga4ScriptSrc(GA4_ID)} strategy="afterInteractive" />
<Script id="meta-pixel" strategy="afterInteractive">{metaPixelScript(META_ID, policy)}</Script>
<Script id="tiktok-pixel" strategy="afterInteractive">{tiktokPixelScript(TIKTOK_ID, policy)}</Script>
<ConsentBanner policy={policy} />
<ClickIdCapture />
</body>
</html>
ConsentBanner and ClickIdCapture are client components; render them only where they belong (not on staff pages). The banner's copy defaults to Dutch; pass copy (e.g. from next-intl) for other locales. Its default classes are Tailwind, so add the package to your sources (@source "../node_modules/@dxv-systems/measurement/dist";) or pass classNames. A footer link reopens it with openConsentSettings().
proxy.ts (or middleware.ts):
import { persistClickIds } from "@dxv-systems/measurement/next";
export default function proxy(req: NextRequest) {
const res = NextResponse.next();
persistClickIds(req, res); // writes dxv_click_ids only when the consent cookie grants ad_storage
return res;
}
Late accepters are covered by ClickIdCapture, which holds the ids in memory and writes them once the visitor grants ad_storage.
Tracking, from client components:
tracker.track("add_to_cart", { item, cartValueCents });
tracker.trackPurchase({ orderNumber: order.number, items, valueCents, taxCents, shippingCents, discountCents });
tracker.trackLead({ leadId: lead.id, source: "offerte" });
trackPurchase and trackLead give Meta and PostHog a deterministic event id (purchaseEventUuid(orderNumber) / leadEventUuid(leadId)). The server report derives the same id from the same key, so the platforms dedupe the browser and server events. The orderNumber / leadId in the browser must equal the order.id / lead.id on the server.
Server, in the route or server action that creates the order or lead:
import { cookies } from "next/headers";
import { readMeasurementCookies, reportPurchase } from "@dxv-systems/measurement/server";
const { consent, clickIds } = readMeasurementCookies(await cookies());
// Store { consent, clickIds } with the order: a payment webhook arrives without the shopper's cookies.
await reportPurchase({
order: { id: order.number, totalCents: order.totalCents, currency: "EUR" },
customer: { email: order.email, phone: order.phone },
consent,
clickIds,
});
Vite + Express
In index.html, before any tag, inline the output of consentBootstrapScript({ ga4MeasurementId }) (a Vite transformIndexHtml hook, or render <ConsentBootstrap /> if the shell is server-rendered). Add the pixels the same way with metaPixelScript / tiktokPixelScript, or skip the ones the app does not use. In the React app, mount ConsentBanner and ClickIdCapture once, and use createTracker as above.
Express:
import { readMeasurementCookies, reportLead } from "@dxv-systems/measurement/server";
app.post("/api/lead", async (req, res) => {
const lead = await saveLead(req.body);
const { consent, clickIds } = readMeasurementCookies(req.headers.cookie);
void reportLead({ lead: { id: String(lead.id) }, customer: { email: lead.email }, consent, clickIds });
res.status(201).json({ id: lead.id });
});
On a serverless runtime, await the report instead of void: a detached promise can be frozen with the function.
A job application on a careers page is a lead with kind: "application" (reportLead({ lead: { id, kind: "application" }, ... }), metis-client 0.15.0): Metis counts it and never forwards it to an ad platform. Do not call tracker.trackLead for an application either, or the browser pixels report it to Meta as a lead after all.
TikTok events (ttq.track for purchases, leads and the like) are not sent: the TikTok Pixel is loaded and sends page views only. They are a possible later addition.
Server reporting
reportPurchase and reportLead:
- read
DXV_CUSTOMER_ID,DXV_CONVERSIONS_RELAY_SECRETandDXV_CONVERSIONS_RELAY_URL(defaulthttps://metis.dxvagentic.com), unlessrelayis passed; - with those missing, do nothing and log one warning per process;
- never throw. They return
{ status: "sent" | "skipped" | "failed" }and log failures (passloggerto use your own); - retry 5xx through metis-client (1s, 2s, 4s), so budget up to ~7 seconds on a dead relay.
consent is required, so a caller cannot forget it:
| You pass | Means | Metis sees |
|---|---|---|
a ConsentState | the visitor answered | { adUserData, adPersonalization } from the answer |
null | no consent cookie: the visitor never answered | { adUserData: false, adPersonalization: false, decided: false } |
NO_CONSENT_MECHANISM | the integration has no consent banner at all | no consent object |
Better matching (0.3.0)
Metis forwards a conversion to Meta and Google with whatever identifiers it carries, so send what the request knows. Everything here is dropped, like the hashes, unless the visitor granted both ad_user_data and ad_personalization:
import { readMeasurementCookies, reportPurchase, requestContext } from "@dxv-systems/measurement/server";
const { consent, clickIds, browserIds } = readMeasurementCookies(req.headers.cookie);
await reportPurchase({
order, consent, clickIds,
customer: { email, phone },
phoneCountryCode: "31", // "06 …" becomes +31 for Meta and Google
request: { ...requestContext(req.headers), ...browserIds }, // IP, user agent, Meta _fbc/_fbp
eventSourceUrl: pageUrl, // without its query string, and only with consent
});
Store request with the order as you store consent and clickIds: the payment webhook has none of it.
Sales staff enter (phone, e-mail, chat) have no browser and no banner. Report them with reportOfflinePurchase({ order, customer, phoneCountryCode, actionSource: "phone_call" | "email" | "chat" | "physical_store" | "other" }): no consent object, the hashes included. Metis keeps them and forwards them only once an operator attests the app's offline sales. A website sale is refused there; it goes through reportPurchase with the visitor's consent.
Funnel steps server-side (0.4.0)
Metis also forwards three browsing steps to Meta: view_item (ViewContent), add_to_cart (AddToCart) and begin_checkout (InitiateCheckout). Give the tracker a route of your own and serve it with the handler:
// lib/measurement.ts
createTracker({ policy, funnelEndpoint: "/api/measurement/funnel" });
// app/api/measurement/funnel/route.ts
import { funnelRouteHandler } from "@dxv-systems/measurement/server";
export const POST = funnelRouteHandler();
With Meta consent, the tracker gives the pixel call a random event id and posts the same step (event id, value, items, page) to that route. The handler reads the consent cookie, Meta's _fbc/_fbp, the IP and the user agent, and reports it with reportFunnelEvent; without ad consent it sends nothing (Metis would drop it) and answers 204. Meta dedupes the browser and server events on the shared id. Funnel steps are never counted as conversions in Metis. The route takes the relay settings from the same environment variables as reportPurchase.
Bots are dropped (0.11.0). A step from a known bot, crawler, link previewer, headless browser or scripted client (isBotUserAgent from @dxv-systems/metis-client: Googlebot, AdsBot, bingbot, facebookexternalhit, HeadlessChrome, curl, wget, python-requests and more), or with no user agent at all, is answered 204 and sent nowhere; reportFunnelEvent answers it { status: "skipped", reason: "bot" }, before consent is read. A crawler that renders the page would otherwise send fake steps to Meta. Purchases and leads are never filtered: they come from paid orders and sent forms. The user agent is the only bot signal the handler uses, so put a per-IP rate limit in front of the route as well.
App hint (0.11.0). Set DXV_METIS_APP_ID (or relay.appId) to the app's id in Metis and every report and the consent-platforms read send it as X-DXV-App-Id, so Metis tries that app's secret first. Optional; without it nothing changes.
Google tag: the customer's e-mail and phone (0.5.0)
Google's enhanced conversions for leads matches the hashed e-mail and phone that DXV Metis uploads against what the Google tag saw on the site next to the ad click. Without it, Google Ads reports "No user-provided data matches" and only uploads that carry a gclid count. Call this when someone orders or sends a form, in the browser:
import { shareUserDataWithGoogle } from "@dxv-systems/measurement";
shareUserDataWithGoogle(
{ email: form.email, phone: form.phone, phoneCountryCode: "31" }, // "06 …" becomes +31…
{ adsId: process.env.NEXT_PUBLIC_GOOGLE_ADS_ID }, // "AW-…"
);
It sets user_data (lowercased e-mail, E.164 phone) and sends a form_submit to the Ads id. That event names no conversion label, so it counts nothing: never add a browser conversion next to Metis, or every sale counts twice. It does nothing without both ad consents (ad_user_data and ad_personalization), the same rule Metis forwards under. In Google Ads, turn on enhanced conversions for leads (method "Google Ads API") and the Google tag's "Form interactions". phoneE164(phone, countryCode?) is exported for anything else that needs the E.164 form.
Express: the funnel route (0.5.0)
funnelRouteHandler is a Next route; Express gets the same behaviour:
import { funnelExpressHandler } from "@dxv-systems/measurement/server";
app.post("/api/metis/funnel", express.json(), funnelExpressHandler());
It reads the parsed body (req.body), the consent cookie, Meta's _fbc/_fbp, the IP and the user agent, reports the step with reportFunnelEvent and answers 204 (400 for a body that is no beacon). It drops bot steps like the Next route. Put a rate limit in front of it.
Both handlers take relay, logger, fetchImpl and (0.5.1) sleepMs. A route with its own time limit passes sleepMs: async () => {} so the retries do not wait.
Which platforms the banner names (0.6.0)
A shop never hard-codes which ad platforms the banner names. The banner asks the shop's own server, which asks Metis which platforms it forwards this app's conversions to. The answer is stored with the consent, every report to Metis carries it, and Metis forwards only to platforms the visitor saw named.
1. Mount the route. Next (App Router), app/api/measurement/consent-platforms/route.ts:
import { consentPlatformsHandler } from "@dxv-systems/measurement/server";
export const GET = consentPlatformsHandler();
Next 15 and later run a GET route per request; on Next 14 also add export const dynamic = "force-dynamic"; so the list is not frozen at build time.
Express:
import { consentPlatformsExpressHandler } from "@dxv-systems/measurement/server";
app.get("/api/measurement/consent-platforms", consentPlatformsExpressHandler());
Both read the relay from DXV_CUSTOMER_ID, DXV_CONVERSIONS_RELAY_SECRET and DXV_CONVERSIONS_RELAY_URL (or take relay, logger and fetchImpl like the funnel handlers) and answer { platforms: ("google" | "meta" | "pinterest")[], version } with Cache-Control: public, max-age=300. The Metis answer is cached in-process for 5 minutes. When Metis cannot be asked (relay env missing, Metis down or refusing) they answer every supported platform, { platforms: ["google", "meta", "pinterest"], version: "fallback" } with max-age=60, so the banner over-discloses rather than under-discloses. No production gate: a preview shows the real list.
2. Pass the route to the banner.
<ConsentBanner
policy={policy}
platformsEndpoint="/api/measurement/consent-platforms"
copyOptions={{ pixels: ["Google Ads", "Meta"], contactTags: ["Google"] }}
/>
The banner fetches the route (1 second timeout; on failure, a slow answer, or the route's fallback answer it names every supported platform but asks no one again, since that list is a guess, not Metis's word) and builds its text with dutchBannerCopy({ ...copyOptions, forwardsTo }), platform names written as Google, Meta and Pinterest. A site that also passes copy keeps its own strings and categories, except the two sharing options (ad_user_data, ad_personalization), which always come from the built list. Until the route answers, an unanswered banner names every supported platform.
What is stored. The answer in consent_v2 and dxv_consent_v2 gains platforms, the list the banner named (writeConsent(state, policy, { platforms }); read it back with readConsentRecord()). An answer without platforms (from before 0.6.0, or a banner without platformsEndpoint) stays valid and counts as Google and Meta. readConsent() still returns the four categories only.
Asking again. When the route names a platform the stored answer lacks (consentCoversPlatforms(stored, current)), the banner opens again, pre-filled with the visitor's earlier choices. A visitor who refused both sharing options is not asked again: no platform receives anything from them, a new one included. A visitor who never answered sees the banner as before.
What Metis gets. readMeasurementCookies returns the stored platforms with the consent, and toMetisConsent puts them on the relay's consent.platforms for reportPurchase, reportLead, reportFunnelEvent and the funnel routes. An answer without a list sends no platforms, and Metis applies its own rule for old answers.
The privacy page names the same platforms (0.6.1). Where the privacy statement says who gets the data, render <ConsentPlatformList endpoint="/api/measurement/consent-platforms" /> (from @dxv-systems/measurement/react; joiner="of" for "Google, Meta of Pinterest"). It renders every supported platform first, so the page reads correctly without JavaScript and over- rather than under-discloses, and swaps in Metis's list when the route answers with one.
Translated or custom banners (0.7.0)
The Dutch builder only helps a shop that accepts Dutch text. Two other shapes keep the same promise (the banner names the platforms Metis forwards to, stores them, asks again when one is added):
Your own words: copy as a function. It gets the platform names ("Google", "Meta", "Pinterest"; every supported one until the route answers) and its result wins everywhere, the two sharing options included. You own the text, translations too, and must name every platform you are given. formatPlatformList(names, { locale: "nl" | "en", joiner: "and" | "or" }) joins them ("Google, Meta and Pinterest").
// next-intl: messages hold "... sent to {platforms} ..."
const t = useTranslations("consent");
const locale = useLocale() === "nl" ? "nl" : "en";
<ConsentBanner
policy={policy}
platformsEndpoint="/api/measurement/consent-platforms"
copy={(platforms) => bannerCopy(t, formatPlatformList(platforms, { locale }))}
/>
bannerCopy(t, platforms) is the shop's own mapper: it passes { platforms } to t(...) for ad_user_data and ad_personalization. When Metis forwards to no platform, the function receives [] and the banner hides the two sharing options, so it needs no empty-list case; ConsentPlatformList renders nothing then, so wrap the privacy sentence in an element you can hide (or render the sentence only when your own check says so). An object copy behaves as before: the built Dutch sharing options still replace yours. The privacy page takes <ConsentPlatformList endpoint="..." locale="en" /> (joiner="or" for "or"). formatPlatformList is exported from the package root and from /react.
Your own banner. Read the list with useConsentPlatforms(endpoint), decide whether to ask again with shouldReaskForPlatforms, and store the list with the answer:
import { readConsentRecord, shouldReaskForPlatforms, writeConsent } from "@dxv-systems/measurement";
import { useConsentPlatforms } from "@dxv-systems/measurement/react";
const listed = useConsentPlatforms("/api/measurement/consent-platforms"); // { platforms, settled }
const names = listed.platforms.map(toDisplayName); // name every one of them in your text
useEffect(() => {
if (shouldReaskForPlatforms(readConsentRecord(), listed)) setOpen(true);
}, [listed]);
function save(state: ConsentState) {
writeConsent(state, policy, { platforms: listed.platforms });
}
useConsentPlatforms returns every supported platform with settled: false until the route answers, when it fails, on the route's fallback and with no endpoint; the same over-disclosure the banner uses. When Metis answers that it forwards to nobody (a shop with no ad accounts connected), it returns { platforms: [], settled: true }: a custom banner then hides the ad_user_data and ad_personalization options and stores both as denied, with writeConsent(state, policy, { platforms: [] }). shouldReaskForPlatforms(record, listed) is the banner's own rule: true only for a visitor who shares (ad_user_data or ad_personalization granted) and whose stored answer lacks a platform Metis now names; never on an unsettled list, never for a refusal. An answer recorded with platforms: [] never showed the sharing options, so its denials are not a refusal: it asks again as soon as Metis names a platform. Pass listed.platforms to writeConsent only when your text named them; without a list the answer counts as Google and Meta.
Ask only what the site needs (0.8.0)
Consent is needed only for non-essential storage or access on the device (ePrivacy art. 5(3), Telecommunicatiewet 11.7a) and for sharing personal data with ad platforms. A site that loads no such tag and whose conversions Metis forwards nowhere needs no banner. Its privacy page stays: the duty to inform does not depend on a banner.
loads: declare the consent-gated tags the site loads in the browser. The keys are the policy destinations (ga4, posthog, sessionReplay, meta, tiktok) plus googleAds, the Google tag with a Google Ads id (gated on ad_storage by Consent Mode, so it has no policy entry). The banner then asks only what is needed, computed by bannerCategories({ loads, policy, platforms }) from the package root:
| Category | Shown when |
|---|---|
analytics_storage | loads has ga4, posthog or sessionReplay whose policy is not "always" |
ad_storage | loads has meta or tiktok whose policy is not "always", or googleAds, or Metis forwards to a platform (the click-id cookie serves that forwarding) |
ad_user_data, ad_personalization | Metis forwards to a platform (as in 0.7.0) |
A destination on "always" does not count: it runs without the visitor's answer (GA4 and PostHog then run cookieless, see Policy). "Analytics only" shows only next to another category. "Accept all", "Reject" and "Save selection" cover what is shown; anything not shown is stored as denied.
No category, no banner. ConsentBanner then renders nothing, also on openConsentSettings(), and writes nothing to the consent cookie or localStorage. Undecided keeps every gate closed, so nothing fires. Hide the footer's "Cookie settings" link the same way with useBannerCategories({ policy, loads, platformsEndpoint }) from /react: null while the platform list is coming, then the categories ([] = no banner).
It waits for the list. With loads, the banner renders only once the platform route answered or gave up (1 second at most), so it does not flash. Metis down, a slow answer, the route's fallback or no platformsEndpoint all mean every supported platform, so the banner shows the ad categories: over-disclosure, as before. A site that wants no banner therefore needs the route, answering [].
Asking again. A visitor who got no banner has no answer, so the banner simply appears once something needs consent (Metis names a platform, or the site adds a tag to loads). With loads, an answer stores the categories it was asked about (categories in consent_v2; not in the cookie, the server does not need it). When loads later needs a category the answer was never asked about, the banner opens again, pre-filled (shouldReaskForCategories(readConsentRecord(), needed) for a custom banner). That holds for analytics too, even for a visitor who refused everything else: they were never asked about the new purpose, so their silence is not a refusal. A category they did see and refused is never asked again (until the answer expires). The platform list keeps its 0.6.0 rule (shouldReaskForPlatforms): a refusal of the sharing options is not asked again when Metis adds a platform.
Without loads the banner behaves exactly as 0.7.0: every category, the sharing options hidden when Metis really forwards to none.
Privacy link. privacyHref renders a real link after the banner body, text copy.privacyLink (Dutch default "Lees de privacyverklaring"; PRIVACY_LINK_TEXT.en is "Read the privacy statement"). The link gets classNames.privacyLink.
A site with no analytics, no ad tags and no Metis platforms (the route answers []): no banner, and no footer link.
const banner = { policy, loads: [], platformsEndpoint: "/api/measurement/consent-platforms" } as const;
<ConsentBanner {...banner} privacyHref="/privacy" />
const categories = useBannerCategories(banner); // in the footer
{categories?.length ? <button onClick={openConsentSettings}>Cookie-instellingen</button> : null}
A site with PostHog (strict policy) and no Metis platforms asks one question, analytics; once Metis names a platform it asks the ad categories too:
<ConsentBanner
policy={policy}
loads={["posthog"]}
platformsEndpoint="/api/measurement/consent-platforms"
privacyHref="/privacy"
/>
With loads, the Dutch body names only the purposes asked ("cookies voor analyse"); an object or function copy still wins.
Customer id, name and address (0.9.0)
Needs @dxv-systems/metis-client 0.14.0 or later (a dependency, nothing to install). customer on reportPurchase, reportLead, reportFunnelEvent and reportOfflinePurchase also takes externalId, firstName, lastName, city, postalCode and country (ISO alpha-2):
await reportPurchase({
order, consent, clickIds,
customer: {
email, phone,
externalId: order.customerId, // the shop's own customer (or lead) id
firstName: order.billing.firstName,
lastName: order.billing.lastName,
city: order.billing.city,
postalCode: order.billing.postalCode,
country: order.billing.country, // "NL"
},
});
They are gated exactly like the e-mail and phone: sent only when the visitor granted both ad_user_data and ad_personalization, and Metis passes them only to the platforms the consent named. metis-client normalises and hashes them; the postal and country code also go plain, because Google takes those unhashed (see the metis-client README). Store them with the order as you store consent and clickIds if the report happens in a webhook.
The click-id capture (ClickIdCapture, persistClickIds) now also keeps ttclid, msclkid and epik, under the same ad_storage gate and first-touch rule, and counts them as paid for paidTouch. Nothing sends ttclid or msclkid to TikTok or Microsoft yet: Metis keeps them for a later forwarder. The cookie's description in the privacy statement should name them.
paidTouch: was this visit from a paid ad?
The Metis consent drop-off monitor splits dropped conversions (refused and undecided) into paid, organic and unknown, so a shop can see whether the visitors it loses to the banner are the ones its ads paid for. That needs one boolean per conversion, paidTouch.
ClickIdCapturerecords it once per page load from the landing URL (gclid,fbclid,gbraid,wbraid,epik,ttclid,msclkid, or a paidutm_mediumsuch ascpcorpaid_social), whatever the consent. Read it withgetPaidTouch()from@dxv-systems/measurement:true,false, orundefinedbefore capture.- Memory only. The value lives in a module variable. It is never written to a cookie, localStorage, sessionStorage or anything else, so no device storage is accessed (ePrivacy 5(3)) and nothing needs consent. The click ids themselves are not sent. Metis counts the flag and never forwards it to an ad platform. A test fails if it is ever written to storage.
- Pass it to your server yourself, with the lead or order payload, then on to
reportLead/reportPurchase. The server does not infer it from cookies (there is none):
// browser
import { getPaidTouch } from "@dxv-systems/measurement";
await fetch("/api/lead", { method: "POST", body: JSON.stringify({ ...form, paidTouch: getPaidTouch() }) });
// server
void reportLead({ lead, consent, clickIds, paidTouch: typeof body.paidTouch === "boolean" ? body.paidTouch : undefined });
falsemeans "landed organically", never "this page had no params". A paid signal in the URL givestrue.falseis recorded only when the page load is provably a landing: a plain navigation (Navigation Timing typenavigate) with an empty or cross-origin referrer. A reload, back/forward, prerender, a same-origin referrer (another page on the same site) or a browser without Navigation Timing leaves the valueundefined(unknown), and the flag is then omitted from the event. Client-side navigation keeps the first value. Consequence: after a full reload or a return visit, or on an order placed after one, the flag is usually unknown, so paid is under-counted as unknown, never as organic. Paid params always win, even on a reload.- Mention it in the shop's privacy statement: an aggregate, in-memory landing-page signal used for measurement, based on legitimate interest, not stored on the device.
How long a consent answer lasts
An answer is valid for 12 months (CONSENT_VALIDITY_DAYS, 365). Regulators commonly point to 6 to 13 months before a visitor should be asked again; change it with setConsentValidityDays(days) at startup (also on the server if it builds the inline scripts, since they embed the period).
The answer lives in two places, localStorage consent_v2 and the cookie dxv_consent_v2 that server code reads, and both carry a decidedAt timestamp. They expire together: a fresh decision sets the cookie's max-age to the full period. On every browser read (readConsent, useConsent, ConsentBanner, ClickIdCapture) the package re-checks them:
- a missing or different cookie is rewritten from localStorage, with the decision's remaining validity as max-age;
- an expired decision is removed from both, so the banner asks again and the gates fall back to denied;
- an answer stored by 0.1.0 has no
decidedAt; it is stamped as decided now, so nobody is re-prompted by the upgrade.
The inline scripts (consentBootstrapScript, the pixel snippets) skip an expired answer too. Old cookies without decidedAt still parse on the server.
How the Metis consent drop-off monitor reads this
Metis derives one of four states per event from its consent object:
| State | Wire | What it says |
|---|---|---|
consented | both flags true | forwardable to the ad platforms |
refused | object present and decided, not both true (includes partial and analytics-only) | the visitor said no |
undecided | decided: false | the visitor never answered the banner |
not_recorded | no object | the sender does not report consent: an integration gap |
The monitor's drop-off rate is refused plus undecided over all events, with not_recorded shown apart. A shop on this package never produces not_recorded unless it passes NO_CONSENT_MECHANISM. It cannot see pixel-side loss (GA4, Meta Pixel, PostHog in the browser), only relay events.