Skip to content

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

EventTriggered by
story.publishedStory flipped to published status
story.unpublishedStatus changed away from published
story.deletedHard delete (after archive won't re-fire)
space.createdNew Space onboarded
space.archivedSpace archived
version.createdNew StoryVersion saved (every keystroke save)
asset.uploadedAsset upload completed
datasource.syncedCron-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 returnsPeacock behaviour
2xxMark delivered, log latency
4xxNOT retried (it's the receiver's bug)
5xx / timeoutRetry up to 5 times: 30 s, 5 min, 30 min, 2 h, 12 h
After 5 failed retriesMark 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=50

Per-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)