Backend quickstart: media on a post
This quickstart adds photos and videos to the posts of a social app. Your API decides who may attach media to which post; the File Service stores the bytes, makes thumbnails and previews, and mints the URLs your feed shows. It is for backend developers with a Node.js API; other stacks follow the same steps with the upload protocol.
files:manageHow the flow works
Section titled “How the flow works”This is the backend-mediated flow: your app never holds a File Service token and never talks to the File Service API.
- The app asks your API for an upload, with the file’s name, size and type.
- Your API checks its own rule (the user wrote the post), then creates an upload ticket with the Node client. The ticket names the user as owner and the post as external reference. Your API answers the
file_idand the ticket’s signed URL, or part URLs for a large file. - The app PUTs the bytes straight to the store with that URL. No token goes with them.
- The app tells your API that it finished, with the part numbers and ETags for a multipart upload.
- Your API completes the ticket and links the
file_idto the post in the same request, so nothing is linked before the bytes exist. - When your API renders the feed, it mints the URLs of a whole page in one call.
-
Install the Node client.
@central-code-station/ccs-files-clientis published on GitHub Packages. Map the scope in your project’s.npmrcand keep the token in your user~/.npmrcor your CI secrets, never in the repository:@central-code-station:registry=https://npm.pkg.github.comTerminal window npm install @central-code-station/ccs-files-clientIt needs Node.js 20 or newer, has no runtime dependencies and works from ES modules and CommonJS.
-
Create one client for your process.
files-client.ts import { FilesClient } from '@central-code-station/ccs-files-client';// One client per process: it fetches its machine token lazily and caches it until shortly before it expires.// It talks to the production platform; CCS_ENVIRONMENT=local (or staging) in your environment switches it, and// issuerUrl, apiUrl and resource can be passed explicitly.export const files = new FilesClient({clientId: process.env.CCS_FILES_CLIENT_ID!, // your backend client, with files:manage on the Files APIclientSecret: process.env.CCS_FILES_CLIENT_SECRET!, // server-side onlytenantId: process.env.CCS_TENANT_ID, // the default context: your tenant ...applicationId: process.env.CCS_APPLICATION_ID, // ... and your tenant application});The values come from step 5 of the checklist. The Identity Server and Files addresses default to production; set
CCS_ENVIRONMENT=localorstagingin your backend’s environment to use another one, or passissuerUrl,apiUrlandresourceyourself. The client gets its machine token withclient_credentials, caches it, and renews it on its own. -
Add the route that creates the upload.
create-media-upload.ts import type { UploadTicket } from '@central-code-station/ccs-files-client';import { files } from './files-client';import { db, type CurrentUser } from '../shared/app';// POST /posts/:postId/media/uploads { name, size, mime_type } (your API, behind your own user check)export async function createMediaUpload(user: CurrentUser, postId: string, body: { name: string; size: number; mime_type: string }) {const post = await db.posts.get(postId);if (post.author_id !== user.id) throw new Error('not your post'); // your rule, before Files is calledconst ticket: UploadTicket = await files.uploads.create({name: body.name, // "IMG_5913.jpg"size: body.size, // bytes, exactly what the app will sendmime_type: body.mime_type, // "image/jpeg"owner_id: user.id, // the file belongs to the user, not to your backend clientexternal_ref: `post:${post.id}`, // your own id: find the post's files later with itvisibility: 'application', // every member of the application may view and download iton_name_conflict: 'rename', // phones repeat names: "IMG_5913 (1).jpg" instead of 409 name_conflict});// Hand the app only what it needs to send the bytes. It never gets a File Service token.return {upload_id: ticket.upload_id,file_id: ticket.file_id,method: ticket.method, // 'put' or 'multipart'url: ticket.url, // put: one PUT of the whole bodyheaders: ticket.headers, // send exactly these headers with every PUT, nothing elsepart_size: ticket.part_size, // multipart: the size of every part but the lastparts: ticket.parts, // multipart: the first part URLs};}The ticket (
POST P/uploads) checks the name, the declared type and size against the application’s policy and the quota, and reserves the size. A file the application does not accept is refused here, before any byte moves:type_not_allowed,file_too_largeorquota_exceeded(with the numbers in the body). Thefile_idexists from this moment, so you can store it on a draft post right away; the file stays invisible until the upload completes.Files up to 64 MiB get
method: "put"and one URL. Larger files getmethod: "multipart", apart_sizeand the first 100 part URLs. A single PUT ticket lives 1 hour, a multipart ticket 24 hours. -
Send the bytes from your app.
put-to-ticket.ts (in your app) // In your app (browser or React Native, no library needed): send the bytes to the URLs your API handed out.// The URLs carry their own authorisation: no token, no cookie, exactly the ticket's headers.interface MediaTicket {method: 'put' | 'multipart';url?: string;headers: Record<string, string>;part_size?: number;parts?: Array<{ part_number: number; url: string }>;}export async function sendBytes(ticket: MediaTicket, file: Blob): Promise<Array<{ part_number: number; etag: string }> | undefined> {if (ticket.method === 'put') {const res = await fetch(ticket.url!, { method: 'PUT', headers: ticket.headers, body: file });if (!res.ok) throw new Error(`upload failed: ${res.status}`);return undefined; // a single PUT completes without parts}// Multipart: every part is the ticket's part_size, the last one is what is left. Collect each part's ETag.// `parts` holds the first 100 URLs; above that your API fetches the next ones (files.uploads.parts).const done: Array<{ part_number: number; etag: string }> = [];for (const part of ticket.parts ?? []) {const start = (part.part_number - 1) * ticket.part_size!;const res = await fetch(part.url, { method: 'PUT', body: file.slice(start, start + ticket.part_size!) });if (!res.ok) throw new Error(`part ${part.part_number} failed: ${res.status}`);done.push({ part_number: part.part_number, etag: res.headers.get('ETag')! });}return done; // send these to your API, which completes the upload}Send exactly the ticket’s
headerswith a single PUT and nothing else; the URL is signed for them. For progress, retries of a failed part and resume after a restart, see Uploading on behalf of users. The web and Flutter user clients do all of it with the same ticket:FilesClient.uploadFromTicketin the web quickstart,UploadController.fromTicketin the Flutter quickstart. -
Add the route that completes the upload and links the file.
complete-media-upload.ts import type { UploadedPart } from '@central-code-station/ccs-files-client';import { files } from './files-client';import { db, type CurrentUser } from '../shared/app';// POST /posts/:postId/media/complete { upload_id, parts?: [{ part_number, etag }] }export async function completeMediaUpload(user: CurrentUser, postId: string, body: { upload_id: string; parts?: UploadedPart[] }) {const post = await db.posts.get(postId);if (post.author_id !== user.id) throw new Error('not your post');// The service checks that the bytes arrived with the declared size; a mismatch fails with size_mismatch.const file = await files.uploads.complete(body.upload_id, { parts: body.parts });if (file.external_ref !== `post:${post.id}`) throw new Error('this upload belongs to another post');// Link it in the same request: nothing is linked before the bytes exist.const media = { file_id: file.file_id, kind: file.kind, mime_type: file.mime_type, name: file.name };await db.posts.addMedia(post.id, media);return media;}Completion (
POST P/uploads/:upload_id/complete) checks that the store holds exactly the declared size. A different size answerssize_mismatchand deletes the object; a multipart completion without every part answersupload_incomplete. On success it answers the file withstate: "available"and every processing steppending. -
Mint the URLs when you render the feed.
render-feed-page.ts import type { UrlsResultItem } from '@central-code-station/ccs-files-client';import { files } from './files-client';import type { Post } from '../shared/app';// GET /feed: one call per page mints every URL the page shows. The URLs are short-lived: never store them.export async function renderFeedPage(page: Post[]) {const ids = page.flatMap((post) => post.media.map((m) => m.file_id));const minted = new Map<string, UrlsResultItem>();// 1. The `preview` rendition (images). One entry per id, in order; a per-item error never fails the batch.const previews = await files.files.urls(ids, { rendition: 'preview' });for (const item of previews.items) minted.set(item.file_id, item);// 2. No preview yet (still processing) or none for this kind (a video): the original, shown inline.const missing = previews.items.filter((i) => i.error === 'rendition_not_available').map((i) => i.file_id);if (missing.length > 0) {const originals = await files.files.urls(missing, { disposition: 'inline' });for (const item of originals.items) minted.set(item.file_id, item);}return page.map((post) => ({...post,media: post.media.map((m) => ({ ...m, url: minted.get(m.file_id)?.url ?? null, expires_at: minted.get(m.file_id)?.expires_at ?? null })),}));}POST P/files/urlstakes up to 200 ids and answers one entry per id, in order. The URLs live 15 minutes by default: put them in the response and never store them. Usedisposition: 'inline'for media you show; a download of the original as an attachment is an audited event.
Verify
Section titled “Verify”- The ticket answers a
file_idand aurl(orparts). A PUT with the ticket’s headers answers 200. - The completion answers the file with
state: "available"andowner_idset to the user. - A few seconds later
GET P/files/:file_idshowsprocessing.renditions: "done"for a photo, andrenditions.preview.status: "ready". GET P/files/by-external-ref/:external_refwithpost:<id>lists the post’s files.
What happens next
Section titled “What happens next”After completion the service processes the file: it detects the real type from the bytes, checks it against the policy again, removes camera metadata from images, reads dimensions and durations, and makes the thumbnail, preview and large renditions of an image or the poster of a video. Until the preview exists, show the original (the feed sample does). The owner gets a file.ready event through the Notification Service when processing is done; see Events. Details are in Processing.
Next steps
Section titled “Next steps”- What to store in your database.
- Feeds and batch URL minting, including public files with stable URLs.
- Permissions, if members should see more than their own files.
- Troubleshooting.