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:
- Body parameter —
/track,/track/beacon,/track/consent:json { "api_key": "dt_xxxxxxxxxxxxxxx", "tracking_id": "dt_xxx", "events": [...] } Authorization: Bearerheader —/projects,/stats/<id>,/summary/<id>,/analytics/utm-aggregate:http Authorization: Bearer dt_xxxxxxxxxxxxxxx/analytics/utm-aggregatealso acceptsX-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.
Privacy & consent
doable.services is privacy-first by default. See GDPR compliance for the full posture. The runtime knobs that affect what API calls do:
anonymize_ip(per-project, default on) — IPs hashed and truncated before storage.respect_dnt(per-project, default on) —DNT: 1request header skips ingestion entirely.require_consent(per-project, default off) — when on, the tracker shows a consent banner and only ingests after Accept.dt_consentcookie persists the choice.
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:
/api/v1/routes,/api/v1/users,/api/v1/logs— admin management API (inapi.py)./api/user/*— session-authed dashboard endpoints (inuser_api.py)./admin/api/*— admin UI XHR endpoints (inadmin.py).
If you need stable programmatic access to one of these, open an issue and we'll evaluate promoting a subset.