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

Webhooks, hooks and the Stats API

Read your numbers from scripts with a share link or an API key, get a signed message when a goal is reached or a payment arrives, and filter or react to events in your own server code.

Three ways to connect analytik to other things: read the numbers with a token, receive messages when something happens, and run your own code inside the server.

Stats API

A share link is also an API token. Make one in Settings → Sharing, copy the token (it is shown once), and send it with any of the read routes. The token can be given three ways:

# In the address
curl "https://analytics.example.com/api/sites/SITE_ID/overview?from=2026-10-01&to=2026-11-01&share=TOKEN"

# As a header
curl -H "x-analytik-share: TOKEN" \
  "https://analytics.example.com/api/sites/SITE_ID/overview?from=2026-10-01&to=2026-11-01"

# As a bearer token, which most tools and libraries know how to send
curl -H "Authorization: Bearer TOKEN" \
  "https://analytics.example.com/api/sites/SITE_ID/breakdown?dimension=source&from=2026-10-01&to=2026-11-01"

The token reads only what the link’s views allow, never settings, payments or any write; a revoked or expired token answers 401. Answers sent for a bearer token or header are not cached publicly. See the HTTP API for the routes.

A share link is meant for showing numbers to someone else. For your own scripts, tools and agents, use an API key instead: it can read payments, single sessions and exports, cover several sites, be limited, rolled and switched off without touching anything else, and optionally make changes.

Outgoing webhooks

Settings → Payments and API → Outgoing webhooks sends a message to another service when a goal is reached or a payment (or refund) is recorded. Add an address, choose the events and the format:

  • Signed JSON posts { delivery, site: { id, name, domain }, type, ... }. A goal carries name, path, props, ts and source; a payment carries id, kind, amount (minor units), currency, provider, ts and source. Visitor and session ids, emails and names are never sent.
  • Chat message posts { "text": "...", "content": "..." }, one line that Slack and Discord incoming webhooks show as is.

Addresses must be https on port 443 and point at a public host: no credentials, IP addresses or internal names. A chat address is a secret, so the list shows only its host. At most 5 webhooks per site.

Every signed request carries x-analytik-delivery (a unique id) and x-analytik-signature: t=SECONDS,v1=HEX, where HEX is the HMAC-SHA256 of SECONDS.BODY with the webhook’s secret (whsec_…, shown once when you add it). Check it on your side:

import { createHmac, timingSafeEqual } from 'node:crypto'

function verify(body: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const expected = createHmac('sha256', secret).update(`${parts.t}.${body}`).digest('hex')
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
  return fresh && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ''))
}

A delivery times out after 5 seconds, does not follow redirects, and is retried once after a second. A failing webhook never delays or fails the event or payment it reports. Send test sends one message without a retry. The list shows the outcome of the last delivery that differed from the one before.

Network safety

The address is checked when it is added and again before each delivery, but the server does not resolve DNS itself, so a name that later points at a private address (DNS rebinding) is not caught. If your server can reach private networks, pass your own fetch that refuses them:

createAnalytik({
  storage,
  admin,
  webhooks: { fetch: (input, init) => guardedFetch(input, init) },
})

Server hooks

Pass hooks to createAnalytik() to run your own code around events. Each hook may be async, runs with a 1 second limit, and an error or timeout is logged and ignored: a hook never fails or delays ingest.

createAnalytik({
  storage,
  admin,
  hooks: {
    // Drop, change or keep an event before it is stored.
    beforeIngest: (event) => {
      if (event.path.startsWith('/internal')) return null
      if (event.name === 'signup') return { ...event, props: { ...event.props, plan: 'free' } }
    },
    // After it was stored. Runs after the response is sent where the platform allows.
    onEvent: async (event) => {
      if (event.type === 'goal') await notify(event.name)
    },
    // After a new payment or refund. Not called again for a retried provider webhook.
    onPayment: async (payment) => {
      await bookkeeping(payment)
    },
  },
})

beforeIngest returns null to drop the event (the tracker gets 202 with filtered: "hook"), a changed event to store that instead, or nothing to keep it. Only name, path and props can change, and they are cleaned like any client event. Pings and sign-ins are not hooked. See Server API for the types.

Was this page helpful?