@dxv-systems/metis-client
DXV Metis conversion tracking SDK. Captures click IDs (gclid, fbclid, gbraid, wbraid, utm_*) on landing and emits signed server-side conversion events to the DXV Metis relay endpoint.
Published as
@dxv-systems/conversionsup to 0.3.0. That name is deprecated; the API is identical, only the package name changed.
Install
npm install @dxv-systems/metis-client
Onboarding
When your Metis account is set up, DXV gives you three values:
customerId— integer, identifies your customer record in MetisrelaySecret— HMAC signing key (treat as a credential)relayUrl— Metis endpoint base, typicallyhttps://metis.dxvagentic.com(the olderhttps://marketing.dxvagentic.comalso still works)
Store these as environment variables in your webshop's deployment. The examples below use DXV_CUSTOMER_ID, DXV_CONVERSIONS_RELAY_SECRET and DXV_CONVERSIONS_RELAY_URL. This package does not read them itself: you pass the values in.
Client side: capture click IDs on landing
In your landing-page handler (Next.js middleware, Express handler, anywhere):
import { CLICK_IDS_COOKIE, captureClickIds, parseClickIdsCookie, serializeClickIdsCookie } from "@dxv-systems/metis-client/client";
// On every request to a public page:
const url = new URL(request.url);
const captured = captureClickIds(url.searchParams);
if (captured) {
// First-touch wins: don't overwrite an existing valid cookie.
const existing = parseClickIdsCookie(request.cookies.get(CLICK_IDS_COOKIE)?.value);
if (!existing) {
response.cookies.set(CLICK_IDS_COOKIE, serializeClickIdsCookie(captured), {
maxAge: 30 * 24 * 60 * 60, // 30 days
httpOnly: false, // intentionally readable from client (analytics)
sameSite: "lax",
secure: true,
});
}
}
CLICK_IDS_COOKIE ("dxv_click_ids") is exported from both /client and /server, so apps no longer repeat the cookie name. Cookies written before 0.7.0 (without gbraid/wbraid) still parse.
Cookie encoding
parseClickIdsCookie takes the value in whatever form you happen to hold it:
the raw percent-encoded string from a Cookie header, or the already-decoded
string a framework cookie API returns. You do not have to know which you have.
This matters in practice. next/headers cookies() and
NextRequest.cookies.get() decode the header themselves, while
NextResponse.cookies.set() encodes what you give it, so a cookie written
through middleware, on top of serializeClickIdsCookie's own encoding, is
stored double-encoded. Values containing a literal % (a utm_campaign of
50%-korting, say) still parse correctly in every form.
Server side: emit purchase event
In your order-completed handler (Stripe webhook, etc.):
import { emitPurchase } from "@dxv-systems/metis-client/server";
import { parseClickIdsCookie } from "@dxv-systems/metis-client/client";
await emitPurchase({
customerId: Number(process.env.DXV_CUSTOMER_ID),
relaySecret: process.env.DXV_CONVERSIONS_RELAY_SECRET!,
relayUrl: process.env.DXV_CONVERSIONS_RELAY_URL!,
eventId: order.id, // optional; see "Deduping with a browser pixel" below
order: {
id: order.id,
totalCents: order.totalCents,
currency: "EUR",
},
clickIds: parseClickIdsCookie(cookies.get(CLICK_IDS_COOKIE)),
customer: {
email: order.customer.email, // plaintext; package hashes per Meta CAPI spec
phone: order.customer.phone,
},
consent: {
adUserData: consentState.adUserData,
adPersonalization: consentState.adPersonalization,
},
});
emitPurchase retries 3 times on 5xx with exponential backoff. It throws typed errors:
InvalidConfigError— missing/emptyrelaySecret,relayUrl, orcustomerId, or an invalid field. Thrown before any network call.RelayBadRequestError— Metis returned 4xx (likely a stalerelaySecretor wrongcustomerId). Not retried.RelayUnavailableError— Metis unreachable after 3 attempts.
Server side: emit lead event
For form-fill / signup conversions with no order attached (a quote request, a contact form, a demo booking):
import { emitLead } from "@dxv-systems/metis-client/server";
import { parseClickIdsCookie } from "@dxv-systems/metis-client/client";
await emitLead({
customerId: Number(process.env.DXV_CUSTOMER_ID),
relaySecret: process.env.DXV_CONVERSIONS_RELAY_SECRET!,
relayUrl: process.env.DXV_CONVERSIONS_RELAY_URL!,
eventId: submission.id, // optional; see "Deduping with a browser pixel" below
lead: {
id: submission.id,
valueCents: 0, // optional; omit if the lead has no estimated value
currency: "EUR", // optional; only meaningful alongside valueCents
},
clickIds: parseClickIdsCookie(cookies.get(CLICK_IDS_COOKIE)),
customer: {
email: submission.email,
phone: submission.phone,
},
});
emitLead shares config validation, PII hashing, signing and retry behavior with emitPurchase — same typed errors, same 5xx retry / 4xx-fatal rules. Only the input shape differs: lead (no order), and lead.id is the only required field.
Metis stores lead events and, with consent, forwards them to the ad platforms like purchases.
Job applications: lead.kind (0.15.0)
A careers page sends a job application as a lead with kind: "application":
await emitLead({ /* ... */, lead: { id: application.id, kind: "application" } });
Metis counts applications apart from enquiries and never forwards them to Meta, Google or Pinterest, whatever the visitor consented to: an ad platform taught on applicants finds applicants instead of customers. It keeps no hashed identifiers for them. kind: "lead" is the same as leaving it out. Any other value throws InvalidConfigError before anything is sent. Needs a Metis that knows the field (October 2026 onwards); an older one ignored it.
Server side: emit a funnel step (0.10.0)
emitFunnelEvent({ event: "view_content" | "add_to_cart" | "initiate_checkout", eventId, funnel?: { valueCents?, currency?, items?: [{ id, quantity, priceCents? }] }, clickIds, customer, consent, context }) reports a browsing step that Metis forwards to Meta only. eventId is required: pass the one the browser pixel used, so Meta dedupes them. Metis keeps the step only when both consent signals are true and the visitor decided, and never counts it as a conversion. context.actionSource may only be website. Most sites use @dxv-systems/measurement's funnelRouteHandler instead.
Bots and the app hint (0.16.0)
isBotUserAgent(userAgent) and BOT_USER_AGENT_TOKENS (from ./server) name known bots, crawlers, link previewers, headless browsers and scripted HTTP clients (Googlebot, AdsBot, bingbot, facebookexternalhit, HeadlessChrome, curl, wget, python-requests and more), matched as lowercase substrings. Explicit names only: a bare "bot" would also match real phones. Absent is not a bot. @dxv-systems/measurement drops funnel steps from these before they are sent, and Metis drops them again at ingest using the same list. Purchases and leads are never filtered.
Every emit, and fetchConsentPlatforms, takes an optional appId (the app's id in Metis, a positive integer, else InvalidConfigError). It is sent as X-DXV-App-Id, so Metis tries that app's secret first instead of each of the organization's (or, for consent-platforms without customerId, every app's). It is only a hint: the signature still names the app, and a Metis without the header ignores it.
Metis limits each app's relay events per minute (600 funnel steps, 120 purchases and leads). Over the limit it answers 429, which is a RelayBadRequestError and not retried.
More match keys (0.14.0)
customer also takes externalId (the shop's own customer or lead id), firstName, lastName, city, postalCode and country (ISO 3166-1 alpha-2, e.g. "NL"), all optional and plaintext. They are normalised and hashed in-memory like the e-mail:
| Field | Sent as | Normalised to |
|---|---|---|
externalId | externalIdSha256 | trimmed, case kept (an id is opaque) |
firstName, lastName | firstNameSha256, lastNameSha256 | NFC, lowercase, no punctuation, single inner spaces ("Jean-Luc" → jeanluc, "Zoë" → zoë) |
city | citySha256 | lowercase letters and digits only, accents dropped ("'s-Hertogenbosch" → shertogenbosch) |
postalCode | postalCodeSha256 | lowercase, no spaces or dashes ("1234 AB" → 1234ab); a US code keeps 5 digits |
country | countrySha256 | lowercase alpha-2 (nl) |
postalCode, country | postalCode, countryCode, plain | trimmed, uppercase (1234 AB, NL) |
The plain pair exists because Google's Data Manager takes an address's postal and region code unhashed (only the names are hashed). An empty field, or a country that is not two letters, is left out, never hashed as an empty string. IP and user agent stay unhashed in context, as before.
Metis sends them to Meta (external_id, fn, ln, ct, zp, country), to Google as an address (hashed names, plain region and postal code) and to Pinterest (external_id, fn, ln, ct, zp, country), each only where the visitor's consent allows, like the e-mail. Needs a Metis version that knows the fields: an older one accepts the body and drops them.
captureClickIds also captures ttclid (TikTok), msclkid (Microsoft) and epik (Pinterest) into the same first-touch dxv_click_ids cookie, and detectPaidTouch counts them as a paid click. Metis forwards epik to Pinterest; ttclid and msclkid are kept for a later forwarder.
Consent
Both emitPurchase and emitLead accept an optional consent field:
consent: {
adUserData: boolean; // Google Consent Mode v2 "ad_user_data"
adPersonalization: boolean; // Google Consent Mode v2 "ad_personalization"
decided?: boolean; // has the visitor answered the banner? absent = yes
platforms?: ("google" | "meta" | "pinterest")[]; // what the banner named (0.13.0); absent = not recorded
}
This is not optional in practice. Metis stores every event regardless, but its Meta CAPI / Google offline-conversions forwarders fail closed: an event that arrives without a consent object is never forwarded, only stored. If you want this conversion to actually reach Meta or Google, you must pass consent — read it from whatever consent-management platform your storefront already runs (a CMP, a cookie banner, your own preference store) and mirror its ad-consent state here. adUserData and adPersonalization are required when the object is present; there is no partial form.
Visitors who have not answered the banner
decided (added in 0.7.0) lets Metis tell a visitor who refused from one who never answered. Absent means decided, so existing senders keep their meaning. When the visitor has not answered the banner yet, send:
consent: { adUserData: false, adPersonalization: false, decided: false }
This still fails closed like any other non-consent: nothing is forwarded to Meta or Google. Omit consent altogether only if your integration has no consent information at all; Metis reports that as a sender gap, which is different from "undecided". decided must be a boolean when present.
Which platforms the visitor consented to (0.13.0)
consent.platforms lists the ad platforms the banner named when the visitor answered; Metis forwards only to those. Names come from CONSENT_PLATFORMS (google, meta, pinterest, exported from /client and /server), each at most once; anything else throws InvalidConfigError. Leave it out when the banner did not record a list.
Server side: ask Metis which platforms the banner names (0.13.0)
import { fetchConsentPlatforms } from "@dxv-systems/metis-client/server";
const answer = await fetchConsentPlatforms({ relayUrl, relaySecret, customerId });
// { platforms: ["google", "meta"], version: "3f2a9c1b7d4e" } or null
It sends GET {relayUrl}/api/relay/v1/consent-platforms?customerId={customerId}, signed like a relay POST over an empty body: X-DXV-Signature is the hex HMAC-SHA256 of `${timestamp}.` with the relay secret, X-DXV-Timestamp the same millisecond timestamp. customerId is optional; without it Metis tries the secret against every active app. It never throws: any failure (network, timeout of 3 s by default, non-2xx, a malformed answer) returns null. Unknown platform names are dropped. Options: fetchImpl, timeoutMs. Most sites use @dxv-systems/measurement's consentPlatformsHandler instead, which caches the answer and falls back to every platform.
Paid-touch flag
detectPaidTouch(input) (from /client or /server, added in 0.8.0) returns true when a landing URL says the visit came from an ad: any of gclid, fbclid, gbraid or wbraid is present and non-empty, or utm_medium (trimmed, case-insensitive) is one of cpc, ppc, cpm, cpv, paid, paidsocial, paid_social, paid-social, paidsearch, paid_search, paid-search, display. It accepts a URLSearchParams, a query string, a full URL string or a URL.
import { detectPaidTouch } from "@dxv-systems/metis-client/client";
const paidTouch = detectPaidTouch(window.location.search); // keep in a variable
await emitLead({ /* ... */, paidTouch });
Both emitPurchase and emitLead accept an optional paidTouch: boolean, sent as a top-level field. Metis's consent monitor counts it to split refused and undecided visitors into paid and organic; it is never forwarded to Meta or Google. Leave it out when you do not know (absent means unknown). It must be a boolean when present.
Hold the result in memory only. Never write it to a cookie, localStorage or any other storage: that would be device storage without consent. A full reload or a return visit should give "unknown" (omit the flag), not a guess.
Deduping with a browser pixel
If your site also reports this conversion from the browser (a Meta Pixel Purchase/Lead event, a gtag conversion event), pass the same eventId to both the pixel call and emitPurchase/emitLead. Meta and Google dedupe a browser event against a server event by matching event name plus a shared event ID — without it, a conversion reported from both sides gets counted twice once Metis starts forwarding server events onward.
eventId is optional — omit it if you have no browser-side counterpart for this conversion. When supplied it must be a non-empty string (whitespace-only is rejected) of at most 256 characters; use whatever ID you already share between your pixel and your server call (a nanoid, an order reference, a UUID).
Deriving a reproducible event id
If your browser pixel and your server-side emit don't share process state — the common case, since the pixel fires on the confirmation page and the server emit fires later from a webhook — you need a way to derive the same id on both sides from something they both already know, like the order number:
import { purchaseEventUuid } from "@dxv-systems/metis-client/client"; // or /server
const eventId = purchaseEventUuid(order.number);
// Pass this to both the pixel call (fbq('track', 'Purchase', {}, { eventID: eventId })
// or posthog.capture(..., { uuid: eventId })) and to emitPurchase's `eventId` field.
purchaseEventUuid and the more general deterministicEventUuid are pure functions available from both /client and /server — the browser needs one to compute the id for its pixel call, and the server needs the identical function to recompute the same id later for the relay emit, without the two ever coordinating. Determinism is the whole point: a random per-event id can't be reproduced after the fact, and Meta/PostHog dedupe a server event against its browser counterpart by matching that id — get it wrong and every forwarded conversion double-counts, silently.
The output is also shaped as a valid RFC-4122 uuid rather than a readable string like purchase:ORD-0001, because several platforms in this chain (PostHog included) accept only a valid uuid as an event id and silently swap in a random one otherwise — again with no error, just a dedup that quietly stops working.
For lead conversions use leadEventUuid(lead.id) the same way (equal to deterministicEventUuid("lead:" + lead.id)), so a browser Lead pixel and emitLead share one event id.
Both sides must use it, or dedup does not work. If your storefront's pixel derives its id one way and the server emit derives it another (or one side uses a random id), Meta/PostHog cannot tell the two events are the same conversion, and it gets counted twice once Metis forwards the server-side event onward.
Conventions
- Money in cents (integer). Metis validates
totalCents > 0. - Currency as ISO 4217 (e.g.
EUR,USD). - PII (email, phone, external id, name, city, postal code, country) is hashed in-memory before the network call; plaintext never leaves the process, except the postal and country code Google takes plain (0.14.0). Metis keeps the hashes only on a row it may forward (consented, or an attested offline sale) and clears them once that is no longer possible.
- A phone is hashed twice when its country is known (
+31…,0031…, orphoneCountryCode: "31"):phoneSha256as digits with the country code (Meta) andphoneE164Sha256with the+(Google). Without a country only the digits-onlyphoneSha256is sent, as before. phoneE164(phone, countryCode?)andphoneWithCountryCodeare exported from./clienttoo (0.12.0), so a browser can format a phone the way the server hashes it.- A Gmail or Googlemail address with dots before the
@is hashed twice too (0.11.0):emailSha256as typed (Meta) andemailGoogleSha256without the dots (Google matches it that way). Any other address sendsemailSha256only. context(0.9.0) carries the client IP, user agent, page URL andactionSource(website, orphone_call/email/chat/physical_store/otherfor a sale staff entered).clickIdsmay carry Meta's_fbc/_fbpcookies asfbc/fbp.- Click-ID cookie is first-touch wins for 30 days — do not overwrite if a valid cookie already exists.
License
MIT