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 withallowLocalhostalso acceptslocalhost.- 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 arenullandfilteredis'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.