@dxv-systems/metis-client
v0.8.0
Published
DXV Metis conversion tracking SDK — click-ID capture and signed conversion event emission
Maintainers
Readme
@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 Metis Portal's 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-clientOnboarding
The DXV Metis operator creates a customer in the Portal and provisions you with three values:
customerId— integer, identifies your customer record in the PortalrelaySecret— HMAC signing key (treat as a credential)relayUrl— Portal endpoint base, typicallyhttps://metis.dxvagentic.com(existing integrations may still usehttps://marketing.dxvagentic.comduring the rebrand migration; both work)
Store these as environment variables in your webshop's deployment.
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.
That tolerance is load-bearing rather than defensive. 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. Before this was handled, a value containing a literal
% (a utm_campaign of 50%-korting, say) failed to parse and the function
returned null with no error, losing attribution silently.
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:
InvalidConfig— missing/emptyrelaySecret,relayUrl, orcustomerId. Thrown synchronously before any network call.RelayBadRequest— Portal returned 4xx (likely a stalerelaySecretor wrongcustomerId). Not retried.RelayUnavailable— Portal 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.
The Portal stores lead events with event_type = 'lead'. As of this release nothing forwards leads to Meta CAPI / Google offline conversions yet — that's a later stage of the conversions rollout; purchases already forward.
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
}This is not optional in practice. The Portal 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.
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). The Portal validates
totalCents > 0. - Currency as ISO 4217 (e.g.
EUR,USD). - PII (email, phone) is hashed in-memory before the network call and never persisted by the Portal.
- Click-ID cookie is first-touch wins for 30 days — do not overwrite if a valid cookie already exists.
License
MIT
