ToldUntold Client API v1 — Analytics Integration Guide
On this page
Machine-to-machine REST API for pulling a brand's ToldUntold learning analytics — brand-level daily aggregates, content-level metrics, and pseudonymised user-level rows — into a BI, ERP or HRIS system.
Formal contract:
openapi.yaml(OpenAPI 3.1). This guide is the narrative companion; the OpenAPI file is authoritative for exact shapes.This is the analytics domain of the ToldUntold Client API. The catalog domain (products, variants, stock) is documented separately in the catalog integration guide. Both share the same key type, auth, error envelope and rate-limit machinery.
1. Overview & audience
The Analytics Client API (/v1/client/analytics/*) is the read-only
programmatic surface a brand's data systems use to pull its ToldUntold learning
analytics. It covers five endpoints:
GET /status— credentials smoke-test + per-dataset freshness.GET /metrics/daily— brand-level daily aggregates (optionally by market).GET /metrics/contents— content-level metrics, brand-global.GET /users/daily— pseudonymised per-user daily rows.GET /users/contents— pseudonymised per-user × content sync rows.
It is read-only and server-to-server: Bearer auth, scoped keys, flat JSON errors with stable machine codes, offset pagination on the small daily dataset and opaque cursor pagination on the large ones.
Audience: back-end / data engineers integrating a brand's BI warehouse, ERP or HRIS with ToldUntold learning data. There is no browser/OAuth flow and no end-user login.
Base URL:
https://api.<environment>.tolduntold.com
Replace <environment> with the value ToldUntold gives you (staging,
production, …). All paths below are relative to this base.
2. Authentication
2.1 Client API keys (tuk_)
Every request authenticates with a Client API key in the Authorization
header:
Authorization: Bearer tuk_<key_id>.<secret>
These are the same tuk_ keys as the catalog surface — a single key can
carry catalog and analytics scopes. The part before the dot (tuk_<key_id>)
is the public key id (safe to log); the part after the dot is the secret, shown
only once at creation. Legacy cfk_ feed tokens are not valid here and
are rejected with 401 unauthenticated.
Each key carries exactly one brand. The brand is never sent in the request — it is resolved from the key, and every row is scoped to it.
2.2 Scopes
Two analytics scopes gate the data endpoints (status needs only a valid key):
| Scope | Grants |
|---|---|
analytics.metrics:read |
GET /metrics/daily, GET /metrics/contents |
analytics.users:read |
GET /users/daily, GET /users/contents |
The two are independent — analytics.users:read is NOT implied by
analytics.metrics:read. A key that lacks the required scope gets
403 insufficient_scope. Ask your ToldUntold super-admin to grant the scopes you
need when the key is issued.
2.3 Smoke test
GET /v1/client/analytics/status
Authorization: Bearer tuk_9f2c1a4b6d8e0f2a4c6e8b0d.<secret>
{
"brand_id": 8347,
"key_id": "tuk_9f2c1a4b6d8e0f2a4c6e8b0d",
"name": "BI production",
"scopes": ["analytics.metrics:read", "analytics.users:read"],
"expires_at": null,
"datasets": {
"daily": { "latest_date": "2026-07-08" },
"contents": { "latest_date": "2026-07-07" },
"users": { "latest_date": "2026-07-08" },
"content_engagement": { "latest_date": "2026-07-06" }
}
}
status never returns 503: if the analytics store is unreachable, the
datasets block degrades to null while the identity block stays intact. The
datasets keys map to the data endpoints as:
datasets key |
Endpoint | Source table |
|---|---|---|
daily |
metrics/daily |
analytics_daily |
contents |
metrics/contents |
learning_snapshots |
users |
users/daily |
learning_users |
content_engagement |
users/contents |
learning_content_users |
A latest_date of null means no data has been ingested for the brand yet.
3. Data freshness & the nightly window
All datasets are produced by nightly aggregation jobs and reflect complete days up to J-1 (yesterday, UTC). Today's partial day is not served.
The aggregation runs roughly 00:05–06:00 UTC. During that window the most recent day (and, briefly, any day being re-aggregated) may be in motion: rows are being rewritten and, because exclusions are applied by a delete-then- reinsert, a row can transiently appear or disappear. To get a stable read:
- Pull J-1 data after 06:00 UTC, once the nightly window has closed.
- Use
GET /statusdatasets.<key>.latest_dateas your freshness signal — only treat a day as final once it is<= latest_dateand you are past the window.
This is the same freshness model the internal ToldUntold dashboards use.
4. Datasets
Common query parameters on every data endpoint:
from,to— inclusiveYYYY-MM-DDday bounds (UTC).todefaults to yesterday (J-1);fromdefaults to the dataset's default window beforeto.frommust be on or beforeto(else422 validation_failed), and the inclusive window may not exceed the dataset's cap (else422 date_range_too_large).
Conventions used throughout: rates and scores are floats on a 0–100 scale;
durations are integer seconds (fields suffixed _seconds); counters are
integers. content.type is an open enum (today episode / program) — do
not hard-fail on an unknown value.
4.1 GET /metrics/daily — brand-level daily aggregates
- Source:
analytics_daily. Grain: one row per day. - Scope:
analytics.metrics:read. - Window: default 30 days, cap 366 days.
- Pagination: offset (
page,per_pagedefault 50 / cap 200) with an exacttotal— the volume is small and bounded (days × markets). - Granularity (three mutually-exclusive modes):
- brand-level (default) —
marketisnullon every row; - single market —
market=<market tu_id>; unknown/cross-brand →404; - per-market breakdown —
group_by=market; one row per market per day (brand-level rows excluded).marketandgroup_by=markettogether →422 validation_failed.
- brand-level (default) —
GET /v1/client/analytics/metrics/daily?from=2026-06-01&to=2026-06-30
Authorization: Bearer tuk_….<secret>
{
"data": [
{
"date": "2026-07-08",
"market": null,
"live_content": { "episodes": 5, "programs": 3, "quests": 2, "products": 1 },
"active_users": 42,
"totals": { "views": 100, "completions": 40, "successes": 20, "xp_earned": 1000, "time_spent_seconds": 3600 },
"episodes": { "views": 60, "completions": 25, "successes": 12, "time_spent_seconds": 1800, "watch_rate": 75, "completion_rate": 41, "success_rate": 20, "quiz_average_score": 80 },
"programs": { "views": 30, "completions": 12, "successes": 6, "time_spent_seconds": 1200, "watch_rate": 60, "completion_rate": 40, "success_rate": 20, "quiz_average_score": 70 },
"quests": { "views": 10, "completions": 3, "successes": 2, "time_spent_seconds": 600, "watch_rate": 50, "completion_rate": 30, "success_rate": 20, "quiz_average_score": 90 },
"overall_completion_rate": 63,
"cumulative": { "views": 5000, "completions": 2000, "successes": 1000, "xp_earned": 50000 }
}
],
"meta": { "current_page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}
Notes:
marketis{ "tu_id": … }for market rows,nullfor brand-level rows.live_content= live counts of each content type on that day.totals= day totals; the per-type blocks (episodes/programs/quests) break them down with rates.quests.quiz_average_scoreis nullable; the other rate/score fields default to0.overall_completion_rateis a stored business metric (float 0–100), nullable.cumulative= brand running totals as of that day.
4.2 GET /metrics/contents — content-level metrics
- Source:
learning_snapshots. Grain: one row per content per day, brand-global (market/POS aggregates excluded —marketandposare alwaysnull). - Scope:
analytics.metrics:read. - Window: default 30 days, cap 92 days.
- Pagination: cursor (§5), keyset
(content_ref_id, date),per_pagedefault 100 / cap 500. - Filters:
type(episode|program) narrows to that type;content=<content tu_id>reverses to a single content and requirestype(barecontent→422; unknown/cross-brand →404).
{
"data": [
{
"date": "2026-07-08",
"content": { "tu_id": "epi_7c1a9f", "type": "episode", "name": "Onboarding — module 1" },
"market": null,
"pos": null,
"metrics": { "views": 15, "unique_views": 12, "completions": 8, "successes": 5, "active_users": 10, "total_users": 20, "engagement_rate": 55, "xp_earned": 300, "time_spent_seconds": 900, "questions_count": 4, "quiz_average_score": 77 },
"cumulative": { "views": 12345, "completions": 400, "successes": 250, "watch_rate": 88, "completion_rate": 66, "success_rate": 44, "quiz_average_score": 72 }
}
],
"meta": { "per_page": 100, "next_cursor": "…opaque…" }
}
Notes:
contentis the resolved{ tu_id, type, name }identity, ornullwhen the reference is orphaned or cross-brand (the metrics row is still returned).nameis a non-contractual best-effort label (may benull, may change) — key your joins ontu_id+type, never onname.metrics.xp_earnedis the day's XP increment (not the cumulative total).
4.3 GET /users/daily — pseudonymised per-user daily rows
- Source:
learning_users. Grain: one row per user per day, brand-level (market/posalwaysnull). - Scope:
analytics.users:read. - Window: default 30 days, cap 92 days.
- Pagination: cursor (§5), keyset
(user_id, date),per_pagedefault 100 / cap 500. Sparse pages possible — see §6. - Filters:
user=<user tu_id>(unknown/excluded/cross-brand → an indistinguishable404).
{
"data": [
{
"date": "2026-01-15",
"user": { "tu_id": "usr_3a9c1f" },
"market": null,
"pos": null,
"metrics": {
"views": 10, "completions": 8, "successes": 6,
"xp_earned": 1500, "time_spent_seconds": 3600, "overall_score": 87.5,
"episodes": { "views": 5, "completions": 4, "successes": 3 },
"programs": { "views": 3, "completions": 2, "successes": 1 },
"quests": { "views": 2, "completions": 1, "successes": 0 }
},
"cumulative": {
"views": 100, "completions": 80, "successes": 60,
"episodes": { "views": 50, "completions": 40, "successes": 30 },
"programs": { "views": 30, "completions": 20, "successes": 10 },
"quests": { "views": 20, "completions": 10, "successes": 5 }
}
}
],
"meta": { "per_page": 100, "next_cursor": null }
}
The only user identifier is user.tu_id (§6). overall_score is a float
0–100.
4.4 GET /users/contents — pseudonymised user × content sync rows
- Source:
learning_content_users. Grain: one row per user × content per day. This is a high-volume sync dataset — its default window is short. - Scope:
analytics.users:read. - Window: default 7 days, cap 92 days.
- Pagination: cursor (§5), keyset
(content_ref_id, date, user_id),per_pagedefault 100 / cap 500. Sparse pages possible — see §6. - Filters:
user=<user tu_id>(flat404on miss);content=<content tu_id>+type(typerequired withcontent; flat404on miss);completed(boolean, applied store-side so pages stay full).
{
"data": [
{
"date": "2026-01-15",
"user": { "tu_id": "usr_3a9c1f" },
"content": { "tu_id": "epi_7c1a9f", "type": "episode" },
"metrics": { "views": 7, "xp_earned": 210, "time_spent_seconds": 900, "is_completed": true, "is_successful": false, "questions_count": 12, "quiz_average_score": 74.5, "quiz_completion_rate": 66 },
"cumulative": { "views": 40, "xp_earned": 1200, "time_spent_seconds": 5400, "quiz_average_score": 80 }
}
],
"meta": { "per_page": 100, "next_cursor": null }
}
Here the content block carries { tu_id, type } only (no name label —
this is a volume dataset), or null for an orphaned/cross-brand reference (the
row is kept). is_completed / is_successful are booleans.
5. Cursor pagination
metrics/contents, users/daily and users/contents use cursor
pagination. The response meta is:
{ "per_page": 100, "next_cursor": "…opaque…" }
To page:
- Call the endpoint (optionally with
per_page, 1–500, default 100). - If
meta.next_cursoris non-null, pass it back verbatim as?cursor=…(keep every other filter identical). - Stop when
meta.next_cursorisnull.
Rules:
- The cursor is opaque and encrypted (authenticated encryption). Do not
decode, construct or mutate it. A tampered, foreign or otherwise malformed
cursor is rejected with
400 invalid_cursor. - There is no
totalon cursor datasets (they are treated as unbounded); don't compute a page count. - Sparse pages can occur on
users/*— a page may come backdata: []with a non-nullnext_cursor(see §6). Always drive the loop offnext_cursor, never off an emptydata. - Keep the window (
from/to) and all filters constant across a cursor loop; changing them invalidates the sequence.
6. Privacy & pseudonymisation
The user-level datasets (users/daily, users/contents) are pseudonymised:
- The only user identifier ever emitted is the public
tu_id. Internal user ids, names, emails and every other PII attribute are never returned — nor are they recoverable from the (encrypted) cursor. - Analytics exclusions (users the brand has opted out of analytics) are
applied both when the nightly data is written and defensively again at read
time: every page re-checks analytics eligibility + brand membership, and a
row whose user no longer qualifies is dropped from
data. Because the cursor advances on the last raw row read, a page can legally come backdata: []with a non-nullnext_cursor(a sparse page). Keep followingnext_cursortonull. - A
user=<tu_id>filter that is unknown, excluded, or belongs to another brand returns an indistinguishable flat404— the API never reveals whether a user exists but is filtered.
6.1 Documented limitation — exclusions on aggregates
The user-level datasets reflect exclusions immediately (a newly-excluded user
disappears from users/* on the next read, retroactively across the window).
The aggregate datasets (metrics/daily, metrics/contents) are different:
they reflect the exclusions that were in force at the moment each day was
aggregated. If a user is excluded after a day has been aggregated, that day's
metrics/* totals still include their historical contribution until the day is
re-aggregated — the removal is not retroactive on aggregates. This matches
the behaviour of the internal ToldUntold dashboards (the aggregates are net of
exclusions as of aggregation time). The differential does not identify any
individual and reflects data the brand already sees internally. Re-aggregation of
past days after an exclusion is an operational runbook on the ToldUntold side.
7. Rate limits
Two per-minute limiters apply on the analytics surface:
| Bucket | Limit | Notes |
|---|---|---|
| Per-key | 120 / min | This is a read-only surface (all GET). |
| Per-brand aggregate | 240 / min | Across all keys of the brand. |
These counters are independent of the catalog surface's limits — spending your analytics budget does not touch your catalog budget, and vice versa.
Every response carries the X-RateLimit-* headers of the most-constrained
bucket applying to the call:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1751965200
X-RateLimit-Reset is a Unix epoch (seconds). When a limit is exceeded you get
429 rate_limited with the same X-RateLimit-* trio (remaining 0) plus
Retry-After (seconds). Back off until then.
8. Errors
All errors use the flat envelope:
{ "code": "date_range_too_large", "message": "The requested date range is too large; the maximum window is 92 days." }
code— a stable, machine-readable string. Branch your logic on this.message— human-readable; may change, do not parse it.errors— present on422 validation_failedonly: a map of field → messages.
8.1 Error code catalogue
Only the codes the analytics surface actually emits:
| HTTP | code |
Meaning |
|---|---|---|
| 400 | invalid_cursor |
The cursor is malformed, tampered or unsupported. |
| 401 | unauthenticated |
Missing, malformed, expired or revoked key — or a cfk_ feed token presented here. |
| 403 | insufficient_scope |
Valid key, but it lacks analytics.metrics:read / analytics.users:read. |
| 404 | not_found |
The addressed market / content / user does not exist or belongs to another brand. |
| 405 | method_not_allowed |
The HTTP method is not allowed on this path (the surface is read-only — use GET). |
| 422 | validation_failed |
Query validation failed (bad date format, from after to, content without type, …). Carries errors. |
| 422 | date_range_too_large |
The inclusive window exceeds the dataset cap (366 days for metrics/daily, else 92). No errors map — the message states the cap. |
| 429 | rate_limited |
A per-key or per-brand rate limit was hit. Carries Retry-After and X-RateLimit-*. |
| 500 | server_error |
Unexpected server error (opaque; never leaks internals). |
| 503 | analytics_unavailable |
The analytics store is temporarily unreachable (§9). Carries Retry-After. |
date_range_too_largeis HTTP422but carries its own machine code (notvalidation_failed) and has noerrorsmap — it is a semantic pre-condition, not field validation.
9. Availability
The analytics data lives in a dedicated store. If it is momentarily unreachable, a data endpoint answers:
HTTP/1.1 503 Service Unavailable
Retry-After: 60
{ "code": "analytics_unavailable", "message": "Analytics store is temporarily unavailable." }
This is never a silent empty page — a 200 with data: [] genuinely means
"no rows in this window", not an outage. Retry after Retry-After seconds.
The status endpoint is the exception: it is a health probe and never
returns 503. On an outage it degrades to 200 with datasets: null (the
identity block stays intact), so you can always confirm your credentials.
10. Out of scope in v1 & roadmap
Not available in v1 — documented so you can plan. Because errors are a stable
{code} contract and reads are additive supersets, future additions will not
break a v1 integration that ignores unknown fields.
| Area | Status in v1 |
|---|---|
metrics/products |
Reserved path. Product-level analytics require a nightly materialisation that does not exist yet (compute-on-read would be unbounded). Roadmap. |
| Asynchronous bulk exports | Reserved. Large back-fills use cursor paging today; a job-based export is roadmap. |
Extended group_by |
Reserved. Only group_by=market on metrics/daily today; more dimensions are roadmap. |
| Writes | Out of scope — the analytics surface is read-only. |
| Webhooks / push | Reserved; today analytics is pull-only. |
11. Changelog
v1.0
- Initial analytics domain of the ToldUntold Client API.
- Endpoints:
GET /status,GET /metrics/daily,GET /metrics/contents,GET /users/daily,GET /users/contents. - Scopes:
analytics.metrics:read,analytics.users:read. - Offset pagination on
metrics/daily; opaque encrypted cursor pagination on the three other datasets. - Rate limits: 120/min per key, 240/min per brand.