Building learning paths (Tracks)¶
A track is an ordered sequence of content items that a learner works through as a path. Tracks can group their items into sections, nest other tracks, and — because they're content items themselves — be assigned, archived, and cloned like anything else in the catalog.
This guide builds a track, organizes it, and reads a learner's progress.
Two ways to manage a track's contents¶
There are two distinct styles, and picking the right one keeps your integration simple:
- Declarative replace (
PUT /track-items/) — you send the complete desired state and the API reconciles to it. Best for "here's what the track should look like" — initial build, bulk reordering, syncing from an external source of truth. - Incremental edits (
POST/PATCH/DELETEon/track-items/) — you add, move, or remove one item at a time. Best for small surgical changes to an existing track.
Both are covered below. Most integrations want the declarative style.
1. Create the track¶
You can create an empty track and fill it later, or seed it with sections and items in one call:
curl -X POST https://acme.plusplus.app/api/v2/tracks/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Engineering Onboarding",
"is_self_assignable": true,
"sections": [
{ "id": "11111111-1111-1111-1111-111111111111", "name": "Week 1: Foundations" },
{ "id": "22222222-2222-2222-2222-222222222222", "name": "Week 2: Systems" }
],
"items": [
{ "content_item_id": "a1b2…", "section_id": "11111111-1111-1111-1111-111111111111", "is_required": true },
{ "content_item_id": "c3d4…", "section_id": "22222222-2222-2222-2222-222222222222", "is_required": true }
]
}'
{
"public_id": "7a8b9c0d-…",
"name": "Engineering Onboarding",
"content_type": "track",
"is_hidden": false,
"track_items_count": 2,
"total_child_items_count": 2,
"sections": [
{ "id": "11111111-1111-1111-1111-111111111111", "name": "Week 1: Foundations" },
{ "id": "22222222-2222-2222-2222-222222222222", "name": "Week 2: Systems" }
]
}
Two rules to know up front:
- An empty track is forced hidden. A track created without items is always
is_hidden: true, because an empty path isn't useful in the catalog. Flipis_hiddentofalseonce it has content. - Sectioned tracks require every item to name a section. When a track has sections, each item's
section_idmust reference one of them. A track with no sections is "flat" and items carrysection_id: null.
You provide section ids yourself (any UUID) so you can reference them from items in the same request. Omit id and the API assigns one.
2. Set the full contents declaratively¶
The idiomatic way to build or reorganize a track is one PUT that describes the entire desired state. The order of items within each section sets item order; the order of sections sets section order.
curl -X PUT https://acme.plusplus.app/api/v2/tracks/7a8b9c0d-…/track-items/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"sections": [
{ "id": "11111111-1111-1111-1111-111111111111", "name": "Week 1: Foundations" },
{ "id": "22222222-2222-2222-2222-222222222222", "name": "Week 2: Systems" }
],
"items": [
{ "content_item_id": "a1b2…", "section_id": "11111111-1111-1111-1111-111111111111", "is_required": true },
{ "content_item_id": "e5f6…", "section_id": "11111111-1111-1111-1111-111111111111", "is_required": false },
{ "content_item_id": "c3d4…", "section_id": "22222222-2222-2222-2222-222222222222", "is_required": true }
],
"confirm_replace": true
}'
Replace is destructive by design
PUT /track-items/ sets the full state. Any item currently in the track but absent from your items array is removed, and its assignments are dropped from everyone assigned to the track. That's exactly what makes it good for "sync to source of truth" — but it's why confirm_replace: true is required. Always send the complete list, not a delta.
The sections key on a replace is optional: omit it to keep the current sections, send [] to flatten the track (ungrouping every item), or send a list to replace them.
3. Or edit incrementally¶
For a one-off change to a live track, the granular endpoints avoid resending everything:
# Add an item at a specific position within a section
curl -X POST https://acme.plusplus.app/api/v2/tracks/7a8b9c0d-…/track-items/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{ "items": [ { "content_item_id": "9d0e…", "section_id": "11111111-1111-1111-1111-111111111111", "position": 1 } ] }'
# Move an item / change its flags (item_public_id is the content item's public_id)
curl -X PATCH https://acme.plusplus.app/api/v2/tracks/7a8b9c0d-…/track-items/9d0e…/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{ "position": 0, "is_required": false }'
# Remove a single item
curl -X DELETE https://acme.plusplus.app/api/v2/tracks/7a8b9c0d-…/track-items/9d0e…/ \
-H "Authorization: Bearer pp_your_token_here"
A few details that trip people up:
- A content item appears at most once per track, so the item is addressed by its own
content_itempublic_id(item_public_id), not a separate join id. Adding an item that's already present is rejected. positionis a zero-based index within the section (or the whole track if flat); omit it to append.- Adding an item creates assignments for everyone already assigned to the track; removing one drops those assignments. The track stays in sync with its assignees automatically.
4. Manage sections separately¶
You can also manage sections without touching items, via PUT /tracks/{public_id}/sections/. Existing sections are matched by id (so you can rename and reorder), new ones (no id) get a UUID, and omitted ones are removed. Removing a section that still contains items is rejected with 422 — send [] to flatten, or use PUT /track-items/ to move items and change sections in one atomic call.
5. Nest tracks¶
Because a track is a content item, a track item's content can itself be a track. Nested tracks let you compose a long program out of smaller paths. Direct items count toward track_items_count; everything including nested children counts toward total_child_items_count.
6. Assign the track and read progress¶
Assign a track the same way you assign anything — POST /assignments/ with the track's public_id as content_item_id (see Assignments & completion). The platform fans the assignment out into one assignment per item.
Read a specific learner's progress with the progress sub-resource:
curl "https://acme.plusplus.app/api/v2/tracks/7a8b9c0d-…/progress/?user=8f3a…" \
-H "Authorization: Bearer pp_your_token_here"
{
"user": { "public_id": "8f3a…", "name": "Jane Doe", "email": "jane.doe@acme.com" },
"total_items": 10,
"completed_items": 7,
"progress_percentage": 70.0,
"is_eligible_for_completion": false
}
Progress is measured over the track's required, completable items. is_eligible_for_completion becomes true once the learner has finished every such item — optional items don't block completion.
Common pitfalls¶
- Sending a delta to
PUT /track-items/. It's a full replace — anything you omit is deleted. Use the incrementalPOST/PATCH/DELETEendpoints for partial changes. - Forgetting
confirm_replace: true. The replace is rejected without it. It's a deliberate guardrail against accidental wipes. - Adding sectionless items to a sectioned track. Once a track has sections, every item needs a
section_id. Flatten the track first (sections: []) if you want ungrouped items. - Publishing an empty track. New tracks with no items are hidden; add items, then set
is_hidden: false. - Expecting clone to copy items.
POST /tracks/{id}/clone/copies metadata, sections, facilitators, and tags — but not track items. The clone starts empty.