Skip to content

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 POST
  • secret: HMAC signing key (encrypted at rest)
  • mode: notify (fire-and-forget) or veto (synchronous, can block)
  • field_path (only for field.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-secret

Generates 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=50

Audit 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/json

Signature: 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 (except http to 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