Content

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

FieldTypeRequiredDefaultDescription
namestring (1–80)YesHuman-readable name
urlstringYesPublic HTTPS endpoint to receive POST requests
eventsstring[]YesEvents to subscribe to
datasetstringYesDataset to watch
activebooleanNotrueEnable 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

EventTriggered When
document.createA new document is created (draft)
document.updateA document is modified
document.deleteA document is deleted
document.publishA draft is published
document.unpublishA published document is unpublished
asset.uploadA new asset is uploaded
asset.deleteAn 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

HeaderDescription
Content-Typeapplication/json
User-AgentCellCMS-Webhook/1.0
X-CellCMS-EventEvent type (e.g., document.publish)
X-CellCMS-DeliveryUnique delivery ID (UUID)
X-CellCMS-TimestampUnix seconds when the signature was generated
X-CellCMS-SignatureVersioned 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, read req.body as a stream before parsing.


Retry Logic

Failed deliveries (5xx / network errors / timeouts) are retried with exponential backoff:

AttemptDelay
1immediate
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"
}