> For the complete documentation index, see [llms.txt](https://docs.millimetric.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.millimetric.ai/recipes/server-side.md).

# Server-side events from a backend

Critical events should originate on the server. The browser is hostile territory: ad blockers, refreshes, navigations, network drops. If you only fire `purchase` from the success page, your revenue numbers are wrong.

## What to fire server-side

| Event                                       | Browser         | Server                        |
| ------------------------------------------- | --------------- | ----------------------------- |
| `$pageview`                                 | ✅ — auto by SDK | —                             |
| `clicked_*`, `viewed_*`, `hovered_*`        | ✅               | —                             |
| `signup`                                    | optional        | ✅ — fire on user creation     |
| `subscription_*`, `purchase`, `refunded`    | —               | ✅ — fire on transaction       |
| `feature_used` (gated by auth)              | optional        | ✅ — fire from the API handler |
| Webhook events from Stripe / Slack / Resend | —               | ✅                             |

## Threading anonymous\_id from the browser

The whole game is making sure server-side events still carry the visitor's `anonymous_id` — otherwise you can't stitch them back to the browser session. Three options, pick one.

### Option A — pass it in the request body

The simplest. The browser includes the SDK's anonymous id when calling your API.

```ts
import { getAnonymousId } from "@millimetric/track";

await fetch("/api/signup", {
  method: "POST",
  body: JSON.stringify({
    email,
    plan,
    anonymous_id: getAnonymousId()
  })
});
```

Server reads it and passes through:

```ts
const { email, plan, anonymous_id } = await req.json();
track({ event: "signup", anonymous_id, user_id: user.id, properties: { plan } });
```

### Option B — first-party cookie

If you'd rather not touch every fetch call. Mirror the SDK's `localStorage` value into a cookie:

```ts
import { getAnonymousId } from "@millimetric/track";

if (typeof document !== "undefined") {
  document.cookie = `aid=${getAnonymousId()}; path=/; max-age=31536000; SameSite=Lax`;
}
```

```ts
// server
const aid = req.cookies.aid;
track({ event: "signup", anonymous_id: aid, /* ... */ });
```

(This is the only "cookie" in the system, and it's *your* cookie, not Millimetric's. We never set one.)

### Option C — derive it server-side and tell the SDK

For SSR-heavy apps (Next.js with cookies, Rails with sessions), you might prefer the server to own the id and the browser to consume it.

```ts
// server: pass aid into HTML as a data attribute
return `<script>window.__aid = ${JSON.stringify(req.cookies.aid)}</script>`;
```

```ts
// client: tell the SDK to use it
import { init, setAnonymousId } from "@millimetric/track";
init({ key });
if (window.__aid) setAnonymousId(window.__aid);
```

## Long-running server vs serverless

```ts
import { init, track, flush } from "@millimetric/track-node";
```

### Long-running (Node server, Express, Fastify, Bun, Express on a VPS)

Batch in the background. The internal timer is `unref`'d, so it won't keep your process alive on its own.

```ts
init({
  key: process.env.AOA_SK!,
  host: process.env.AOA_HOST!,
  flushAt: 50,           // batch up to 50 events before sending
  flushIntervalMs: 2000  // …or every 2s
});

app.post("/signup", async (req, res) => {
  // ...
  track({ event: "signup", anonymous_id: req.cookies.aid, user_id: user.id });
  res.json({ ok: true });
});

process.on("SIGTERM", async () => {
  await flush();
  process.exit(0);
});
```

### Serverless / edge (Vercel, Cloudflare Workers, Lambda, Deno Deploy)

The instance freezes after the response. Always `await flush()` before returning, or queued events disappear.

```ts
init({ key: process.env.AOA_SK!, host: process.env.AOA_HOST!, flushAt: 1 });

export async function POST(req: Request) {
  // ...
  track({ event: "purchase", anonymous_id, user_id, properties: { amount_cents: 4900 } });
  await flush();
  return new Response("ok");
}
```

## Webhooks (Stripe, Slack, Resend, etc.)

Treat webhook events as server-side `track()` calls with idempotency. Use the provider's event id as `event_id`:

```ts
import Stripe from "stripe";

export async function POST(req: Request) {
  const event = stripe.webhooks.constructEvent(
    await req.text(),
    req.headers.get("stripe-signature")!,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;
    track({
      event: "completed_checkout",
      event_id: event.id,                              // Stripe's event id
      user_id: session.metadata?.user_id ?? undefined,
      properties: {
        order_id: session.id,
        amount_cents: session.amount_total ?? 0,
        currency: session.currency ?? "usd",
        is_first_purchase: session.metadata?.is_first === "true"
      }
    });
  }

  await flush();
  return Response.json({ received: true });
}
```

The server doesn't dedupe on `event_id` yet, so if Stripe retries the webhook you might get two rows. Either:

* **De-dupe in queries** (`SELECT DISTINCT … ORDER BY event_id`).
* **Track receipts in your own DB** and skip if already processed before calling `track()`.

## Batching backfills

Loading historical data — old orders from your DB into Millimetric — is exactly what `/v1/batch` is for.

```ts
import { MillimetricClient } from "@millimetric/track-node";
import fs from "node:fs/promises";

const client = new MillimetricClient({
  key: process.env.AOA_SK!,
  host: process.env.AOA_HOST!,
  flushAt: 1000              // batch up to 1000 events per request
});

const orders = JSON.parse(await fs.readFile("orders-2026.json", "utf8"));

for (const o of orders) {
  client.track({
    event: "completed_checkout",
    event_id: `order_${o.id}`,
    user_id: String(o.user_id),
    timestamp: o.created_at,
    properties: {
      order_id: o.id,
      amount_cents: o.total_cents,
      currency: o.currency
    }
  });
}

await client.flush();
console.log(`Backfilled ${orders.length} events.`);
```

`/v1/batch` is rate-limited to 5 requests/sec — at 1000 events/batch that's 5,000 events/sec sustained, fine for any reasonable backfill.

## Common pitfalls

* **Not awaiting `flush()` in serverless.** Events queued but never sent. Always `await flush()` before returning.
* **Putting `sk_*` in `NEXT_PUBLIC_*` / `VITE_PUBLIC_*` env vars.** Bundlers will inline the secret in the browser bundle. Use a non-public env var name.
* **Server-side events without `anonymous_id`.** Stitching breaks. Pass the id from the request.
* **Tracking the same event from both client and server.** Pick one source of truth. For `signup`, the server is canonical.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.millimetric.ai/recipes/server-side.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
