Skip to content
Analytik
Esc
↑↓navigate↵open⌘Jpreview
On this page

Server API

createAnalytik, its options and the object it returns, with the admin, privacy and geo options.

Exported from analytik/server. The server is a Hono app, so it runs anywhere that serves a fetch handler.

import { createAnalytik } from 'analytik/server'

createAnalytik(options)

function createAnalytik(options: CreateAnalytikOptions): Analytik

Options

Option Type Default Description
storage Storage required A storage driver.
admin { password, secret } or { verify(request) } none How the admin routes are protected. Without it, every admin route answers 401. See below.
privacy { mode?: PrivacyMode, keepQuery?: string[], respectDoNotTrack?: boolean } strict, no extra keys, true Default privacy mode for new sites (each site can override it), extra query keys to keep in stored paths, and whether Do Not Track and Global Privacy Control force strict.
allowedOrigins 'site-domains', 'any' or string[] site-domains Which pages may send events, checked by hostname. See below.
basePath string /api Prefix of every route.
autoMigrate boolean true Apply pending migrations before the first request.
geo (request: Request) => Geo | null | Promise<Geo | null> edge headers Supply location when the platform does not. Default: Vercel x-vercel-ip-* headers, then Cloudflare request.cf. Coordinates are rounded to two decimals.
trustProxy 'auto', 'cloudflare', 'vercel', 'forwarded' or false auto Where the client IP comes from. See below.
rateLimit { perClient?: number | false; perSite?: number | false } { perClient: 300 } Events a minute accepted on /event, per client IP and site, and per whole site. false turns a limit off. Counted in memory by each server instance; over the limit the route answers 429 with Retry-After.
clientIp (request: Request) => string | null from trustProxy Your own client IP lookup. Overrides trustProxy. Return null when the address is unknown.
hooks { beforeIngest?, onEvent?, onPayment? } none Your code around events. See Hooks.
webhooks { fetch?: typeof fetch } global fetch Replaces the fetch that sends outgoing webhooks, e.g. with one that refuses private addresses.
updateCheck boolean true Lets GET /version ask the npm registry for the newest release (at most every six hours, from the server), so the dashboard can offer an update. false makes no request.
email { send, from, to?, url?, cronSecret? } none Turns on owner emails: payment alerts, a summary and a tracker-down alert, chosen in Settings → Email. send comes from analytik/email. See Email notifications.
debug boolean false Turns on the /debug admin routes and the dashboard’s Debug settings, which fill the database with test data. Local and staging only.
now () => number Date.now Clock, for tests.
random (size: number) => Uint8Array Web Crypto Random source, for tests.

Hooks

interface HookEvent {
  siteId: string
  type: 'pageview' | 'goal' | 'outbound' | 'revenue'
  name: string | null
  path: string
  props: Props | null
  ts: number
}
interface AnalytikHooks {
  beforeIngest?(event: HookEvent): HookEvent | null | void | Promise<HookEvent | null | void>
  onEvent?(event: HookEvent & { visitorId: string; sessionId: string }): void | Promise<void>
  onPayment?(payment: Payment): void | Promise<void>
}

beforeIngest runs before the write: null drops the event, a changed event (only name, path and props) is stored instead. onEvent and onPayment run after the write, through the platform’s waitUntil where there is one. Each hook has 1 second; a throw or timeout is logged and ignored. See the guide.

Admin

admin.password is the dashboard password and admin.secret signs the session cookie. createAnalytik() throws at startup when the password is empty or the secret is shorter than 32 characters; generate one with openssl rand -hex 32. To integrate with your own authentication, pass { verify } instead: it receives the Request and resolves to true to allow it.

createAnalytik({
  storage,
  admin: { verify: async (request) => request.headers.get('x-internal-user') !== null },
})

Allowed origins

allowedOrigins is matched against hostnames, not full origins, so write 'blog.example.com', not 'https://blog.example.com'.

  • 'site-domains' (default): the page must be on the site’s domain or one of its subdomains. A site with allowLocalhost also accepts localhost.
  • A list of hostnames: those hostnames and their subdomains are accepted in addition to the site’s own domain.
  • 'any': the check is off.

Client IP and trustProxy

The server needs the visitor’s IP address for rate limits, the site’s ignored-IP list and the strict visitor hash. The address is used and then discarded, never stored. trustProxy decides which request headers may supply it, because a client can send any header it likes:

