# Deliverable units · Management API

> Management API reference for deliverable units: PUT /sites/{id}/deliverables/{deliverableId}/units, POST /sites/{id}/deliverables/{deliverableId}/units/reorder, PATCH /sites/{id}/deliverable-units/{unitId}, GET /sites/{id}/deliverables/{deliverableId}/unit-insights.

> Markdown version of https://kanorio.com/en/developers/management-api/deliverable_units. Append `.md` to any developer docs URL for this format. Index: https://kanorio.com/llms.txt · MCP: https://app.kanorio.com/api/mcp

Base URL: `https://app.kanorio.com/api/v1` · [Errors](https://kanorio.com/en/developers/basics/errors) · [Pagination](https://kanorio.com/en/developers/basics/pagination)

### Replace tree

`PUT /sites/{id}/deliverables/{deliverableId}/units`

- Stability: beta
- Permission: `commerce:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `deliverable_units_replace_tree`

Replace the whole chapter/unit tree of a deliverable (PUT semantics): units with an id are updated, units without an id are created, units missing from the payload are deleted along with learner progress on them. Two levels only: top-level nodes may have children; children cannot. For a flat download bundle just pass units without children. Unit files (primaryAssetId / attachmentAssetIds) must be protected files: upload them with media.upload (visibility "protected", up to 40 MB as base64 or 100 MB by url) or media.request_upload + media.confirm_upload (up to 5 GB), or reuse a product file from media.list (assetKind "product"). Protected files are only served to buyers through signed short-lived links; public files are rejected because anyone with the link could open them. Errors: FORBIDDEN, DELIVERABLE_NOT_FOUND, UNIT_TREE_INVALID (details.reason), ASSET_NOT_FOUND (details.assetIds: not in the media library, in the trash or an older version), ASSET_NOT_ALLOWED (details.assetIds: public files; unit files must be protected files), VALIDATION.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `deliverableId` | path | string | yes | Deliverable id. Get it from deliverables.list. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `units` | object[] | yes | — |
| `units[].id` | string | no | — |
| `units[].kind` | string | yes | — |
| `units[].title` | string \| null | no | — |
| `units[].description` | string \| null | no | — |
| `units[].primaryAssetId` | string \| null | no | — |
| `units[].attachmentAssetIds` | string[] | no | — |
| `units[].embed` | object \| null | no | — |
| `units[].embed.provider` | "youtube" \| "vimeo" \| "loom" | yes | — |
| `units[].embed.id` | string | yes | — |
| `units[].body` | any | no | — |
| `units[].isPreview` | boolean | no | — |
| `units[].visibility` | "VISIBLE" \| "HIDDEN" | no | — |
| `units[].releaseAt` | string \| null | no | — |
| `units[].releaseAfterDays` | integer \| null | no | — |
| `units[].allowDownload` | boolean | no | — |
| `units[].countsTowardCompletion` | boolean | no | — |
| `units[].isPrerequisite` | boolean | no | — |
| `units[].children` | object[] | no | — |
| `units[].children[].id` | string | no | — |
| `units[].children[].kind` | string | yes | — |
| `units[].children[].title` | string \| null | no | — |
| `units[].children[].description` | string \| null | no | — |
| `units[].children[].primaryAssetId` | string \| null | no | — |
| `units[].children[].attachmentAssetIds` | string[] | no | — |
| `units[].children[].embed` | object \| null | no | — |
| `units[].children[].body` | any | no | — |
| `units[].children[].isPreview` | boolean | no | — |
| `units[].children[].visibility` | "VISIBLE" \| "HIDDEN" | no | — |
| `units[].children[].releaseAt` | string \| null | no | — |
| `units[].children[].releaseAfterDays` | integer \| null | no | — |
| `units[].children[].allowDownload` | boolean | no | — |
| `units[].children[].countsTowardCompletion` | boolean | no | — |
| `units[].children[].isPrerequisite` | boolean | no | — |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `title` | string | yes | — |
| `description` | string \| null | yes | — |
| `coverAssetId` | string \| null | yes | — |
| `structure` | "COURSE" \| "DOWNLOAD" | yes | COURSE has chapters and tracks progress; DOWNLOAD is a flat file list (no progress, new units allow download by default). Access rules are identical. |
| `status` | "ACTIVE" \| "ARCHIVED" | yes | — |
| `grants` | string[] | yes | Commercial feature keys (e.g. ["premium"]) granted when a buyer purchases or is granted this deliverable; matched by ContentCollection.accessPolicy and entitlements.check. |
| `authorIds` | string[] | yes | Authors/instructors shown on the sales page and the learner page, in order. Ids come from the site's shared author list (also used by posts; managed in the console under Posts › Settings). |
| `unitCount` | integer | yes | Leaf units only; chapters are not counted. |
| `productIds` | string[] | yes | Products that grant this deliverable on purchase (see products.update deliverables). |
| `createdAt` | string | yes | — |
| `updatedAt` | string | yes | — |
| `settings` | object | yes | — |
| `settings.completionRule` | object | yes | — |
| `settings.completionRule.type` | "all_units" | yes | — |
| `settings.certificate` | object | yes | When enabled, a certificate (serial + issue date) is issued the first time a learner completes the deliverable; learners fetch it with Customer API GET /deliverables/:id/certificate. The PDF is generated on download from the current settings, course title, learner name and site branding (logo, brand colour), so edits apply to every certificate; only the serial and issue date are fixed. Send every certificate field when updating it. Use deliverables.certificate_preview to see it. |
| `settings.certificate.enabled` | boolean | yes | — |
| `settings.certificate.title` | string \| null | yes | Certificate heading; null = the site-language default ("Certificate of Completion"). |
| `settings.certificate.signatureName` | string \| null | yes | Signer's name or organisation; null = no signature block. |
| `settings.certificate.signatureTitle` | string \| null | yes | Signer's title printed under the name, e.g. "Lead instructor". |
| `settings.certificate.signatureAssetId` | string \| null | yes | Signature image: a public image asset id (same rule as coverAssetId); printed above the signature line. |
| `settings.certificate.showLogo` | boolean | yes | Print the site's brand logo (site settings) at the top; icon logos are skipped. |
| `settings.certificate.note` | string \| null | yes | One line printed under the course title, e.g. "12 hours of instruction". |
| `settings.certificate.backgroundAssetId` | string \| null | yes | Background image: a public image asset id (same rule as coverAssetId), centred and cropped to fill an A4 landscape page (recommended 3508 × 2480 px). When set, the default border is not drawn; text positions stay fixed, so keep the centre of the design clear. |
| `settings.certificate.backgroundOverlay` | boolean | yes | Lay a semi-transparent white layer over the background image so text stays readable on busy designs; no effect without a background image. |
| `settings.comments` | object | yes | — |
| `settings.comments.enabled` | boolean | yes | — |
| `settings.comments.threaded` | boolean | yes | — |
| `settings.comments.requireReview` | boolean | yes | — |
| `settings.comments.reactions` | boolean | yes | — |
| `settings.requireSequential` | boolean | yes | Learners must complete each unit before opening the next (free-preview units are exempt). Locked units come back with access.reason prerequisite. |
| `settings.mediaCompletionPercent` | integer | yes | Share of a video/audio unit a learner must actually watch for it to count as completed (50-100, step 5, default 90). Measured from the segments played, so skipping to the end does not count; 100 keeps a 2-second tolerance at the end. Custom players that only report positionSeconds are measured by the furthest position. Raising it never un-completes learners. |
| `fieldData` | object \| null | yes | — |
| `units` | object[] | yes | Flat list ordered by chapter then unit; use parentId to rebuild the tree. |
| `units[].id` | string | yes | — |
| `units[].parentId` | string \| null | yes | Chapter id for units inside a chapter; null for top-level chapters or flat units. |
| `units[].order` | integer | yes | — |
| `units[].kind` | string | yes | Open string. Known values: video, audio, file, text, quiz; "chapter" for grouping rows. Unknown kinds render as a download card. |
| `units[].title` | string \| null | yes | — |
| `units[].description` | string \| null | yes | — |
| `units[].primaryAssetId` | string \| null | yes | Media-library asset (private products bucket). Upload with media.upload first. |
| `units[].attachmentAssetIds` | string[] | yes | — |
| `units[].fileUrl` | string \| null | yes | Only for Kanorio template sample media (not a media-library asset). Real files are referenced by primaryAssetId. |
| `units[].embed` | object \| null | yes | External video embed (youtube/vimeo/loom). Not protected by Kanorio; use for FREE sites that cannot host video. |
| `units[].embed.provider` | string | yes | — |
| `units[].embed.id` | string | yes | — |
| `units[].body` | any | yes | text units: { markdown }. quiz units: { questions: [{ id, type: "single" \| "multiple" \| "true_false", prompt, options: [{ id, text, correct: boolean }], explanation? }], settings: { passingPercent (0-100, default 70), maxAttempts (1-99 or null = unlimited), showAnswers: "never" \| "after_pass" \| "always" } }; validated on write (UNIT_TREE_INVALID quiz_body_invalid). Learners never see correct/explanation; the server grades Customer API POST /units/:id/quiz. |
| `units[].isPreview` | boolean | yes | Free preview: anonymous visitors can open it. |
| `units[].visibility` | "VISIBLE" \| "HIDDEN" | yes | — |
| `units[].releaseAt` | string \| null | yes | Absolute unlock time. |
| `units[].releaseAfterDays` | integer \| null | yes | Unlock N days after the learner's entitlement started. |
| `units[].allowDownload` | boolean | yes | — |
| `units[].countsTowardCompletion` | boolean | yes | false = bonus unit: not counted in percent or completion, never locked by requireSequential and never a gate for later units. |
| `units[].isPrerequisite` | boolean | yes | Units after this one stay locked until the learner completes it, even when requireSequential is off. |
| `products` | object[] | yes | Products (variants) that grant this deliverable; tierId null = every variant. Access duration is pricingTiers[].accessDays on the product. |
| `products[].productId` | string | yes | — |
| `products[].tierId` | string \| null | yes | — |
| `changes` | object | yes | — |
| `changes.created` | integer | yes | — |
| `changes.updated` | integer | yes | — |
| `changes.deleted` | integer | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `DELIVERABLE_NOT_FOUND` | 404 | — |
| `UNIT_TREE_INVALID` | 422 | details.reason |
| `ASSET_NOT_FOUND` | 404 | details.assetIds: not in the media library, in the trash or an older version |
| `ASSET_NOT_ALLOWED` | 422 | details.assetIds: public files; unit files must be protected files |
| `VALIDATION` | 400 | — |

```bash
curl -X PUT "https://app.kanorio.com/api/v1/sites/{id}/deliverables/{deliverableId}/units" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"units":[{"kind":"chapter","title":"Week 1 · Getting started","children":[{"kind":"video","title":"Welcome and course overview","primaryAssetId":"asset_example01","isPreview":true},{"kind":"text","title":"Set up your workspace","body":{"markdown":"## Install\n\n1. Download the starter kit…"},"releaseAfterDays":2}]}]}'
```

```json
{
  "id": "dlv_example01",
  "title": "Sourdough Fundamentals",
  "description": "Six-week video course with downloadable recipe cards.",
  "coverAssetId": "asset_example01",
  "structure": "COURSE",
  "status": "ACTIVE",
  "grants": [
    "premium"
  ],
  "authorIds": [
    "author_headbaker01"
  ],
  "unitCount": 3,
  "productIds": [
    "prod_example01"
  ],
  "createdAt": "2026-10-01T02:30:00.000Z",
  "updatedAt": "2026-10-06T08:00:00.000Z",
  "settings": {
    "completionRule": {
      "type": "all_units"
    },
    "certificate": {
      "enabled": false,
      "title": null,
      "signatureName": null,
      "signatureTitle": null,
      "signatureAssetId": null,
      "showLogo": true,
      "note": null,
      "backgroundAssetId": null,
      "backgroundOverlay": false
    },
    "comments": {
      "enabled": false,
      "threaded": true,
      "requireReview": true,
      "reactions": false
    },
    "requireSequential": false,
    "mediaCompletionPercent": 90
  },
  "fieldData": null,
  "units": [
    {
      "id": "unit_chapter01",
      "parentId": null,
      "order": 0,
      "kind": "chapter",
      "title": "Week 1 · Getting started",
      "description": null,
      "primaryAssetId": null,
      "attachmentAssetIds": [],
      "fileUrl": null,
      "embed": null,
      "body": null,
      "isPreview": false,
      "visibility": "VISIBLE",
      "releaseAt": null,
      "releaseAfterDays": null,
      "allowDownload": false,
      "countsTowardCompletion": true,
      "isPrerequisite": false
    },
    {
      "id": "unit_video01",
      "parentId": "unit_chapter01",
      "order": 0,
      "kind": "video",
      "title": "Welcome and course overview",
      "description": "What you will build in six weeks.",
      "primaryAssetId": "asset_example01",
      "attachmentAssetIds": [],
      "fileUrl": null,
      "embed": null,
      "body": null,
      "isPreview": true,
      "visibility": "VISIBLE",
      "releaseAt": null,
      "releaseAfterDays": null,
      "allowDownload": false,
      "countsTowardCompletion": true,
      "isPrerequisite": false
    },
    {
      "id": "unit_text01",
      "parentId": "unit_chapter01",
      "order": 1,
      "kind": "text",
      "title": "Set up your workspace",
      "description": null,
      "primaryAssetId": null,
      "attachmentAssetIds": [],
      "fileUrl": null,
      "embed": null,
      "body": {
        "markdown": "## Install\n\n1. Download the starter kit…"
      },
      "isPreview": false,
      "visibility": "VISIBLE",
      "releaseAt": null,
      "releaseAfterDays": 2,
      "allowDownload": false,
      "countsTowardCompletion": true,
      "isPrerequisite": false
    }
  ],
  "products": [
    {
      "productId": "prod_example01",
      "tierId": null
    }
  ],
  "changes": {
    "created": 3,
    "updated": 0,
    "deleted": 0
  }
}
```

### Reorder deliverable units

`POST /sites/{id}/deliverables/{deliverableId}/units/reorder`

- Stability: beta
- Permission: `commerce:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `deliverable_units_reorder`

