Spoar

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.

EntryExportUse it for
@spoar/sdk/servercreateServerAnalyticsEvents from route handlers, webhooks, jobs and scripts
@spoar/sdk/proxycreateProxyA same-origin path such as /_ra that forwards browser events to the API
@spoar/sdk/proxycreatePageCounterpage_request events for HTML page loads, counted in middleware

Install and keys

npm install @spoar/sdk@next
RA_SECRET=sk_...
RA_ENDPOINT=https://api.example.com

RA_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 its headers, forwards the visitor's IP and user agent, the site's origin as Origin and 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 request or headers, 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. Set origin on the client or on the call to give such events a host.
  • Server events use server (exported as serverVisitor) as visitor and session. Reads count those events in pageviews, events and breakdowns, but never as a visitor or a session. Pass visitor and session from 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) => Response handler, 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 ..." }
      }
    ]
  }'
FieldRule
vAlways 1
sentAt, tsISO 8601 timestamps
events1 to 50 events; the body is at most 60 KB
idA UUID per event. Resending the same id counts as a duplicate, so retries are safe
name1 to 64 characters
visitor, session1 to 64 characters
page.pathRequired, up to 2048 characters; route, title and referrer are optional
propsRequired, 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)
contextOptional. 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.

On this page