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

Messaging APIs (transactional email & SMS) have their own guides: - Email — docs/email-api.md: POST /api/v1/email/send. - SMS — docs/sms-api.md: POST /api/v1/sms/send (requires operator account approval; usage-metered, per-part billing, STOP opt-out).

Both authenticate with a Bearer API key, same as the endpoints below.


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)
can_read_metrics GET /metrics only (fleet-wide aggregates, CR-084). Set by an admin at /admin/api-keys.

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
GET /metrics calls per key per hour 60 (429 beyond) metrics_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 (dt.min.js) emits page.view only out of the box — on initial load and on SPA navigation. The three engagement events below are not in the core bundle; they come from the optional engagement block you add alongside the init snippet (see docs/tracker-implementation.md, Step 3).

event_name Emitted by Trigger Common fields
page.view core tracker Initial load + SPA navigation (default page fields)
scroll engagement block 25 / 50 / 75 / 100 % depth thresholds depth
outbound_click engagement block Click on any cross-origin link url, host
time_on_page engagement block 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.

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 when event or utm_source is set — there is no stored per-filter unique counter, and scanning raw events on every request is too expensive. With only utm_medium and/or utm_campaign set, unique (per day and in total) is the day's unfiltered unique_sessions: the filter does not apply to it. The window total unique is the sum of the per-day distinct-session counts (CR-085 1e), so a visitor active on several days counts once per day.

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

Fleet metrics

GET /api/v1/metrics

CR-084 endpoint for external dashboards (Dashbox). One call returns daily aggregates for every route, every tracking project and the server as a whole, so a collector needs one request per run.

Auth: Authorization: Bearer <api_key> with can_read_metrics=True. The key is the only credential: a logged-in session or a private-network address is not accepted, and a ?key= parameter is ignored. The flag grants this endpoint and nothing else.

Caching and limits: the response is cached for 60 s per server process and sent with Cache-Control: no-store; 60 calls per key per hour.

Response (200)

{
  "generated_at": "2026-10-10T16:30:00Z",
  "day": "2026-10-10",
  "day_tz": "UTC",
  "routes": {
    "transcraib": {
      "name": "Transcraib", "enabled": true,
      "requests":      {"today": 2648, "yesterday": 4376, "avg7": 4773.14, "avg30": 4996.73, "last24h": 4266},
      "human":         {"today": 469, "yesterday": 788, "avg7": 1379.29, "avg30": 1631.2, "last24h": null},
      "pageviews":     {"today": 121, "yesterday": 240, "avg7": null, "avg30": null, "last24h": null},
      "visits":        {"today": 64, "yesterday": 131, "avg7": null, "avg30": null, "last24h": null},
      "ai_bots":       {"today": 260, "yesterday": 611, "avg7": 698.0, "avg30": 400.53, "last24h": null},
      "bot_share_pct": {"today": 82.52, "yesterday": 82.29, "avg7": 71.35, "avg30": 67.83, "last24h": null},
      "server_errors": {"today": 0, "yesterday": 0, "avg7": 0.43, "avg30": 1.7, "last24h": 0},
      "p95_ms":        {"today": 117.0, "yesterday": 120.0, "avg7": 123.86, "avg30": 118.73, "last24h": null}
    }
  },
  "trackers": {
    "dt_abc123": {"name": "test",
                  "pageviews": {"today": 0, "yesterday": 12, "avg7": 9.43, "avg30": 7.1, "last24h": null},
                  "sessions":  {"today": 0, "yesterday": 5, "avg7": 4.0, "avg30": 3.2, "last24h": null}}
  },
  "server": {
    "requests":    {"today": 6965, "yesterday": 12586, "avg7": 17046.14, "avg30": 22397.63, "last24h": 14547},
    "denied":      {"today": 1292, "yesterday": 3163, "avg7": null, "avg30": null, "last24h": 2159},
    "bans":        {"today": 28, "yesterday": 37, "avg7": 38.0, "avg30": 39.5, "last24h": 33},
    "mail_sent":   {"today": 0, "yesterday": 0, "avg7": 0.0, "avg30": 0.0, "last24h": 0},
    "mail_failed": {"today": 0, "yesterday": 0, "avg7": 0.0, "avg30": 0.0, "last24h": 0},
    "instances": [{"id": "blade-b-250", "version": "2.30.1", "heartbeat_age_min": 0}]
  }
}

Routes are keyed by slug and tracking projects by tracking_id; name is a label only (two projects may share one). Disabled routes are included with "enabled": false.

Windows

Window Meaning
today the current UTC day so far
yesterday the whole previous UTC day
avg7 / avg30 mean per day over the last 7 / 30 complete UTC days (today excluded)
last24h rolling 24 h ending at generated_at, where the source has timestamps; otherwise null

Sources and known differences

Field Daily windows from last24h from
route requests, human, ai_bots, bot_share_pct route_analytics_daily access_logs (requests only)
route server_errors, p95_ms route_performance_daily access_logs (5xx only)
route pageviews, visits (CR-085) route_analytics_daily, from the coverage start on — (always null)
tracker pageviews, sessions external_analytics_daily —
tracker sessions over several days (CR-085 1e) sum of per-day distinct sessions: a visitor active on several days counts once per day —
server requests the global row of route_analytics_daily access_logs
server denied access_logs (access_granted = false), today and yesterday only access_logs
server bans banned_ips.banned_at (new or re-armed bans) same
server mail_sent / mail_failed email_send_log by status (rejected is in neither) same

Route human, pageviews and visits (CR-085)

Privacy: aggregates only. The response contains no IP addresses, user agents, paths, email addresses, subjects or key data.

Errors: 401 missing or invalid key · 403 key without can_read_metrics · 429 rate limit.


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.