Theme
Plugins / Extensions API
Custom outbound HTTP calls Peacock makes at well-defined lifecycle moments — pre-save, post-publish, field-validate. Used for:
- Send a Slack message when a Story is published.
- Run a custom validation against an external linter (
pre-save veto). - Push translated content into a third-party TMS.
- Pre-fill a field with data from a CRM.
Conceptual model
A Plugin is a row in the database with:
event: which lifecycle hook (story.publishing,story.published,field.validate)webhook_url: where to POSTsecret: HMAC signing key (encrypted at rest)mode:notify(fire-and-forget) orveto(synchronous, can block)field_path(only forfield.validate): which field this guards
CRUD
http
GET /v1/spaces/{slug}/plugins
POST /v1/spaces/{slug}/plugins
PATCH /v1/spaces/{slug}/plugins/{id}
DELETE /v1/spaces/{slug}/plugins/{id}POST body:
json
{
"label": "Slack publish notifier",
"event": "story.published",
"webhook_url": "https://hooks.slack.com/services/...",
"mode": "notify",
"config": {
"channel": "#editorial-feed"
}
}The response includes the secret (only shown once on create; re-fetch via POST .../{id}/rotate-secret).
Rotate secret
http
POST /v1/spaces/{slug}/plugins/{id}/rotate-secretGenerates a new secret, invalidates the old one. Used when an operator-owned key may have been exposed.
Event log
http
GET /v1/spaces/{slug}/plugins/{id}/log?limit=50Audit log of every plugin invocation: timestamp, story_uuid (when applicable), HTTP status, response body (truncated to 4KB), duration.
Errors do NOT cascade — a plugin failing never blocks a legitimate save. Vetoes are the exception: see below.
Veto mode
When mode: "veto" on a story.publishing plugin, Peacock waits for the response synchronously (timeout 5s). The response shape:
json
{
"allow": false,
"reason": "Headline ends in a question mark — reviewer rule"
}If allow: false, the publish is rejected with HTTP 422:
json
{
"error": "plugin_veto",
"reason": "Headline ends in a question mark — reviewer rule",
"plugin": "Editorial gatekeeper"
}Timeouts default to allow: true (failing-open) — change with config.timeout_action: "deny" to fail-closed.
Field validation
event: "field.validate" with field_path: "headline" fires on every keystroke save. Response must be:
json
{ "valid": true }or
json
{
"valid": false,
"errors": ["Headline must end with a period."]
}The Block Editor surfaces the errors inline next to the field — no publish required to see them.
HMAC signing
Every outbound request carries:
http
X-Peacock-Signature: sha256=<hex>
X-Peacock-Timestamp: <unix>
Content-Type: application/jsonSignature: hex(hmac_sha256(secret, "${ts}.${raw_body}")). Verify on the plugin server before trusting any payload.
SSRF protection
The plugin URL is validated against the shared WebhookUrlGuard:
- Reject private IP ranges (RFC 1918, 169.254.0.0/16, localhost, IPv6 ULA)
- Reject schemes other than
https(excepthttpto RFC-1918 in dev mode) - Reject
0.0.0.0, AWS metadata IP, GCP metadata IP
This prevents a malicious tenant from pointing a plugin at an internal admin endpoint.
See also
/api/webhooks— cache-invalidation webhooks (different beast, different signing)/api/workflow— full approval flow if veto-by-plugin is too narrow- Security audit — SSRF defense details