Documentation
Webhooks
ReframeUI sends an HTTP POST request to a URL you configure whenever a key event occurs in your workspace. Each request is signed so your server can confirm it came from ReframeUI.
Overview
Webhooks let your infrastructure react to changes in your design system without polling. When an event fires, ReframeUI sends a JSON payload to your configured URL and includes a signature you can verify before processing the request.
Event types
| Event | When it fires |
|---|---|
token.published | A token release is published from a workspace. |
version.tagged | A version is tagged in a workspace. |
branch.merged | A branch is merged into the main line. |
member.invited | A new member is invited to the workspace. |
Payload shape
Every payload includes event, projectId, and timestamp (ISO 8601, UTC). Event-specific fields appear at the top level of the object.
token.published
{
"event": "token.published",
"projectId": "proj_abc123",
"timestamp": "2024-06-01T12:00:00.000Z",
"version": "1.4.0",
"bumpType": "minor",
"publishedBy": "user_xyz"
}version.tagged
{
"event": "version.tagged",
"projectId": "proj_abc123",
"timestamp": "2024-06-01T12:05:00.000Z",
"version": "1.4.0",
"bumpType": "minor",
"publishedBy": "user_xyz"
}branch.merged
{
"event": "branch.merged",
"projectId": "proj_abc123",
"timestamp": "2024-06-01T12:10:00.000Z",
"branchId": "branch_def456",
"branchName": "feature/dark-mode",
"mergedBy": "user_xyz"
}member.invited
{
"event": "member.invited",
"projectId": "proj_abc123",
"timestamp": "2024-06-01T12:15:00.000Z",
"inviteeEmail": "designer@example.com",
"role": "editor",
"invitedBy": "user_xyz",
"orgId": "org_ghi789"
}Request headers
ReframeUI sends four headers with every webhook request.
Content-Type: application/json
X-ReframeUI-Event: token.published
X-Delivery-Id: 018f1a2b-3c4d-7e5f-a6b7-c8d9e0f1a2b3
X-ReframeUI-Signature: sha256=<64-char hex>| Header | Description |
|---|---|
Content-Type | Always application/json. |
X-ReframeUI-Event | The event type string, e.g. token.published. |
X-Delivery-Id | A unique UUID per delivery attempt. Use this for deduplication. |
X-ReframeUI-Signature | HMAC-SHA256 of the raw request body, formatted as sha256=<hex>. |
Verifying signatures
Every request includes an X-ReframeUI-Signature header formatted as:
sha256=<64-character lowercase hex>To verify the request, compute HMAC-SHA256 over the raw request body using your signing secret, then compare the result to the header value. Always use a constant-time comparison to prevent timing attacks.
Node.js example
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhookSignature(rawBody, signingSecret, signatureHeader) {
const expected = createHmac('sha256', signingSecret)
.update(rawBody)
.digest('hex');
const received = signatureHeader.replace(/^sha256=/, '');
return timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(received, 'hex'),
);
}Read the raw body before any JSON parsing. Parsing and re-serializing the payload can change whitespace and break the signature check.
Reject any request where the signature does not match.
Your signing secret
Your signing secret is shown once when you create a webhook in workspace settings. Copy it immediately — it cannot be retrieved again. If you lose it, delete the webhook and create a new one to get a fresh secret.
Handler examples
Both examples follow the same pattern: capture the raw request body before parsing, verify the signature, acknowledge with a 200, then process the event asynchronously.
Node.js + Express
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';
const app = express();
// Capture raw body before JSON parsing
app.post('/webhooks/reframeui', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-reframeui-signature'];
if (!verifyWebhookSignature(req.body, process.env.REFRAMEUI_SIGNING_SECRET, signature)) {
return res.status(401).send('Invalid signature');
}
// Acknowledge immediately
res.status(200).send('OK');
// Process asynchronously
const event = JSON.parse(req.body.toString());
processEvent(event).catch(console.error);
});
function verifyWebhookSignature(rawBody, secret, header) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const received = header.replace(/^sha256=/, '');
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'));
}
async function processEvent(event) {
// Handle event types
if (event.event === 'token.published') {
await triggerSync(event.projectId, event.version);
}
}Next.js App Router
import { createHmac, timingSafeEqual } from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
// Capture raw body before any parsing
const rawBody = await req.text();
const signature = req.headers.get('x-reframeui-signature') ?? '';
if (!verifyWebhookSignature(rawBody, process.env.REFRAMEUI_SIGNING_SECRET!, signature)) {
return new NextResponse('Invalid signature', { status: 401 });
}
// Acknowledge immediately
const event = JSON.parse(rawBody);
// Process asynchronously — do not await long-running work here
processEvent(event).catch(console.error);
return NextResponse.json({ ok: true });
}
function verifyWebhookSignature(rawBody: string, secret: string, header: string): boolean {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const received = header.replace(/^sha256=/, '');
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'));
}
async function processEvent(event: Record<string, unknown>) {
if (event.event === 'token.published') {
await triggerSync(event.projectId as string, event.version as string);
}
}Retries
ReframeUI makes one initial attempt and up to 3 retries, for a total of 4 attempts. Each request times out after 10 seconds. If all 4 attempts fail, an in-app notification is sent to the project.
Respond with a 2xx status as quickly as possible and process the event asynchronously. Long-running work in the response handler will cause timeouts.
Managing webhooks
Use the REST API to register and manage webhooks programmatically. Only workspace owners can create, update, or delete webhooks.
List webhooks
List all webhooks for a project. The signing secret is never included in list responses.
GET /api/projects/:projectId/webhookscurl https://reframeui.app/api/projects/proj_abc123/webhooks \
-H "Authorization: Bearer <token>"Create a webhook
Create a webhook. Requires a url (must be https) and an events array. The signing secret is returned once on creation and cannot be retrieved again. Store it securely.
POST /api/projects/:projectId/webhookscurl -X POST https://reframeui.app/api/projects/proj_abc123/webhooks \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/reframeui",
"events": ["token.published", "branch.merged"]
}'Response (201)
{
"id": "wh_abc123",
"url": "https://example.com/webhooks/reframeui",
"events": ["token.published", "branch.merged"],
"createdAt": "2024-06-01T12:00:00.000Z",
"secret": "a1b2c3d4e5f6..."
}Update a webhook
Update the URL or events for an existing webhook.
PATCH /api/projects/:projectId/webhooks/:webhookIdcurl -X PATCH https://reframeui.app/api/projects/proj_abc123/webhooks/wh_abc123 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "events": ["token.published", "branch.merged", "member.invited"] }'Delete a webhook
Delete a webhook. Returns 204 with no body.
DELETE /api/projects/:projectId/webhooks/:webhookIdcurl -X DELETE https://reframeui.app/api/projects/proj_abc123/webhooks/wh_abc123 \
-H "Authorization: Bearer <token>"List delivery attempts
List the last 50 delivery attempts for a webhook.
GET /api/projects/:projectId/webhooks/:webhookId/deliveriescurl https://reframeui.app/api/projects/proj_abc123/webhooks/wh_abc123/deliveries \
-H "Authorization: Bearer <token>"Next steps
Build on webhooks with the full REST API, including project, token, and member endpoints.
API Reference →