Theme
Webhooks API
Outbound webhooks fire when content changes. Used for cache invalidation, search-index rebuilds, third-party sync. Every payload is HMAC-signed so receivers can verify authenticity.
Different from /api/plugins
Plugins run synchronously on lifecycle hooks (with veto power). Webhooks fire after the operation succeeds — fire-and-forget. Use plugins when you need to block an operation; use webhooks when you just need to be notified.
Events
| Event | Triggered by |
|---|---|
story.published | Story flipped to published status |
story.unpublished | Status changed away from published |
story.deleted | Hard delete (after archive won't re-fire) |
space.created | New Space onboarded |
space.archived | Space archived |
version.created | New StoryVersion saved (every keystroke save) |
asset.uploaded | Asset upload completed |
datasource.synced | Cron-triggered fetch succeeded |
Subscribing
http
GET /v1/spaces/{slug}/webhooks
POST /v1/spaces/{slug}/webhooks
PATCH /v1/spaces/{slug}/webhooks/{id}
DELETE /v1/spaces/{slug}/webhooks/{id}POST body:
json
{
"label": "Vercel deploy hook",
"url": "https://api.vercel.com/v1/integrations/deploy/...",
"events": ["story.published", "story.unpublished"],
"active": true
}Response includes secret (only shown once). Re-fetch via POST .../{id}/rotate-secret.
Payload shape
Every event posts the same envelope:
http
POST <your-url>
Content-Type: application/json
X-Peacock-Event: story.published
X-Peacock-Signature: sha256=<hex>
X-Peacock-Timestamp: <unix-seconds>
X-Peacock-Delivery: <uuid>json
{
"event": "story.published",
"delivered_at": "2026-05-27T19:00:00Z",
"space": { "slug": "demo" },
"story": {
"uuid": "abc-...",
"slug": "welcome",
"full_path": "/welcome",
"lang": "en",
"version_no": 12,
"status": "published"
}
}Signature verification
Server side:
ts
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyPeacock(req: Request, secret: string): boolean {
const sig = req.headers['x-peacock-signature']?.replace(/^sha256=/, '');
const ts = req.headers['x-peacock-timestamp'];
if (!sig || !ts) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 5 min replay window
const expected = createHmac('sha256', secret)
.update(`${ts}.${req.rawBody}`) // raw bytes, not parsed JSON
.digest('hex');
return timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'));
}php
function verifyPeacock(Request $request, string $secret): bool
{
$sig = preg_replace('/^sha256=/', '', $request->header('X-Peacock-Signature') ?? '');
$ts = $request->header('X-Peacock-Timestamp') ?? '';
if ($sig === '' || $ts === '') return false;
if (abs(time() - (int) $ts) > 300) return false;
$expected = hash_hmac('sha256', "{$ts}.{$request->getContent()}", $secret);
return hash_equals($sig, $expected);
}Retry policy
| Receiver returns | Peacock behaviour |
|---|---|
| 2xx | Mark delivered, log latency |
| 4xx | NOT retried (it's the receiver's bug) |
| 5xx / timeout | Retry up to 5 times: 30 s, 5 min, 30 min, 2 h, 12 h |
| After 5 failed retries | Mark webhook_delivery as failed, surface in admin UI |
The webhook itself stays active — failed deliveries don't auto-disable the subscription. Operator must investigate.
Delivery log
http
GET /v1/spaces/{slug}/webhooks/{id}/deliveries?limit=50Per-delivery: timestamp, response status, response body (4 KB truncated), latency, retry count. Useful for debugging "why didn't my deploy hook fire."
See also
/api/plugins— synchronous lifecycle hooks with veto/api/datasources— webhook-push inbound ingest endpoint (note: that's the opposite direction — sources push data into Peacock)