Developers

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

  1. 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:

PurposeExtensionsMaximum size
image.jpg, .jpeg, .png, .gif, .webp10 MiB
pdf.pdf100 MiB
import.xls, .xlsx20 MiB
editor, documentDocuments, 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.

  1. Request URLs for the parts you are about to send (POST /uploads/{upload_id}/parts with part_numbers). Each URL accepts a PUT request sent directly to the storage service and is valid for 15 minutes. Keep the ETag that the storage service returns for each part.

  2. Complete the upload with the list of parts and their ETags (POST /uploads/{upload_id}/completions). The response is the upload session with status completed and a file object containing the file id (file_...), extension, size, content_type, checksum, and expires_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 its failure_reason tells you why:

Checkfailure_reasonResponse
The content matches the declared size, checksum, and content typesize_mismatch, checksum_mismatch, content_mismatch422 upload_integrity_mismatch
The actual size is within the limit of the session purposetoo_large422 validation_failed on size_bytes
The filename extension is in the list of the session purposeextension_not_allowed422 validation_failed on filename
The actual size fits in the account's storage quotastorage_quota409 quota_exceeded

A failed session cannot be completed again. Open a new session to retry.

  1. Use the file_id in 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 /uploads checks the declared size_bytes and rejects the upload before opening a session.
  • POST /uploads/{upload_id}/completions checks the actual size. A file that would exceed the quota is removed, and the session becomes failed with failure_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

LimitValue
File sizeSee the purpose table above
Part URL lifetime15 minutes
Download link lifetime900 seconds
Open session lifetime24 hours
Unused file lifetime24 hours after the upload completes
Storage quota50 GiB per account, unless a different quota was agreed
CodeStatusWhen
upload_integrity_mismatch422The 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_conflict409The session was already completed, cancelled, failed, or expired.
storage_unavailable503The storage service did not respond. Retry with backoff.
validation_failed422The 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_exceeded409The 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.

On this page