GET /api/v1/enrollments/{enrollmentId}/timeline
Returns what changed on one enrollment, most recent first: actions, blockers, notes, coaching sessions, attendance, lesson and task completions, submissions and reviews. Each line is a dated fact, enriched with its event log entry when one exists — actor, correlation id and sequence. Nothing is stored: the timeline is recomputed on read over the caller's own scope.
| Role in the organization | This endpoint |
|---|---|
OWNER | Allowed — the whole history |
ADMIN | Allowed — the whole history |
MEMBER | Conditional — only with a business role below |
| Requirement | Rule for this endpoint |
|---|---|
| Business role | program_director scoped to the organization, or coach scoped to this enrollment |
| Resource scope | Enrollment of the organization attached to the API key |
| Participation | A coach reads only the enrollments they are assigned to |
| Resource state | None — an enrollment in any status can be read |
A bearer with no scope on this enrollment receives 404, exactly like an
unknown enrollment: the endpoint never confirms that an enrollment exists.
| Parameter | Type | Required | Description |
|---|---|---|---|
enrollmentId | UUID | yes | Enrollment visible from the key's organization |
| Parameter | Type | Required | Description |
|---|---|---|---|
type | string list | no | Comma-separated families to keep. Omitted means every family |
from | ISO 8601 string | no | Keeps events at or after this instant, UTC |
to | ISO 8601 string | no | Keeps events at or before this instant, UTC |
cursor | string | no | Opaque position returned as nextCursor by the previous page |
Accepted type values: enrollment, next_action, blocker, note,
coaching, attendance, lesson, member_task, submission. An unknown
value is a 400, never a silent fallback to the full list. A cursor that does
not decode is a 400 too; changing a filter invalidates the cursor, so request
the first page again.
curl "$COHORTE_API_BASE_URL/api/v1/enrollments/ENROLLMENT_ID/timeline?type=blocker,coaching&from=2026-08-01T00:00:00.000Z" \
-H "x-api-key: $COHORTE_API_KEY"200 OK — one page of at most 20 events, most recent first.400 Bad Request — unknown type, unparseable from/to, or invalid cursor.401 Unauthorized — the key is missing, invalid, or no longer attached to a member.404 Not Found — the enrollment is unknown, belongs to another organization, or the bearer has no scope on it.| Field | Type | Nullable | Description |
|---|---|---|---|
success | boolean | no | true for a successful request |
data.events[].id | UUID | no | Identifier of the underlying fact |
data.events[].key | string | no | Stable line identity, type:id; also the second key of the cursor |
data.events[].family | string | no | One of the nine families listed above |
data.events[].type | string | no | Precise event name, for example blocker.opened |
data.events[].occurredAt | ISO 8601 string | no | Instant of the fact, UTC |
data.events[].subject | string | yes | Free text of the fact: action title, blocker description, lesson name |
data.events[].qualifier | string | yes | Enumerated value: blocker type, attendance status, review decision |
data.events[].counts | object | yes | Coaching sessions only: visible notes, decisions and commitments |
data.events[].actor | object | no | {kind} plus userId and name when kind is user |
data.events[].correlationId | UUID | yes | Shared by every line a single command produced; null without an event |
data.events[].sequence | number | yes | Event log order; null when the fact predates the log |
data.events[].maskedEntries | number | no | Coach-only entries removed for this reader |
data.nextCursor | string | yes | Position of the next page, null on the last one |
data.appliedFilters | object | no | types, from and to as the server understood them |
data.provenance | object | no | source and the asOf instant of the computation |
actor.kind is user, member, system or unknown. unknown means the
fact predates the event log — it does not mean nobody acted. A member acting
on their own enrollment is reported as member, without an account identifier.
{
"success": true,
"data": {
"events": [
{
"id": "6f1c2b90-0000-4000-8000-000000000001",
"key": "blocker.opened:6f1c2b90-0000-4000-8000-000000000001",
"family": "blocker",
"type": "blocker.opened",
"occurredAt": "2026-09-01T16:05:00.000Z",
"subject": "My local environment will not start after the install.",
"qualifier": "technical",
"counts": null,
"actor": {"kind": "member"},
"correlationId": null,
"sequence": null,
"maskedEntries": 0
}
],
"nextCursor": null,
"appliedFilters": {"types": ["blocker"], "from": null, "to": null},
"provenance": {
"source": "timeline_projection",
"asOf": "2026-09-02T10:00:00.000Z"
}
}
}correlationId; that is not a duplicate, and
replaying an idempotent command never adds one.maskedEntries says how many were hidden.correlationId: null,
sequence: null and an unknown actor. It is still returned.nextCursor; keep the same filters, since a
cursor is only valid for the filtered set that produced it.