# Node.js server SDK

URL: https://traqio.app/docs/sdks/node
Last updated: 2026-10-16

Report sign-ups and purchases from a Node.js backend with @traqio/node, and tie each one to the ad click it came from. Express, Next.js, NestJS, Fastify.

`@traqio/node` is the Traqio SDK for Node.js backends — the server half of Traqio's web SDK. The conversion that counts on a website — a sign-up, a subscription, a purchase — happens on your backend, where the user id is known. This package reports it with the **trackID**, the ad click id that [@traqio/web](https://traqio.app/docs/sdks/web) kept in first-party storage since the landing page, and Traqio credits the campaign that brought the user — [deterministically](https://traqio.app/docs/concepts/web-attribution), by that click's id, never by a guess. No dependency; Express, Next.js, NestJS, Fastify or plain `node:http`.

## Requirements

- Node.js 18 or later (the SDK uses the built-in `fetch`). ESM and CommonJS, with TypeScript types.
- A **server key** for the product (`tq_sk_…`). Neither the app's SDK key nor a web key works here.
- [@traqio/web](https://traqio.app/docs/sdks/web) on the site, and ads pointing at [tracking links](https://traqio.app/docs/concepts/tracking-links) with a website destination — that is how the trackID reaches your sign-up form.

## Set up

### Create a server key

In the console, open **Integrations → SDK** and create a **server key**. It is shown once: put it in the backend's environment, never in code or in a page. Traqio keeps only a fingerprint of it; a lost key is replaced by a new one, and the old one revoked.

.env:
```bash
TRAQIO_SECRET_KEY=tq_sk_...
```

### Install the package

```bash
npm install @traqio/node
```

### Create the client

```ts
import { Traqio } from "@traqio/node";

// One client per process. The key comes from the server's environment, never from code.
export const traqio = new Traqio({ secretKey: process.env.TRAQIO_SECRET_KEY! });
```

The client holds no connection: one per process is enough. It refuses a web key (`tq_web_…`) and refuses to start in a browser.

### Report the sign-up with its trackID

Call `signup()` once, where the account is created. @traqio/web writes the trackID in the `traq_click` cookie on your own domain, so the browser sends it with the sign-up request:

Express:
```ts
import { trackIdFromRequest } from "@traqio/node";
import { traqio } from "./traqio";

app.post("/signup", async (req, res) => {
  const user = await users.create(req.body);

  // The trackID arrives with the request: @traqio/web set the traq_click cookie.
  const result = await traqio
    .signup({ userId: user.id, trackId: trackIdFromRequest(req) })
    .catch((err) => logger.warn(err)); // never fail the sign-up itself

  res.status(201).json(user);
});
```

Next.js:
```ts
// app/api/signup/route.ts (Next.js App Router)
import { cookies } from "next/headers";
import { TRACK_ID_COOKIE } from "@traqio/node";
import { traqio } from "@/lib/traqio";

export async function POST(request: Request) {
  const user = await users.create(await request.json());
  const trackId = (await cookies()).get(TRACK_ID_COOKIE)?.value ?? null;
  await traqio.signup({ userId: user.id, trackId }).catch(console.warn);
  return Response.json(user, { status: 201 });
}
```

NestJS:
```ts
// users.service.ts (NestJS)
import { Injectable, Logger } from "@nestjs/common";
import { Traqio } from "@traqio/node";

@Injectable()
export class UsersService {
  private readonly traqio = new Traqio({ secretKey: process.env.TRAQIO_SECRET_KEY! });
  private readonly log = new Logger(UsersService.name);

  async register(dto: RegisterDto, trackId: string | null): Promise<User> {
    const user = await this.users.create(dto);
    await this.traqio.signup({ userId: user.id, trackId }).catch((e: Error) => this.log.warn(e.message));
    return user;
  }
}
```

If the backend is on another domain than the site, the cookie does not reach it: send `Traqio.getTrackId()` from the browser with the form instead. The sign-up is the moment the user is **acquired**; the last click before it, inside your [attribution window](https://traqio.app/docs/concepts/attribution-windows), gets the credit.

### Track the conversions

```ts
await traqio.track({
  userId: user.id,
  name: "purchase",
  value: 49,
  currency: "EUR",
  transactionId: paymentIntent.id, // one payment, one row — whoever reports it
  store: "stripe",
});
```

Conversions follow the user: they are credited to the campaign that acquired them. Several events can go in one call:

```ts
await traqio.send({
  userId: user.id,
  events: [
    { name: "trial_started" },
    { name: "purchase", value: 49, currency: "EUR", transactionId: invoice.id, store: "stripe" },
  ],
});
```

## What the response tells you

```ts
const result = await traqio.signup({ userId: user.id, trackId });

result.trackId;     // "matched" | "unknown" | "expired" | "claimed" | "refused" (absent without a trackID)
result.acquired;    // true: the sign-up is recorded
result.attribution; // { type: "deterministic", network: "meta", campaignId: "…", adsetId, adId }
```

| trackId | Meaning |
| --- | --- |
| matched | The click is now this user's. A retry answers matched again. |
| unknown | No click of this product has that id — a typo, another product's link, or a value @traqio/web did not write. |
| expired | The click is older than the product's attribution window. |
| claimed | Another user, or an app install, already owns that click. A click credits one identity. |
| refused | The click failed an anti-fraud rule (it came through an anonymising network). It is counted, never credited. |

## What it does today, and what it does not

|  | Today |
| --- | --- |
| Tie a sign-up to its click, and say who was credited | Yes — in the response |
| Record purchases with value, currency and payment id, deduplicated | Yes |
| Leave your apps' installs, revenue and ROAS untouched | Yes — never attached to an app install |
| Show web conversions in the console's reports | Yes — per campaign, beside installs |
| Send web conversions to Meta or TikTok | Not yet |

Conversions sent here appear in **Live events**, marked as not matched to an install — expected for a website. A sign-up credited to a campaign is counted in the ROAS report as a web conversion of that campaign, beside its installs, and the response of each call gives you the attribution at once.

## Errors and retries

A network error, a timeout, a `429` or a `5xx` is retried — twice by default, with the same body, so nothing is counted twice. A `4xx` (wrong or revoked key, invalid payload) is not retried. Both end as a `TraqioError`:

```ts
import { TraqioError } from "@traqio/node";

try {
  await traqio.signup({ userId: user.id, trackId });
} catch (err) {
  if (err instanceof TraqioError) {
    // err.status: 401 = wrong or revoked key, 400 = invalid payload, undefined = network
    logger.warn({ status: err.status, body: err.body }, err.message);
  }
}
```

> **Report after your own write** Call Traqio once the account or the payment is saved, and never let a Traqio error fail it.

## API reference

TraqioOptions:
```ts
interface TraqioOptions {
  secretKey: string;     // the server key, tq_sk_… — required
  endpoint?: string;     // default "https://api.traqio.app"
  timeoutMs?: number;    // per attempt — default 10000
  maxRetries?: number;   // after a network error, a 429 or a 5xx — default 2
  fetch?: typeof fetch;  // a custom fetch (proxies, tests)
}
```

| Method | What it does |
| --- | --- |
| signup({ userId, trackId?, events? }) | Reports the sign-up — the acquisition — with its trackID. The first one is kept. |
| track({ userId, name, value?, currency?, transactionId?, store?, eventId?, trackId? }) | Records one conversion of a known user. |
| send({ userId, trackId?, signup?, events? }) | The raw call behind both, for several events at once. |
| trackIdFromRequest(req) | The trackID from a request's cookies (Express, Fastify, Koa, node:http, a Fetch Request), or null. |
| trackIdFromCookieHeader(header) | The same, from a raw Cookie header. |

> **Proof so far** Unit-tested, and run end to end — ESM and CommonJS builds — against a running Traqio API: a sign-up carrying a trackID came back matched with its campaign, a purchase reported twice was stored once, a wrong key was refused. Not yet run in a customer's production backend.

## Frequently asked questions

**Why does a server SDK need its own key?**
Because it is the only one allowed to tie a conversion to an ad click. The web key of @traqio/web sits in your page where anyone can read it, so it can never attribute anything; the server key stays on your backend, is sent as an Authorization header and is stored by Traqio only as a fingerprint.

**Can I use @traqio/node in the browser?**
No. It refuses to start when it finds a browser document, and it refuses a web key. In the browser, use @traqio/web, which keeps the trackID until the sign-up request carries it to your backend.

**What if the visitor did not come from an ad?**
Pass a null trackId, or none. The sign-up is still recorded, and the user is organic: no campaign is credited.

**Will a retry count a purchase twice?**
No. Each call mints its event ids once and retries the identical body, and a purchase is also keyed on its transactionId, so the same payment reported twice is stored once.
