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
- Store the secret in env vars, not in code or git.
- Always verify signatures — never trust the payload without checking.
- Use HTTPS endpoints only. UnfoldCMS will still POST to HTTP, but signatures don't protect against MitM with a non-TLS endpoint.
- 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. - One secret per integration. If you have a Next.js subscriber and a Slack-notify subscriber, create two separate subscriptions with different secrets.