Files and uploads
The v3 API never receives file content, and no operation accepts multipart/form-data. Your client sends the bytes directly to the storage service through presigned URLs that the API issues. The API manages the upload session and returns a file_id when the upload is complete.
Upload a file
- Open an upload session with the purpose, file name, size, and, if available, the checksum:
POST /api/v3/uploads
Idempotency-Key: 5f7d9f0a-3f8f-4a14-8b5c-6f9d2e0c1a77
{"purpose": "pdf", "filename": "handbook.pdf", "size_bytes": 10485760, "content_type": "application/pdf", "sha256": "9f86d0..."}The response includes the session id (upl_...), part_size, parts_total, and expires_at. purpose is a closed set of five values: image, pdf, import, editor, document. Any other value, including export, returns 422 validation_failed.
Each purpose accepts a fixed list of extensions (the last suffix of filename, case-insensitive) and a maximum size:
| Purpose | Extensions | Maximum size |
|---|---|---|
image | .jpg, .jpeg, .png, .gif, .webp | 10 MiB |
pdf | .pdf | 100 MiB |
import | .xls, .xlsx | 20 MiB |
editor, document | Documents, spreadsheets, and presentations (.pdf, .doc, .docx, .xls, .xlsx, .xlsm, .ppt, .pptx, .pps, .ppsx, .odt, .ods, .odp, .txt, .csv, .rtf, .md, .epub), images (.jpg, .jpeg, .png, .gif, .webp, .svg), audio (.mp3, .m4a, .wav, .ogg, .aac, .opus), and archives (.zip, .rar, .7z) | 100 MiB, or less when the account sets a lower attachment limit |
Video is not accepted for any purpose. Host videos through the integrated video players instead.
A filename without an extension, or with an extension outside the list for its purpose, returns 422 validation_failed on filename. A size_bytes above the limit for its purpose returns 422 validation_failed on size_bytes. A size_bytes that would exceed the account's storage quota returns 409 quota_exceeded (see Storage quota). None of these cases opens an upload session.
-
Request URLs for the parts you are about to send (
POST /uploads/{upload_id}/partswithpart_numbers). Each URL accepts aPUTrequest sent directly to the storage service and is valid for 15 minutes. Keep theETagthat the storage service returns for each part. -
Complete the upload with the list of parts and their ETags (
POST /uploads/{upload_id}/completions). The response is the upload session with statuscompletedand afileobject containing the fileid(file_...), extension, size,content_type, checksum, andexpires_at.Before the upload completes, the API runs these checks on the assembled file. If any check fails, the file is removed, the session becomes
failed, and itsfailure_reasontells you why:
| Check | failure_reason | Response |
|---|---|---|
| The content matches the declared size, checksum, and content type | size_mismatch, checksum_mismatch, content_mismatch | 422 upload_integrity_mismatch |
The actual size is within the limit of the session purpose | too_large | 422 validation_failed on size_bytes |
The filename extension is in the list of the session purpose | extension_not_allowed | 422 validation_failed on filename |
| The actual size fits in the account's storage quota | storage_quota | 409 quota_exceeded |
A failed session cannot be completed again. Open a new session to retry.
- Use the
file_idin the resource that needs it, such as a lesson attachment, a showcase image, or an import spreadsheet, within 24 hours of completing the upload (see Expiration).
While the session is open, GET /uploads/{upload_id} returns its current status and GET /uploads/{upload_id}/parts lists the parts the storage service has already received. To abandon an upload, send PATCH /uploads/{upload_id} with status: "cancelled".
You can send large files in parts, in parallel, and resume an interrupted upload: the session survives network failures, and sending the same part again is safe. An open session expires after 24 hours.
Download a file
Downloads always go through a short-lived link, never through a URL stored in a resource:
POST /api/v3/files/{file_id}/download-links
{"ttl_seconds": 300, "inline": false, "download_name": "handbook.pdf"}The response includes url, method, and expires_at. A link is valid for at most 900 seconds, which is also the default when you omit ttl_seconds. inline controls whether the link opens in the browser (true) or downloads as an attachment (false, the default); download_name overrides the file name the browser uses to save it.
Ticket replies embed one of these short-lived links: attachments[].url expires up to 15 minutes after the response. Store attachments[].file_id, not the URL, and get a fresh link by retrieving the reply again or with POST /files/{file_id}/download-links.
GET /files lists the files in your account and can be filtered by purpose and by creation date (created_after, created_before). Results are sorted from newest to oldest; sort accepts only -created_at, the same order, and any other value returns 422 validation_failed. DELETE /files/{file_id} permanently deletes the file from storage and from the account's file inventory. Deleted files are not moved to the trash and cannot be restored.
For every field and parameter, see the Uploads and Files references.
Expiration
A file uploaded through an upload session must be used by a resource within 24 hours of the upload being completed. You use a file when an API request sets it as a lesson attachment, an import source, a ticket or ticket reply attachment, a user avatar, the image of a product, module, lesson, taxonomy term, banner, or exam question, a settings asset, or a certificate template background, or when it references the file or its URL from lesson content, an FAQ answer, a legal term, or a question reply. Once used, a file no longer expires, even if the resource that used it is later removed.
The File object exposes expires_at: the deadline while the file is still unused, and null once it has been used or when the file was uploaded outside the API. Shortly after the deadline, an unused file is deleted: GET /files no longer lists it, GET /files/{file_id} returns 404 not_found, and it can no longer be referenced by file_id.
Storage quota
Each account has a storage quota of 50 GiB, unless a different quota was agreed. Accounts billed together, such as an account and its replicas, share the same quota. Files attached to student questions do not count toward it.
The quota is checked twice:
POST /uploadschecks the declaredsize_bytesand rejects the upload before opening a session.POST /uploads/{upload_id}/completionschecks the actual size. A file that would exceed the quota is removed, and the session becomesfailedwithfailure_reason: "storage_quota".
In both cases the API returns 409 quota_exceeded. details[0] carries reason: "storage_quota", used_bytes (the storage in use before this upload), and quota_bytes. GET /account/usage also reports the current usage and quota, with changes made in the dashboard reflected within a day (see Limits, quotas, and retention). Files are never deleted or hidden because of the quota.
Limits and errors
| Limit | Value |
|---|---|
| File size | See the purpose table above |
| Part URL lifetime | 15 minutes |
| Download link lifetime | 900 seconds |
| Open session lifetime | 24 hours |
| Unused file lifetime | 24 hours after the upload completes |
| Storage quota | 50 GiB per account, unless a different quota was agreed |
| Code | Status | When |
|---|---|---|
upload_integrity_mismatch | 422 | The received bytes do not match the declared size, checksum, or content type. The exact cause is in details[].reason. The session is marked as failed, and you must open a new one. |
state_conflict | 409 | The session was already completed, cancelled, failed, or expired. |
storage_unavailable | 503 | The storage service did not respond. Retry with backoff. |
validation_failed | 422 | The purpose is not supported (details[].field: "purpose"), the filename extension is not in the list for its purpose (field: "filename", code: "extensions"), the size exceeds the limit for its purpose (field: "size_bytes", code: "max"), or a part number is out of range. On completion, the extension and size cases also mark the session as failed. |
quota_exceeded | 409 | The upload would take the account's storage in use above its quota, when opening the session or on completion. details[0] carries reason: "storage_quota", used_bytes, and quota_bytes. See Storage quota. |
Presigned URLs are never written to logs, audit entries, or events. They are kept only in the idempotency receipt, which lasts 48 hours, so a replay can return a URL that has already expired. To get a valid URL, send the request again with a new Idempotency-Key.