Webhooks
A backend cannot listen to a user’s live stream, so the File Service calls your backend instead: one signed HTTPS POST per event to one endpoint per application. Use it to mark a post’s media ready when file.ready arrives, to react to file.failed and file.quarantined, or to keep your own index in step with folder.changed, without polling. This guide is for backend developers.
files.settings.manageMachine scope: files:config.manageHow it works
Section titled “How it works”| One per application | The webhook belongs to the (tenant, application) context and receives the events of that application you subscribe to |
| Events | file.ready, file.failed, file.quarantined, file.status.changed, file.shared, file.version.created, folder.changed (and file.lock.changed once check-out exists). Payloads are on Events reference |
| Body | The event envelope { id, type, source: "files", tenant_id, application_id, payload, published_at }: ids and names, never bytes, text or URLs |
| Signature | X-Files-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256> under the secret you set |
| Delivery | At least once, retried on failure, logged per attempt for 7 days |
Every request carries these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
ccs-files-webhooks/1.0 |
X-Files-Event |
The event type, for example file.ready |
X-Files-Delivery |
The id of this attempt (a new one on every retry) |
X-Files-Signature |
t=<unix seconds>,v1=<signature> |
Configure it
Section titled “Configure it”-
Choose a secret of 16 characters to 256 characters, random, and keep it in your backend’s secret store. The service stores it encrypted and never returns it (
secret_set: truesays one is stored). -
Deploy the receiver (below) first: saving the webhook calls it.
-
Set the webhook with
PUT P/webhooks, from a client withfiles:config.manage(or on the console’s Settings page, withfiles.settings.manage):configure-webhook.ts import { FilesApiError, FilesClient } from '@central-code-station/ccs-files-client';// The webhook is part of the application's configuration: a client with files:config.manage sets it, for example from// a deployment script. One webhook per application.const admin = new FilesClient({issuerUrl: process.env.CCS_ISSUER_URL!,clientId: process.env.CCS_FILES_CLIENT_ID!,clientSecret: process.env.CCS_FILES_CLIENT_SECRET!,apiUrl: process.env.FILES_API_URL!,resource: process.env.FILES_API_RESOURCE!,tenantId: process.env.CCS_TENANT_ID,applicationId: process.env.CCS_APPLICATION_ID,scope: 'files:config.manage',});export async function configureWebhook(): Promise<void> {try {// The service first sends a signed `ping` to the URL and saves only when it answers 2xx.const webhook = await admin.webhooks.set({url: 'https://api.fitness.example/hooks/files', // https, a public address, no credentials in itsecret: process.env.FILES_WEBHOOK_SECRET!, // 16 to 256 characters; required the first time, kept when left out laterevents: ['file.ready', 'file.failed', 'file.quarantined'],});console.log(webhook.active, webhook.secret_set); // true true} catch (error) {// webhook_url_not_allowed (400): not https, or a private address. webhook_unreachable (422): the ping failed.if (error instanceof FilesApiError) console.error(error.code, error.message);throw error;}}
events lists what you subscribe to. secret is required the first time and kept when you leave it out later. active: false saves the webhook switched off. GET P/webhooks answers the configuration with secret_set, consecutive_failures, last_delivery_at, disabled_at and updated_at; url is null when none is set. Every change is audited as webhook.updated.
The ping
Section titled “The ping”Saving an active webhook first sends it a signed ping event (type: "ping", payload: { events }), and saves only when your endpoint answers 2xx within 5 seconds. Otherwise the answer is 422 webhook_unreachable with the reason in the message, such as answered 500, connection refused or timeout after 5 s, and nothing is saved. Answer the ping like any other event: verify the signature, then 2xx.
URL rules
Section titled “URL rules”| Rule | Otherwise |
|---|---|
An absolute https URL |
400 webhook_url_not_allowed |
| No user name or password in it: authenticate deliveries with the signature | The same |
| At most 2,048 characters | The same |
| A public address: the host may not be, or resolve to, a private, loopback, link-local, shared, multicast or reserved address | The same. The address is checked again each time a delivery connects, so a name that later resolves to an internal address is refused then |
| Answer directly: redirects are never followed | A 3xx is a failed delivery |
Receive and verify
Section titled “Receive and verify”Verify the signature against the raw body, before you parse it: the signature covers the exact bytes. FilesClient.verifyWebhookSignature of the Node client compares in constant time, refuses a timestamp more than 300 seconds from now, and answers false (it never throws) for a missing, malformed, tampered or expired header.
import { FilesClient, type WebhookEvent } from '@central-code-station/ccs-files-client';import { db } from '../shared/app';
// POST /hooks/files. Read the body as raw bytes (for example express.raw({ type: 'application/json' })): the signature// covers the exact bytes, and JSON parsed and serialised again is not the same.export async function filesWebhook(rawBody: Buffer, headers: Record<string, string | undefined>): Promise<number> { const ok = FilesClient.verifyWebhookSignature({ rawBody, signatureHeader: headers['x-files-signature'], // t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> secret: process.env.FILES_WEBHOOK_SECRET!, }); if (!ok) return 400; // missing, tampered, or older than 5 minutes: never act on it
const event = JSON.parse(rawBody.toString('utf8')) as WebhookEvent<{ file_id?: string }>; if (event.type === 'ping') return 204; // sent when the webhook is saved // At least once: the same event can arrive again (a retry after a timeout). Its `id` is the same on every attempt. if (!(await db.webhookEvents.add(event.id))) return 204;
const fileId = event.payload.file_id; if (fileId && event.type === 'file.ready') await db.posts.setMediaState(fileId, 'ready'); if (fileId && event.type === 'file.failed') await db.posts.setMediaState(fileId, 'failed'); if (fileId && event.type === 'file.quarantined') await db.posts.setMediaState(fileId, 'removed'); return 204; // answer within 5 seconds; do slow work after answering}- Answer fast. Answer 2xx within 5 seconds; a slower answer is a timeout and the event is tried again. Do slow work after answering, or in a queue of your own.
- De-duplicate on
id. Delivery is at least once: an event whose answer was lost arrives again. The body’sidis the same on every attempt of an event (X-Files-Deliveryis not). Keep the ids you handled, with a unique index, and skip one you have seen. - Treat events as hints. An event says something changed; read the file (
GET P/files/:file_id) when you need its current state. Events of one file can arrive out of order after a retry.
Retries and switching off
Section titled “Retries and switching off”| Your endpoint | The service |
|---|---|
| Answers 2xx | Delivered. consecutive_failures goes back to 0 |
| Does not answer, times out, answers 5xx or 429 | Tries again, up to 5 attempts in all, after 10 seconds, 1 minute, 5 minutes and 15 minutes |
| Answers anything else (a 4xx, a 3xx) | Final at once: no retry |
An event whose last attempt failed adds one to consecutive_failures. After 20 such events in a row the service switches the webhook off: active: false, disabled_at set, and the audit log gets webhook.disabled with the system as the actor. Events published while it is off are not delivered later. Fix the endpoint, then set the webhook again (the same PUT, with or without the secret): it is pinged, switched on, and the count starts over.
GET P/webhooks/deliveries lists every attempt of the last 7 days, newest first: delivery_id, event_id (the body’s id), event, attempt, status (delivered, retrying with next_attempt_at, or failed), response_code, error (why it failed) and duration_ms. Paging is ?after=&limit=.
{ "items": [ { "delivery_id": "01JA2B3C4D5E6F7G8H9J0K1M2N", "event_id": "01JA2B3B9Z8Y7X6W5V4T3S2R1Q", "event": "file.ready", "attempt": 2, "status": "retrying", "response_code": 503, "error": "answered 503", "duration_ms": 41, "next_attempt_at": "2026-10-23T10:01:10.000Z", "at": "2026-10-23T10:00:10.000Z" } ], "next_cursor": null }Verifying in other stacks
Section titled “Verifying in other stacks”The algorithm, to port to any language:
- Read the request body as raw bytes, and the
X-Files-Signatureheader. - Split the header on
,intokey=valuepairs. Taket(decimal Unix seconds) and everyv1(lower-case hex). Notor nov1: refuse. - Refuse when
tis more than 300 seconds before or after your clock. - Compute HMAC-SHA256 with the secret’s UTF-8 bytes as the key, over the bytes of
t, a., then the raw body. - Hex-decode each
v1and compare it with your result in constant time (hmac.compare_digestin Python,MessageDigest.isEqualin Java,CryptographicOperations.FixedTimeEqualsin .NET,hash_equalsin PHP,hmac.Equalin Go). Accept when one matches.
The same steps in TypeScript with nothing but the standard library:
import { createHmac, timingSafeEqual } from 'node:crypto';
// The signature check without the library, step by step, to port to another language.export function verify(rawBody: Buffer, header: string, secret: string, nowSeconds = Math.floor(Date.now() / 1000)): boolean { // 1. Split "t=1761213600,v1=5f2b...": one t and one v1 today; accept several v1 values, as the Node client does. const fields = header.split(',').map((part) => part.trim().split('=', 2) as [string, string]); const t = fields.find(([key]) => key === 't')?.[1]; const candidates = fields.filter(([key]) => key === 'v1').map(([, value]) => value); if (!t || !/^\d+$/.test(t) || candidates.length === 0) return false; // 2. Refuse a timestamp more than 300 seconds away from now (replays). if (Math.abs(nowSeconds - Number(t)) > 300) return false; // 3. HMAC-SHA256 under the secret (its UTF-8 bytes) of the timestamp, a dot, and the raw body bytes. const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest(); // 4. Compare the hex-decoded v1 values in constant time; any one matching is enough. return candidates.some((v1) => /^[0-9a-f]{64}$/i.test(v1) && timingSafeEqual(Buffer.from(v1, 'hex'), expected));}Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Fix |
|---|---|---|
422 webhook_unreachable on save |
The ping did not get a 2xx in time | Deploy the receiver first; check the reason in the message; answer before doing work |
400 webhook_url_not_allowed |
Not https, credentials in the URL, or not a public address | Use the public https address of your backend |
Every delivery failed with a 401 or 403 |
Your endpoint checks a session or a token | Exempt the route from your user authentication; the signature authenticates it |
| Signature never verifies | The body was parsed and serialised again, or the wrong secret | Verify the raw bytes; set the secret again if in doubt |
active: false with disabled_at |
20 events in a row failed | Fix the endpoint and set the webhook again |