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';
const decisa = new Decisa({ apiKey: process.env.DECISA_API_KEY! });
const result = await decisa.conversions.record({
type: 'sale',
externalId: 'order_1001', // your order id — also the idempotency key
valueCents: cents(199.9), // 199.90 -> 19990 integer cents
currency: '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); // 19990
cents(19.99); // 1999
await decisa.conversions.record({ type: 'sale', externalId: 'o1', valueCents: 19.99 });
// throws DecisaError: `valueCents` must be an integer number of cents...const decisa = new Decisa({
apiKey: process.env.DECISA_API_KEY!, // required, starts with dcs_ak_
baseUrl: 'https://api.decisa.ai', // default
timeoutMs: 10_000, // default per-request timeout
maxRetries: 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):
await decisa.events.record({
eventName: 'Purchase',
eventId: 'evt_7f3a91c0', // 8–64 chars; the idempotency key for events
valueCents: 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:
await decisa.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 {
await decisa.conversions.record({ type: 'sale', externalId: 'order_1001', valueCents: 1999 });
} catch (err) {
if (err instanceof DecisaValidationError) {
// 422 — per-field messages: { external_id: ["..."] }
console.error(err.fields);
} else if (err instanceof DecisaAuthError) {
// 401 — key missing / malformed / unknown / revoked / expired
} else if (err instanceof DecisaForbiddenError) {
// 403 — err.code is "INSUFFICIENT_SCOPE" or "ENTITLEMENT_MISSING"
if (err.code === 'ENTITLEMENT_MISSING') {
// workspace lacks the attribution capability
}
} else if (err instanceof DecisaRateLimitError) {
// 429 — err.retryAfter (seconds) when the server provided Retry-After
} else if (err instanceof DecisaServerError) {
// 5xx — transient (already retried by the SDK before throwing)
} else if (err instanceof DecisaError) {
// 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.
await decisa.conversions.record({ type: 'sale', externalId: 'order_1001', valueCents: 1999 });
await decisa.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