Webhooks
A webhook sends a signed POST to your server when something changes, so your app can react without polling. There are two kinds: workspace webhooks for everything in a workspace, and subscriptions to one brand system.
Workspace webhooks
Workspace owners and admins add endpoints in Settings, API keys, Webhooks; other members see the list and the delivery log read-only. Each workspace can have up to 10 endpoints, each a public https:// URL.
The signing secret (whsec_...) is shown once after you create or rotate it. An owner or admin can reveal it again later, and each reveal is recorded in the audit log.
| Event | When it fires |
|---|---|
system.activated | A brand system was approved and its tokens are live |
design.ready | A design finished its identity run (mood board, logo, imagery) |
design.published | A design version was published with its finished deliverables |
project.review_ready | A project finished its design run and is waiting for review |
project.finalized | A project was approved and finalized |
webhook.test | Sent only by Send test in settings |
Every delivery is a POST with a JSON body. data holds ids and names only, never a secret or brief text.
{
"id": "<delivery id>",
"event": "design.ready",
"createdAt": "2026-10-13T09:30:00.000Z",
"workspaceId": "...",
"data": { "designId": "...", "designName": "..." }
}| Header | Value |
|---|---|
X-BrandPilot-Event | The event name |
X-BrandPilot-Delivery | The delivery id, the same as id in the body |
X-Webhook-Timestamp | Unix seconds when this attempt was signed |
X-Webhook-Signature | Hex HMAC-SHA256 of the timestamp, a dot and the raw body, with the endpoint secret |
Retries: a non-2xx answer, a timeout (10 seconds) or a failed connection is retried twice, three attempts in total. Redirects are not followed. The delivery log keeps the latest 50 deliveries per endpoint for 30 days. Answer 2xx quickly and ignore a delivery id you have already handled.
Verify the signature
Check the signature against the raw body before you parse it, and reject a timestamp more than 5 minutes from your clock so a captured delivery cannot be replayed:
import crypto from 'crypto';
const TOLERANCE_SECONDS = 300;
function verifyWebhook(body: string, signature: string | null, timestamp: string | null, secret: string): boolean {
if (!signature || !timestamp || !/^\d{1,12}$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
if (!/^[0-9a-f]{64}$/i.test(signature)) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest();
const provided = Buffer.from(signature, 'hex');
if (provided.length !== expected.length) return false;
return crypto.timingSafeEqual(provided, expected);
}Brand system subscriptions
To follow one brand system, subscribe while signed in. The response includes a secret for the signature check above, shown once.
POST /api/v1/systems/{name}/webhooks
Content-Type: application/json
{
"targetUrl": "https://your-app.com/api/design-webhook",
"events": ["system.published", "system.updated"]
}| Event | When it fires |
|---|---|
system.published | The system is approved for the first time and goes live |
system.updated | The foundation is approved, a live system is re-approved, or a design version of it is published |
Retries of a system delivery keep the original timestamp and signature and land inside the 5-minute window. Make your handler safe to run twice for the same system, version and event.