Skip to content

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.

Permission: files.settings.manageMachine scope: files:config.manage
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>
  1. 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: true says one is stored).

  2. Deploy the receiver (below) first: saving the webhook calls it.

  3. Set the webhook with PUT P/webhooks, from a client with files:config.manage (or on the console’s Settings page, with files.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 it
    secret: process.env.FILES_WEBHOOK_SECRET!, // 16 to 256 characters; required the first time, kept when left out later
    events: ['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.

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.

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

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.

files-webhook.ts
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’s id is the same on every attempt of an event (X-Files-Delivery is 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.
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 }

The algorithm, to port to any language:

  1. Read the request body as raw bytes, and the X-Files-Signature header.
  2. Split the header on , into key=value pairs. Take t (decimal Unix seconds) and every v1 (lower-case hex). No t or no v1: refuse.
  3. Refuse when t is more than 300 seconds before or after your clock.
  4. Compute HMAC-SHA256 with the secret’s UTF-8 bytes as the key, over the bytes of t, a ., then the raw body.
  5. Hex-decode each v1 and compare it with your result in constant time (hmac.compare_digest in Python, MessageDigest.isEqual in Java, CryptographicOperations.FixedTimeEquals in .NET, hash_equals in PHP, hmac.Equal in Go). Accept when one matches.

The same steps in TypeScript with nothing but the standard library:

verify-signature.ts
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));
}
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