Value Behaviour
'auto' Default. On Cloudflare (the request has request.cf) it reads cf-connecting-ip. On Vercel (the process runs on Vercel and x-vercel-id is present) it reads x-vercel-forwarded-for. Anywhere else there is no IP and forwarding headers are ignored, so they cannot be spoofed.
'cloudflare' Always read cf-connecting-ip.
'vercel' Always read x-vercel-forwarded-for, then x-real-ip.
'forwarded' Read the rightmost public address in X-Forwarded-For. Use it only behind a proxy that overwrites that header with the real client address; otherwise a visitor can choose their own IP.
false Ignore every header.

Without an IP, strict-mode visitor ids are built from the user agent and the site only, so all visitors with the same user agent collapse into one visitor. The per-client event limit is then skipped, and the attribution and login limits are shared by everyone. On a VPS behind Caddy or nginx, set trustProxy: 'forwarded'. If your proxy sets a different header, read it yourself:

createAnalytik({
  storage,
  admin,
  clientIp: (request) => request.headers.get('x-real-ip'),
})

The returned object

Member Type Description
app Hono The HTTP app. Export its fetch, or mount it with app.route.
storage Storage The driver you passed in.
scheduled(options?) (options?: { url?: string }) => Promise<ScheduledResult> Sends the summary if it is due and the tracker-down alerts. Safe to call every hour; each email goes out once. url is the dashboard address for the link in the summary.
ingest(input) (input: IngestInput) => Promise<IngestResult> Record an event from server code, such as SSR or a queue consumer. Validated like an HTTP request body.
recordPayment(input) (input: RecordPaymentInput) => Promise<RecordPaymentResult> Store a payment, with the same attribution as POST /v1/payments.
stats StatsApi The queries behind the admin routes, as functions.

ingest

IngestInput has a payload (the untrusted tracker payload, unknown) and optional ip, userAgent, origin, geo and doNotTrack. The IP and user agent feed the strict-mode hash and the ignored-IP list and are never stored. The result is an IngestResult:

  • { status: 202, visitorId, sessionId } when the event was accepted. Both ids are null and filtered is 'bot', 'ignored-ip' or 'paused' (tracking is paused for the site) when the event was dropped on purpose.
  • { status: 400 | 403 | 404, error } when it was rejected.
const result = await analytik.ingest({
  payload: { w: 'SITE_ID', t: 'goal', u: 'https://example.com/pricing', n: 'signup' },
  ip: request.headers.get('x-real-ip'),
  userAgent: request.headers.get('user-agent'),
})
if (result.status !== 202) console.warn(result.error)

recordPayment

RecordPaymentInput is a PaymentInput plus a required siteId and an optional provider (default api). The result is { payment, duplicate }; duplicate is true when a payment with that id already exists and nothing changed.

const { payment, duplicate } = await analytik.recordPayment({
  siteId: 'SITE_ID',
  id: 'ord_8123',
  amount: 4900,
  currency: 'USD',
})

stats

const { stats } = analytik

const overview = await stats.overview('SITE_ID', {
  from: Date.UTC(2026, 9, 1),
  to: Date.UTC(2026, 10, 1),
  interval: 'day',
  tz: 'Europe/Bratislava',
  filters: { country: 'DE' },
})

The query argument is a Query and the result is an Overview; see Types. from is inclusive and to exclusive, both in Unix milliseconds. Unknown sites throw an AnalytikError with status 404.

stats.sessions, stats.events and stats.payments return a Page and take the list options of the HTTP API as a third argument:

const refunds = await stats.payments(
  'SITE_ID',
  { from: Date.UTC(2026, 9, 1), to: Date.UTC(2026, 10, 1) },
  { kind: 'refund', sort: 'amount', order: 'desc', offset: 0, limit: 25 },
)
console.log(refunds.total, refunds.items)

stats.properties(siteId, query, { name, key? }) returns the custom properties of one goal’s events as a PropertiesResult: its keys, or with key the values of that key (50 at most, the rest in other). Filter by a property with filters: { 'prop:plan': 'pro' } in any query.

stats.targets(siteId) takes no query: it returns the progress of the site’s goal targets in their own periods, as a TargetsResult.

stats.vitals(siteId, query) returns the 75th percentile of Core Web Vitals over the range as a VitalsResult.

stats.journeys(siteId, query) returns the paths sessions take after the first page as a JourneysResult.

stats.retention(siteId, query) returns weekly cohorts of new visitors and how many came back as a RetentionResult.

Mounting

On Cloudflare Workers, Bun and Vercel, export analytik.app.fetch or the app itself. On Node, use @hono/node-server. For Next.js, Express, Nuxt, SvelteKit, Astro and Cloudflare Pages Functions, see the framework guides. To nest it in an existing Hono app:

import { Hono } from 'hono'

const app = new Hono()
app.route('/', analytik.app) // routes keep their basePath, /api by default

The /event and /attribution routes have open CORS. Event bodies are limited to 8 KB.

Was this page helpful?