Reorder sibling units: pass every id of one level (top level when parentId is null) in the new order. Errors: FORBIDDEN, DELIVERABLE_NOT_FOUND, UNIT_TREE_INVALID.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `deliverableId` | path | string | yes | Deliverable id. Get it from deliverables.list. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `parentId` | string \| null | yes | Chapter id, or null for the top level. |
| `orderedIds` | string[] | yes | — |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `count` | integer | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `DELIVERABLE_NOT_FOUND` | 404 | — |
| `UNIT_TREE_INVALID` | 422 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/deliverables/{deliverableId}/units/reorder" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"parentId":"unit_chapter01","orderedIds":["unit_text01","unit_video01"]}'
```

```json
{
  "count": 2
}
```

### Update a deliverable unit

`PATCH /sites/{id}/deliverable-units/{unitId}`

- Stability: beta
- Permission: `commerce:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `deliverable_units_update`

Update one unit in place (title, file, preview flag, unlock schedule, completion rules) without touching the tree. Every unit is available to everyone who has the deliverable; to sell extra content to a higher plan, put it in a separate deliverable and link it only to that plan (products.update deliverables[].tierId). Files must be protected files (see deliverable_units.replace_tree). Errors: FORBIDDEN, DELIVERABLE_NOT_FOUND, ASSET_NOT_FOUND (details.assetIds: not in the media library, in the trash or an older version), ASSET_NOT_ALLOWED (details.assetIds: public files; unit files must be protected files), VALIDATION.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `unitId` | path | string | yes | Unit id. Get it from deliverables.get. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | string | no | — |
| `title` | string \| null | no | — |
| `description` | string \| null | no | — |
| `primaryAssetId` | string \| null | no | — |
| `attachmentAssetIds` | string[] | no | — |
| `embed` | object \| null | no | — |
| `embed.provider` | "youtube" \| "vimeo" \| "loom" | yes | — |
| `embed.id` | string | yes | — |
| `body` | any | no | — |
| `isPreview` | boolean | no | — |
| `visibility` | "VISIBLE" \| "HIDDEN" | no | — |
| `releaseAt` | string \| null | no | — |
| `releaseAfterDays` | integer \| null | no | — |
| `allowDownload` | boolean | no | — |
| `countsTowardCompletion` | boolean | no | — |
| `isPrerequisite` | boolean | no | — |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `parentId` | string \| null | yes | Chapter id for units inside a chapter; null for top-level chapters or flat units. |
| `order` | integer | yes | — |
| `kind` | string | yes | Open string. Known values: video, audio, file, text, quiz; "chapter" for grouping rows. Unknown kinds render as a download card. |
| `title` | string \| null | yes | — |
| `description` | string \| null | yes | — |
| `primaryAssetId` | string \| null | yes | Media-library asset (private products bucket). Upload with media.upload first. |
| `attachmentAssetIds` | string[] | yes | — |
| `fileUrl` | string \| null | yes | Only for Kanorio template sample media (not a media-library asset). Real files are referenced by primaryAssetId. |
| `embed` | object \| null | yes | External video embed (youtube/vimeo/loom). Not protected by Kanorio; use for FREE sites that cannot host video. |
| `embed.provider` | string | yes | — |
| `embed.id` | string | yes | — |
| `body` | any | yes | text units: { markdown }. quiz units: { questions: [{ id, type: "single" \| "multiple" \| "true_false", prompt, options: [{ id, text, correct: boolean }], explanation? }], settings: { passingPercent (0-100, default 70), maxAttempts (1-99 or null = unlimited), showAnswers: "never" \| "after_pass" \| "always" } }; validated on write (UNIT_TREE_INVALID quiz_body_invalid). Learners never see correct/explanation; the server grades Customer API POST /units/:id/quiz. |
| `isPreview` | boolean | yes | Free preview: anonymous visitors can open it. |
| `visibility` | "VISIBLE" \| "HIDDEN" | yes | — |
| `releaseAt` | string \| null | yes | Absolute unlock time. |
| `releaseAfterDays` | integer \| null | yes | Unlock N days after the learner's entitlement started. |
| `allowDownload` | boolean | yes | — |
| `countsTowardCompletion` | boolean | yes | false = bonus unit: not counted in percent or completion, never locked by requireSequential and never a gate for later units. |
| `isPrerequisite` | boolean | yes | Units after this one stay locked until the learner completes it, even when requireSequential is off. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `DELIVERABLE_NOT_FOUND` | 404 | — |
| `ASSET_NOT_FOUND` | 404 | details.assetIds: not in the media library, in the trash or an older version |
| `ASSET_NOT_ALLOWED` | 422 | details.assetIds: public files; unit files must be protected files |
| `VALIDATION` | 400 | — |

