# Pieowl – Full documentation for LLMs
Pieowl is simple, private product analytics you can just ask: visitors, funnels, revenue and session replay, plus answers from Claude or Cursor over MCP. No cookies, cloud in the EU.
This file contains the full text of all documentation pages. Each section is separated by --- and includes a canonical URL.
---
## What is Pieowl?
URL: https://pieowl.com/docs
Pieowl is web and product analytics in one place. You get the simple traffic view you expect from a privacy-friendly tool, plus funnels, retention, cohorts, session replay and revenue. When you want an answer, ask Claude, Cursor or another AI assistant: it reads your data through the Pieowl MCP server.
Key features [#key-features]
* **Web analytics**: Visitors, sessions, page views, bounce rate, visit duration, sources, pages, locations and devices
* **Product analytics**: [Funnels](/features/funnels), retention, cohorts, user profiles and session history
* **Session replay**: [Watch real sessions](/features/session-replay), with text and form fields always hidden
* **Revenue attribution**: [See which source, page and country makes you money](/features/revenue-attribution). Every purchase is credited to the visit where it happened
* **MCP server**: [Ask your AI assistant about your users](/features/mcp) from Claude, Cursor or any MCP client
* **Dashboards and reports**: Build your own charts and [dashboards](/features/data-visualization), and share them by link
* **Realtime**: See who is on your site right now
* **Notifications**: Get alerts for events and funnels in Slack, Discord or the app
* **Integrations**: [Google Search Console](/features/integrations), plus data export to S3 or Google Cloud Storage
* **SDKs and API**: Script tag, JavaScript, Next.js, React, Vue, Astro, Nuxt, Express and React Native, plus an HTTP API for any language
* **Privacy**: No cookies and no stored IP addresses
* **Hosting**: Our cloud in the EU
Quick start [#quick-start]
To start tracking, create an account.
1. **[Install Pieowl](/docs/get-started/install-pieowl)**: Add the script tag or use one of our SDKs
2. **[Track events](/docs/get-started/track-events)**: Measure what users do
3. **[Identify users](/docs/get-started/identify-users)**: Connect events to the people behind them
4. **[Track revenue](/docs/revenue-attribution)**: See which sources, pages and countries bring in money
Built for privacy [#built-for-privacy]
* **No cookies**: The tracker never sets a cookie and stores nothing on your visitors' devices, so in many cases you don't need a consent banner for analytics. [When you still do](/docs/consent-and-cookie-banners)
* **No stored IP addresses**: The IP is used for the location lookup and a daily-rotating visitor hash, then dropped
* **EU hosting**: Your data is stored in the EU
Read [how Pieowl tracks visitors](/docs/how-it-works) for the details.
Need help? [#need-help]
Email us at [hello@pieowl.com](mailto:hello@pieowl.com).
---
## Avoid adblockers with proxy
URL: https://pieowl.com/docs/adblockers
This page covers why ad blockers block analytics, how they do it, and how to avoid it.
Ad blockers started out blocking ads, but most now block tracking scripts too. That's mostly for privacy, and we respect it. Still, you have good reasons to understand your visitors. Pieowl sets no cookies and doesn't track people across sites, but generic blocklists often block every analytics tool the same way.
The best fix is to send events through your own domain. Ad blockers usually can't block requests to your own domain (first-party requests) without breaking the site itself.
Built-in support [#built-in-support]
`@pieowl/bots` has a ready-made proxy. It serves the tracking script at `/pieowl.js`, passes on what the script should collect (`/config/tracking`, from Settings → Site) and forwards events (`/track`) to Pieowl with the visitor's IP and user agent, so locations and devices stay correct.
```bash
npm install @pieowl/bots
```
**Next.js** (or any framework with Fetch `Request` handlers):
```typescript title="app/api/pieowl/[...pieowl]/route.ts"
import { createRouteHandler } from '@pieowl/bots/route-handler';
export const { GET, POST } = createRouteHandler();
```
The same handler works in Hono (`app.all('/api/pieowl/*', (c) => handler(c.req.raw))`), Astro endpoints, SvelteKit `+server.ts` files, Nuxt server routes (`export default fromWebHandler(createRouteHandler())`) and Cloudflare Workers.
**Express**:
```typescript
import { createRouteHandler } from '@pieowl/bots/express';
app.use('/api/pieowl', createRouteHandler());
```
Then point the script at your route: set `apiUrl: '/api/pieowl'` and load `/api/pieowl/pieowl.js` (see [Frontend configuration](#frontend-configuration)). The proxy serves only `pieowl.js`. If you use [session replay](/docs/session-replay) with the proxy, set `sessionReplay.scriptUrl` to `https://pieowl.com/pieowl-replay.js`.
Proxying in any framework [#proxying-in-any-framework]
If your backend doesn't run JavaScript, build the same proxy yourself. Set up an endpoint on your domain (for example `api.domain.com` or `domain.com/api`) that forwards requests to Pieowl.
Below is the logic written out with a [Hono](https://hono.dev/) server. It follows the same steps as `@pieowl/bots`.
Hono example [#hono-example]
```typescript
import { Hono } from 'hono'
const app = new Hono()
// 1. Serve the tracking script (and the session replay script) from your domain
for (const file of ['pieowl.js', 'pieowl-replay.js']) {
app.get(`/${file}`, async (c) => {
try {
const res = await fetch(`https://pieowl.com/${file}`)
const text = await res.text()
c.header('Content-Type', 'text/javascript')
// Optional caching for 24 hours
c.header('Cache-Control', 'public, max-age=86400, stale-while-revalidate=86400')
return c.body(text)
} catch (e) {
return c.json({ error: 'Failed to fetch script' }, 500)
}
})
}
// 2. Forward tracked events
app.post('/track', async (c) => {
const body = await c.req.json()
// Forward the visitor's IP address (pick the right header for your hosting)
const ip = c.req.header('cf-connecting-ip') ??
c.req.header('x-forwarded-for')?.split(',')[0]
const headers = new Headers()
headers.set('Content-Type', 'application/json')
headers.set('Origin', c.req.header('origin') ?? '')
headers.set('User-Agent', c.req.header('user-agent') ?? '')
headers.set('pieowl-client-id', c.req.header('pieowl-client-id') ?? '')
if (ip) {
headers.set('pieowl-client-ip', ip)
}
try {
const res = await fetch('https://api.pieowl.com/track', {
method: 'POST',
headers,
body: JSON.stringify(body),
})
return c.json(await res.text(), res.status)
} catch (e) {
return c.json(e, 500)
}
})
// 3. Pass on what the script should collect (Settings → Site)
app.get('/config/tracking', async (c) => {
const clientId = c.req.query('clientId') ?? ''
const res = await fetch(
`https://api.pieowl.com/config/tracking?clientId=${encodeURIComponent(clientId)}`
)
return c.json(await res.json(), res.status)
})
export default app
```
This sets up these endpoints:
1. `GET /pieowl.js` and `GET /pieowl-replay.js`: Fetch the Pieowl scripts and serve them from your domain. You only need the replay script if you use [session replay](/docs/session-replay).
2. `POST /track`: Receives events from the browser, adds the headers Pieowl needs (`User-Agent`, `Origin`, `Content-Type`, `pieowl-client-id` and `pieowl-client-ip`), and forwards them to the Pieowl API.
3. `GET /config/tracking`: Returns what the script collects by itself, as set in Settings → Site. Without it the script falls back to its defaults.
Frontend configuration [#frontend-configuration]
Once your proxy is running, point the Pieowl script at it instead of the default URLs.
```html
```
Now every request goes to your domain first, so ad blockers that look for third-party tracking domains let it through.
---
## Track AI crawlers and bots
URL: https://pieowl.com/docs/ai-bots
The **AI & bots** card on the overview shows which crawlers and AI assistants requested your pages, per company, over time. Bot requests are kept apart from your visitors: they never show up as sessions, page views or visitors.
The three categories [#the-three-categories]
Every recognised bot belongs to one category:
* **AI answers**: fetched live because someone asked an AI assistant something, for example ChatGPT, Claude or Perplexity looking up your site to answer a question.
* **Indexing**: search engines and AI search building their index, for example Googlebot, Bingbot, OAI-SearchBot or PerplexityBot.
* **Training**: collected to train AI models, for example GPTBot, ClaudeBot, CCBot (Common Crawl) or Bytespider.
Why you need a server-side snippet [#why-you-need-a-server-side-snippet]
Bots don't run JavaScript, so the Pieowl tracking script never sees them. Your server sees every request, so it reports the bot requests to Pieowl. The snippets below check the user agent first and only send requests that look like a bot, so normal visitors cause no extra network calls.
All options need your client secret. Keep it on the server and never put it in browser code. See [Authentication](/docs/api/authentication).
@pieowl/bots [#pieowlbots]
One package for every server that runs JavaScript: Node.js, edge runtimes and Cloudflare Workers. It uses only `fetch` and depends on no framework.
```bash
npm install @pieowl/bots
```
It reports only requests whose user agent looks like a bot, never blocks or changes your response, and never throws. Reports are sent in the background, in batches of up to 50 requests or every second. Each report has the path, host and method, the response status where the framework gives it, and the client IP for [verification](#verification): the first address in `X-Forwarded-For`, else `X-Real-IP`, else `CF-Connecting-IP`. Pass `apiUrl` to send to a different API URL (default `https://api.pieowl.com`).
Next.js [#nextjs]
```typescript title="proxy.ts"
import { trackBots } from '@pieowl/bots/next';
export const proxy = trackBots({
clientId: process.env.PIEOWL_CLIENT_ID!,
clientSecret: process.env.PIEOWL_CLIENT_SECRET!,
});
```
On Next.js 15 or older, name the file `middleware.ts` and use `export const middleware = trackBots({ ... })`. The request continues as if there were no proxy, and the report is kept alive with `event.waitUntil`.
Already have a proxy? Call `trackBotRequest` in it and keep your own response:
```typescript title="proxy.ts"
import { trackBotRequest } from '@pieowl/bots';
import { type NextFetchEvent, type NextRequest, NextResponse } from 'next/server';
export function proxy(request: NextRequest, event: NextFetchEvent) {
trackBotRequest(request, {
clientId: process.env.PIEOWL_CLIENT_ID!,
clientSecret: process.env.PIEOWL_CLIENT_SECRET!,
}, event);
return NextResponse.next();
}
```
If your proxy or middleware has a `matcher`, make sure it covers the pages bots request. Requests the proxy doesn't run for are not reported.
Express [#express]
```typescript
import { trackBots } from '@pieowl/bots/express';
app.use(trackBots({
clientId: process.env.PIEOWL_CLIENT_ID,
clientSecret: process.env.PIEOWL_CLIENT_SECRET,
}));
```
The request is reported after the response is sent, with its status. The IP is `req.ip`: behind a proxy or load balancer, set Express's [`trust proxy`](https://expressjs.com/en/guide/behind-proxies.html) so `req.ip` is the client and not the proxy. It also works with Connect and other `(req, res, next)` servers.
Hono [#hono]
```typescript
import { trackBots } from '@pieowl/bots/hono';
app.use(trackBots({
clientId: process.env.PIEOWL_CLIENT_ID!,
clientSecret: process.env.PIEOWL_CLIENT_SECRET!,
}));
```
On Cloudflare Workers, read the keys with `import { env } from 'cloudflare:workers'`. The report is kept alive with `executionCtx.waitUntil`.
Cloudflare Workers and Pages [#cloudflare-workers-and-pages]
Wrap the Worker's fetch handler:
```typescript title="src/index.ts"
import { env } from 'cloudflare:workers';
import { withBotTracking } from '@pieowl/bots';
export default {
fetch: withBotTracking((request) => fetch(request), {
clientId: env.PIEOWL_CLIENT_ID,
clientSecret: env.PIEOWL_CLIENT_SECRET,
}),
};
```
`withBotTracking` wraps any `(request, ...) => Response` handler (Deno, Bun, Vercel Edge Functions) and uses the first argument that has `waitUntil`.
For Cloudflare Pages, add a middleware:
```typescript title="functions/_middleware.ts"
import { trackBotRequest } from '@pieowl/bots';
export const onRequest: PagesFunction = (context) => {
trackBotRequest(context.request, {
clientId: context.env.PIEOWL_CLIENT_ID,
clientSecret: context.env.PIEOWL_CLIENT_SECRET,
}, context);
return context.next();
};
```
Astro, Nuxt, SvelteKit and others [#astro-nuxt-sveltekit-and-others]
Call `trackBotRequest(request, options, context?)` in your framework's server middleware. It takes a Fetch `Request`, or `{ url, headers, method?, ip?, status? }`. On serverless and edge platforms, pass anything with `waitUntil` as `context`.
```typescript title="Astro: src/middleware.ts"
import { trackBotRequest } from '@pieowl/bots';
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware((context, next) => {
trackBotRequest(context.request, {
clientId: import.meta.env.PIEOWL_CLIENT_ID,
clientSecret: import.meta.env.PIEOWL_CLIENT_SECRET,
});
return next();
});
```
```typescript title="Nuxt: server/middleware/pieowl-bots.ts"
import { trackBotRequest } from '@pieowl/bots';
export default defineEventHandler((event) => {
trackBotRequest({ url: getRequestURL(event).href, headers: getRequestHeaders(event) }, {
clientId: process.env.PIEOWL_CLIENT_ID!,
clientSecret: process.env.PIEOWL_CLIENT_SECRET!,
});
});
```
```typescript title="SvelteKit: src/hooks.server.ts"
import { trackBotRequest } from '@pieowl/bots';
import { env } from '$env/dynamic/private';
export const handle = async ({ event, resolve }) => {
trackBotRequest(event.request, {
clientId: env.PIEOWL_CLIENT_ID,
clientSecret: env.PIEOWL_CLIENT_SECRET,
});
return resolve(event);
};
```
In Remix and React Router, call it at the top of your root `loader` with `request`.
HTTP API [#http-api]
From any other server, send bot requests to the API yourself:
```bash
curl -X POST 'https://api.pieowl.com/bot' \
-H 'Content-Type: application/json' \
-H 'pieowl-client-id: YOUR_CLIENT_ID' \
-H 'pieowl-client-secret: YOUR_CLIENT_SECRET' \
-d '{
"requests": [
{
"userAgent": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
"path": "/pricing",
"host": "example.com",
"method": "GET",
"status": 200,
"timestamp": "2026-01-15T10:30:00.000Z",
"ip": "20.171.206.17"
}
]
}'
```
The body has a `requests` array with 1 to 100 items. Each item:
| Field | Type | Required | Description |
| ----------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userAgent` | string | Yes | The `User-Agent` header of the request, 1 to 1024 characters. |
| `path` | string | Yes | Requested path, 1 to 2048 characters. |
| `host` | string | No | Requested host, up to 255 characters. |
| `method` | string | No | HTTP method, up to 16 characters. |
| `status` | integer | No | Response status, 100 to 599. |
| `timestamp` | string | No | ISO 8601 date-time with an offset, for example `2026-01-15T10:30:00.000Z`. Defaults to the time Pieowl receives it. Times more than 1 hour in the past are moved to 1 hour ago, and future times to now. |
| `ip` | string | No | IPv4 or IPv6 address the request came from, without a port. Used to [verify](#verification) the bot, not stored. |
A valid request returns `202` with the number of stored and ignored items:
```json
{ "accepted": 1, "ignored": 0 }
```
Requests without a valid client secret get `401`. The endpoint allows 600 calls per 10 seconds per client ID, so batch bot requests (up to 100 per call) instead of sending one call each.
Verification [#verification]
Anyone can put `GPTBot` in a user agent. When you send the client IP, Pieowl checks it against what the bot's company publishes: its official IP list, or a reverse DNS lookup confirmed by a forward lookup. Each request gets one of three results:
* **Verified**: the IP belongs to the company. Shown with a check mark in the card.
* **Fake**: the company publishes a way to check its bots, and the IP fails it. Fake requests are left out of the card's charts and totals. A line under the tabs says how many were hidden and which bots they claimed to be.
* **Can't be verified**: the company publishes no IP list or DNS name for its bot, the request was sent without an IP, or the check could not run (for example a DNS timeout). These requests are counted as normal.
Meta, ByteDance (Bytespider), DeepSeek and xAI publish no addresses for their bots, so their requests are always shown as "can't be verified". Companies Pieowl verifies include OpenAI, Anthropic, Google, Microsoft (Bing), Apple, Amazon, Perplexity, Mistral, DuckDuckGo, Common Crawl and Yandex.
The IP is only used for this check. Pieowl stores the result, not the IP.
The IP must be the bot's, not your proxy's. `@pieowl/bots` takes it from the usual proxy headers (Express: `req.ip`); if you call the HTTP API yourself, forward the client IP your load balancer or CDN reports.
Notes [#notes]
* **Bot requests don't count toward your event limit.**
* **Requests are classified by user agent.** Pieowl matches the user agent against a list of known AI and search bots and assigns each one a company and a category. The IP then [confirms](#verification) the claim where the company makes that possible.
* **Unknown bots are ignored.** Requests from user agents that aren't on the list are not stored. In the HTTP API response they count as `ignored`.
---
## Authentication
URL: https://pieowl.com/docs/api-reference/authentication
Client ID and secret [#client-id-and-secret]
Pieowl uses client credentials. Every API client has a **client ID** and a **client secret**, which you create in the dashboard.
Send them as request headers:
```http
pieowl-client-id:
pieowl-client-secret:
```
The base URL is `https://api.pieowl.com`.
API client types [#api-client-types]
| Type | What it can do |
| ------- | ------------------------------------------------------- |
| `write` | Send events and profile updates |
| `read` | Read analytics data (Insights, Export, Import) |
| `root` | Full access, including the Manage API. Server use only. |
Create a client [#create-a-client]
In the dashboard, go to **Settings → Developers → Keys**. Create a client with the type you need. Copy the secret right away, because it is only shown once.
For permissions, error responses and security tips, see the [Authentication guide](/docs/api/authentication).
---
## Rate Limits
URL: https://pieowl.com/docs/api-reference/rate-limits
Limits [#limits]
| Endpoint group | Limit |
| -------------- | -------------------- |
| Insights | 100 req / 10 seconds |
| Export | 100 req / 10 seconds |
| Manage | 20 req / 10 seconds |
Limits apply per **client ID**. A request without a client ID is counted against its IP address.
Track, Profile and Import endpoints have no rate limit.
Handling 429s [#handling-429s]
When you go over the limit, the API returns `429 Too Many Requests`:
```json
{
"status": 429,
"error": "Too Many Requests",
"message": "You have exceeded the rate limit for this endpoint."
}
```
Wait for the 10-second window to reset, then retry. Exponential backoff works well.
---
## Authentication
URL: https://pieowl.com/docs/api/authentication
Authentication [#authentication]
Every request to the Pieowl API is tied to a client. A client has a client ID and a client secret. Each API needs a certain client type:
* **Track API**: any client. The default `write` client works.
* **Export API**: `read` or `root`
* **Insights API**: `read` or `root`
* **Import API**: `read` or `root`
* **Manage API**: `root` only
When you add a site, Pieowl creates a `write` client for it. That client can send events, but it can't use the Export, Insights, Import or Manage APIs. Create another client with the right type for those.
Headers [#headers]
Send these headers with every request:
* `pieowl-client-id`: your client ID
* `pieowl-client-secret`: your client secret
Example [#example]
```bash
curl 'https://api.pieowl.com/insights/YOUR_PROJECT_ID/metrics?range=30d' \
-H 'pieowl-client-id: YOUR_CLIENT_ID' \
-H 'pieowl-client-secret: YOUR_CLIENT_SECRET'
```
Replace `YOUR_PROJECT_ID` with your site ID (`projectId`), and `YOUR_CLIENT_ID` and `YOUR_CLIENT_SECRET` with your own values.
Create a client [#create-a-client]
In the dashboard, go to **Settings → Developers → Keys**. Create a client and pick its type. Copy the secret right away, because it is only shown once.
Client types [#client-types]
| Type | What it can do |
| ------- | ----------------------------------------------------------- |
| `write` | Send events and profile updates (Track API) |
| `read` | Read data for its own site (Export, Insights, Import) |
| `root` | Read data for any site in your team, and use the Manage API |
A `root` client has access to every site in your team. Use it only on a server, and keep its secret safe.
Keep your credentials safe [#keep-your-credentials-safe]
1. **Keep secrets on the server.** Never put a client secret in browser or app code.
2. **Use HTTPS** for every request.
3. **Rotate secrets** from time to time. Create a new client, switch over, then delete the old one.
4. **Use the smallest access you need.** Pick `read` over `root` when you only read data.
Error responses [#error-responses]
If authentication fails on the Export, Insights, Import or Manage APIs, you get `401 Unauthorized`. The message tells you what went wrong:
```json
{
"error": "Unauthorized",
"message": "Export: Invalid client secret"
}
```
Common causes:
* The client ID or secret is wrong.
* The client ID is not a valid UUID.
* The client type is too low, for example a `write` client calling the Export API, or a `read` client calling the Manage API.
* The client has no secret.
The Track API also returns `401`, with a plain-text message such as `Ingestion: Invalid cors or secret`.
Rate limits [#rate-limits]
Some APIs limit how many requests one client can make:
* **Export and Insights APIs**: 100 requests per 10 seconds
* **Manage API**: 20 requests per 10 seconds
* **Track, Profile and Import APIs**: no rate limit
If you go over the limit, you get `429 Too Many Requests`. Wait a little, then retry with exponential backoff. See [Rate limits](/docs/api-reference/rate-limits) for details.
---
## Export
URL: https://pieowl.com/docs/api/export
Authentication [#authentication]
Requires a `read` or `root` client. The default `write` client does not have access. See the [Authentication](/docs/api/authentication) guide.
A `read` client can only export its own site. A `root` client can export any site in your team by passing its site ID (`projectId`).
Endpoints [#endpoints]
| Endpoint | What it returns |
| -------------------- | ------------------------------------------------- |
| `GET /export/events` | A paged list of raw events, with optional filters |
| `GET /export/charts` | Time-series chart data, with optional breakdowns |
Export events [#export-events]
`/export/events` accepts these query parameters:
| Parameter | Description |
| -------------- | --------------------------------------------------------------------------------------- |
| `projectId` | The site ID of the site to export. Optional for a `read` client. |
| `event` | Only return events with this name. Repeat it for more names. |
| `start`, `end` | Date range, for example `2026-09-01` |
| `profileId` | Only return events for one profile |
| `page` | Page number, starting at `1` |
| `limit` | Events per page. Default `50`, max `1000`. |
| `filters` | JSON array of property filters |
| `includes` | Extra fields to return, comma separated, for example `profile,meta,properties,referrer` |
```bash
curl 'https://api.pieowl.com/export/events?projectId=YOUR_PROJECT_ID&event=signup&start=2026-09-01&end=2026-09-30&limit=100&includes=properties' \
-H 'pieowl-client-id: YOUR_CLIENT_ID' \
-H 'pieowl-client-secret: YOUR_CLIENT_SECRET'
```
The response has a `meta` object (`count`, `totalCount`, `pages`, `current`) and a `data` array with the events.
Export chart data [#export-chart-data]
`/export/charts` takes a `series` array. Each item names an event and can add filters, a segment (`event`, `user`, `session`, `property_sum` and more) and a property. You can also pass `range` or `startDate` and `endDate`, an `interval` and `breakdowns`.
For every query parameter and the full response schema, see the [API Reference](/docs/api-reference).
Other ways to get your data out [#other-ways-to-get-your-data-out]
You can also send raw events to your own S3 or Google Cloud Storage bucket. Set this up under **Integrations** in the dashboard.
---
## Insights
URL: https://pieowl.com/docs/api/insights
Authentication [#authentication]
Requires a `read` or `root` client. The default `write` client does not have access. See the [Authentication](/docs/api/authentication) guide.
Base URL [#base-url]
```
https://api.pieowl.com/insights/{projectId}
```
`{projectId}` is your site ID.
Example [#example]
```bash
curl 'https://api.pieowl.com/insights/YOUR_PROJECT_ID/metrics?range=30d' \
-H 'pieowl-client-id: YOUR_CLIENT_ID' \
-H 'pieowl-client-secret: YOUR_CLIENT_SECRET'
```
Common endpoints [#common-endpoints]
| Endpoint | What it returns |
| ---------------------------------------------------- | ------------------------------------------------- |
| `GET /metrics` | Sessions, pageviews, bounce rate and other totals |
| `GET /live` | The number of visitors on your site right now |
| `GET /pages` | Top pages |
| `GET /referrer`, `/referrer_name`, `/referrer_type` | Traffic sources |
| `GET /country`, `/region`, `/city` | Location breakdown |
| `GET /device`, `/browser`, `/os` | Device and technology breakdown |
| `GET /utm_source`, `/utm_medium`, `/utm_campaign`, … | UTM parameter breakdown |
| `GET /funnel` | Conversion for a sequence of events |
| `GET /retention` | Retention over time |
There are more endpoints for events, profiles, sessions, groups, saved reports and Google Search Console data. The [API Reference](/docs/api-reference) lists them all.
Query parameters [#query-parameters]
Most endpoints accept:
* `range`: a preset such as `today`, `7d`, `30d`, `3m`, `6m` or `12m`. The default is `7d`.
* `startDate` and `endDate`: a custom date range.
* `filters`: a JSON array of filters.
* `cursor` and `limit`: paging for list endpoints.
For full schemas, see the [API Reference](/docs/api-reference).
---
## Manage API Overview
URL: https://pieowl.com/docs/api/manage
Authentication [#authentication]
The Manage API requires a **root** client. A root client has access to every site in your team and can manage all of their resources. See the [Authentication](/docs/api/authentication) guide.
Base URL [#base-url]
```
https://api.pieowl.com/manage
```
Example [#example]
```bash
curl 'https://api.pieowl.com/manage/projects' \
-H 'pieowl-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'pieowl-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
Resources [#resources]
| Resource | What you can do |
| --------------------------------------------- | -------------------------------------------------------------- |
| [Sites (projects)](/docs/api/manage/projects) | Create, update and delete sites |
| [Clients](/docs/api/manage/clients) | Manage API clients (`read`, `write`, `root`) and their secrets |
| [References](/docs/api/manage/references) | Mark important dates on your charts |
You can also list a site's dashboards with `GET /manage/projects/{projectId}/dashboards`, and the reports on a dashboard with `GET /manage/projects/{projectId}/dashboards/{dashboardId}/reports`.
Rate limiting [#rate-limiting]
20 requests per 10 seconds per client.
For full endpoint schemas, see the [API Reference](/docs/api-reference).
---
## Clients
URL: https://pieowl.com/docs/api/manage/clients
Authentication [#authentication]
Requires a `root` client. See the [Authentication](/docs/api/authentication) guide.
Base URL [#base-url]
```
https://api.pieowl.com/manage/clients
```
Client types [#client-types]
| Type | What it can do |
| ------- | --------------------------------------- |
| `write` | Send events and profile updates |
| `read` | Read analytics data (Insights, Export) |
| `root` | Full access: Manage API, read and write |
Endpoints [#endpoints]
| Method | Path | What it does |
| -------- | ---------------------- | ------------------------------------------------------- |
| `GET` | `/manage/clients` | Lists all clients. Add `projectId` (site ID) to filter. |
| `GET` | `/manage/clients/{id}` | Gets one client |
| `POST` | `/manage/clients` | Creates a client |
| `PATCH` | `/manage/clients/{id}` | Renames a client |
| `DELETE` | `/manage/clients/{id}` | Deletes a client for good |
Create a client [#create-a-client]
```bash
curl -X POST 'https://api.pieowl.com/manage/clients' \
-H 'Content-Type: application/json' \
-H 'pieowl-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'pieowl-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-d '{
"name": "Reporting script",
"projectId": "YOUR_PROJECT_ID",
"type": "read"
}'
```
If you leave out `type`, the client is a `write` client. The client secret is only returned when you create the client. You can't get it again later.
For full request and response schemas, see the [API Reference](/docs/api-reference).
---
## Sites (projects)
URL: https://pieowl.com/docs/api/manage/projects
In the API, a site is called a project: the path is `/manage/projects`, and a site ID is `projectId`.
Authentication [#authentication]
Requires a `root` client. See the [Authentication](/docs/api/authentication) guide.
Base URL [#base-url]
```
https://api.pieowl.com/manage/projects
```
Endpoints [#endpoints]
| Method | Path | What it does |
| -------- | ----------------------- | ------------------------------------------- |
| `GET` | `/manage/projects` | Lists all sites in your team |
| `GET` | `/manage/projects/{id}` | Gets one site |
| `POST` | `/manage/projects` | Creates a site |
| `PATCH` | `/manage/projects/{id}` | Updates a site |
| `DELETE` | `/manage/projects/{id}` | Schedules the site for deletion in 24 hours |
Create a site [#create-a-site]
```bash
curl -X POST 'https://api.pieowl.com/manage/projects' \
-H 'Content-Type: application/json' \
-H 'pieowl-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'pieowl-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-d '{
"name": "My website",
"domain": "https://example.com",
"cors": ["https://example.com"],
"types": ["website"]
}'
```
When you create a site, Pieowl also creates a default `write` client and returns it in the response. The client secret is only shown once, so save it right away.
For full request and response schemas, see the [API Reference](/docs/api-reference).
---
## References
URL: https://pieowl.com/docs/api/manage/references
Authentication [#authentication]
Requires a `root` client. See the [Authentication](/docs/api/authentication) guide.
Base URL [#base-url]
```
https://api.pieowl.com/manage/references
```
What are references? [#what-are-references]
References are markers on your charts. Use them for product launches, campaign starts, feature releases, or any date you want to compare against changes in your numbers.
Endpoints [#endpoints]
| Method | Path | What it does |
| -------- | ------------------------- | ---------------------------------------------------------- |
| `GET` | `/manage/references` | Lists all references. Add `projectId` (site ID) to filter. |
| `GET` | `/manage/references/{id}` | Gets one reference |
| `POST` | `/manage/references` | Creates a reference |
| `PATCH` | `/manage/references/{id}` | Updates a reference |
| `DELETE` | `/manage/references/{id}` | Deletes a reference |
Create a reference [#create-a-reference]
```bash
curl -X POST 'https://api.pieowl.com/manage/references' \
-H 'Content-Type: application/json' \
-H 'pieowl-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'pieowl-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-d '{
"projectId": "YOUR_PROJECT_ID",
"title": "New pricing page",
"description": "Launched the new pricing page",
"datetime": "2026-09-15T09:00:00Z"
}'
```
For full request and response schemas, see the [API Reference](/docs/api-reference).
---
## Track
URL: https://pieowl.com/docs/api/track
Good to know [#good-to-know]
* Send the `pieowl-client-ip` header with the visitor's IP address to get their location. Pieowl uses the IP only for the location lookup and the visitor hash. It does not store it.
* Send the `user-agent` header with the visitor's user agent to get their device, browser and OS.
Authentication [#authentication]
Any client type works. From a server, send your client ID and client secret. See the [Authentication](/docs/api/authentication) guide.
```bash
-H "pieowl-client-id: YOUR_CLIENT_ID" \
-H "pieowl-client-secret: YOUR_CLIENT_SECRET"
```
Base URL [#base-url]
```
https://api.pieowl.com
```
Event types [#event-types]
Send a `POST` request to `/track`. The `type` field decides what gets recorded, and `payload` holds the data:
| Type | What it does |
| -------------- | ---------------------------------------------- |
| `track` | Records a named event with optional properties |
| `identify` | Creates or updates a user profile |
| `increment` | Adds to a number property on a profile |
| `decrement` | Subtracts from a number property on a profile |
| `group` | Creates or updates a group |
| `assign_group` | Links a profile to one or more groups |
Example: track an event [#example-track-an-event]
```bash
curl -X POST 'https://api.pieowl.com/track' \
-H 'Content-Type: application/json' \
-H 'pieowl-client-id: YOUR_CLIENT_ID' \
-H 'pieowl-client-secret: YOUR_CLIENT_SECRET' \
-H 'pieowl-client-ip: 203.0.113.42' \
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' \
-d '{
"type": "track",
"payload": {
"name": "signup",
"properties": { "plan": "pro" },
"profileId": "user_123"
}
}'
```
A successful request returns `200` with the `deviceId` and `sessionId` the event was linked to.
Example: send a purchase [#example-send-a-purchase]
A purchase is a `track` event named `revenue`. `__amount` is in normal units, like `49.99`. `__currency` is optional and defaults to your site currency. `__orderId` is your order number: the same order sent twice counts once. `__deviceId` is the visitor's device ID from `pieowl.getDeviceId()` in the browser, so the purchase is credited to their visit.
```bash
curl -X POST 'https://api.pieowl.com/track' \
-H 'Content-Type: application/json' \
-H 'pieowl-client-id: YOUR_CLIENT_ID' \
-H 'pieowl-client-secret: YOUR_CLIENT_SECRET' \
-d '{
"type": "track",
"payload": {
"name": "revenue",
"properties": {
"__amount": 49.99,
"__currency": "EUR",
"__orderId": "A-1001",
"__deviceId": "DEVICE_ID"
}
}
}'
```
For a refund, send the same event with `"__refund": true` and the `__orderId`. Leave out `__amount` to refund the whole order. See [Revenue attribution](/docs/revenue-attribution).
Example: identify a user [#example-identify-a-user]
```bash
curl -X POST 'https://api.pieowl.com/track' \
-H 'Content-Type: application/json' \
-H 'pieowl-client-id: YOUR_CLIENT_ID' \
-H 'pieowl-client-secret: YOUR_CLIENT_SECRET' \
-d '{
"type": "identify",
"payload": {
"profileId": "user_123",
"email": "jane@example.com",
"firstName": "Jane",
"properties": { "company": "Example Inc" }
}
}'
```
Groups and events [#groups-and-events]
Pieowl never adds groups to events on its own, not even after `assign_group`. Pass `groups` on each `track` call that needs group data.
For the full request and response schema of every event type, see the [API Reference](/docs/api-reference/track/track/post).
---
## Do you need a cookie banner?
URL: https://pieowl.com/docs/consent-and-cookie-banners
Pieowl sets no cookies, so in many cases you don't need a consent banner for analytics. But "no cookies" doesn't settle it on its own. The answer depends on your country, on the features you turn on and on the other tools on your site.
This page is information, not legal advice. Ask your legal advisor about your own site.
What Pieowl stores and reads [#what-pieowl-stores-and-reads]
* **No cookies.** The script never sets a cookie.
* **Nothing stored on your visitors' devices.** No local storage, no session storage, no IndexedDB.
* **IP addresses are never stored.** The IP is used to look up an approximate location and to build the visitor ID, then dropped.
* **Visitor IDs reset every day.** The ID is a hash of the browser's user agent, the IP, your site and a secret salt. The salt changes every day.
* **No cross-site tracking.** The ID includes your site, so the same visitor gets a different ID on every site.
* **Session replay is off unless you turn it on.** Text and form fields are always hidden in session replays.
The script does read some things in the browser and send them to Pieowl: the page address and title, the referrer, and the clicks and events you track. See [What gets collected](/docs/sdks/script#what-gets-collected) and our [data policy](/data-policy) for the full list.
Why the rules can apply without cookies [#why-the-rules-can-apply-without-cookies]
The EU rule on devices (ePrivacy Directive, Article 5(3)) covers reading information from a device, not only storing cookies. The EDPB, the board of EU data protection regulators, says a script that makes the browser send information falls under that rule (Guidelines 2/2023, paragraph 33). So the question is not only "are there cookies?" but "does an exemption cover this?". The EU has no single exemption for analytics. Some countries have one.
By country [#by-country]
* **France.** The CNIL (the French regulator) lets audience measurement run without consent if it meets its conditions. The main ones: it only measures your own site's audience, for you alone; it produces only anonymous statistics; the data isn't combined with other data or passed on; and it doesn't follow people across sites. The CNIL also expects an opt-out and recommends keeping the data no longer than 25 months. Session replay, revenue and conversion tracking, and campaign (UTM) tracking fall outside this exemption.
* **Netherlands.** The law has an exemption for analytics with little or no impact on privacy (Telecommunicatiewet, Article 11.7a).
* **Germany, Poland and Ireland.** There is no general exemption for analytics. Germany's regulators give no blanket answer. Poland's law (Prawo komunikacji elektronicznej, Article 399) only exempts what is needed to send a message or provide the service the visitor asked for. Ireland's regulator says analytics needs consent. Ask your advisor. You may need a consent tool: see [Consent management](/docs/consent-management).
* **Other countries.** Check your own regulator's guidance.
Features that need consent [#features-that-need-consent]
Session replay [#session-replay]
Recording what a visitor does needs their consent. When you turn session replay on, keep **Record only after visitors agree** on in **Settings → Site → What Pieowl counts**. It's on by default. Pieowl then records nothing until your site calls `allowReplay` after the visitor accepts in your own consent banner:
```js
// Script tag
window.pieowl('allowReplay');
// npm package
pieowl.allowReplay();
```
Pieowl stores nothing in the browser, so it can't remember the choice for you. Call `allowReplay` on every page where the visitor has agreed. See [Consent management](/docs/consent-management#session-replay-only-after-consent).
Revenue sent from the browser [#revenue-sent-from-the-browser]
A `purchase()` call from the browser links a sale to the visit. That is conversion measurement, and France's exemption doesn't cover it. Treat it as needing consent. Prefer sending purchases from your server or with your payment provider's webhooks: see [Revenue attribution](/docs/revenue-attribution#from-your-payment-provider-no-code).
What you must do anyway [#what-you-must-do-anyway]
Even where you don't need a banner:
* **Name Pieowl in your privacy policy.** Say that you use Pieowl for analytics and what it collects. Our [data policy](/data-policy#copy-this-into-your-privacy-policy) has text you can copy.
* **Sign the DPA.** Pieowl processes visit data for you. Our [data processing agreement](/dpa) covers that.
* **Keep an opt-out.** Give visitors a way to say no, for example a link in your privacy policy. The script's [`filter` option](/docs/sdks/script#filtering-events) can check their choice.
* **Have a legal basis under GDPR.** Most sites rely on legitimate interest for analytics.
Sources [#sources]
* EDPB Guidelines 2/2023 on the technical scope of Art. 5(3) ePrivacy Directive: [https://www.edpb.europa.eu/system/files/2024-10/edpb\_guidelines\_202302\_technical\_scope\_art\_53\_eprivacydirective\_v2\_en\_0.pdf](https://www.edpb.europa.eu/system/files/2024-10/edpb_guidelines_202302_technical_scope_art_53_eprivacydirective_v2_en_0.pdf)
* CNIL, audience measurement tools (in French): [https://www.cnil.fr/fr/cookies-et-autres-traceurs/regles/cookies-solutions-pour-les-outils-de-mesure-daudience](https://www.cnil.fr/fr/cookies-et-autres-traceurs/regles/cookies-solutions-pour-les-outils-de-mesure-daudience)
* CNIL, self-assessment for consent-exempt audience measurement (in French): [https://www.cnil.fr/sites/default/files/2025-07/outil\_d\_auto-evaluation\_mesure\_d\_audience.pdf](https://www.cnil.fr/sites/default/files/2025-07/outil_d_auto-evaluation_mesure_d_audience.pdf)
* CNIL, draft recommendation on session replay (in French): [https://www.cnil.fr/sites/default/files/2026-02/projet\_de\_recommandation\_rejeu\_session.pdf](https://www.cnil.fr/sites/default/files/2026-02/projet_de_recommandation_rejeu_session.pdf)
* Netherlands, Telecommunicatiewet Article 11.7a: [https://wetten.overheid.nl/BWBR0009950/2024-01-01#Hoofdstuk11\_Artikel11.7a](https://wetten.overheid.nl/BWBR0009950/2024-01-01#Hoofdstuk11_Artikel11.7a)
* Germany, DSK guidance on digital services: [https://datenschutzkonferenz-online.de/media/oh/OH\_Digitale\_Dienste.pdf](https://datenschutzkonferenz-online.de/media/oh/OH_Digitale_Dienste.pdf)
* Poland, Prawo komunikacji elektronicznej: [https://isap.sejm.gov.pl/isap.nsf/DocDetails.xsp?id=WDU20240001221](https://isap.sejm.gov.pl/isap.nsf/DocDetails.xsp?id=WDU20240001221)
* Ireland, DPC guidance on cookies and other tracking technologies: [https://www.dataprotection.ie/sites/default/files/uploads/2020-04/Guidance%20note%20on%20cookies%20and%20other%20tracking%20technologies.pdf](https://www.dataprotection.ie/sites/default/files/uploads/2020-04/Guidance%20note%20on%20cookies%20and%20other%20tracking%20technologies.pdf)
Related [#related]
* [Consent management](/docs/consent-management): hold tracking until the visitor agrees
* [Session replay](/docs/session-replay): privacy controls for recordings
* [Data policy](/data-policy): everything Pieowl collects
---
## Consent management
URL: https://pieowl.com/docs/consent-management
Some places require explicit consent before you track events or record sessions. Pieowl supports both:
* **Hold all tracking:** initialize with `disabled: true` and nothing is sent until you call `ready()`.
* **Hold only session replay:** keep **Record only after visitors agree** on, and call `allowReplay()` when the visitor accepts. Analytics keeps running.
Not sure which you need? See [Do you need a cookie banner?](/docs/consent-and-cookie-banners).
How it works [#how-it-works]
When `disabled: true` is set, all calls to `track`, `identify`, `screenView`, and session replay chunks are held in an in-memory queue instead of being sent to the API. Once the user consents, call `ready()` and the entire queue is flushed immediately.
```ts
const pieowl = new Pieowl({
clientId: 'YOUR_CLIENT_ID',
disabled: true, // nothing sent until ready() is called
});
// Later, when the user accepts your consent banner:
pieowl.ready();
```
If the user declines, don't call `ready()`. The queue is thrown away when the page unloads.
With the script tag, use the [`init` form](/docs/sdks/script#options-in-code), add `disabled: true` to the `init` options, and call `window.pieowl('ready')` when the user accepts.
Session replay only after consent [#session-replay-only-after-consent]
Session replay needs your visitors' consent. In **Settings → Site → What Pieowl counts**, keep **Record only after visitors agree** on when you turn replay on. It's on by default. Pieowl then doesn't start the recorder until your site calls `allowReplay()`. Page views and events are still counted.
```ts
// Script tag: when the visitor accepts in your consent banner
window.pieowl('allowReplay');
// npm package
pieowl.allowReplay();
```
If the visitor declines, don't call it. Nothing is recorded.
Pieowl stores nothing in the browser, so it doesn't remember the choice. Your consent tool does. Call `allowReplay()` on every page where the visitor has agreed, for example right after the script loads when your consent tool says yes.
With session replay and `disabled` [#with-session-replay-and-disabled]
You can use both. With `disabled: true`, replay chunks are queued like events and sent when you call `ready()`. With **Record only after visitors agree** on, the recorder also waits for `allowReplay()`. If one banner covers both, call both when the visitor accepts:
```ts
const pieowl = new Pieowl({
clientId: 'YOUR_CLIENT_ID',
disabled: true,
});
// User accepts consent:
pieowl.ready();
pieowl.allowReplay();
```
If you turn **Record only after visitors agree** off, the recorder starts as soon as the page loads and only `ready()` holds the data back. Keep the setting on so nothing is recorded before the visitor agrees.
Waiting for a user profile [#waiting-for-a-user-profile]
If you want to hold events until you know who the user is, rather than waiting for consent, use `waitForProfile`. Events are queued until `identify()` is called with a `profileId`.
`waitForProfile` is deprecated. It still works, but for new code we recommend `disabled: true` and calling `ready()` yourself.
```ts
const pieowl = new Pieowl({
clientId: 'YOUR_CLIENT_ID',
waitForProfile: true,
});
// Events queue here...
pieowl.track('page_view');
// Queue is flushed once a profileId is set:
pieowl.identify({ profileId: 'user_123' });
```
If the user never authenticates, the queue is never flushed automatically — no events will be sent. To handle anonymous users or guest flows, call `ready()` explicitly when you know the user won't identify:
```ts
// User skipped login — flush queued events without a profileId
pieowl.ready();
```
`ready()` always releases the queue regardless of whether `waitForProfile` or `disabled` is set.
Related [#related]
* [Do you need a cookie banner?](/docs/consent-and-cookie-banners) — what Pieowl stores and when you need consent
* [Consent management guide](/guides/consent-management) — full walkthrough with a cookie banner example
* [Session replay](/docs/session-replay) — privacy controls for replay recordings
* [Identify users](/docs/get-started/identify-users) — link events to a user profile
---
## How to set up notifications and integrations
URL: https://pieowl.com/docs/dashboard/notifications-and-integrations
How it works [#how-it-works]
There are two separate concepts to understand before you start:
* **Integrations** are connections to external services like Slack, Discord, or a custom webhook. Each integration belongs to one site.
* **Notification rules** are the conditions that trigger a notification. Rules live inside individual sites and reference one or more integrations. A rule does nothing until it has an integration attached—and an integration does nothing until a rule uses it.
* **Notifications** are the messages that are sent when a rule is triggered. A notification can be sent as a json object or a template with variables.
Step 1: Create an integration [#create-integration]
Open the site you want alerts for and click **Integrations** in the sidebar. On the **Available** tab, choose the service you want to connect.
For notifications, Pieowl supports:
* **Slack** — authenticate via OAuth and pick a channel
* **Discord** — paste a Discord webhook URL for a channel
* **Webhook** — send an HTTP POST to any URL you control
Fill in the required details and save. The integration is now available to the notification rules for this site.
The same page also has S3 and Google Cloud Storage integrations. They export your raw events to your own storage instead of sending alerts.
Step 2: Go to your site's notification rules [#create-rule]
Integrations alone don't do anything. To start receiving alerts, open the site you want to monitor, click **Notifications** in the left sidebar, and switch to the **Rules** tab.
Click **Add Rule** to open the rule editor on the right side of the screen.
Give your rule a name, then choose a **Type**. There are two types:
| Type | When it triggers |
| ---------- | ---------------------------------------------------------------------- |
| **Event** | Immediately when a matching event is received |
| **Funnel** | After a session ends and all funnel steps have been completed in order |
Event rules [#event-rules]
Event rules fire in real time. The moment Pieowl receives an event that matches your filters, the notification is sent.
In the rule editor:
1. Set **Type** to **Events**
2. Add one or more events from the **Events** list. You can filter each event by its properties (for example, only trigger when `path` starts with `/onboarding`)
3. Write a **Template** for the notification message. Use `{{property_name}}` to insert event properties dynamically—for example, `New user with their first event from {{country}}`.
4. Under **Integrations**, select which integration(s) should receive the notification
Click **Update** to save the rule.
Templates are not used if your webhook integration uses a JavaScript transformer.
Funnel rules [#funnel-rules]
Funnel rules let you track multi-step flows and notify you only when a user completes every step in the correct sequence—for example, `session_start` → `subscription_checkout` → `subscription_created`.
In the rule editor:
1. Set **Type** to **Funnel**
2. Add each event in the funnel, in the order they must occur. You can optionally add property filters to each step
3. Write a **Template** for the notification message
4. Select your **Integration(s)**
Click **Update** to save.
**Important:**
Funnel rule notifications are sent after the session ends, not immediately when the last step fires. Pieowl waits until the session is complete before checking the funnel sequence.
Templates are not used if your webhook integration uses a JavaScript transformer.
View notifications [#view-notifications]
Switch to the **Notifications** tab (the default view) to see every notification that has been triggered for your site. Each row shows the notification title alongside the country, OS, browser, and profile of the user who triggered it.
You can filter the list by creation date or search by title to find specific events.
Frequently asked questions [#frequently-asked-questions]
No. Each integration belongs to one site. To get alerts from another site, add the integration to that site too.
Funnel rules trigger after the session ends, not when the last event fires. If the user's session is still active, the notification is queued until the session closes. Make sure the full funnel sequence was completed within a single session.
Yes. For each event in the rule, click the filter icon to add property conditions—for example, only trigger when `plan` equals `enterprise` or `country` equals `US`.
For notifications: Slack, Discord, and custom webhooks. For data export: S3 and Google Cloud Storage.
Yes. The integrations selector on each rule allows you to pick multiple destinations. A single triggered rule will send a notification to all selected integrations simultaneously.
---
## Understand the overview
URL: https://pieowl.com/docs/dashboard/understand-the-overview
Top stats [#top-stats]
The row of metric cards at the top of the page is the fastest way to understand the health of your site. Each card shows the value for the selected time range and a comparison to the previous period of the same length.
Unique Visitors [#unique-visitors]
The number of distinct profile IDs recorded in the selected period. How accurate this is depends on whether you use [identify](/docs/get-started/identify-users):
* **Without identify**: Pieowl generates a profile ID that rotates every day. A visitor returning on 10 different days will be counted as 10 unique visitors, because each day produces a new ID.
* **With identify**: The profile ID is tied to the user's real identity. The same person visiting on 10 different days is counted as 1 unique visitor across the entire period.
If cross-day deduplication matters to your analysis, set up [user identification](/docs/get-started/identify-users).
Sessions [#sessions]
The total number of sessions in the selected period. A session begins when someone arrives on your site and ends after 30 minutes without activity. One visitor can have many sessions across a day.
Pageviews [#pageviews]
The total number of page views (`screen_view` events) recorded across all sessions. Every time a visitor loads a page—including navigating between pages in a single session—it counts as one pageview.
Pages per Session [#pages-per-session]
The average number of pages viewed within a single session, calculated as `total pageviews / total sessions`. A higher number means visitors are exploring more of your site before leaving.
Bounce Rate [#bounce-rate]
The percentage of sessions where a visitor viewed only a single page and left. Calculated as `single-page sessions / total sessions × 100`. Lower is generally better—it means more visitors are engaging beyond the first page.
> A session is counted as a bounce if the visitor triggered exactly one `screen_view` event before the session ended. Sessions where visitors read one article deeply and leave still count as bounces.
Session Duration [#session-duration]
The average length of a session, shown in minutes, calculated only from sessions where the visitor did something after the first page load (duration > 0). Sessions where a visitor immediately left are excluded from the average to avoid skewing the number.
Revenue [#revenue]
The total value of purchases credited to visits in the selected period. It's shown in your site currency, set in Settings → Site → Revenue. Purchases in other currencies are converted. Revenue only appears if you track revenue events. See [Revenue attribution](/docs/revenue-attribution) for setup and for how purchases are credited to sources, pages and countries.
***
The time-series chart [#the-time-series-chart]
Directly below the stat cards is a line chart that shows how the selected metric changes over time. Click any stat card to switch the chart to that metric.
The chart uses the **interval** you select (hour, day, week, or month) to group data points. A faint dashed line shows the equivalent period from the previous comparison window, so you can spot trends at a glance.
When any metric other than Revenue is active, the chart also overlays revenue as green bars on a secondary Y-axis—this lets you correlate traffic patterns with revenue without switching cards.
The trailing edge of the line (the current, incomplete interval) is shown as a dashed segment to remind you that the period is still accumulating data.
***
Insights [#insights]
A scrollable row of insight cards appears below the chart once Pieowl has found notable changes in your data. Pieowl detects significant trends across pages, entry pages, referrers, locations and devices on its own—no setup needed.
Each card shows:
* **Share**: The percentage of total traffic that property represents (e.g., "United States: 42% of all sessions")
* **Absolute change**: The raw increase or decrease in sessions compared to the previous period
* **Percentage change**: How much that property grew or declined relative to its own previous value
For example, if the US had 1,000 sessions last week and 1,200 this week, the card shows "+200 sessions (+20%)".
Clicking any insight card filters the entire overview page to show only data matching that property—letting you drill into what's driving the trend.
***
Sources [#sources]
The Sources widget shows where your visitors came from. Switch between tabs to see different dimensions:
| Tab | What it shows |
| ------------ | ----------------------------------------------------------------- |
| **Refs** | Grouped referrer names (e.g., "Google", "Twitter", "Hacker News") |
| **Urls** | Raw referrer URLs |
| **Types** | Referrer categories: `search`, `social`, `email`, `unknown` |
| **Source** | `utm_source` query parameter values |
| **Medium** | `utm_medium` query parameter values |
| **Campaign** | `utm_campaign` query parameter values |
| **Term** | `utm_term` query parameter values |
| **Content** | `utm_content` query parameter values |
Referrer names and types are resolved automatically from the raw referrer URL using a built-in lookup table. Direct traffic (no referrer) appears as `(not set)`.
Each row shows sessions and pageviews. Clicking a row filters the entire overview page to only show data from that source.
***
Pages [#pages]
The Pages widget shows which URLs your visitors are landing on, exiting from, and spending time on.
| Tab | What it shows |
| --------------- | --------------------------------------------------------------------------- |
| **Top pages** | Pages ranked by unique sessions. Each row is a `origin + path` combination. |
| **Entry pages** | The first page of each session—the page where visitors arrived. |
| **Exit pages** | The last page of each session—the page where visitors left. |
High exit rates on a page are not always bad—they can reflect a page that successfully answers a question. High bounce on an entry page is more diagnostic. Compare entry and exit distributions to understand the shape of your user journeys.
Clicking a page row filters the whole overview to sessions that included that page.
***
Devices [#devices]
The Devices widget breaks down your audience by hardware and software. Switch between tabs:
| Tab | What it shows |
| ---------------- | ----------------------------------------------------- |
| **Device** | Device type: Desktop, Mobile, Tablet |
| **Brand** | Hardware brand (Apple, Samsung, etc.) |
| **Model** | Specific device model |
| **Browser** | Browser name (Chrome, Safari, Firefox, etc.) |
| **Browser ver.** | Browser version number |
| **OS** | Operating system (macOS, Windows, iOS, Android, etc.) |
| **OS ver.** | Operating system version |
Each row shows sessions and pageviews. Use this widget to prioritize which browsers and operating systems to test and optimize for.
***
Events [#events]
The Events widget shows the most frequent custom events fired in the selected period, ranked by count. System events (`session_start`, `session_end`, `screen_view`) are excluded—only the events you instrument yourself appear here.
Click any event to filter the overview to sessions where that event was fired.
***
Geo [#geo]
The Geo widget shows the geographic distribution of your visitors. Switch between tabs:
| Tab | What it shows |
| ----------- | -------------------------------------------- |
| **Country** | Visitor country, derived from IP geolocation |
| **Region** | State or province |
| **City** | City level |
Below the table, a world map plots the same data as a heatmap—darker areas represent more sessions. This gives you a quick visual of where your audience is concentrated.
Clicking a country, region, or city filters the whole overview to that location.
***
Activity heatmap [#activity-heatmap]
The activity heatmap at the bottom of the page shows when your visitors are most active, broken down by day of the week (Monday through Sunday) and hour of the day (00:00–23:00). Each cell shows the **average** of the selected metric at that day-and-hour combination, averaged across all weeks in the selected period.
Darker cells indicate higher average values. Hover any cell to see the exact average.
You can switch the metric being visualized using the tabs above the heatmap:
* **Unique Visitors**
* **Sessions**
* **Pageviews**
* **Bounce Rate**
* **Pages / Session**
* **Session Duration**
Use the heatmap to identify peak traffic windows, plan campaigns, and schedule maintenance during quiet periods.
***
User Journey [#user-journey]
The User Journey (Sankey) diagram at the very bottom visualizes how visitors flow through your site within a session. It answers the question: after landing on page A, where do visitors go next?
**How it works:**
1. Pieowl finds the top 3 most common entry pages in the selected period.
2. From each entry page, it finds the top 3 most frequent next pages (step 2), then the top 3 from those (step 3), and so on up to the configured number of steps (default 5, adjustable to a maximum of 10).
3. Paths that represent less than 0.25% of total sessions are filtered out to reduce visual noise.
4. Consecutive duplicate pages within a session are collapsed into one step (e.g., if someone refreshed a page, it only counts once in the journey).
Each node shows the page URL. The width of the connecting flows is proportional to the number of sessions that followed that path.
Use the User Journey to find drop-off points, discover unexpected popular paths, and understand whether visitors are reaching your key conversion pages.
***
Filters and time controls [#filters-and-time-controls]
Every widget on the overview page responds to the same set of global filters and time controls at the top of the page.
**Range**: choose a preset (like Today or Last 7 days) or a custom date range.
**Interval**: controls how data is grouped in the time-series chart (hour, day, week, month).
**Event filter**: narrow the entire overview to sessions that include a specific event—useful for analyzing the behavior of users who completed a particular action.
**Dimension filters**: clicking any row in any widget (a country, a source, a page) applies that value as a filter. Active filters are shown as chips below the time controls. Remove a filter by clicking the × on its chip.
**Live counter**: a green badge in the top-right corner shows the number of active visitors (visitors who fired an event in the last 5 minutes). Click it for a 30-minute session histogram.
---
## Groups
URL: https://pieowl.com/docs/get-started/groups
Groups let you associate users with a shared entity — like a company, workspace, or team — and analyze behavior at that level. Instead of asking "what did Jane do?", you can ask "what is Acme Inc doing?"
This is especially useful for B2B SaaS products where a single paying account has many users.
How Groups work [#how-groups-work]
There are two separate concepts:
1. **The group entity** — created/updated with `upsertGroup()`. Stores metadata about the group (name, plan, etc.).
2. **Group membership** — set with `setGroup()` / `setGroups()`. Links a user profile to one or more groups, and automatically attaches those group IDs to every subsequent `track()` call.
The examples use `pieowl`, a `Pieowl` instance from `@pieowl/web` or `@pieowl/sdk`. With the script tag, call the same methods on `window.pieowl`, for example `window.pieowl('setGroup', 'org_acme')`.
Creating or updating a group [#creating-or-updating-a-group]
Call `upsertGroup()` to create a group or update its properties. The group is identified by its `id` and `type`.
```typescript
pieowl.upsertGroup({
id: 'org_acme', // Your group's unique ID
type: 'company', // Group type (company, workspace, team, etc.)
name: 'Acme Inc', // Display name
properties: {
plan: 'enterprise',
seats: 25,
industry: 'logistics',
},
});
```
Group payload [#group-payload]
| Field | Type | Required | Description |
| ------------ | -------- | -------- | --------------------------------------------------- |
| `id` | `string` | Yes | Unique identifier for the group |
| `type` | `string` | Yes | Category of group (e.g. `"company"`, `"workspace"`) |
| `name` | `string` | Yes | Human-readable display name |
| `properties` | `object` | No | Custom metadata about the group |
Managing groups in the dashboard [#managing-groups-in-the-dashboard]
The easiest way to create, edit, and delete groups is directly in the Pieowl dashboard. Open your site and go to the **Groups** section — from there you can manage group names, types, and properties without touching any code.
`upsertGroup()` is the right tool when your group properties are **dynamic and driven by your own data** — for example, syncing a customer's current plan, seat count, or MRR from your backend at login time.
A good rule of thumb: call `upsertGroup()` on login or when group properties change — not on every request or page view. If you find yourself calling it frequently with the same data, the dashboard is probably the better place to manage that group.
Assigning a user to a group [#assigning-a-user-to-a-group]
After identifying a user, call `setGroup()` to link them to a group. This also attaches the group ID to all future `track()` calls for the current session.
```typescript
// After login
pieowl.identify({ profileId: 'user_123' });
// Link the user to their organization
pieowl.setGroup('org_acme');
```
For users that belong to multiple groups:
```typescript
pieowl.setGroups(['org_acme', 'team_engineering']);
```
`setGroup()` and `setGroups()` persist group IDs on the SDK instance. All subsequent `track()` calls will automatically include these group IDs until `clear()` is called.
Full login flow example [#full-login-flow-example]
`setGroup()` doesn't require the group to exist first. You can call it with just an ID — events will be tagged with that group ID, and you can create the group later in the dashboard or via `upsertGroup()`.
```typescript
// 1. Identify the user
pieowl.identify({
profileId: 'user_123',
firstName: 'Jane',
email: 'jane@acme.com',
});
// 2. Assign the user to the group — the group doesn't need to exist yet
pieowl.setGroup('org_acme');
// 3. All subsequent events are now tagged with the group
pieowl.track('dashboard_viewed'); // → includes groups: ['org_acme']
pieowl.track('report_exported'); // → includes groups: ['org_acme']
```
If you want to sync dynamic group properties from your own data (plan, seats, MRR), add `upsertGroup()` to the flow:
```typescript
pieowl.identify({ profileId: 'user_123', email: 'jane@acme.com' });
// Sync group metadata from your backend
pieowl.upsertGroup({
id: 'org_acme',
type: 'company',
name: 'Acme Inc',
properties: { plan: 'pro' },
});
pieowl.setGroup('org_acme');
```
Per-event group override [#per-event-group-override]
You can attach group IDs to a specific event without affecting the SDK's persistent group state:
```typescript
pieowl.track('file_shared', {
filename: 'q4-report.pdf',
groups: ['org_acme', 'org_partner'], // Only applies to this event
});
```
Groups passed in `track()` are **merged** with any groups already set on the SDK instance.
Clearing groups on logout [#clearing-groups-on-logout]
`clear()` resets the profile, device, session, and all groups. Always call it on logout.
```typescript
function handleLogout() {
pieowl.clear();
// redirect to login...
}
```
Common patterns [#common-patterns]
B2B SaaS — company accounts [#b2b-saas--company-accounts]
```typescript
// On login
pieowl.identify({ profileId: user.id, email: user.email });
pieowl.upsertGroup({
id: user.organizationId,
type: 'company',
name: user.organizationName,
properties: { plan: user.plan, mrr: user.mrr },
});
pieowl.setGroup(user.organizationId);
```
Multi-tenant — workspaces [#multi-tenant--workspaces]
```typescript
// When user switches workspace
pieowl.upsertGroup({
id: workspace.id,
type: 'workspace',
name: workspace.name,
});
pieowl.setGroup(workspace.id);
```
Teams within a company [#teams-within-a-company]
```typescript
// User belongs to a company and a specific team
pieowl.setGroups([user.organizationId, user.teamId]);
```
API reference [#api-reference]
`upsertGroup(payload)` [#upsertgrouppayload]
Creates the group if it doesn't exist, or merges properties into the existing group.
```typescript
pieowl.upsertGroup({
id: string; // Required
type: string; // Required
name: string; // Required
properties?: Record;
});
```
`setGroup(groupId)` [#setgroupgroupid]
Adds a single group ID to the SDK's internal group list and sends an `assign_group` event to link the current profile to that group.
```typescript
pieowl.setGroup('org_acme');
```
`setGroups(groupIds)` [#setgroupsgroupids]
Same as `setGroup()` but for multiple group IDs at once.
```typescript
pieowl.setGroups(['org_acme', 'team_engineering']);
```
What to avoid [#what-to-avoid]
* **Calling `upsertGroup()` on every event or page view** — call it on login or when group properties actually change. For static group management, use the dashboard instead.
* **Not calling `setGroup()` after `identify()`** — without it, events won't be tagged with the group and you won't see group-level data in the dashboard.
* **Forgetting `clear()` on logout** — groups persist on the SDK instance, so a new user logging in on the same session could inherit the previous user's groups.
* **Using `upsertGroup()` to link a user to a group** — `upsertGroup()` manages the group entity only. Use `setGroup()` to link a user profile to it.
---
## Identify Users
URL: https://pieowl.com/docs/get-started/identify-users
By default, Pieowl doesn't know which of your users a visitor is. To connect their events to a user in your database, identify them.
How it works [#how-it-works]
When a user logs in or signs up, call `identify`. This links their current session and all later events to the ID from your system.
```javascript
pieowl.identify({
profileId: 'user_123'
});
```
Adding user traits [#adding-user-traits]
You can also pass traits like name, email or plan. They show up on the user's profile in your dashboard.
```javascript
pieowl.identify({
profileId: 'user_123',
firstName: 'Jane',
lastName: 'Doe',
email: 'jane@example.com',
properties: {
company: 'Acme Inc',
plan: 'pro',
},
});
```
Standard traits [#standard-traits]
These keys have their own fields on the profile, so they display correctly in the dashboard:
* `firstName`
* `lastName`
* `email` (must be a valid email address)
* `avatar` (must be a URL)
Put everything else, like company or plan, inside `properties`.
Best practices [#best-practices]
1. **Call on login**: Identify the user right after they log in.
2. **Call on update**: If a user changes their profile, call `identify` again with the new details.
3. **Unique IDs**: Use a stable ID from your database (like a UUID), not an email address or username that might change.
---
## Install Pieowl
URL: https://pieowl.com/docs/get-started/install-pieowl
The fastest way to start is the script tag. It works on any website.
Quick start [#quick-start]
Add this tag to the `` of your site. Replace `YOUR_CLIENT_ID` with the client ID from your site settings.
```html title="index.html"
```
That's it. Pieowl now tracks:
* Page views
* Visit duration
* Referrers
* Device and browser
* Location (country and city)
What gets collected [#what-gets-collected]
You choose what the script collects by itself in **Settings → Site → What Pieowl counts**:
* **Page views**: every page someone opens. On by default.
* **Clicks on links to other sites**: which outside links people click. On by default.
* **Clicks you marked in your code**: elements with `data-track="…"`. On by default.
* **Address changes after #**: for one-page sites that change only the part after `#`. Off by default.
The script loads these settings on every page. A change reaches your site within about a minute, with no code to edit.
To send your own events, see [Track events](/docs/get-started/track-events).
Using a framework? [#using-a-framework]
Pick the SDK for your stack. It gives you typed methods and a setup that fits the framework.
See all options [#see-all-options]
Read the [SDKs overview](/docs/sdks) for every framework and language.
---
## Track Events
URL: https://pieowl.com/docs/get-started/track-events
Events are the core of Pieowl. An event records one action a user takes, like clicking a button, sending a form or buying something.
The examples below use `pieowl`, the SDK instance you created during [install](/docs/get-started/install-pieowl). With the script tag, use `window.pieowl` instead. It works once `pieowl.js` has loaded; to call it earlier, use the `window.pieowl('track', ...)` form and add the [queue line](/docs/sdks/script#calling-pieowl-before-the-script-loads) before the script tag.
Tracking an event [#tracking-an-event]
Call `track` with an event name.
```javascript
pieowl.track('button_clicked');
```
With the script tag:
```javascript
window.pieowl('track', 'button_clicked');
```
Adding properties [#adding-properties]
Pass a properties object to add context to the event.
```javascript
pieowl.track('signup_button_clicked', {
location: 'header',
color: 'blue',
variant: 'primary'
});
```
Common property types [#common-property-types]
* **Strings**: Text values like names, categories or IDs.
* **Numbers**: Numeric values like price, quantity or score.
* **Booleans**: True or false values.
Using data attributes [#using-data-attributes]
You can track clicks without writing JavaScript. Add a `data-track` attribute to a button or link. This needs **Clicks you marked in your code** to be on in Settings → Site (it is by default).
```html
```
When a user clicks this button, Pieowl tracks a `signup_clicked` event with the property `location: 'header'`. Every other `data-*` attribute on the element becomes a property, with its name in camelCase (`data-plan-name` becomes `planName`).
---
## How it works
URL: https://pieowl.com/docs/how-it-works
Device ID [#device-id]
A **device ID** is a unique identifier generated for each device/browser combination. It's calculated using a hash function that combines:
* **User Agent** (browser/client information)
* **IP Address**
* **Origin** (site ID)
* **Salt** (a rotating secret key)
```typescript
export function generateDeviceId({
salt,
ua,
ip,
origin,
}: GenerateDeviceIdOptions) {
return createHash(`${ua}:${ip}:${origin}:${salt}`, 16);
}
```
Salt Rotation [#salt-rotation]
The salt used for device ID generation rotates **daily at midnight** (00:00 server time). This means:
* Device IDs remain consistent throughout a single day
* Device IDs reset each day for privacy purposes
* The system maintains both the current and previous day's salt to handle events that may arrive slightly after midnight
```typescript
// Salt rotation happens daily at midnight (pattern: '0 0 * * *')
```
When the salt rotates, all device IDs change. The same visitor can't be linked from one day to the next, while sessions within a day still hold together.
No cookies, no stored IPs [#no-cookies-no-stored-ips]
The device ID is computed on the server, so the tracker never sets a cookie. The IP address is only used for this hash and for the location lookup (country and city). It is not stored with your events. The tracker stores nothing on the visitor's device. See [Do you need a cookie banner?](/docs/consent-and-cookie-banners).
Session ID [#session-id]
A **session** represents a continuous period of user activity. Sessions are used to group related events together and understand user behavior patterns.
Session Duration [#session-duration]
Sessions have a **30-minute timeout**. If no events are received for 30 minutes, the session automatically ends. Each new event resets this 30-minute timer.
```typescript
export const SESSION_TIMEOUT = 1000 * 60 * 30; // 30 minutes
```
Session Creation Rules [#session-creation-rules]
Sessions are **only created for client events**, not server events. This means:
* Events sent from browsers, mobile apps, or client-side SDKs will create sessions
* Events sent from backend servers, scripts, or server-side SDKs will **not** create sessions
* If you only track events from your backend, no sessions will be created
Additionally, sessions are **not created for events older than 15 minutes**. This prevents historical data imports from creating artificial sessions.
```typescript
// Sessions are not created if:
// 1. The event is from a server (uaInfo.isServer === true)
// 2. The timestamp is from the past (isTimestampFromThePast === true)
if (uaInfo.isServer || isTimestampFromThePast) {
// Event is attached to existing session or no session
}
```
Profile ID [#profile-id]
A **profile ID** is a persistent identifier for a user across multiple devices and sessions. It allows you to track the same user across different browsers, devices, and time periods.
Profile ID Assignment [#profile-id-assignment]
If a `profileId` is provided when tracking an event, it will be used to identify the user. However, **if no `profileId` is provided, it defaults to the `deviceId`**.
This means:
* Anonymous users (without a profile ID) are tracked by their device ID
* Once you identify a user (by providing a profile ID), all their events will be associated with that profile
* The same user can be tracked across multiple devices by using the same profile ID
```typescript
// If no profileId is provided, it defaults to deviceId
if (!payload.profileId && payload.deviceId) {
payload.profileId = payload.deviceId;
}
```
Client Events vs Server Events [#client-events-vs-server-events]
Pieowl distinguishes between **client events** and **server events** based on the User-Agent header.
Client Events [#client-events]
Client events are sent from:
* Web browsers (Chrome, Firefox, Safari, etc.)
* Mobile apps using client-side SDKs
* Any client that sends a browser-like User-Agent
Client events:
* Create sessions
* Generate device IDs
* Support full [session tracking](/features/session-tracking)
Server Events [#server-events]
Server events are detected when the User-Agent matches server patterns, such as:
* `Go-http-client/1.0`
* `node-fetch/1.0`
* Other single-name/version patterns (e.g., `LibraryName/1.0`)
Server events:
* Do **not** create sessions
* Are attached to existing sessions if available
* Are useful for backend tracking without session management
```typescript
// Server events are detected by patterns like "Go-http-client/1.0"
function isServer(res: UAParser.IResult) {
if (SINGLE_NAME_VERSION_REGEX.test(res.ua)) {
return true;
}
// ... additional checks
}
```
The distinction is made in the event processing pipeline:
```typescript
const uaInfo = parseUserAgent(userAgent, properties);
// Only client events create sessions
if (uaInfo.isServer || isTimestampFromThePast) {
// Server events or old events don't create new sessions
}
```
Timestamps [#timestamps]
Events can include custom timestamps to track when events actually occurred, rather than when they were received by the server.
Setting Custom Timestamps [#setting-custom-timestamps]
You can provide a custom timestamp using the `__timestamp` property in your event properties:
```javascript
track('page_view', {
__timestamp: '2024-01-15T10:30:00Z'
});
```
Timestamp Validation [#timestamp-validation]
The system validates timestamps to prevent abuse and ensure data quality:
1. **Future timestamps**: If a timestamp is more than **1 minute in the future**, the server timestamp is used instead
2. **Past timestamps**: If a timestamp is older than **15 minutes**, it's marked as `isTimestampFromThePast: true`
```typescript
// Timestamp validation logic
const ONE_MINUTE_MS = 60 * 1000;
const FIFTEEN_MINUTES_MS = 15 * ONE_MINUTE_MS;
// Future check: more than 1 minute ahead
if (clientTimestampNumber > safeTimestamp + ONE_MINUTE_MS) {
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
}
// Past check: older than 15 minutes
const isTimestampFromThePast =
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
```
Timestamp Impact on Sessions [#timestamp-impact-on-sessions]
**Important**: Events with timestamps older than 15 minutes (`isTimestampFromThePast: true`) will **not create new sessions**. This prevents historical data imports from creating artificial sessions in your analytics.
```typescript
// Events from the past don't create sessions
if (uaInfo.isServer || isTimestampFromThePast) {
// Attach to existing session or track without session
}
```
This ensures that:
* Real-time tracking creates proper sessions
* Historical data imports don't interfere with session analytics
* Backdated events are still tracked but don't affect session metrics
---
## MCP Server
URL: https://pieowl.com/docs/mcp
Pieowl runs an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server. It lets AI assistants like Claude, Cursor and Windsurf query your analytics data right in the conversation. This is how you ask questions about your data: there is no chat inside Pieowl. The server is hosted, so there is nothing to install, and it shows up in your client as **Pieowl**.
Endpoint [#endpoint]
```
https://api.pieowl.com/mcp
```
Authentication [#authentication]
Pass the token in an `Authorization` header:
```
Authorization: Bearer YOUR_TOKEN
```
If your client can't set headers, pass it as a query parameter instead:
```
https://api.pieowl.com/mcp?token=YOUR_TOKEN
```
Both work. Prefer the header: query strings end up in browser history, shell history and proxy logs, so a token in the URL is easier to leak by accident.
Token format [#token-format]
The token is your client ID and client secret, joined by a colon and **base64-encoded**:
```
base64(clientId:clientSecret)
```
From the dashboard [#from-the-dashboard]
The easiest way to get your MCP token is from the dashboard, no terminal needed:
* **AI assistants** — in **Settings → Developers → AI assistants**, pick your client (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code or Raycast) and click **Create token**. Pieowl makes a read-data key for the site and puts the token straight into a ready-made config. Copy it right away: it is shown only once.
* **New key** — when you create a read-data key in **Settings → Developers → Keys**, the success screen shows a **Token for AI assistants** field ready to copy. Use **Download** to save your key ID, secret and token as `credentials.txt`.
From the terminal [#from-the-terminal]
```bash
echo -n "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" | base64
```
Then send it with every request:
```
Authorization: Bearer
```
Or, for clients that can't set headers, add it to the MCP URL:
```
https://api.pieowl.com/mcp?token=
```
Required client type [#required-client-type]
Only `read` and `root` clients can use MCP. Write-only clients are rejected.
| Client type | Access |
| ----------- | ---------------------------------------- |
| `read` | One site (the one the client belongs to) |
| `root` | Any site in your team |
Use a `read` client to limit the AI assistant to one site. Use a `root` client if you need access across sites or want to list all sites.
Create keys in **Settings → Developers** in your dashboard. See the [Authentication guide](/docs/api/authentication) for details.
Connecting to Claude Desktop [#connecting-to-claude-desktop]
Add this to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"pieowl": {
"type": "streamable-http",
"url": "https://api.pieowl.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_TOKEN"
}
}
}
}
```
If your client doesn't read `headers`, drop that block and put the token in the URL instead: `"url": "https://api.pieowl.com/mcp?token=YOUR_TOKEN"`.
Connecting with Claude Code CLI [#connecting-with-claude-code-cli]
Use the `--header` flag to pass the token as `Authorization: Bearer`:
```bash
claude mcp add --transport http pieowl https://api.pieowl.com/mcp \
--header "Authorization: Bearer YOUR_TOKEN"
```
Or with the token in the URL:
```bash
claude mcp add --transport http pieowl "https://api.pieowl.com/mcp?token=YOUR_TOKEN"
```
Connecting to Cursor [#connecting-to-cursor]
Add this to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (one project):
```json
{
"mcpServers": {
"pieowl": {
"url": "https://api.pieowl.com/mcp",
"transport": "streamable-http",
"headers": {
"Authorization": "Bearer YOUR_TOKEN"
}
}
}
}
```
For other clients, copy the config from **Settings → Developers → AI assistants** in your dashboard.
Available tools [#available-tools]
Site access [#site-access]
| Tool | Description |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list_projects` | List all sites your credentials can access, with their site IDs (`projectId`). Root clients see every site in the team; read clients see only their own. |
| `get_dashboard_urls` | Get dashboard links for the current site — overview, events, profiles, sessions, and deep links to specific items. |
Dashboards & reports [#dashboards--reports]
| Tool | Description |
| ----------------- | --------------------------------------------------------------------------- |
| `list_dashboards` | List all dashboards for a site. |
| `get_dashboard` | Get a dashboard with its full report settings and saved layouts. |
| `list_reports` | List all reports in a dashboard with their chart types and tracked events. |
| `get_report_data` | Run a saved report and return its data (time series, funnel, metric, etc.). |
Dashboard management (root clients) [#dashboard-management-root-clients]
| Tool | Description |
| ------------------------ | ----------------------------------------------------------------------------- |
| `create_dashboard` | Create a dashboard for a site. |
| `update_dashboard` | Rename a dashboard. |
| `delete_dashboard` | Delete an empty dashboard, or delete it with its reports using `forceDelete`. |
| `create_report` | Add a saved chart to a dashboard. |
| `update_report` | Replace a saved chart's settings. |
| `delete_report` | Delete a saved chart. |
| `duplicate_report` | Duplicate a saved chart in its dashboard. |
| `update_report_layout` | Set a saved chart's grid position and size on the dashboard. |
| `reset_dashboard_layout` | Remove all saved chart layouts from a dashboard. |
Discovery [#discovery]
| Tool | Description |
| --------------------------- | -------------------------------------------------------------------------------------------------------- |
| `list_event_names` | Get the 50 most common event names on the site. Call this first if you don't know the exact event names. |
| `list_event_properties` | List all property keys tracked for an event (or across all events). |
| `get_event_property_values` | Get all distinct values for one event property. |
Events & sessions [#events--sessions]
| Tool | Description |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `query_events` | Query raw events with optional filters. Returns single records with path, device, country, referrer and custom properties. |
| `query_sessions` | Query sessions with optional filters. Each session includes duration, entry and exit pages, bounce status and attribution data. |
Profiles (users) [#profiles-users]
| Tool | Description |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `find_profiles` | Search and filter user profiles by name, email, location, inactivity, session count, or whether they did a specific event. |
| `get_profile` | Get one user profile with their most recent events. |
| `get_profile_sessions` | Get all sessions for a user profile, newest first. |
| `get_profile_metrics` | Get lifetime metrics for a user: sessions, page views, bounce rate, revenue and more. |
Groups (B2B) [#groups-b2b]
| Tool | Description |
| ------------------ | -------------------------------------------------------------------------------------------- |
| `list_group_types` | List all group types on the site (e.g. `company`, `team`). Call this before querying groups. |
| `find_groups` | Search for groups by name, ID or type. |
| `get_group` | Get one group with its properties and member profiles. |
Aggregated metrics [#aggregated-metrics]
| Tool | Description |
| -------------------------- | ------------------------------------------------------------------------------------------------------- |
| `get_analytics_overview` | Key metrics for a date range: visitors, page views, sessions, bounce rate and average session duration. |
| `get_rolling_active_users` | Active users over time with a rolling window: daily (DAU), weekly (WAU) or monthly (MAU). |
| `get_top_pages` | Most visited pages, ranked by page views. |
| `get_page_performance` | Bounce rate, average session duration, sessions and page views per page. |
| `get_page_conversions` | Pages ranked by how many visitors went on to convert after viewing them. |
| `get_entry_exit_pages` | Most common entry pages (session start) or exit pages (session end). |
| `get_top_referrers` | Top traffic sources by referrer name and type. |
| `get_country_breakdown` | Visitor counts by country, region or city. |
| `get_device_breakdown` | Visitor counts by device type, browser or OS. |
User behavior [#user-behavior]
| Tool | Description |
| --------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `get_funnel` | Analyze a conversion funnel between 2 or more events — sign-up flows, checkout, onboarding. |
| `get_retention_cohort` | Weekly retention cohort table that shows how well users stick around. |
| `get_weekly_retention_series` | Week-over-week retention as a time series. |
| `get_user_last_seen_distribution` | How recently users were last active, grouped into fresh, stale and churned. Useful for churn analysis. |
| `get_user_flow` | User navigation flows as a Sankey diagram (before, after or between events). |
Google Search Console [#google-search-console]
These tools need Google Search Console connected for the site. Connect it in the dashboard on the **Google search** page.
| Tool | Description |
| ----------------------------- | -------------------------------------------------------------------------------- |
| `gsc_get_overview` | Search performance over time: clicks, impressions, CTR and average position. |
| `gsc_get_top_pages` | Top pages from Search Console, ranked by clicks. |
| `gsc_get_page_details` | Search performance for one page, including all queries that bring traffic to it. |
| `gsc_get_top_queries` | Top search queries, ranked by clicks. |
| `gsc_get_query_opportunities` | Quick SEO wins: queries ranking 4–20 with meaningful search volume. |
| `gsc_get_query_details` | Data for one search query, with every page that ranks for it. |
| `gsc_get_cannibalization` | Queries where several of your pages compete with each other in Google. |
Rate limiting [#rate-limiting]
60 requests per minute, counted per IP address.
---
## Revenue attribution
URL: https://pieowl.com/docs/revenue-attribution
[Revenue attribution](/features/revenue-attribution) shows which sources, campaigns, pages and countries make you money, not just which bring visits. You send each purchase to Pieowl, and Pieowl credits it to the visit where it happened.
This page covers how to send purchases, how they are credited, and where to find revenue in the dashboard. For step-by-step checkout flows with Stripe and other payment providers, see [Revenue tracking](/docs/revenue-tracking).
Send purchases [#send-purchases]
**Settings → Site → Revenue** in your dashboard has everything in one place: your currency, ready code to copy for your site or your server, and the last payment Pieowl received, so you can see it works.
Call `purchase()` when your site learns the payment went through. The SDKs send it as an event named `revenue`.
```js
pieowl.purchase({ id: 'A-1001', amount: 49.99, currency: 'EUR', plan: 'pro' });
```
* **`id`** is your order number. See [Order numbers](#order-numbers).
* **`amount`** is in normal units, like `49.99`, not cents. It must be zero or more.
* **`currency`** is an ISO 4217 code, like `EUR`. Leave it out and Pieowl uses your [site currency](#currency).
* Add any other properties you want to break revenue down by, like `plan` or `product`.
From your payment provider, no code [#from-your-payment-provider-no-code]
Stripe, Paddle, Lemon Squeezy and Polar can send your payments to Pieowl by themselves. In Pieowl, open **Settings → Site → Revenue → Your payment provider** and pick yours. You get your own webhook address and the steps for that provider:
| Provider | Where to add the webhook | Events | Secret |
| ------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| Stripe | **Workbench → Webhooks → Create an event destination**, payload **Snapshot** | `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `invoice.paid`, `charge.refunded` | Copy the **Signing secret** (`whsec_…`) into Pieowl |
| Paddle | **Events → Notifications → New destination**, type **URL** | `transaction.completed`, `adjustment.created`, `adjustment.updated` | Copy the destination's **Secret key** into Pieowl |
| Lemon Squeezy | **Settings → Webhooks → +** | `order_created`, `order_refunded`, `subscription_created`, `subscription_payment_success`, `subscription_payment_refunded` | Paste the secret Pieowl shows into Lemon Squeezy |
| Polar | **Settings → Webhooks → Add Endpoint**, format **Raw** | `order.paid`, `refund.created`, `refund.updated` | Copy the endpoint's secret (`whsec_…`) into Pieowl |
From then on:
* One-off payments, subscription payments and renewals are counted. Each event counts once, even when the provider sends it again.
* Refunds are subtracted from the visit the order was paid in.
* Test-mode payments show that the connection works, but they aren't counted.
* The card shows when the provider last sent an event, and what to fix if something is wrong (for example, a wrong secret).
**Who paid.** The Pieowl script adds the visitor to checkout links by itself: Stripe payment links and buy buttons, Lemon Squeezy and Polar checkout links, and Paddle.js buttons. If your own code creates the checkout, pass the visitor yourself:
* Stripe: `client_reference_id: pieowl.getDeviceId()` (or `metadata.pieowl_device_id`)
* Paddle: `customData: { pieowl_device_id: pieowl.getDeviceId() }`
* Lemon Squeezy: `checkout_data.custom.pieowl_device_id`
* Polar: `metadata.pieowl_device_id`
Without it the payment counts in your total, without a source. If you also send `purchase()` from your site for a Stripe payment, use the PaymentIntent id (`pi_…`) as `id`, so the payment counts once.
From the browser [#from-the-browser]
Call `purchase()` with the [Web SDK](/docs/sdks/web) or the [script tag](/docs/sdks/script). The purchase is credited to the visit that is going on in that browser.
```js
pieowl.purchase({ id: 'A-1001', amount: 49.99, currency: 'EUR', plan: 'pro' });
// Script tag
window.pieowl('purchase', { id: 'A-1001', amount: 49.99, currency: 'EUR', plan: 'pro' });
```
Payment in a modal or an embedded card form [#payment-in-a-modal-or-an-embedded-card-form]
If people pay without leaving the page, there is no success page. Send the purchase where your code learns that the payment went through. For example, with Stripe's Payment Element in a modal, `confirmPayment` with `redirect: 'if_required'` returns the paid PaymentIntent for card payments:
```js
const { paymentIntent } = await stripe.confirmPayment({
elements,
redirect: 'if_required',
});
if (paymentIntent?.status === 'succeeded') {
window.pieowl('purchase', {
id: paymentIntent.id,
amount: paymentIntent.amount / 100, // Stripe sends cents
currency: paymentIntent.currency.toUpperCase(),
});
}
```
Some currencies, like JPY, have no decimals. Stripe sends `1500` for ¥1,500, so don't divide those by 100. If your payment provider gives you the amount with decimals, send that.
The visitor is still on the same visit, so the purchase gets that visit's source, pages and country. Payment methods that redirect to a bank come back to your `return_url`: send the purchase there, as on a success page.
Payment on another site [#payment-on-another-site]
If the visitor pays on another site, like a hosted checkout page, the simplest way is your payment provider's webhook: [no code](#from-your-payment-provider-no-code) or [from your server](#from-your-server). Pieowl stores nothing in the browser to carry the purchase across the redirect.
To send it from the browser instead, call `purchase()` on your success page. If the page doesn't know the order, look it up on your server, for example with the checkout session ID your payment provider adds to the success address:
```js
// Success address: /thanks?session_id={CHECKOUT_SESSION_ID}
const sessionId = new URLSearchParams(location.search).get('session_id');
const order = await fetch(`/api/orders/${sessionId}`).then((res) => res.json());
pieowl.purchase({ id: order.id, amount: order.total, currency: order.currency });
```
Pieowl rejects purchases and refunds sent from the browser by default, because anyone can send browser events. Turn on **Accept revenue sent from the browser** in Settings → Site → Revenue to accept them. Sending from your server is more reliable.
From your server [#from-your-server]
Send the purchase when your server learns the payment went through, for example in your payment provider's webhook. Use the [JavaScript SDK](/docs/sdks/javascript) with your client secret, or the [HTTP API](/docs/api/track) from any language.
```js
import { Pieowl } from '@pieowl/sdk';
const pieowl = new Pieowl({
clientId: 'YOUR_CLIENT_ID',
clientSecret: 'YOUR_CLIENT_SECRET',
});
// When the payment went through
await pieowl.purchase({
id: order.number,
amount: order.total, // 49.99
currency: order.currency, // 'EUR'
deviceId: order.deviceId, // from pieowl.getDeviceId() in the browser
plan: 'pro',
});
```
```bash
curl -X POST 'https://api.pieowl.com/track' \
-H 'Content-Type: application/json' \
-H 'pieowl-client-id: YOUR_CLIENT_ID' \
-H 'pieowl-client-secret: YOUR_CLIENT_SECRET' \
-d '{
"type": "track",
"payload": {
"name": "revenue",
"properties": {
"__amount": 49.99,
"__currency": "EUR",
"__orderId": "A-1001",
"__deviceId": "DEVICE_ID",
"plan": "pro"
}
}
}'
```
A server has no visit of its own, so Pieowl needs to know who paid. Send one of these, or both (the `deviceId` is tried first):
* **`deviceId`**, when you can. In the browser, read it with `pieowl.getDeviceId()` before checkout and keep it with the order, for example in your payment provider's metadata. Send it as `deviceId` with the SDK, or as the `__deviceId` property over HTTP.
* **`profileId`**. In the browser, call [`identify()`](/docs/get-started/identify-users) with the visitor's `profileId` before they pay, for example when they log in or start checkout. Send the same `profileId` with the purchase.
Pieowl credits the purchase to that visitor's current visit. If the visit has already ended (a visit ends after 30 minutes without activity), for example because the payment webhook came hours later, the purchase goes to the visitor's most recent visit that started up to 7 days before the purchase. A purchase without a `deviceId` or `profileId`, or with no visit in those 7 days, is still saved as a `revenue` event, but it isn't credited to a visit, so it doesn't show up in the revenue of sources, pages or countries.
Order numbers [#order-numbers]
Send your order number as `id`. Pieowl counts each order once, so the same order sent twice adds nothing the second time. This happens more than you'd think: a visitor reloads the thank-you page, or your payment provider retries a webhook.
Use an ID that is unique on your site, like your order number or your payment provider's payment ID. For subscription renewals, use the ID of each invoice, not of the subscription.
Refunds [#refunds]
Call `refund()` with the order number when you give money back. Pieowl subtracts the order from your revenue.
```js
// The whole order
pieowl.refund({ id: 'A-1001' });
// Part of it
pieowl.refund({ id: 'A-1001', amount: 10, currency: 'EUR' });
// Script tag
window.pieowl('refund', { id: 'A-1001' });
```
* Without `amount`, Pieowl refunds what was paid for that order. The order must have been sent with `purchase()`.
* Each refund counts once. For several partial refunds of one order, give each a `refundId` (refunds without one are told apart by amount).
* A refund lowers the revenue of the visit the order was paid in, so its source, pages and country lose it too.
Over HTTP, send the same `revenue` event with `"__refund": true` and the `__orderId`. `__amount` and `__currency` are optional.
Currency [#currency]
Pieowl shows all revenue in your site currency. Set it in Settings → Site → **Revenue** → **Your currency**. It's USD until you change it.
* You can send purchases in any of these currencies: USD, EUR, GBP, PLN, CHF, CZK, DKK, HUF, NOK, RON, SEK, ISK, TRY, AUD, BRL, CAD, CNY, HKD, IDR, ILS, INR, JPY, KRW, MXN, MYR, NZD, PHP, SGD, THB and ZAR. Any other code is rejected with an error, so a wrong currency never gets counted.
* A purchase in another currency is converted at the European Central Bank reference rate of the payment's day. On weekends and holidays, Pieowl uses the last published rate.
* Pieowl keeps the original amount and currency of each purchase.
* If you change your site currency, past purchases are recalculated in the background. It takes a few minutes.
The older revenue() call [#the-older-revenue-call]
Before `purchase()`, the SDKs had `pieowl.revenue(4900)`. It still works. It takes a whole number in the smallest unit of your site currency, like cents. It has no order number, so a resent purchase counts twice. Use `purchase()` for new code.
How a purchase is credited [#how-a-purchase-is-credited]
Every purchase is credited to **the visit where it happened**. That visit's details become the purchase's details:
* Source and referrer
* UTM campaign, source and medium
* Entry page (the first page of the visit)
* Country
* Device, browser and OS
Other visits by the same visitor get nothing, even if they came first.
Example [#example]
A visitor comes to your site twice:
| Visit | Came from | Entry page | Bought |
| -------- | ------------- | -------------------- | ------- |
| Monday | Google search | `/blog/pricing-tips` | Nothing |
| Thursday | Instagram | `/products` | $49 |
The $49 is credited to **Instagram** and to the entry page **`/products`**. Google and `/blog/pricing-tips` get no revenue. Both visits still count as visits for their own source and page.
If the visitor had come back on Thursday by typing your address, the $49 would go to **Direct**.
Where to see revenue [#where-to-see-revenue]
In the overview, switch the metric to **Revenue** in any of these places:
* **Sources**: referrers, UTM sources, mediums and campaigns
* **Entry pages**: revenue by the page each visit started on. Each purchase belongs to one entry page, so the shares add up to 100%.
* **Viewed pages**: revenue of the visits that viewed each page. A visit that viewed three pages counts for all three, so the shares overlap and add up to more than 100%. Use it to see which pages are part of buying visits.
* **Countries and the map**
* **Devices, browsers and OS**
* **Events**: the revenue of visits that had each event. For example, revenue of visits with `signup_clicked`.
Revenue also shows up in:
* **People**: each person's lifetime revenue, from all of their visits
* **Groups** and **cohorts**: revenue for each group and each cohort
Metrics [#metrics]
| Metric | What it means |
| ----------------------- | -------------------------------------------------------------- |
| **Revenue** | The sum of purchases credited to visits in the selected period |
| **Paying visits** | Visits with at least one purchase |
| **Conversion** | Paying visits divided by all visits, as a percentage |
| **Revenue per visitor** | Revenue divided by unique visitors |
Revenue is shown in your [site currency](#currency).
Filters [#filters]
Add the **Bought** filter to the overview:
* **Bought: yes** shows only visits with a purchase. Use it to see where buyers come from and what they view.
* **Bought: no** shows only visits without a purchase.
All other filters work with revenue too. Filter by a UTM campaign and every revenue number on the page is for that campaign.
Reports [#reports]
In [reports](/features/data-visualization), revenue works with any event:
* **Revenue (visits with this event)**: pick it as the metric for any event to get the revenue of the visits that had that event.
* **Breakdowns by visit**: break revenue down by entry page, referrer or UTM fields.
* **Funnels and conversion**: see the revenue of the visits that completed.
* **Formulas**: combine revenue with other series.
Example: revenue per visitor over time [#example-revenue-per-visitor-over-time]
1. Series **A**: pick `screen_view` with the metric **Revenue (visits with this event)**.
2. Series **B**: pick `screen_view` with the metric **Unique users**.
3. Click **Add formula** and enter `A / B`.
The formula line is revenue per visitor for each day, week or month.
Privacy [#privacy]
You don't need personal data for revenue attribution. Send the order number and the amount and, from a server, a `deviceId` or `profileId`. The `profileId` can be your internal user ID, it doesn't need to be an email address. Don't put names, emails or card details in the purchase properties.
Pieowl sets no cookies, stores nothing on the visitor's device and doesn't store IP addresses. See [How it works](/docs/how-it-works).
Revenue by source is conversion measurement. In some countries that needs your visitors' consent, and France's exemption for audience measurement doesn't cover it. Sending purchases from your server or with webhooks keeps payment data out of the browser. See [Do you need a cookie banner?](/docs/consent-and-cookie-banners).
FAQ [#faq]
The currency the customer paid in. Pieowl converts it to your site currency at the European Central Bank rate of the payment's day, and keeps the original. See [Currency](#currency).
Yes, from the moment you connect it: see [From your payment provider, no code](#from-your-payment-provider-no-code). Stripe, Paddle, Lemon Squeezy and Polar then send every payment, renewal and refund by themselves. Payments made before the connection aren't imported.
Call `refund()` with the order number. Pass `amount` for a partial refund. See [Refunds](#refunds).
Yes, send a purchase for each renewal, with the invoice ID as `id`. A renewal goes to the customer's most recent visit if it started up to 7 days before the renewal. Otherwise there is no visit to credit it to: it's still saved as a `revenue` event, but it doesn't add to the revenue of sources or pages.
Pieowl credits each purchase to the visit where it happened. It doesn't split a purchase across earlier visits. Without cookies, Pieowl can't reliably link an anonymous visitor's visits on different days, so crediting the visit where the purchase happened gives numbers you can trust.
Check that you send the visitor's `deviceId` (from `pieowl.getDeviceId()`) or a `profileId` that the browser also passed to `identify()`, and that the visitor had a visit that started up to 7 days before the purchase.
---
## Revenue tracking
URL: https://pieowl.com/docs/revenue-tracking
[Revenue attribution](/features/revenue-attribution) shows you which sources, campaigns, pages and countries bring in money. This page walks through checkout flows step by step. For how purchases are credited to visits and where to see revenue in the dashboard, see [Revenue attribution](/docs/revenue-attribution).
First, a few basics about how Pieowl and your payment provider work, and how to link a payment to a visitor.
Payment providers [#payment-providers]
Usually, you create your checkout from your backend, which then returns a payment link that your visitor will be redirected to. When creating the checkout link, you usually add additional fields such as metadata, customer information, or order details. We'll add the device ID information in this metadata field to be able to link your payment to a visitor.
Pieowl [#pieowl]
Pieowl doesn't use cookies. It identifies visitors with a `device_id`. To link a payment to a visitor, you need to capture their `device_id` before they complete checkout. This `device_id` will be stored in your payment provider's metadata, and when the payment webhook arrives, you'll use it to associate the revenue with the correct visitor.
Some typical flows [#some-typical-flows]
* [Revenue tracking from your backend (not identified)](#revenue-tracking-from-your-backend-webhook)
* [Revenue tracking from your backend (identified)](#revenue-tracking-from-your-backend-webhook---identified-users)
* [Revenue tracking from your frontend](#revenue-tracking-from-your-frontend)
* [Revenue tracking without linking it to a identity or device](#revenue-tracking-without-linking-it-to-an-identity-or-device)
In the backend examples, `pieowl` is a server-side instance created with your client secret (see the [JavaScript SDK](/docs/sdks/javascript)). In the browser examples, it's your [Web SDK](/docs/sdks/web) instance or `window.pieowl`.
Revenue tracking from your backend (webhook) [#revenue-tracking-from-your-backend-webhook]
This is the most common flow and most secure one. Your backend receives webhooks from your payment provider, and here is the best opportunity to do revenue tracking.
Pieowl credits the purchase to the visit of that `deviceId`: the visit going on when the webhook arrives or, if it has already ended, the visitor's most recent visit that started up to 7 days before the purchase. If there is no such visit, the purchase is still saved as a `revenue` event, but it doesn't show up in the revenue of sources, pages or countries.
When you create the checkout, first call `pieowl.getDeviceId()`. It returns the visitor's current `deviceId`. Pass it to your checkout endpoint.
```javascript
fetch('https://domain.com/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deviceId: pieowl.getDeviceId(), // keep the visitor's deviceId with the payment
// ... other checkout data
}),
})
.then(response => response.json())
.then(data => {
// Handle checkout response, e.g., redirect to payment link
window.location.href = data.paymentUrl;
})
```
```javascript
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req: Request) {
const { deviceId, amount, currency } = await req.json();
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: currency,
product_data: { name: 'Product Name' },
unit_amount: amount * 100, // Convert to cents
},
quantity: 1,
},
],
mode: 'payment',
metadata: {
deviceId: deviceId, // keep the deviceId with the payment
},
success_url: 'https://domain.com/success',
cancel_url: 'https://domain.com/cancel',
});
return Response.json({
paymentUrl: session.url,
});
}
```
```javascript
export async function POST(req: Request) {
const event = await req.json();
// Stripe sends events with type and data.object structure
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
pieowl.purchase({
id: session.id, // a resent webhook counts once
amount: session.amount_total / 100, // Stripe sends cents
currency: session.currency.toUpperCase(),
deviceId: session.metadata.deviceId, // credited to this visitor's visit
});
}
return Response.json({ received: true });
}
```
Stripe sends amounts in the smallest currency unit. For currencies without decimals, like JPY, send `session.amount_total` as it is. If your payment provider gives you the amount with decimals, send that.
***
Revenue tracking from your backend (webhook) - Identified users [#revenue-tracking-from-your-backend-webhook---identified-users]
If your visitors are identified (meaning you have called `identify` with a `profileId`), this process gets a bit easier. You don't need to pass the `deviceId` when creating your checkout, and you only need to provide the `profileId` (in backend) to the `purchase()` call.
Pieowl credits the purchase to the visitor's current visit, so its source, entry page and country get the revenue. If the visit has already ended (a visit ends after 30 minutes without activity), the purchase goes to the visitor's most recent visit that started up to 7 days before it. If you also have the visitor's `deviceId`, send it too: Pieowl tries it first. See [Revenue attribution](/docs/revenue-attribution#from-your-server).
When a visitor logs in or is identified, call `pieowl.identify()` with their unique `profileId`.
```javascript
pieowl.identify({
profileId: 'user-123', // Unique identifier for this user
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
});
```
Since the visitor is already identified, you don't need to fetch or pass the `deviceId`. Just send the checkout data.
```javascript
fetch('https://domain.com/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
// No deviceId needed - user is already identified
// ... other checkout data
}),
})
.then(response => response.json())
.then(data => {
// Handle checkout response, e.g., redirect to payment link
window.location.href = data.paymentUrl;
})
```
Since the user is authenticated, you can get their `profileId` from the session and store it in metadata for easy retrieval in the webhook.
```javascript
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req: Request) {
const { amount, currency } = await req.json();
// Get profileId from authenticated session
const profileId = req.session.userId; // or however you get the user ID
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: currency,
product_data: { name: 'Product Name' },
unit_amount: amount * 100, // Convert to cents
},
quantity: 1,
},
],
mode: 'payment',
metadata: {
profileId: profileId, // Store profileId instead of deviceId
},
success_url: 'https://domain.com/success',
cancel_url: 'https://domain.com/cancel',
});
return Response.json({
paymentUrl: session.url,
});
}
```
In the webhook handler, retrieve the `profileId` from the session metadata.
```javascript
export async function POST(req: Request) {
const event = await req.json();
// Stripe sends events with type and data.object structure
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
pieowl.purchase({
id: session.id,
amount: session.amount_total / 100, // Stripe sends cents
currency: session.currency.toUpperCase(),
profileId: session.metadata.profileId, // Use profileId instead of deviceId
});
}
return Response.json({ received: true });
}
```
***
Revenue tracking from your frontend [#revenue-tracking-from-your-frontend]
This flow sends the purchase from your frontend. Call `pieowl.purchase()` on your success page, or where your code learns that the payment went through. If your success page doesn't know the order, look it up on your server, for example with the checkout session ID your payment provider adds to the success address. Pieowl stores nothing in the browser between checkout and the success page.
Create the checkout with a success address that tells your page which order was paid. With Stripe Checkout, add `session_id={CHECKOUT_SESSION_ID}` to `success_url`.
Look up the paid order and send it with `purchase()`. The same order sent twice counts once.
```javascript
const sessionId = new URLSearchParams(location.search).get('session_id');
const order = await fetch(`/api/orders/${sessionId}`).then((res) => res.json());
pieowl.purchase({
id: order.id,
amount: order.total, // in normal units, like 49.99
currency: order.currency,
});
```
Pros: [#pros]
* Quick way to get going
* No webhook required
Cons: [#cons]
* Missed if the visitor closes the tab before the success page loads
* Less "secure": anyone could post revenue data
* Needs **Accept revenue sent from the browser** turned on in Settings → Site → Revenue
* Revenue sent from the browser is conversion measurement. In some countries it needs your visitors' consent. See [Do you need a cookie banner?](/docs/consent-and-cookie-banners)
***
Revenue tracking without linking it to an identity or device [#revenue-tracking-without-linking-it-to-an-identity-or-device]
If you simply want to track revenue totals without linking payments to specific visitors or devices, you can call `pieowl.purchase()` directly from your backend without providing a `deviceId` or `profileId`. This is the simplest approach and works well when you only need aggregate revenue data.
Call `pieowl.purchase()` with the order number and amount. No `deviceId` or `profileId` is needed.
```javascript
export async function POST(req: Request) {
const event = await req.json();
// Stripe sends events with type and data.object structure
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
// Simple revenue tracking without linking to a visitor
pieowl.purchase({
id: session.id,
amount: session.amount_total / 100,
currency: session.currency.toUpperCase(),
});
}
return Response.json({ received: true });
}
```
Pros: [#pros-1]
* Simplest implementation
* No need to capture or pass device IDs
* Works well for aggregate revenue tracking
Cons: [#cons-1]
* **You can't dive deeper into where this revenue came from.** For instance, you won't be able to see which source generates the best revenue, which campaigns are most profitable, or which visitors are your highest-value customers.
* Revenue events won't be linked to specific user journeys or sessions
* The overview's revenue only counts purchases credited to a visit, so these purchases don't show up there. You can still chart the `revenue` event in reports.
Available methods [#available-methods]
Purchase [#purchase]
Sends a paid order. For security, Pieowl rejects purchases that aren't sent with your client secret. To send them from the browser, turn on **Accept revenue sent from the browser** in Settings → Site → Revenue.
* `id`: your order number. The same order sent twice counts once.
* `amount`: in normal units, like `49.99`, not cents.
* `currency`: an ISO 4217 code, like `EUR`. Optional, your site currency by default. See [Currency](/docs/revenue-attribution#currency).
* `deviceId`: from a server, the visitor's device ID from `pieowl.getDeviceId()`.
* Any other properties, like `plan`.
```javascript
pieowl.purchase({ id: string, amount: number, currency?: string, deviceId?: string, ...properties }): Promise
```
Refund [#refund]
Subtracts an order from your revenue. Without `amount`, the whole order is refunded. Pass `amount` (and `currency`) for a partial refund, and a `refundId` to count several partial refunds of one order. The visit the order was paid in loses the amount. Browser refunds need the same setting as purchases.
```javascript
pieowl.refund({ id: string, amount?: number, currency?: string, deviceId?: string }): Promise
```
Revenue (older) [#revenue-older]
The older way to send a purchase. The amount is a whole number in the smallest unit of your site currency, like cents (`4900` for 49.00). It has no order number, so a resent purchase counts twice. Use `purchase()` for new code.
```javascript
pieowl.revenue(amount: number, properties?: Record): Promise
```
Fetch your current users device id [#fetch-your-current-users-device-id]
```javascript
pieowl.getDeviceId(): string
```
---
## SDKs Overview
URL: https://pieowl.com/docs/sdks
Pieowl has SDKs for the web, Node.js and React Native. For every other language, you send events with a plain HTTP request to the [Track API](/docs/api/track).
Quick start [#quick-start]
For most web projects, start with one of these:
* **[Script Tag](/docs/sdks/script)**: The fastest way to start, no build step
* **[Web SDK](/docs/sdks/web)**: For TypeScript and more control
* **[Next.js](/docs/sdks/nextjs)**: The script tag via `next/script` | [Setup guide](/guides/nextjs-analytics)
Web and browser [#web-and-browser]
Simple integration [#simple-integration]
* **[Script Tag](/docs/sdks/script)**: Add analytics with one `