Skip to content

Uploads

File bytes never pass through the File Service API. An upload is three calls: a ticket that checks everything and answers signed URLs, the PUTs of the bytes to those URLs, and a completion. This page explains the protocol and the choices around it. It is for backend developers, for app developers who send the bytes, and for anyone integrating from a stack without a library. The field-by-field version is the upload protocol reference.

client File Service API storage
│ POST P/uploads {name,size,…} │ │
│ ─────────────────────────────► │ policy, quota reservation, ticket │
│ ◄───────────────────────────── │ {upload_id, file_id, method, url…} │
│ PUT url (the bytes) ──────────┼──────────────────────────────────► │
│ (or PUT each part URL) │ │
│ POST P/uploads/:id/complete │ │
│ ─────────────────────────────► │ checks the size, writes the version│
│ ◄───────────────────────────── │ the file (state: available) │
  1. The ticket. POST P/uploads with name, size, mime_type and optional folder_id (the root by default), metadata, visibility, external_ref, policy and, for a machine client, owner_id and acting_user_id. The service checks the name, the declared type and size against the application’s policy, reserves the size against the quota and answers 201 with an upload ticket. The file_id exists from now on, in state pending_upload, invisible to listings and search.
  2. The bytes. A PUT of the whole file to url, or of each part to its part URL. The URLs carry their own authorisation: send no token and no cookie.
  3. The completion. POST P/uploads/:upload_id/complete, with parts: [{ part_number, etag }] for multipart. The service checks that the store holds exactly the declared size, makes the version available, and starts processing. It answers 200 with the file.

The service chooses, from the declared size:

method When What the ticket holds
put Up to 64 MiB url and headers. Send one PUT with exactly those headers and nothing else
multipart Larger part_size, parts_total and the first 100 part URLs in parts. Every part is part_size bytes except the last

For multipart, collect the ETag response header of every part PUT and send them all on completion. More part URLs come from POST P/uploads/:upload_id/parts (?from=101&count=100). The service picks part_size (at least 8 MiB) so that no file needs more than 10,000 parts. A completion with more than 1,000 parts answers 202 with state: "pending_upload" and a job_id, and finishes in the background.

A web app that reads ETag across origins gets it: the storage exposes it, and part PUTs need no other header.

Rule Value
Single PUT ticket 1 hour, then upload_expired
Multipart ticket 24 hours; each part URL lives 1 hour and can be requested again
Size check A different size at completion deletes the object and fails the ticket with size_mismatch
Missing parts A multipart completion that does not list every part answers upload_incomplete
Checksum Send content_sha256 (hex) on the ticket: a single PUT URL is then signed for it and the store rejects a corrupted body; the service verifies it again after upload (hash_mismatch)
Abort DELETE P/uploads/:upload_id releases the reservation at once. An expired ticket is cleaned up by the service, and its file is removed
Ticket state open, completing, completed, aborted or expired. A completed or aborted ticket answers upload_not_open

GET P/uploads/:upload_id answers the ticket with parts_uploaded: [{ part_number, etag, size }], read from the store. An app that restarted in the middle of a multipart upload asks for it, uploads only the missing parts (requesting fresh URLs when the old ones expired) and completes. A single PUT simply starts again with the same URL while the ticket lives.

  • A machine client with files:manage: in any folder of its context, for any member as owner_id.
  • A user with edit on the target folder (Permissions).
  • Only the creator of a ticket (the same user, or the same client) may read, extend, complete or abort it.

Tickets are rate limited: 60 per minute per user. A suspended tenant answers tenant_suspended; a store that is unavailable answers 503 storage_unavailable and writes nothing.

Check Error
The name: at most 255 characters, no /, unique in the folder (ignoring case) invalid_name, name_conflict
The declared type against allowed_types, or the named upload policy’s type_not_allowed
The size against max_file_size_bytes and the tenant’s and platform’s ceilings file_too_large
The quota of the application and the tenant quota_exceeded, with quota_bytes, used_bytes, reserved_bytes
visibility: public needs the application to allow public files public_visibility_disabled

A name that is taken fails by default. Send on_name_conflict: "rename" on the ticket and the service picks a free name instead, such as IMG_5913 (1).jpg: useful for phone photos, which repeat names. policy names an entry of the application’s upload_policies (for example chat, which CCS Chat asks for: see Media in Chat); an unknown name falls back to the application’s own policy, and the ticket’s policy_applied says which one applied. After the upload, processing checks the detected type again: see Processing.

Creating a ticket is not idempotent by itself: two tickets reserve the size twice. Send an Idempotency-Key header (any unique string, for example a UUID you keep for the retry): the same key with the same body within 24 hours answers the first response again, and the same key with another body answers idempotency_key_reused. The Node client does this for you on every call that creates something.