Webhooks

UnfoldCMS sends signed HTTP POST events when content changes. Register a webhook subscription with a URL and a list of events, and the CMS will push to your endpoint whenever those events fire.

This is what makes UnfoldCMS work as a real headless CMS — your Next.js or Astro frontend can revalidate ISR caches the moment a post publishes, without polling.

How It Works

1. Admin publishes a post in /admin/posts/267
                  ↓
2. POST /api/v1/admin/posts/267/publish fires PostPublished event
                  ↓
3. WebhookDispatcher looks up all subscribers for "post.published"
                  ↓
4. For each subscriber, signs the JSON payload with HMAC-SHA256
                  ↓
5. POSTs to subscriber.url with Signature header
                  ↓
6. Subscriber verifies the signature, then acts on the event

Registering a Webhook

curl -X POST https://your-site.com/api/v1/admin/webhooks \
  -H "Authorization: Bearer ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Next.js ISR Revalidation",
    "url": "https://your-nextjs-app.com/api/revalidate",
    "events": ["post.published", "post.updated"]
  }'

Response:

{
  "success": true,
  "message": "Webhook subscription created successfully",
  "data": {
    "id": 1,
    "name": "Next.js ISR Revalidation",
    "url": "https://your-nextjs-app.com/api/revalidate",
    "events": ["post.published", "post.updated"],
    "is_active": true,
    "total_deliveries": 0,
    "failed_deliveries": 0,
    "secret": "SSC8otq2Md6wCBYdewXEOuSi6IOvMUo..."
  }
}

⚠️ The secret is returned ONLY on creation. Save it immediately — future GET/list calls hide it. If you lose the secret, you'll need to delete the subscription and create a new one.

Available Events

Event When it fires
post.published A post is published (admin endpoint or scheduled publish)
post.updated A published post's content changes
post.deleted A post is deleted
page.published A page is published
comment.created A new comment is submitted
newsletter.subscribed A user confirms newsletter signup
user.registered A new user account is created
* Wildcard — receives ALL events (use sparingly)

Payload Format

Every webhook POST body has the same envelope:

{
  "event": "post.published",
  "occurred_at": "2026-05-26T15:00:00+00:00",
  "data": {
    "id": 267,
    "title": "UnfoldCMS Now Has a Real REST API",
    "slug": "unfoldcms-public-api-v1-launch",
    "url": "https://your-site.com/blog/unfoldcms-public-api-v1-launch",
    "posted_at": "2026-05-26T15:00:00+00:00"
  }
}

The data shape varies by event type but always includes id, slug (where applicable), and a public URL.

Verifying the Signature

Every delivery includes a Signature header containing an HMAC-SHA256 of the request body, using your subscription's secret as the key.

Node.js / Next.js Verification

// pages/api/revalidate.ts
import crypto from 'crypto';
import type { NextApiRequest, NextApiResponse } from 'next';

export const config = {
  api: {
    bodyParser: false, // we need the raw body for signature verification
  },
};

async function getRawBody(req: NextApiRequest): Promise<string> {
  return new Promise((resolve) => {
    let body = '';
    req.on('data', (chunk) => (body += chunk));
    req.on('end', () => resolve(body));
  });
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).end();

  const raw = await getRawBody(req);
  const signature = req.headers['signature'] as string;

  const expected = crypto
    .createHmac('sha256', process.env.UNFOLDCMS_WEBHOOK_SECRET!)
    .update(raw)
    .digest('hex');

  if (!signature || !crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  )) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const { event, data } = JSON.parse(raw);

  if (event === 'post.published' || event === 'post.updated') {
    await res.revalidate(`/blog/${data.slug}`);
    await res.revalidate('/blog');
  }

  return res.status(200).json({ revalidated: true });
}

PHP Verification

$signature = $_SERVER['HTTP_SIGNATURE'] ?? '';
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, env('UNFOLDCMS_WEBHOOK_SECRET'));

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

$payload = json_decode($raw, true);
// ... handle $payload['event']

Python Verification

import hmac, hashlib, os

def verify(raw_body: bytes, signature: str) -> bool:
    secret = os.environ['UNFOLDCMS_WEBHOOK_SECRET'].encode()
    expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Always use a constant-time comparison (crypto.timingSafeEqual, hash_equals, hmac.compare_digest). A naive === comparison leaks timing information that lets attackers brute-force the signature byte-by-byte.

Testing a Webhook

Before pointing a webhook at a real production endpoint, test it:

curl -X POST https://your-site.com/api/v1/admin/webhooks/1/test \
  -H "Authorization: Bearer ADMIN_TOKEN"

The CMS sends a test.ping event to your subscriber URL. Verify your endpoint accepts it (returns 2xx), then point a real subscription at it.

Managing Subscriptions

List your subscriptions

curl https://your-site.com/api/v1/admin/webhooks \
  -H "Authorization: Bearer ADMIN_TOKEN"

Returns subscriptions belonging to the authenticated user. The secret field is never included in list responses — you only see it on creation.

Update a subscription

curl -X PATCH https://your-site.com/api/v1/admin/webhooks/1 \
  -H "Authorization: Bearer ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"is_active": false, "events": ["post.published"]}'

Setting is_active: false pauses deliveries without deleting the subscription. Useful for debugging.

Delete a subscription

curl -X DELETE https://your-site.com/api/v1/admin/webhooks/1 \
  -H "Authorization: Bearer ADMIN_TOKEN"

Permanent. No undo.

Retry & Failure Handling

If your endpoint returns a non-2xx response (or times out after 5 seconds), the delivery is recorded as failed and the subscription's failed_deliveries counter increments.

UnfoldCMS does NOT currently retry failed deliveries in v1. This is a known limitation — the use case (Next.js ISR revalidation) is mostly idempotent, so a single failed delivery just means content takes one ISR cycle longer to refresh.

v1.2 will add retry-with-backoff for failed deliveries.

Security Best Practices

  1. Store the secret in env vars, not in code or git.
  2. Always verify signatures — never trust the payload without checking.
  3. Use HTTPS endpoints only. UnfoldCMS will still POST to HTTP, but signatures don't protect against MitM with a non-TLS endpoint.
  4. Reject unknown events. If your handler only cares about post.published, return 200 quickly for other events (or 204). Don't error — that drives up the failed counter.
  5. One secret per integration. If you have a Next.js subscriber and a Slack-notify subscriber, create two separate subscriptions with different secrets.