ToldUntold Logo
Analytics

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 independentanalytics.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 /status datasets.<key>.latest_date as your freshness signal — only treat a day as final once it is <= latest_date and 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 — inclusive YYYY-MM-DD day bounds (UTC). to defaults to yesterday (J-1); from defaults to the dataset's default window before to. from must be on or before to (else 422 validation_failed), and the inclusive window may not exceed the dataset's cap (else 422 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_page default 50 / cap 200) with an exact total — the volume is small and bounded (days × markets).
  • Granularity (three mutually-exclusive modes):
    • brand-level (default) — market is null on every row;
    • single marketmarket=<market tu_id>; unknown/cross-brand → 404;
    • per-market breakdowngroup_by=market; one row per market per day (brand-level rows excluded). market and group_by=market together → 422 validation_failed.
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:

  • market is { "tu_id": … } for market rows, null for 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_score is nullable; the other rate/score fields default to 0.
  • overall_completion_rate is 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 — market and pos are always null).
  • Scope: analytics.metrics:read.
  • Window: default 30 days, cap 92 days.
  • Pagination: cursor (§5), keyset (content_ref_id, date), per_page default 100 / cap 500.
  • Filters: type (episode|program) narrows to that type; content=<content tu_id> reverses to a single content and requires type (bare content422; 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:

  • content is the resolved { tu_id, type, name } identity, or null when the reference is orphaned or cross-brand (the metrics row is still returned). name is a non-contractual best-effort label (may be null, may change) — key your joins on tu_id + type, never on name.
  • metrics.xp_earned is 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/pos always null).
  • Scope: analytics.users:read.
  • Window: default 30 days, cap 92 days.
  • Pagination: cursor (§5), keyset (user_id, date), per_page default 100 / cap 500. Sparse pages possible — see §6.
  • Filters: user=<user tu_id> (unknown/excluded/cross-brand → an indistinguishable 404).
{
  "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_page default 100 / cap 500. Sparse pages possible — see §6.
  • Filters: user=<user tu_id> (flat 404 on miss); content=<content tu_id> + type (type required with content; flat 404 on 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:

  1. Call the endpoint (optionally with per_page, 1–500, default 100).
  2. If meta.next_cursor is non-null, pass it back verbatim as ?cursor=… (keep every other filter identical).
  3. Stop when meta.next_cursor is null.

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 total on cursor datasets (they are treated as unbounded); don't compute a page count.
  • Sparse pages can occur on users/* — a page may come back data: [] with a non-null next_cursor (see §6). Always drive the loop off next_cursor, never off an empty data.
  • 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 back data: [] with a non-null next_cursor (a sparse page). Keep following next_cursor to null.
  • A user=<tu_id> filter that is unknown, excluded, or belongs to another brand returns an indistinguishable flat 404 — 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 on 422 validation_failed only: 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_large is HTTP 422 but carries its own machine code (not validation_failed) and has no errors map — 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.
Training. Intelligence. Content Production. Visual Merchandising. AI. One platform built for retail performance.