Some resource fields take a file rather than text: a course thumbnail, a scene visual, a test question's audio, a module's PDF. To fill them from your system, upload the file to TutorFlow's storage first, then put the returned assetKey in the field.
The upload is two steps. You ask the Content API for an upload URL, and then you send the file bytes straight to storage with PUT. The file never passes through the Content API, so the 100 KB request body limit does not apply to it.
| Method | Path | Scope | Success |
|---|---|---|---|
POST | /v1/content/classrooms/{classroomId}/assets | content:write | 201 with the upload URL. Takes Idempotency-Key. |
POST | /v1/content/classrooms/{classroomId}/assets/{assetId}/complete | content:write | 200 with the checked upload. Optional. |
Uploading is free. Uploads send no webhooks.
1. Ask for an upload URL
curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/assets" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: asset:course-thumbnail:onboarding:v1" \
-d '{ "purpose": "thumbnail", "contentType": "image/png", "contentLength": 48213, "filename": "onboarding.png" }'| Field | Required | Notes |
|---|---|---|
purpose | Yes | What the file is for. Decides the allowed types, the size limit, and the fields that accept the key. See Purposes and limits. |
contentType | Yes | The media type without parameters, such as image/png. Case does not matter. |
contentLength | Yes | The exact size of the file in bytes, at least 1. |
filename | No | Up to 255 characters, kept for your records only; it never appears in the key. If it has an extension, the extension must fit contentType. |
Response 201:
{
"id": "00000000-0000-4000-8000-000000000e01",
"purpose": "thumbnail",
"status": "pending",
"assetKey": "orgs/00000000-0000-4000-8000-000000000001/classrooms/00000000-0000-4000-8000-000000000010/content-api/thumbnail/2026/10/00000000-0000-4000-8000-000000000e01.png",
"uploadUrl": "https://.../content-api/thumbnail/2026/10/00000000-0000-4000-8000-000000000e01.png?X-Amz-Signature=...",
"method": "PUT",
"headers": { "Content-Type": "image/png", "Content-Length": "48213" },
"expiresAt": "2026-10-01T09:15:00.000Z"
}uploadUrl works for 15 minutes, until expiresAt. Anyone holding it can upload to that key until then, so treat it like a secret and do not log it.
2. Upload the file
Send the file as the raw request body to uploadUrl, with PUT and exactly the headers from the response. The content type and size are part of the URL's signature, so storage answers 403 if either differs from what you declared.
FILE=onboarding.png
ASSET_JSON="$(curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/assets" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: asset:course-thumbnail:onboarding:v1" \
-d "{ \"purpose\": \"thumbnail\", \"contentType\": \"image/png\", \"contentLength\": $(wc -c < "$FILE" | tr -d ' ') }")"
UPLOAD_URL="$(printf '%s' "$ASSET_JSON" | jq -r '.uploadUrl')"
export ASSET_ID="$(printf '%s' "$ASSET_JSON" | jq -r '.id')"
export ASSET_KEY="$(printf '%s' "$ASSET_JSON" | jq -r '.assetKey')"
# curl sets Content-Length from the file; send the Content-Type you declared
curl -sS -f -X PUT "$UPLOAD_URL" -H "Content-Type: image/png" --data-binary "@$FILE"import { readFile } from 'node:fs/promises'
import { request } from './tutorflow.js'
const classroomPath = `/v1/content/classrooms/${process.env.CLASSROOM_ID}`
export async function uploadAsset({ purpose, contentType, path, idempotencyKey }) {
const bytes = await readFile(path)
const upload = await request('POST', `${classroomPath}/assets`, {
idempotencyKey,
body: { purpose, contentType, contentLength: bytes.byteLength },
})
// fetch sets Content-Length from the body; send the other signed header as given.
const put = await fetch(upload.uploadUrl, {
method: upload.method,
headers: { 'Content-Type': upload.headers['Content-Type'] },
body: bytes,
})
if (!put.ok) throw new Error(`Upload to storage failed with ${put.status}`)
// Optional: confirm TutorFlow sees the file with the declared type and size.
await request('POST', `${classroomPath}/assets/${upload.id}/complete`)
return upload.assetKey
}
const thumbnail = await uploadAsset({
purpose: 'thumbnail',
contentType: 'image/png',
path: './onboarding.png',
idempotencyKey: 'asset:course-thumbnail:onboarding:v1',
})3. Use the key
Put assetKey in a field that takes its purpose. The key works as soon as the PUT succeeds:
curl -sS -X PATCH "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/courses/$COURSE_ID" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-d "{ \"thumbnail\": \"$ASSET_KEY\" }"Confirm the upload (optional)
POST .../assets/{assetId}/complete checks that the stored file has the declared size and type, and marks the upload ready. Call it when you want to know the upload landed before you use the key. It is safe to repeat.
{
"id": "00000000-0000-4000-8000-000000000e01",
"purpose": "thumbnail",
"status": "ready",
"assetKey": "orgs/00000000-0000-4000-8000-000000000001/classrooms/00000000-0000-4000-8000-000000000010/content-api/thumbnail/2026/10/00000000-0000-4000-8000-000000000e01.png",
"contentType": "image/png",
"contentLength": 48213,
"completedAt": "2026-10-01T09:01:12.000Z",
"createdAt": "2026-10-01T09:00:00.000Z"
}| Status | error.code | When |
|---|---|---|
404 | content_asset_not_found | No upload with this id in this classroom. |
409 | content_asset_not_uploaded | Nothing has been uploaded to the URL yet. Upload, then call it again. |
422 | content_asset_mismatch | The stored file differs from the declared size or type. TutorFlow deletes it and marks the upload rejected; ask for a new upload URL. |
Purposes and limits
purpose | Types | Max size | Fields that take the key |
|---|---|---|---|
thumbnail | image/png, image/jpeg, image/webp, image/gif | 20 MB | Video thumbnailKey; course, slide, test, and game thumbnail |
scene-visual | image/png, image/jpeg, image/webp, image/gif | 20 MB | Scene visualKey |
scene-visual | video/mp4, video/webm | 500 MB | Scene visualKey |
overlay | image/png, image/jpeg, image/webp, image/gif | 20 MB | Scene visualOverlays[].assetKey and visualOverlays[].frames[].assetKey |
bgm | audio/mpeg, audio/wav, audio/ogg, audio/mp4 | 50 MB | Video bgmAudioKey |
test-audio | audio/mpeg, audio/wav, audio/ogg, audio/mp4 | 50 MB | Test items[].questionAudioKey |
lesson-pdf | application/pdf | 100 MB | Module pdfKey |
slide-file | image/png, image/jpeg, image/webp, application/pdf | 50 MB | None yet. Reserved for creating slides from a source file. |
A MB is 1,048,576 bytes. A filename extension must be one of those of its type: .png; .jpg or .jpeg; .webp; .gif; .mp4; .webm; .mp3; .wav; .ogg or .oga; .m4a or .mp4 for audio/mp4; .pdf.
A key goes only into the fields of its purpose. Anything else answers 400 content_invalid_request, for example "thumbnail takes a Content API upload with purpose thumbnail, not test-audio". Module videoUrl and lectureKey take no upload at all ("videoUrl does not take a Content API upload").
Who can load the file
thumbnail, scene-visual, overlay, and bgm files are public by design: anyone with the file's address can load it, as with files uploaded in the TutorFlow editor. lesson-pdf, test-audio, and slide-file files are private. They are served only through signed, expiring links in responses, such as a module's pdfUrl and a test item's questionAudioUrl.
Key format
orgs/{organizationId}/classrooms/{classroomId}/content-api/{purpose}/{yyyy}/{mm}/{assetId}{ext}
orgs/{organizationId}/classrooms/{classroomId}/content-api/storage/{purpose}/{yyyy}/{mm}/{assetId}{ext}The second form is for private purposes. Store the key as TutorFlow returns it and do not build one yourself; the format is shown so you can recognize keys in your logs.
Upload limits
Each organization can ask for 1,000 upload URLs and declare 20 GB (21,474,836,480 bytes) of uploads in any rolling 24 hours, across all its keys. Every upload URL counts, whatever became of it. Past either limit the request answers 429:
{
"error": {
"code": "content_asset_daily_limit_reached",
"message": "An organization may create 1000 uploads totalling 21474836480 bytes in any 24 hours",
"uploadLimit": 1000,
"byteLimit": 21474836480,
"uploadsUsed": 1000,
"bytesUsed": 9126805504,
"retryAfterSeconds": 5400,
"status": 429,
"requestId": "3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11"
}
}The Retry-After header carries the same seconds: the time until the oldest upload in the window drops out of it.
Test keys have a separate allowance for the sandbox classroom: 50 uploads and 500 MB (524,288,000 bytes) in any rolling 24 hours, reported in the same fields. Sandbox uploads never count toward the live allowance. See Test Mode.
Errors when asking for a URL
Checks run in this order: body, type, size, extension.
| Status | error.code | When | Extra fields |
|---|---|---|---|
422 | content_invalid_request | The body fails validation: an unknown purpose, contentLength below 1, or a field too long. | details |
400 | content_asset_type_not_allowed | contentType is not allowed for the purpose. | allowedContentTypes |
400 | content_asset_too_large | contentLength is over the purpose's limit. | maxBytes |
400 | content_asset_extension_mismatch | The filename extension does not fit contentType. | acceptedExtensions |
409 | content_conflict | The Idempotency-Key was used with a different body, or its first request is still running. | |
429 | content_asset_daily_limit_reached | See Upload limits. | uploadLimit, byteLimit, uploadsUsed, bytesUsed, retryAfterSeconds |
Retries and idempotency
Send an Idempotency-Key. A retry with the same key and body returns the same id, assetKey, and headers, with Idempotent-Replayed: true:
- While the upload is still
pending, the replay carries a freshly signeduploadUrland a newexpiresAt, so a retry after a slow network or an expired URL is safe. - Once the upload is settled (
ready,unconfirmed,abandoned, orrejected), the replay reports thatstatuswithuploadUrlandexpiresAtset tonull. To upload another file, send a new request with a new key.
Upload lifecycle
status | Meaning |
|---|---|
pending | The URL was issued. |
ready | complete confirmed the file. |
rejected | complete found a different file and deleted it. |
unconfirmed | An hour passed without complete, and a file exists. The file is kept and the key keeps working. |
abandoned | An hour passed and nothing was uploaded. |
Upload records are deleted after 90 days. Deleting a record does not delete the uploaded file.
Keys in resource fields are checked
The same fields reject a storage key that points outside the calling classroom, whether it came from an upload or not: 400 content_invalid_request with a message such as "pdfKey must reference an asset stored in this classroom". The check covers video thumbnailKey and bgmAudioKey, scene visualKey and overlay keys, course, slide, test, and game thumbnail, test items[].questionAudioKey, and module pdfKey, videoUrl, and lectureKey.
A value is accepted when it is one of:
- a key under this classroom,
classrooms/{classroomId}/...ororgs/{organizationId}/classrooms/{classroomId}/...; - a key under this organization,
organizations/{organizationId}/...; - a track from TutorFlow's shared music library,
assets/videos/background-music/...; - an external
https://URL, such as a YouTube link invideoUrl. TutorFlow never signs these; - a URL on TutorFlow's own storage whose path is one of the keys above.
A value the resource already holds can be saved again unchanged, so content created before this check keeps saving. On reads, questionAudioUrl and pdfUrl are null when the stored private key belongs to another classroom.