@firepixel/node
v0.3.0
Published
Server-side conversion tracking for FirePixel. One dependency-free ESM helper that normalises, hashes, signs, sends and retries your events.
Downloads
325
Maintainers
Readme
@firepixel/node
Server-side conversion tracking for FirePixel. You hand it the order data you already have, in the shape your app already uses. Normalising, per-network hashing, reading the attribution cookies, HMAC signing, sending and retrying all happen inside the package.
Zero runtime dependencies. One module, the same file the FirePixel headless guide walks through, comments and all.
The problem it exists to solve. Every identifier an ad network receives is a SHA-256 digest, and a digest computed under the wrong normalisation rule is still a valid-looking 64-character string. Get it wrong and nothing fails: no error, no log line, no rejected request. The conversions deliver and match nobody, and the only symptom is a match rate somebody notices three months later. This package owns those rules so your checkout does not have to.
Install
npm install @firepixel/nodeNode 20.19+ or 22.12+. The package is ESM only, with no CommonJS build and therefore no
dual-package hazard. require() of an ES module works on that floor, and the test suite asserts
it, so a CommonJS codebase needs no changes:
const { createFirePixel } = require('@firepixel/node');One caveat, and only for types: a TypeScript project that emits CommonJS needs
"moduleResolution": "nodenext". The older "node16" predates require() of an ES module and
reports TS1479 even though the call succeeds at runtime. bundler and every ESM configuration
are unaffected.
Quick start
import { createFirePixel } from '@firepixel/node';
const fp = createFirePixel({ kid: process.env.FIREPIXEL_KID, secret: process.env.FIREPIXEL_SECRET });
await fp.purchase({
eventId: order.id, // your order id, so a retry deduplicates
customer: { email, phone, firstName, lastName, country: 'us' },
order: { currency: 'USD', value: order.total, orderId: order.id, items },
request: req, // the INCOMING request: IP, UA, click cookies
});FIREPIXEL_SECRET is server side only. Never prefix it NEXT_PUBLIC_, never import it into
client code, never return it from a route. Anyone holding it can sign events for your store. Both
values come from Stores, then your store, then Credentials in the FirePixel app.
Pass plaintext to customer. The package produces the digests each network actually wants (em
and em_google for the two different email rules, ph and ph_e164 for the two different phone
rules) plus the plaintext address block Google's addressInfo needs. Nothing unhashed leaves
your network.
Events
Twelve canonical events, each with a named convenience method. fp.purchase(data) is exactly
fp.track('Purchase', data).
| Method | Event |
|---|---|
| fp.pageView() | PageView |
| fp.viewContent() | ViewContent |
| fp.viewCart() | ViewCart |
| fp.addToCart() | AddToCart |
| fp.removeFromCart() | RemoveFromCart |
| fp.addToWishlist() | AddToWishlist |
| fp.search() | Search |
| fp.initiateCheckout() | InitiateCheckout |
| fp.addShippingInfo() | AddShippingInfo |
| fp.addPaymentInfo() | AddPaymentInfo |
| fp.purchase() | Purchase |
| fp.refund() | Refund |
fp.track(name, data) also accepts common aliases, so GA4 spellings (view_item,
begin_checkout) and Shopify's (checkout_completed) map to the canonical name instead of being
dropped. Pass an array as data to send a batch.
The rest of the surface
| | |
|---|---|
| fp.verify(name, data) | Run the whole path and get back what WOULD be delivered, sending nothing. Do not ship with it on. |
| fp.captureAttribution(request, response) | Persist ad-click ids into first-party cookies at landing. Omit response to get the Set-Cookie values back and set them yourself. |
| fp.readAttribution(request) | The shopper's click ids and client hints as a plain object. Save it on the order at checkout and pass it back as identity from the payment webhook, which has no cookies of its own. |
| fp.buildEvent(name, data) | The exact object that would be POSTed, without posting it. |
| fp.send(events, options) | POST an already-built event. |
| fp.sign(rawBody, ts) | The Authorization: FirePixel-HMAC ... header for a raw body. |
It warns, it does not throw
A tracking call must never take down a checkout. Anything wrongly typed gets a console.warn
prefixed [FirePixel], and is then coerced if the intent is obvious ("19.99" is a number) or
dropped if it is not ("$19.99" is not). Transport failures come back as { ok: false, error },
never as a rejected promise. The only throw is a missing kid or secret at construction,
because nothing can work without them and failing at startup beats failing per event.
Logging
Pass logger to send the package's diagnostics somewhere other than console:
const fp = createFirePixel({ kid, secret, logger: myLogger });warn is required and receives every warning as one string, prefixed [FirePixel]. debug,
info and error are optional; when your logger has them, a retry is reported at debug and a
send that has used up its retries at error. They are called as level(message, meta), where
message is a complete sentence and meta a plain object with the same facts (attempt,
delay_ms, status, attempts). That shape suits console and winston as they are; for pino,
which wants the object first, wrap it: { warn: (m) => log.warn(m), debug: (m, meta) => log.debug(meta, m), error: (m, meta) => log.error(meta, m) }.
Without a logger, warnings go to console.warn and the other levels are silent. The debug and
error lines never include your secret, a signature or any part of an event.
The client header
Every ingest request carries X-FirePixel-Client: node/<version> (the exported SDK_VERSION),
so FirePixel can see which SDK versions are still sending. It is not part of the signature and
carries no credential.
Full guide
The complete walkthrough, including capturing attribution at landing, sending events from a server-rendered page, snapshotting attribution onto the order so the payment webhook can still attribute a sale, and what to do when nothing arrives, lives in the FirePixel headless integration guide and its companion example storefront.
The shipped dist/firepixel.mjs is itself the reference: every normalisation rule is commented
with why it exists rather than what it does. Reading it is a supported way to use this package.
TypeScript declarations ship with the package. There is nothing to install from @types.
Developing this package
src/firepixel.ts is the only file to edit. dist/firepixel.mjs and dist/firepixel.d.mts are
generated from it and are not committed.
npm install
npm run build # generate dist/
npm test # drift guard, then 126 tests against dist/The runtime file is produced by module.stripTypeScriptTypes() rather than by tsc, because tsc
re-prints from the AST and would destroy the blank lines and indentation on a file that is meant
to be read. tsc still emits the declarations. npm run check regenerates both into a temp
directory and fails if dist/ differs, so the suite cannot pass against a stale build.
Licence
MIT. See LICENSE.
