Server
Track from Node, Bun, Deno, Vercel Functions and Cloudflare Workers, run the proxy there, or send events from any language.
/server and /proxy run on any runtime with fetch and the standard Request and Response. They authenticate with the project's secret key, which never goes to the browser. Other languages post to /v2/events directly.
| Entry | Export | Use it for |
|---|---|---|
@spoar/sdk/server | createServerAnalytics | Events from route handlers, webhooks, jobs and scripts |
@spoar/sdk/proxy | createProxy | A same-origin path such as /_ra that forwards browser events to the API |
@spoar/sdk/proxy | createPageCounter | page_request events for HTML page loads, counted in middleware |
Install and keys
npm install @spoar/sdk@nextRA_SECRET=sk_...
RA_ENDPOINT=https://api.example.comRA_SECRET and RA_ENDPOINT are names used on this page. The SDK itself reads only RA_CONFIG, a JSON object with options you leave out, such as RA_CONFIG='{"secret":"sk_...","endpoint":"https://api.example.com"}'. The endpoint is the API's base URL; the SDK adds /v2/events.
Track events
analytics.ts:
import { createServerAnalytics } from "@spoar/sdk/server";
import type { Events } from "./events";
export const serverAnalytics = createServerAnalytics<Events>({
secret: process.env.RA_SECRET,
endpoint: process.env.RA_ENDPOINT,
});const result = await serverAnalytics.track("signup", { plan: "pro" }, { request });
if (!result.ok) console.warn(result.error.code);- Passing the incoming
request, or itsheaders, forwards the visitor's IP and user agent, the site's origin asOriginand the admin session cookie, and uses the request's path as the page path. The API flags localhost and preview hosts from the origin, and marks events from a signed-in owner or admin as internal. - Without a
requestorheaders, the event has no IP and no user agent: the API does not use the calling server's own, so it gets no IP hash, no location and no bot weight for them. Setoriginon the client or on the call to give such events a host. - Server events use
server(exported asserverVisitor) as visitor and session. Reads count those events in pageviews, events and breakdowns, but never as a visitor or a session. Passvisitorandsessionfrom the browser to join them to the browser's events. - Events tracked in the same tick go out together, one request per origin and session cookie. Every method resolves to
{ ok, error, accepted, duplicates, failed }and never throws. withErrors(handler)wraps a(request) => Responsehandler, records anything it throws and rethrows it.
See Server for every method.
Node
In a job, script or webhook consumer there is no incoming request. Await the call, which resolves once the event is sent, before the process exits:
import { serverAnalytics } from "./analytics";
await serverAnalytics.track("signup", { plan: "pro" });Node frameworks that hand you a standard Request work the same way as Bun and Deno below. With node:http, build a Headers object from the incoming headers and pass it as headers.
Bun
import { createProxy } from "@spoar/sdk/proxy";
import { serverAnalytics } from "./analytics";
const proxy = createProxy({ secret: Bun.env.RA_SECRET, endpoint: Bun.env.RA_ENDPOINT });
Bun.serve({
routes: {
"/_ra": { POST: proxy },
"/api/signup": {
POST: serverAnalytics.withErrors(async (request) => {
await serverAnalytics.track("signup", { plan: "pro" }, { request });
return Response.json({ ok: true });
}),
},
},
});Deno
import { createProxy } from "npm:@spoar/sdk/proxy";
import { createServerAnalytics } from "npm:@spoar/sdk/server";
const options = { secret: Deno.env.get("RA_SECRET"), endpoint: Deno.env.get("RA_ENDPOINT") };
const proxy = createProxy(options);
const serverAnalytics = createServerAnalytics(options);
Deno.serve(async (request) => {
const { pathname } = new URL(request.url);
if (pathname === "/_ra") return proxy(request);
if (pathname === "/api/signup" && request.method === "POST") {
await serverAnalytics.track("signup", { plan: "pro" }, { request });
return Response.json({ ok: true });
}
return new Response("Not found", { status: 404 });
});Vercel Functions
A function that exports web-standard handlers, such as api/signup.ts:
import { serverAnalytics } from "../analytics";
export const POST = serverAnalytics.withErrors(async (request: Request) => {
void serverAnalytics.track("signup", { plan: "pro" }, { request });
return Response.json({ ok: true });
});On Vercel the SDK reads waitUntil from the runtime, so the send finishes after the response without awaiting it. api/_ra.ts is not routed because of the leading underscore, so serve the proxy from another file and point the client's endpoint at it, or use a rewrite.
Cloudflare Workers
Workers get their secrets per request through env, and keep work alive with ctx.waitUntil:
import { createProxy } from "@spoar/sdk/proxy";
import { createServerAnalytics } from "@spoar/sdk/server";
type Env = { RA_SECRET: string; RA_ENDPOINT: string };
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const options = { secret: env.RA_SECRET, endpoint: env.RA_ENDPOINT };
const { pathname } = new URL(request.url);
if (pathname === "/_ra") return createProxy(options)(request);
if (pathname === "/api/signup" && request.method === "POST") {
const serverAnalytics = createServerAnalytics({
...options,
waitUntil: (promise) => ctx.waitUntil(promise),
});
void serverAnalytics.track("signup", { plan: "pro" }, { request });
return Response.json({ ok: true });
}
return fetch(request);
},
};The visitor's IP comes from cf-connecting-ip.
The proxy
createProxy returns a (request) => Promise<Response> handler. It accepts only same-site POST requests with bodies up to 60 KB, adds the secret key, the visitor's IP and user agent, the page's origin and the admin session cookie, forwards the body to /v2/events and returns the API's answer. The browser client's endpoint is the path you serve it on. See Proxy.
Other languages
Any language can post events to POST /v2/events with the secret key in Authorization: Bearer. A secret-key request may come from any origin and is not rate-limited.
curl -X POST https://api.example.com/v2/events \
-H "authorization: Bearer $RA_SECRET" \
-H "content-type: application/json" \
-d '{
"v": 1,
"sentAt": "2026-09-30T12:00:00.000Z",
"events": [
{
"id": "01928c3e-7a4b-7c1d-9f00-2b7c1e5d8a11",
"name": "signup",
"ts": "2026-09-30T12:00:00.000Z",
"visitor": "server",
"session": "server",
"page": { "path": "/api/signup" },
"props": { "plan": "pro" },
"context": { "ip": "203.0.113.7", "ua": "Mozilla/5.0 ..." }
}
]
}'| Field | Rule |
|---|---|
v | Always 1 |
sentAt, ts | ISO 8601 timestamps |
events | 1 to 50 events; the body is at most 60 KB |
id | A UUID per event. Resending the same id counts as a duplicate, so retries are safe |
name | 1 to 64 characters |
visitor, session | 1 to 64 characters |
page.path | Required, up to 2048 characters; route, title and referrer are optional |
props | Required, flat: at most 25 string, number, boolean or null values, keys up to 255 characters, strings up to 255 characters (2048 for stack and breadcrumbs on error events) |
context | Optional. ip and ua forward the visitor's details and are used only with the secret key. Without them a secret-key event has no IP and no user agent |
The answer is 202 with { "accepted": 1, "duplicates": 0, "rejected": [] }; a rejected event, such as one over the prop limits, is listed by its index with VALIDATION_FAILED while the rest are stored. Send an Origin header with the site's origin to give the events a host. The API reference has the full schema and error codes.