```bash
curl -X PATCH "https://app.kanorio.com/api/v1/sites/{id}/deliverable-units/{unitId}" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Welcome!","isPreview":true}'
```

```json
{
  "id": "unit_video01",
  "parentId": "unit_chapter01",
  "order": 0,
  "kind": "video",
  "title": "Welcome!",
  "description": "What you will build in six weeks.",
  "primaryAssetId": "asset_example01",
  "attachmentAssetIds": [],
  "fileUrl": null,
  "embed": null,
  "body": null,
  "isPreview": true,
  "visibility": "VISIBLE",
  "releaseAt": null,
  "releaseAfterDays": null,
  "allowDownload": false,
  "countsTowardCompletion": true,
  "isPrerequisite": false
}
```

### Deliverable unit insights

`GET /sites/{id}/deliverables/{deliverableId}/unit-insights`

- Stability: beta
- Permission: `store:view`
- Rate limit: default (120/minute per token)
- MCP tool: `deliverable_units_insights`

Per-unit viewing and learning figures for one course: reached and completed learners, plays, viewers, watched seconds, average share watched, quick-exit rate (left within 15 seconds), stall rate, share of playback at 1.5x or faster, last play, quiz pass rate and the most-missed question; plus ranked symptoms (drop, rewatch, quick_exit, stall, fast, quiz). Pass unitId to also get that unit's 100-bucket retention curve (share of learners who watched each bucket, null under 5 learners), rewatch and seek curves and seek hotspots. Cumulative figures ignore the period; plays, viewers, watched seconds, rates and curves other than retention are for the period. Errors: FORBIDDEN, DELIVERABLE_NOT_FOUND, UNIT_NOT_FOUND, VALIDATION.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `deliverableId` | path | string | yes | Deliverable id. Get it from deliverables.list. |
| `unitId` | query | string | no | Also return the retention curve of this unit (must belong to the course). |
| `days` | query | integer | no | Number of calendar days ending today in the website's timezone (1 to 365, defaults to 30). Ignored when from and to are provided. |
| `from` | query | string | no | Start calendar date (YYYY-MM-DD) in the website's timezone, inclusive. Must be paired with to. |
| `to` | query | string | no | End calendar date (YYYY-MM-DD) in the website's timezone, inclusive, up to today. Must be paired with from. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `range` | object | yes | — |
| `range.from` | string | yes | First calendar date of the period (website timezone). |
| `range.to` | string | yes | Last calendar date of the period (website timezone). |
| `range.timezone` | string | yes | — |
| `units` | object[] | yes | — |
| `units[].unitId` | string | yes | — |
| `units[].title` | string | yes | — |
| `units[].kind` | string | yes | — |
| `units[].reached` | integer | yes | Cumulative learners who opened the unit. |
| `units[].completed` | integer | yes | Cumulative learners who completed the unit. |
| `units[].plays` | integer | yes | Playback sessions in the period. |
| `units[].viewers` | integer | yes | Distinct learners who played in the period. |
| `units[].watchedSeconds` | integer | yes | — |
| `units[].avgCoveredPercent` | number \| null | yes | Media: mean share watched per learner (cumulative), 0-100; otherwise null. |
| `units[].quickExitRate` | number | yes | Share of period plays that ended within 15 seconds, 0-1. |
| `units[].stallRate` | number | yes | Share of period plays with at least one stall, 0-1. |
| `units[].fastShare` | number | yes | Share of period playback at 1.5x or faster, 0-1. |
| `units[].lastPlayedAt` | string \| null | yes | — |
| `units[].quiz` | object \| null | yes | Quiz units only (cumulative). |
| `units[].quiz.passRate` | number \| null | yes | 0-1; null until someone has attempted. |
| `units[].quiz.avgScore` | number \| null | yes | — |
| `units[].quiz.avgAttempts` | number \| null | yes | — |
| `units[].quiz.worstQuestion` | object \| null | yes | — |
| `symptoms` | object[] | yes | — |
| `symptoms[].unitId` | string | yes | — |
| `symptoms[].unitTitle` | string | yes | — |
| `symptoms[].kind` | "drop" \| "rewatch" \| "quick_exit" \| "stall" \| "fast" \| "quiz" | yes | — |
| `symptoms[].severity` | number | yes | 0-1, higher is worse; already sorted. |
| `symptoms[].value` | number | yes | The figure behind the symptom (rate or count). |
| `symptoms[].questionId` | string | no | — |
| `symptoms[].questionPrompt` | string | no | — |
| `retention` | object \| null | yes | Only with unitId. |
| `retention.unitId` | string | yes | — |
| `retention.title` | string | yes | — |
| `retention.duration` | number \| null | yes | — |
| `retention.retention` | number[] \| null | yes | 100 buckets across the file: share of learners who watched that part (cumulative, 0-1); null when fewer than 5 learners watched. |
| `retention.rewatch` | integer[] | yes | 100 buckets: period plays that rewatched that part. |
| `retention.seeks` | integer[] | yes | 100 buckets: period seeks that landed in that part. |
| `retention.sample` | integer | yes | — |
| `retention.seekHotspots` | integer[] | yes | Up to three bucket indexes most often jumped to. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `DELIVERABLE_NOT_FOUND` | 404 | — |
| `UNIT_NOT_FOUND` | 404 | — |
| `VALIDATION` | 400 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/deliverables/{deliverableId}/unit-insights?unitId=unit_video01&days=30" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "range": {
    "from": "2026-09-07",
    "to": "2026-10-06",
    "timezone": "Asia/Taipei"
  },
  "units": [
    {
      "unitId": "unit_video01",
      "title": "Welcome",
      "kind": "video",
      "reached": 47,
      "completed": 38,
      "plays": 71,
      "viewers": 44,
      "watchedSeconds": 36900,
      "avgCoveredPercent": 86,
      "quickExitRate": 0.08,
      "stallRate": 0.03,
      "fastShare": 0.21,
      "lastPlayedAt": "2026-10-06T08:00:00.000Z",
      "quiz": null
    },
    {
      "unitId": "unit_text01",
      "title": "Reading list",
      "kind": "text",
      "reached": 31,
      "completed": 24,
      "plays": 0,
      "viewers": 0,
      "watchedSeconds": 0,
      "avgCoveredPercent": null,
      "quickExitRate": 0,
      "stallRate": 0,
      "fastShare": 0,
      "lastPlayedAt": null,
      "quiz": null
    }
  ],
  "symptoms": [
    {
      "unitId": "unit_text01",
      "unitTitle": "Reading list",
      "kind": "drop",
      "severity": 0.34,
      "value": 0.34
    }
  ],
  "retention": {
    "unitId": "unit_video01",
    "title": "Welcome",
    "duration": 612,
    "retention": [
      1,
      0.99,
      0.99,
      0.98,
      0.98,
      0.97,
      0.96,
      0.96,
      0.95,
      0.94,
      0.94,
      0.93,
      0.93,
      0.92,
      0.91,
      0.91,
      0.9,
      0.89,
      0.89,
      0.88,
      0.88,
      0.87,
      0.86,
      0.86,
      0.85,
      0.84,
      0.84,
      0.83,
      0.83,
      0.82,
      0.81,
      0.81,
      0.8,
      0.79,
      0.79,
      0.78,
      0.78,
      0.77,
      0.76,
      0.76,
      0.75,
      0.74,
      0.74,
      0.73,
      0.73,
      0.72,
      0.71,
      0.71,
      0.7,
      0.69,
      0.69,
      0.68,
      0.68,
      0.67,
      0.66,
      0.66,
      0.65,
      0.64,
      0.64,
      0.63,
      0.63,
      0.62,
      0.61,
      0.61,
      0.6,
      0.59,
      0.59,
      0.58,
      0.57,
      0.57,
      0.56,
      0.56,
      0.55,
      0.54,
      0.54,
      0.53,
      0.53,
      0.52,
      0.51,
      0.51,
      0.5,
      0.49,
      0.49,
      0.48,
      0.48,
      0.47,
      0.46,
      0.46,
      0.45,
      0.44,
      0.44,
      0.43,
      0.43,
      0.42,
      0.41,
      0.41,
      0.4,
      0.39,
      0.39,
      0.38
    ],
    "rewatch": [
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      6,
      6,
      6,
      6,
      6,
      6,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0
    ],
    "seeks": [
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      9,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0
    ],
    "sample": 44,
    "seekHotspots": [
      42
    ]
  }
}
```
