Skip to content

Uploading on behalf of users

Your backend creates the upload tickets, so every rule of yours runs before a byte moves, and your app never needs a File Service token. This guide covers the choices the quickstart made for you and what to do when something goes wrong. It is for backend developers using ccs-files-client.

Machine scope: files:manage
Field Choose
owner_id The user the file belongs to. The owner holds the application’s owner actions on it and sees it in their listings. Without it, the file belongs to acting_user_id, or else to no user
acting_user_id The user you act for, recorded on the audit row
folder_id Where the file lives; the root by default. A folder per user, per team or per record keeps permissions simple: see Folders and files
visibility application when every member may see it (a social feed); private when only the owner and the entries decide; public for a stable link (if the application allows it)
external_ref Your record, such as post:123
policy A named upload policy of the application, for example avatar with images only and a small size cap
on_name_conflict rename when names repeat (phone photos); the default fail answers name_conflict
notify_user_ids Who besides the owner gets file.ready
title, description, tags, category, department, custom, status Metadata, all optional and changeable later
content_sha256 The hex SHA-256 of the file, when the app can compute it: the store then rejects a corrupted body

Before you offer an upload at all, read the application’s rules once with files.settings.public() (GET P/settings/public): allowed types, the largest file, the named policies, and single_put_max_bytes. Your app can then refuse a wrong file without a round trip.

A ticket is refused before any byte is sent. Map the codes to messages your app shows:

ticket-or-reason.ts
import {
FilesApiError,
FilesForbiddenError,
FilesNotFoundError,
FilesPayloadTooLargeError,
FilesRateLimitedError,
FilesUnprocessableError,
} from '@central-code-station/ccs-files-client';
import { files } from './files-client';
// Turn a refused ticket into an answer your app can show before any byte is sent.
export async function ticketOrReason(input: { name: string; size: number; mime_type: string; owner_id: string }) {
try {
return { ticket: await files.uploads.create(input) };
} catch (error) {
if (error instanceof FilesPayloadTooLargeError) {
// file_too_large, or quota_exceeded with the numbers in the body
return { reason: error.code, quota: error.code === 'quota_exceeded' ? error.body : undefined };
}
if (error instanceof FilesUnprocessableError) return { reason: error.code }; // type_not_allowed, ...
if (error instanceof FilesForbiddenError) return { reason: error.code }; // tenant_suspended, missing_action, ...
if (error instanceof FilesNotFoundError) return { reason: error.code }; // folder_not_found
if (error instanceof FilesRateLimitedError) return { reason: 'rate_limited', retryAfterSeconds: error.retryAfterSeconds };
if (error instanceof FilesApiError) console.error(error.status, error.code, error.body?.message);
throw error;
}
}
Code Tell the user
type_not_allowed This kind of file is not accepted here
file_too_large The file is larger than the limit (read it from the public settings)
quota_exceeded The storage is full; the body has quota_bytes, used_bytes, reserved_bytes
tenant_suspended Uploads are paused for this organisation
name_conflict A file of that name exists (or send on_name_conflict: "rename")
rate_limited Wait retryAfterSeconds

The app sends the bytes, so it owns the retries:

  • A failed PUT (network error or 5xx): retry the same URL with a back-off. The URL stays valid for the ticket’s lifetime (a single PUT) or 1 hour (a part).
  • An expired part URL (403 from the store): ask your API for fresh URLs; your API calls files.uploads.parts(uploadId, { from, count }) (POST P/uploads/:upload_id/parts).
  • The app restarted mid-upload: ask your API for the ticket’s state; your API calls files.uploads.status(uploadId) (GET P/uploads/:upload_id), which lists the parts the store already has. Send only the missing ones, then complete.
  • The user cancelled: tell your API, which calls files.uploads.abort(uploadId) (DELETE P/uploads/:upload_id) so the reserved quota is released at once.

Your API’s completion route must accept a retry too: if the app sends “finished” twice, the second completion answers upload_not_open. Read the file with files.files.get(fileId) then, and link it if it is not linked yet.

When the bytes are already on your server (an import job, a generated invoice, a file another service sent you), files.upload() runs the ticket, the PUTs and the completion in one call:

import-avatar.ts
import { FilesConflictError, FilesUploadError } from '@central-code-station/ccs-files-client';
import { files } from './files-client';
// A file your backend already has (an import job, a generated report): the whole protocol in one call.
export async function importAvatar(userId: string, path: string, signal: AbortSignal) {
try {
const file = await files.upload(
{ path }, // or a Buffer, Uint8Array, Blob, or a Readable with input.size
{ mime_type: 'image/png', owner_id: userId, acting_user_id: userId, external_ref: `avatar:${userId}`, on_name_conflict: 'rename' },
{ concurrency: 4, onProgress: ({ loaded, total }) => console.log(`${Math.round((100 * loaded) / total)} %`), signal },
);
// Optional: wait until processing finished (thumbnails made, metadata read).
return await files.files.waitUntilReady(file.file_id, { timeoutMs: 60_000, pollMs: 1_000 });
} catch (error) {
if (error instanceof FilesUploadError) console.error(`part ${error.partNumber} could not be sent; the ticket was aborted`);
if (error instanceof FilesConflictError) console.error(`processing refused the file: ${error.code}`); // file_rejected, ...
throw error;
}
}
  • The source is a Buffer, a Uint8Array, a Blob, a { path } or a Readable (with size in the input). name defaults to the file name of a path, mime_type to a Blob’s type.
  • Large files go up in parts, 4 at a time by default (concurrency), each retried up to 5 times; only concurrency parts are held in memory.
  • When it gives up, it aborts the ticket and throws FilesUploadError with partNumber. A refused ticket throws its API error before any byte is sent. signal cancels and aborts the ticket.
  • onProgress({ loaded, total, parts }) fires after the ticket and after every finished part.

files.files.waitUntilReady(fileId) polls the file until processing is done and rejects when it was refused or failed. Use it only where you must wait; most pages can show the original until the rendition exists.