---
title: Server API
description: createAnalytik, its options and the object it returns, with the admin, privacy and geo options.
---

Exported from `analytik/server`. The server is a [Hono](https://hono.dev) app, so it runs anywhere that serves a `fetch` handler.

```ts
import { createAnalytik } from 'analytik/server'
```

## createAnalytik(options)

```ts
function createAnalytik(options: CreateAnalytikOptions): Analytik
```

### Options

| Option           | Type                                                                        | Default                         | Description                                                                                                                                                                                                      |
| ---------------- | --------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `storage`        | `Storage`                                                                   | required                        | A [storage driver](/reference/storage).                                                                                                                                                                          |
| `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](#hooks).                                                                                                                                                                    |
| `webhooks`       | `{ fetch?: typeof fetch }`                                                  | global `fetch`                  | Replaces the `fetch` that sends [outgoing webhooks](/guides/webhooks-and-api#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](/guides/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

```ts
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](/guides/webhooks-and-api#server-hooks).

### 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.

```ts
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:

```ts
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.

```ts
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.

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

### stats

```ts
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](/reference/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](/reference/http-api#lists) as a third argument:

```ts
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](/guides/frameworks). To nest it in an existing Hono app:

```ts
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.
