← Back to home

API reference

Last updated 2026-05-08 — covers the adopter-facing HTTP surface served from https://doable.services.

This page documents the public doable.services API: everything you need to integrate the tracker, read your own analytics, and manage consent. Admin-only endpoints (/api/v1/routes, /api/v1/users, /api/v1/logs, /admin/api/*) are not part of the adopter contract and are not stable — they are intentionally omitted.

If you only need the embed snippet, the Quickstart gets you running in three steps. This page is for direct HTTP integration and server-side use.


Overview

Item Value
Base URL https://doable.services
Path prefix /api/v1
Content type application/json (form-encoded also accepted on /track/beacon)
CORS Access-Control-Allow-Origin: * on all /api/v1/track* endpoints
Date format ISO 8601 (YYYY-MM-DD for query params, YYYY-MM-DDTHH:MM:SSZ for event timestamps)

Response envelope

Tracker endpoints return {"success": true, ...} on success and {"success": false, "error": "..."} on failure. The summary and analytics endpoints return the payload directly with {"error": "..."} on failure (HTTP status carries the signal — check it).

Status codes

Code Meaning
200 OK
204 No content — beacon and consent endpoints fail silently with this status to avoid blocking the tracker
400 Malformed request (bad JSON, missing required parameter, invalid date)
401 Missing or invalid API key
403 API key valid but lacks the required scope (e.g. read endpoint called with an ingest-only key)
404 Tracking project / route not found, or the docs slug isn't allowlisted
429 Rate limit exceeded

Authentication & API keys

API keys are created at Dashboard → API Keys (/dashboard after login). The key value is shown once at creation — store it immediately.

Keys carry one or more permission flags. The two that matter for the public API are:

Flag Grants
can_track (External Tracking) POST /track, POST /track/beacon
can_read_analytics GET /analytics/utm-aggregate (read-scoped UTM aggregation)

The other flags (can_manage_routes, can_manage_users, can_view_logs) gate admin endpoints and are out of scope for this reference.

How to send the key

Two transports are supported, depending on the endpoint:

  1. Body parameter/track, /track/beacon, /track/consent: json { "api_key": "dt_xxxxxxxxxxxxxxx", "tracking_id": "dt_xxx", "events": [...] }
  2. Authorization: Bearer header — /projects, /stats/<id>, /summary/<id>, /analytics/utm-aggregate: http Authorization: Bearer dt_xxxxxxxxxxxxxxx /analytics/utm-aggregate also accepts X-API-Key: <key> as an alternative header.

The body-parameter transport is what the JS tracker uses (it can't set arbitrary headers cross-origin without preflight). Use the Bearer transport for any server-to-server call.

Read-key vs write-key (optional)

Each tracking project has one write key (the one you embed in the <script> tag) and may have an optional separate read key for server-side reads. This lets you keep the public-facing key write-only. Configure both via the Dashboard's tracking project edit form.


Rate limits

Limit Value Source
Requests per API key per minute 1000 tracking_api.py
Events per POST /track batch 100 tracking_api.py
Events per POST /track/beacon call 10 tracking_api.py
/analytics/utm-aggregate date range 366 days analytics_api.py

When exceeded, ingest endpoints respond 429 with {"success": false, "error": "Rate limit exceeded"}. The beacon endpoint silently drops with 204.


Tracking — ingest

POST /api/v1/track

Batch event ingestion. Accepts up to 100 events per request.

Body

{
  "api_key": "dt_xxxxxxxxxxxxxxxxxxxx",
  "tracking_id": "dt_yourProjectId",
  "events": [
    {
      "url": "https://yoursite.com/page",
      "path": "/page",
      "title": "Page Title",
      "referrer": "https://google.com/",
      "user_agent": "Mozilla/5.0 ...",
      "language": "en-US",
      "timezone": "Europe/Stockholm",
      "screen_width": 1920,
      "screen_height": 1080,
      "viewport_width": 1280,
      "viewport_height": 800,
      "session_id": "abc123",
      "timestamp": "2026-05-08T10:30:00Z",
      "utm_source": "google",
      "utm_medium": "cpc",
      "utm_campaign": "spring",
      "utm_term": "keywords",
      "utm_content": "ad-variant",
      "fbclid": "...",
      "gclid": "...",
      "page_load_time": 1234,
      "event_name": "page.view"
    }
  ]
}

tracking_id is optional — if omitted, the first project linked to the API key is used. All event fields except either url or path are optional. event_name defaults to page.view when absent.

Event-name vocabulary

event_name is a free-form string (≤ 64 chars). Anything you send is preserved in raw events and counted in the daily event_counts rollup. The bundled JS tracker emits four standard names out of the box:

event_name Emitted by tracker on Common fields
page.view Initial load + SPA navigation (default page fields)
scroll 25 / 50 / 75 / 100 % depth thresholds depth
outbound_click Click on any cross-origin link url, host
time_on_page visibilitychange:hidden / pagehide seconds

Custom events from your own code (doable('track', 'cta.click', {...}) or a backend SDK call) are accepted and counted the same way — there's no allow-list. Names containing dots are conventional but not required.

pageviews in daily aggregates and read endpoints counts only events with event_name == "page.view". All other event names roll up into event_counts but do not inflate pageview totals.

Response (200)

{ "success": true, "events_received": 1 }

Errors

Code Body
400 {"success": false, "error": "Invalid JSON"} or "No data provided"
401 {"success": false, "error": "Invalid API key"} or "API key lacks tracking permission"
404 {"success": false, "error": "No tracking project found for this API key"}
429 {"success": false, "error": "Rate limit exceeded"}

Example

curl -X POST https://doable.services/api/v1/track \
  -H "Content-Type: application/json" \
  -d '{
    "api_key": "dt_yourkey",
    "tracking_id": "dt_yourProjectId",
    "events": [{"url":"https://yoursite.com/x","path":"/x","event_name":"page.view"}]
  }'

POST /api/v1/track/beacon

Fire-and-forget endpoint optimised for the browser navigator.sendBeacon() API. Accepts JSON or form-encoded data with an event field containing one event JSON-stringified. Limited to 10 events per call.

Always returns 204 No Content, even on auth failure or rate limiting — the browser's beacon API has no response semantics, so silent failure is the correct behaviour. Don't use this endpoint for anything you need to know succeeded — use /track instead.

POST /api/v1/track/consent

CR-018 audit-log endpoint. Records that a visitor granted consent for GDPR Article 7(1) evidence. No API key required — the tracking_id is the correlation key.

Body

{
  "tracking_id": "dt_yourProjectId",
  "session_id_hash": "<sha256 of session id, hex>",
  "consent_text_hash": "<sha256 of the banner text shown, hex>",
  "privacy_url": "https://yoursite.com/privacy"
}

All fields except tracking_id are optional. Always responds 204, including on validation/auth failure (intentional — the tracker calls this opportunistically and must never block the page).

The doable-tracker JS bundle calls this automatically when the user clicks Accept on the consent banner. Adopters using their own consent UI can call it directly.

GET /api/v1/track/config/<tracking_id>

CR-018 public config endpoint. Returns whether consent is required and the banner text to show. Cached by the browser for 1 hour (Cache-Control: public, max-age=3600).

Response (200)

{
  "require_consent": false,
  "consent_text": "This site uses analytics to improve your experience.",
  "privacy_url": null,
  "anonymize_ip": true,
  "respect_dnt": true
}

404 if the tracking_id does not exist or its project is disabled.


Tracking — read

GET /api/v1/projects

List tracking projects accessible to the bearer's API key.

Headers: Authorization: Bearer <api_key>

Response (200)

{
  "success": true,
  "projects": [
    {
      "id": 8,
      "name": "Deep Thought",
      "tracking_id": "dt_SeoeLH7Rn8I",
      "domains": ["deep-thought.cloud", "www.deep-thought.cloud"],
      "anonymize_ip": true,
      "respect_dnt": true
    }
  ]
}

GET /api/v1/stats/<tracking_id>

Last 30 days of aggregated daily stats for one project. The bearer key must be the write key of the project (i.e. the same key embedded in the tracker snippet) — read-key separation is supported on /summary and /analytics/utm-aggregate but not here.

Headers: Authorization: Bearer <api_key>

Response (200)

{
  "success": true,
  "project": {
    "id": 8,
    "name": "Deep Thought",
    "tracking_id": "dt_SeoeLH7Rn8I"
  },
  "stats": [
    {
      "date": "2026-05-08",
      "pageviews": 42,
      "unique_sessions": 17,
      "browsers": {"Chrome": 30, "Safari": 12},
      "operating_systems": {"macOS": 18, "Windows": 14, "iOS": 10},
      "device_types": {"desktop": 30, "mobile": 12},
      "languages": {"en-US": 25, "sv-SE": 17},
      "utm_sources": {"google": 12, "(direct)": 30},
      "utm_mediums": {"organic": 12, "(none)": 30},
      "utm_campaigns": {},
      "referrers": {"google.com": 12},
      "top_pages": {"/": 28, "/en/about": 14},
      "event_counts": {"page.view": 42, "scroll": 87, "outbound_click": 5, "time_on_page": 31},
      "logged_in_count": 0,
      "public_count": 42,
      "logged_in_signals": {},
      "client_types": {"browser": 42},
      "backend_clients": {}
    }
  ]
}

For a richer rollup (timeframes, deltas vs the previous window, LLM-friendly compact mode), use /summary/<tracking_id> below.


Unified analytics summary

GET /api/v1/summary/<identifier>

Single endpoint that returns a rolling-window summary for either a tracking project (identifier starts with dt_) or a route slug. For adopters, the tracking-project form is the relevant one.

Auth: Authorization: Bearer <api_key> where the key is one of: - the project's write key (api_key_id), or - the project's optional read key (read_api_key_id).

Local-network and admin-session callers also pass; that's how the admin dashboard renders the same view without a key.

Query params

Param Default Notes
timeframe 30d 7d, 30d, 90d, 365d
preset (none) CR-028 presets — mtd, last-month, ytd, last-year, etc. Wins over timeframe.
from, to (none) Custom range, ISO dates. Wins over preset and timeframe.
detail summary summary (top-N, compact, LLM-friendly) or full (all data)
audience human human (default — bots and internal traffic filtered out) or all (unfiltered breakdowns)

Response (200) — abbreviated; the full payload is large:

{
  "meta": {
    "source": "tracking",
    "name": "Deep Thought",
    "identifier": "dt_SeoeLH7Rn8I",
    "timeframe": "30d",
    "date_range": {
      "start": "2026-04-09",
      "end": "2026-05-08",
      "previous_start": "2026-03-10",
      "previous_end": "2026-04-08",
      "label": "Last 30 days",
      "days": 30
    },
    "consent": { ... },
    "ai_insights": { ... },
    "gsc": { ... }
  },
  "window": {
    "totals": { "pageviews": 1234, "unique_sessions": 567, "delta_pct": 12.3 },
    "top_pages": [ ... ],
    "top_sources": [ ... ],
    "top_referrers": [ ... ],
    "browsers": [ ... ],
    "device_types": [ ... ],
    "ai_traffic": { "assistants": 0, "agents": 0, "chatbots": 0 }
  }
}

The dashboard at /admin/tracking/<id> and /dashboard both render from this endpoint — what you see in the UI is what you get from the API.


Generic UTM aggregation

GET /api/v1/analytics/utm-aggregate

CR-027 endpoint for arbitrary UTM and event filtering against the aggregate counters. Designed for ad-hoc reporting questions like "how many form.submit events came from utm_source=newsletter between two dates" without needing to query raw events (which only retain for 7 days).

Auth: Authorization: Bearer <api_key> (or X-API-Key header). The key must have can_read_analytics=True AND belong to the tracking project (either api_key_id or read_api_key_id).

Query params

Param Required Notes
tracking_id yes The project's dt_* ID
from yes ISO date, inclusive
to yes ISO date, inclusive; range capped at 366 days
event no Filter by event name (page.view, cta.click, custom names)
utm_source no Filter by source
utm_medium no Filter by medium (only when utm_source not set)
utm_campaign no Filter by campaign (only when utm_source and utm_medium not set)

Response (200)

{
  "tracking_id": "dt_yourProjectId",
  "event": "form.submit",
  "filters": {
    "utm_source": "newsletter",
    "utm_medium": null,
    "utm_campaign": null,
    "from": "2026-04-01",
    "to": "2026-04-30"
  },
  "count": 142,
  "unique": null,
  "by_day": [
    { "day": "2026-04-01", "count": 3, "unique": null },
    { "day": "2026-04-02", "count": 7, "unique": null }
  ]
}

unique is null whenever a filter is set — exact unique counts under filters would require querying raw events, which expire after 7 days. Honest null rather than a misleading number.

When utm_source is not set, the response also includes a top_sources array with the top 20 sources for the window.

Errors

Code Body
400 Missing tracking_id, bad date, from > to, or range > 366 days
401 Missing or invalid API key
403 Key lacks can_read_analytics, or doesn't belong to the project
404 Unknown tracking_id

Event vocabulary

Reserved event names — recognised by the tracker, dashboard, and aggregation endpoint:

Name When
page.view Page loaded (default if event_name omitted)
page.exit Page unloaded (sent via beacon)
cta.click Click on an element you've marked as a call-to-action
outbound.click Click on a link to a different domain
form.view Form became visible in viewport
form.submit Form submitted — attribution metadata only, no form contents

Custom event names are allowed. They flow into the event_counts counter in the daily aggregate and become queryable via /analytics/utm-aggregate?event=<name>.

PII rejection

The server strips top-level event keys whose names match the PII pattern at ingestion (tracking_api.py:46-50):

^(email|e.?mail|phone|tel|mobile|address|postal|zip|ssn|personnummer|
  birth(day|date|dob)|passport|iban|card|cvv|password|pwd|token)$

Matches are case-insensitive. Stripped keys are dropped silently before storage — the event is otherwise accepted. The browser tracker also strips these client-side; the server check is a belt-and-braces safety net for tampered or misconfigured trackers. form.submit events should carry attribution data (utm_*, referrer, path), not the form's field values — store those in your own backend.


doable.services is privacy-first by default. See GDPR compliance for the full posture. The runtime knobs that affect what API calls do:

The proxy-side equivalent (when the site is fronted by the doable gateway, not just tracker-only) is documented in CR-019.


Out of scope for this page

The following endpoints exist but are not part of the adopter contract — they're admin- or session-authenticated and may change without notice:

If you need stable programmatic access to one of these, open an issue and we'll evaluate promoting a subset.