@dxv-systems/metis-client

中文文档
0.16.1发布于 2026/11/10MITTypeScriptCommonJS零依赖近 30 天下载 5295

DXV Metis conversion tracking SDK — click-ID capture and signed conversion event emission

原始使用文档
文档目录19 节
  1. Install
  2. Onboarding
  3. Client side: capture click IDs on landing
  4. Cookie encoding
  5. Server side: emit purchase event
  6. Server side: emit lead event
  7. Job applications: lead.kind (0.15.0)
  8. Server side: emit a funnel step (0.10.0)
  9. Bots and the app hint (0.16.0)
  10. More match keys (0.14.0)
  11. Consent
  12. Visitors who have not answered the banner
  13. Which platforms the visitor consented to (0.13.0)
  14. Server side: ask Metis which platforms the banner names (0.…
  15. Paid-touch flag
  16. Deduping with a browser pixel
  17. Deriving a reproducible event id
  18. Conventions
  19. License

@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/conversions up 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 Metis
  • relaySecret — HMAC signing key (treat as a credential)
  • relayUrl — Metis endpoint base, typically https://metis.dxvagentic.com (the older https://marketing.dxvagentic.com also 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.

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/empty relaySecret, relayUrl, or customerId, or an invalid field. Thrown before any network call.
  • RelayBadRequestError — Metis returned 4xx (likely a stale relaySecret or wrong customerId). 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:

FieldSent asNormalised to
externalIdexternalIdSha256trimmed, case kept (an id is opaque)
firstName, lastNamefirstNameSha256, lastNameSha256NFC, lowercase, no punctuation, single inner spaces ("Jean-Luc" → jeanluc, "Zoë" → zoë)
citycitySha256lowercase letters and digits only, accents dropped ("'s-Hertogenbosch" → shertogenbosch)
postalCodepostalCodeSha256lowercase, no spaces or dashes ("1234 AB" → 1234ab); a US code keeps 5 digits
countrycountrySha256lowercase alpha-2 (nl)
postalCode, countrypostalCode, countryCode, plaintrimmed, 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.

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…, or phoneCountryCode: "31"): phoneSha256 as digits with the country code (Meta) and phoneE164Sha256 with the + (Google). Without a country only the digits-only phoneSha256 is sent, as before.
  • phoneE164(phone, countryCode?) and phoneWithCountryCode are exported from ./client too (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): emailSha256 as typed (Meta) and emailGoogleSha256 without the dots (Google matches it that way). Any other address sends emailSha256 only.
  • context (0.9.0) carries the client IP, user agent, page URL and actionSource (website, or phone_call / email / chat / physical_store / other for a sale staff entered). clickIds may carry Meta's _fbc / _fbp cookies as fbc / fbp.
  • Click-ID cookie is first-touch wins for 30 days — do not overwrite if a valid cookie already exists.

License

MIT

版本与发布记录

@dxv-systems/metis-client 共发布 15 个版本,首次发布于 2026/09/06;过去 12 个月发布了 15 个版本。

版本发布日期距今
0.16.1latest2026/11/103 天前
0.16.02026/10/3113 天前
0.15.02026/10/3113 天前
0.14.02026/10/3113 天前
0.13.02026/10/2816 天前
0.12.02026/10/2716 天前

关于 @dxv-systems/metis-client 的常见问题

@dxv-systems/metis-client 是什么?

DXV Metis conversion tracking SDK — click-ID capture and signed conversion event emission

如何安装 @dxv-systems/metis-client?

在项目目录运行 npm install @dxv-systems/metis-client,或使用 pnpm add / yarn add / bun add。

@dxv-systems/metis-client 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@dxv-systems/metis-client 支持 ESM 还是 CommonJS?

以 CommonJS 形式发布,可以直接 require();在 ES Module 项目中也可以用 import 引入(Node.js 会把 module.exports 作为默认导出)。

@dxv-systems/metis-client 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@dxv-systems/metis-client 可以商用吗?

@dxv-systems/metis-client 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@dxv-systems/metis-client 的下载量有多少?

根据 npm 官方下载统计,@dxv-systems/metis-client 在 2026/10/13 – 2026/11/11 这 30 天内被下载 5,295 次,日均约 177 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@dxv-systems/metis-client 还在维护吗?

最新版本 0.16.1 发布于 2026/11/10(3 天前);至今共发布 15 个版本,其中过去 12 个月发布了 15 个;首次发布于 2026/09/06。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。