Official server-side Node.js SDK for the Decisa ingest API. Record conversions and events from your backend with typed inputs, typed errors, and safe automatic retries.
- Zero runtime dependencies (uses built-in
fetch/AbortController) - First-class TypeScript types, ESM + CJS
- camelCase in, snake_case on the wire — you never touch the wire format
- A money guard that catches the #1 install footgun (sending decimals instead of integer cents)
- Idempotent by design: re-sending the same id is safe
Server-side only — never ship this in a browser bundle. This SDK authenticates with a secret API key (
dcs_ak_…). If that key reaches a browser it can be stolen and used to forge conversions. Browser tracking is a different job handled by Decisa'spixel.js. Run this only on a server you control (an API route, a worker, a webhook handler).
npm install @decisa/nodeRequires Node.js 18+ (for the built-in fetch).
import{Decisa,cents}from'@decisa/node';constdecisa=newDecisa({apiKey: process.env.DECISA_API_KEY!});constresult=awaitdecisa.conversions.record({type: 'sale',externalId: 'order_1001',// your order id — also the idempotency keyvalueCents: cents(199.9),// 199.90 -> 19990 integer centscurrency: 'BRL',method: 'pix',customerEmail: 'buyer@example.com',// hashed server-side; raw is never stored});console.log(result.data.id,result.data.attributed);valueCents is integer cents — 19990, not 199.90. Use the cents() helper to convert from major units; it rounds half-up. Passing a decimal to valueCents throws before any network call:
import{cents}from'@decisa/node';cents(199.9);// 19990cents(19.99);// 1999awaitdecisa.conversions.record({type: 'sale',externalId: 'o1',valueCents: 19.99});// throws DecisaError: `valueCents` must be an integer number of cents...constdecisa=newDecisa({apiKey: process.env.DECISA_API_KEY!,// required, starts with dcs_ak_baseUrl: 'https://api.decisa.ai',// defaulttimeoutMs: 10_000,// default per-request timeoutmaxRetries: 2,// default retry attempts for transient failures});Events are top-of-funnel and signal events (PageView, AddToCart, Purchase, …). Pass an array to pixelKeys and the SDK joins it to a comma-separated string on the wire automatically (events differ from conversions, which take an array):
awaitdecisa.events.record({eventName: 'Purchase',eventId: 'evt_7f3a91c0',// 8–64 chars; the idempotency key for eventsvalueCents: cents(49.0),currency: 'USD',url: 'https://shop.example.com/checkout/complete',pixelKeys: ['px_main','px_secondary'],// -> "px_main,px_secondary"});occurredAt is optional but recommended when you record after the fact — use ISO-8601 with an explicit UTC offset (2025-06-01T12:00:00Z).
Both conversions.record and events.record accept the same optional click-id and cookie match signals to improve match quality downstream (fbclid, gclid, gbraid, wbraid, ttclid, msclkid, twclid, epik, fbp, fbc, ttp, ip, userAgent, utmSource). Forward whatever you captured at the edge:
awaitdecisa.conversions.record({type: 'sale',externalId: 'order_2002',valueCents: cents(99.0),currency: 'USD',fbclid: req.query.fbclid,gclid: req.query.gclid,ip: req.ip,userAgent: req.headers['user-agent'],});Every failure maps to a typed error. Catch the base DecisaError to handle anything, or narrow to a subclass:
import{DecisaError,DecisaValidationError,DecisaAuthError,DecisaForbiddenError,DecisaRateLimitError,DecisaServerError,}from'@decisa/node';try{awaitdecisa.conversions.record({type: 'sale',externalId: 'order_1001',valueCents: 1999});}catch(err){if(errinstanceofDecisaValidationError){// 422 — per-field messages: { external_id: ["..."] }console.error(err.fields);}elseif(errinstanceofDecisaAuthError){// 401 — key missing / malformed / unknown / revoked / expired}elseif(errinstanceofDecisaForbiddenError){// 403 — err.code is "INSUFFICIENT_SCOPE" or "ENTITLEMENT_MISSING"if(err.code==='ENTITLEMENT_MISSING'){// workspace lacks the attribution capability}}elseif(errinstanceofDecisaRateLimitError){// 429 — err.retryAfter (seconds) when the server provided Retry-After}elseif(errinstanceofDecisaServerError){// 5xx — transient (already retried by the SDK before throwing)}elseif(errinstanceofDecisaError){// network/timeout/other}}Every error carries httpStatus, code, message, and requestId (when the response provides one). The API key is never included in any error message or in console.log / util.inspect output.
The SDK retries automatically — but only on transient failures: network errors, 5xx, and 429. Deterministic 4xx errors (401, 403, 422) are never retried. Backoff is exponential with jitter; on a 429 the SDK honors the Retry-After header when present. Tune with maxRetries (default 2) and timeoutMs (default 10000).
externalId (conversions) and eventId (events) are idempotency keys. The server dedupes on them, so re-sending the same id is safe — it returns the stored record and still responds with a success. A duplicate is not an error. This is what makes retries safe: if you're unsure whether a request landed, just send it again with the same id.
// Both calls return the same conversion; the second is a no-op server-side.awaitdecisa.conversions.record({type: 'sale',externalId: 'order_1001',valueCents: 1999});awaitdecisa.conversions.record({type: 'sale',externalId: 'order_1001',valueCents: 1999});You write camelCase; the SDK sends snake_case. You never construct the wire body yourself.
| SDK input | Wire field |
|---|---|
externalId | external_id |
eventId | event_id |
eventName | event_name |
valueCents | value_cents |
customerEmail | customer_email |
pixelKey | pixel_key |
pixelKeys | pixel_keys |
clickId | click_id |
occurredAt | occurred_at |
refundsExternalId | refunds_external_id |
isTest | is_test |
userAgent | user_agent |
utmSource | utm_source |
pixelKeysis an array for conversions but a comma-separated string on the events wire — pass an array in both cases and the SDK handles the difference.
MIT