Webhooks
CellCMS can send HTTP webhooks when content changes — useful for triggering builds, syncing external systems, or sending notifications.
Overview
Webhooks are configured per project and fire on document and asset lifecycle events. Each delivery includes:
- A JSON payload describing the change
- A timestamped HMAC-SHA256 signature for verification
- Automatic retries with exponential backoff on transient failures
Creating a Webhook
curl -X POST https://api.cellcms.com/api/v1/webhooks/my-project \
-H "Authorization: Bearer YOUR_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "Vercel deploy trigger",
"url": "https://api.vercel.com/v1/integrations/deploy/prj_xxx",
"events": ["document.publish", "document.unpublish"],
"dataset": "production"
}'
The create response returns the webhook's signing secret exactly once. Store it somewhere safe — it is never shown again. To rotate it, call PUT /api/v1/webhooks/:project/:id?rotateSecret=true.
Configuration Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string (1–80) | Yes | — | Human-readable name |
url | string | Yes | — | Public HTTPS endpoint to receive POST requests |
events | string[] | Yes | — | Events to subscribe to |
dataset | string | Yes | — | Dataset to watch |
active | boolean | No | true | Enable or disable |
URL constraints: Must be http:// or https://, must resolve to a public IP (private, loopback, and link-local ranges are blocked to prevent SSRF), must not contain user:pass@ credentials, and must be ≤ 2048 characters. You can register at most 20 webhooks per project.
Event Types
| Event | Triggered When |
|---|---|
document.create | A new document is created (draft) |
document.update | A document is modified |
document.delete | A document is deleted |
document.publish | A draft is published |
document.unpublish | A published document is unpublished |
asset.upload | A new asset is uploaded |
asset.delete | An asset is deleted |
Fetch the live list at GET /api/v1/webhooks/events.
Payload Format
Each webhook delivery is a POST request with a JSON body:
{
"event": "document.publish",
"dataset": "production",
"documentId": "doc-uuid",
"documentType": "post",
"timestamp": "2025-01-15T12:00:00.000Z"
}
Headers
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | CellCMS-Webhook/1.0 |
X-CellCMS-Event | Event type (e.g., document.publish) |
X-CellCMS-Delivery | Unique delivery ID (UUID) |
X-CellCMS-Timestamp | Unix seconds when the signature was generated |
X-CellCMS-Signature | Versioned HMAC signature (e.g. v1=<hex>) |
Signature Verification
Every webhook delivery is signed with HMAC-SHA256 using the webhook's secret. Always verify the signature before processing a webhook.
Signing Format
The signed payload is: v1:<X-CellCMS-Timestamp>:<raw body>
signature = HMAC_SHA256(secret, "v1:" + timestamp + ":" + body)
header = "v1=" + hex(signature)
Prefixing the version lets us add new signature schemes in future (e.g. v2=...) without breaking existing verifiers.
Node.js Example
import crypto from "node:crypto";
function verifyWebhook(
rawBody: string,
signatureHeader: string | undefined,
timestampHeader: string | undefined,
secret: string
): boolean {
if (!signatureHeader || !timestampHeader) return false;
// Reject messages older than 5 minutes — blocks replay attacks.
const ts = parseInt(timestampHeader, 10);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) {
return false;
}
// Parse the versioned header: "v1=<hex>"
const match = signatureHeader.match(/^v1=([a-f0-9]+)$/);
if (!match) return false;
const received = Buffer.from(match[1], "hex");
const expected = crypto
.createHmac("sha256", secret)
.update(`v1:${ts}:${rawBody}`)
.digest();
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(received, expected);
}
// Express / Next.js handler
app.post("/api/webhook", (req, res) => {
const ok = verifyWebhook(
req.rawBody, // the raw request body, NOT the parsed JSON
req.headers["x-cellcms-signature"] as string,
req.headers["x-cellcms-timestamp"] as string,
process.env.WEBHOOK_SECRET!
);
if (!ok) return res.status(401).send("Invalid signature");
const { event, documentId, documentType } = JSON.parse(req.rawBody);
console.log(`${event}: ${documentType} ${documentId}`);
res.status(200).send("OK");
});
Important: You must verify against the raw request body, not the parsed JSON. Any re-serialization will change whitespace and invalidate the signature. In Express, use
express.raw({ type: "application/json" }); in Next.js, readreq.bodyas a stream before parsing.
Retry Logic
Failed deliveries (5xx / network errors / timeouts) are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | +1 second |
| 3 | +5 seconds (30 seconds if attempt 2 also failed) |
After 3 failed attempts, the delivery is marked failed. Client errors (400, 401, 403, 404, 410, 422) are treated as permanent and are not retried — there is no point hammering an endpoint that is rejecting every request.
Each POST has a 10-second timeout. The dispatcher refuses HTTP redirects (redirect: "error") so a 302 to an internal host can't bypass the SSRF allow-list.
Delivery Logs
View delivery history for a webhook:
curl "https://api.cellcms.com/api/v1/webhooks/my-project/webhook-uuid/deliveries?limit=20" \
-H "Authorization: Bearer YOUR_JWT"
CellCMS keeps the most recent 50 deliveries per webhook.
Rotating the Secret
If a webhook secret leaks, rotate it:
curl -X PUT "https://api.cellcms.com/api/v1/webhooks/my-project/webhook-uuid?rotateSecret=true" \
-H "Authorization: Bearer YOUR_JWT"
The new secret is returned once in the response. Deliveries after this point sign with the new key.
Managing Webhooks
List Webhooks
curl https://api.cellcms.com/api/v1/webhooks/my-project \
-H "Authorization: Bearer YOUR_JWT"
Update a Webhook
curl -X PUT https://api.cellcms.com/api/v1/webhooks/my-project/webhook-uuid \
-H "Authorization: Bearer YOUR_JWT" \
-H "Content-Type: application/json" \
-d '{"active": false}'
Delete a Webhook
curl -X DELETE https://api.cellcms.com/api/v1/webhooks/my-project/webhook-uuid \
-H "Authorization: Bearer YOUR_JWT"
Deleting a webhook also removes all its delivery logs.
All webhook management endpoints require an interactive user session with project admin role. API tokens cannot manage webhooks — this prevents a leaked token from silently exfiltrating future events by registering a new receiver.
Common Integrations
Vercel Rebuild
{
"name": "Vercel rebuild",
"url": "https://api.vercel.com/v1/integrations/deploy/prj_xxx",
"events": ["document.publish", "document.unpublish"],
"dataset": "production"
}
Netlify Build Hook
{
"name": "Netlify rebuild",
"url": "https://api.netlify.com/build_hooks/xxx",
"events": ["document.publish", "document.unpublish"],
"dataset": "production"
}
Custom Build Trigger
{
"name": "Custom CI/CD",
"url": "https://your-ci.example.com/api/trigger",
"events": ["document.publish", "document.unpublish", "asset.upload"],
"dataset": "production"
}
Related Documentation
- API Reference — Webhook endpoints
- Deployment — Production configuration