Resources
Asset Uploads

Asset Uploads

Upload images, video clips, audio, and PDFs into a classroom with presigned URLs, then use the returned key in thumbnails, scenes, test audio, and lesson PDFs.

On this page

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.

MethodPathScopeSuccess
POST/v1/content/classrooms/{classroomId}/assetscontent:write201 with the upload URL. Takes Idempotency-Key.
POST/v1/content/classrooms/{classroomId}/assets/{assetId}/completecontent:write200 with the checked upload. Optional.

Uploading is free. Uploads send no webhooks.

1. Ask for an upload URL

bash
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" }'
FieldRequiredNotes
purposeYesWhat the file is for. Decides the allowed types, the size limit, and the fields that accept the key. See Purposes and limits.
contentTypeYesThe media type without parameters, such as image/png. Case does not matter.
contentLengthYesThe exact size of the file in bytes, at least 1.
filenameNoUp 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:

JSON
{
  "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.

bash
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"

3. Use the key

Put assetKey in a field that takes its purpose. The key works as soon as the PUT succeeds:

bash
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.

JSON
{
  "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"
}
Statuserror.codeWhen
404content_asset_not_foundNo upload with this id in this classroom.
409content_asset_not_uploadedNothing has been uploaded to the URL yet. Upload, then call it again.
422content_asset_mismatchThe 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

purposeTypesMax sizeFields that take the key
thumbnailimage/png, image/jpeg, image/webp, image/gif20 MBVideo thumbnailKey; course, slide, test, and game thumbnail
scene-visualimage/png, image/jpeg, image/webp, image/gif20 MBScene visualKey
scene-visualvideo/mp4, video/webm500 MBScene visualKey
overlayimage/png, image/jpeg, image/webp, image/gif20 MBScene visualOverlays[].assetKey and visualOverlays[].frames[].assetKey
bgmaudio/mpeg, audio/wav, audio/ogg, audio/mp450 MBVideo bgmAudioKey
test-audioaudio/mpeg, audio/wav, audio/ogg, audio/mp450 MBTest items[].questionAudioKey
lesson-pdfapplication/pdf100 MBModule pdfKey
slide-fileimage/png, image/jpeg, image/webp, application/pdf50 MBNone 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:

JSON
{
  "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.

Statuserror.codeWhenExtra fields
422content_invalid_requestThe body fails validation: an unknown purpose, contentLength below 1, or a field too long.details
400content_asset_type_not_allowedcontentType is not allowed for the purpose.allowedContentTypes
400content_asset_too_largecontentLength is over the purpose's limit.maxBytes
400content_asset_extension_mismatchThe filename extension does not fit contentType.acceptedExtensions
409content_conflictThe Idempotency-Key was used with a different body, or its first request is still running.
429content_asset_daily_limit_reachedSee 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 signed uploadUrl and a new expiresAt, so a retry after a slow network or an expired URL is safe.
  • Once the upload is settled (ready, unconfirmed, abandoned, or rejected), the replay reports that status with uploadUrl and expiresAt set to null. To upload another file, send a new request with a new key.

Upload lifecycle

statusMeaning
pendingThe URL was issued.
readycomplete confirmed the file.
rejectedcomplete found a different file and deleted it.
unconfirmedAn hour passed without complete, and a file exists. The file is kept and the key keeps working.
abandonedAn 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}/... or orgs/{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 in videoUrl. 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.

Was this page helpful?