edit.pink
Pink Editor docs

Publish Protocol

How Pink Editor publishes to a site you host yourself — endpoints, auth, responses and errors.

The Publish Protocol is the small JSON-over-HTTPS contract Pink Editor uses to deliver finished pages to a site you host. Pink is the planner; your site is the receiver. Pink POSTs a page, your receiver stores it (insert or update by slug) and renders it.

You only need this if you host the site yourself. If Pink hosts it, there is nothing to build.

Set it up in Pink

  1. Implement the receiver endpoints below on your site, over HTTPS.
  2. Choose a shared secret, for example with openssl rand -hex 32, and configure your receiver to accept it.
  3. In Pink Editor, open Settings → Destination, choose Self-hosted, and enter:
    • Publish URL: the base of your receiver, e.g. https://example.com/api/content. Pink appends /articles and /questions.
    • Publish API key: the same secret.
  4. Press Test in Settings. Pink posts an empty article to {publish_url}/articles with your key. Your receiver should check the key first, then refuse the article with 400 because it has no title or body, so nothing is stored: 400 passes, and 401 or 403 means the key doesn't match.

Only the site owner and managers can see and change these settings.

Endpoints

All paths are relative to your publish URL.

MethodPathPurposeRequired
POST{publish_url}/articlesCreate or update one articleYes
POST{publish_url}/questionsCreate or update one Q&A pageYes
GET{publish_url}/articles/{slug}Return the article as you hold it nowStrongly recommended
GET{publish_url}/components-condensedDeclare the blocks your site rendersOptional

And one call the other way, from your site to Pink:

MethodURLPurpose
POSThttps://app.edit.pink/api/content/notifyTell Pink a page changed on your site

There is deliberately no delete, in either direction. Pink takes a page down by publishing it with status: "draft". If you delete a page on your site, tell Pink with notify and action: "deleted".

Why the GET matters

Before Pink rewrites a live article (set_article), it reads your copy back from GET {publish_url}/articles/{slug}, so a change someone made on your site is never overwritten with older text. Answer with { id, slug, title, copy, status, updated_at }, or 404 for an unknown slug. Without this endpoint Pink skips such republishes rather than risk overwriting your edits.

Declaring your blocks

If you answer GET {publish_url}/components-condensed with { "blocks": [{ "id": "statBar", "description": "…", "example": { … } }], "notes": [] }, Pink fetches it at each publish and writes pages using the blocks you list. Without it, Pink uses the standard blocks.

Auth

Every request from Pink carries your key:

X-API-Key: <your publish API key>
X-CM-Protocol-Version: 1
Content-Type: application/json; charset=utf-8

Compare the key with the one you configured and refuse anything else with 401, before you validate the body. X-CM-Protocol-Version names the protocol version Pink speaks; refuse a version you don't support with 400 validation_failed. There is no signature or replay protection in version 1, so serve the receiver over HTTPS only. To rotate the key, change it on your receiver and in Pink's settings together.

The same key authenticates your notify calls to Pink.

Slugs

The slug is the page's address on your site and the key you upsert on. Pink sends each slug as one path segment of lowercase letters, digits and hyphens (^[a-z0-9-]+$), at most 200 characters. Answer slugs come from the question text and can contain runs of hyphens or end in one (cost---per-day), so accept those as they are.

The one exception is on self-hosted sites: a planned page that maps an existing multi-level address on your site (for example locations/split) is sent with its segments joined by /, each segment following the rule above. If your site serves only one segment, refuse such a slug with 400.

Treat each POST as idempotent on slug: posting the same slug again overwrites the page.

Responses

Answer a successful POST with:

{
  "id": "<your id for the page>",
  "status": "published",
  "public_url": "https://example.com/articles/best-time-to-charter",
  "indexable": true
}
FieldMeaning
idYour primary key for the row, opaque to Pink. Your notify calls quote it.
statusWhat you actually stored: "published" or "draft". If you held the page back (a review queue, a missing field), say "draft": Pink records this and won't tell anyone the page is live. The legacy value "accepted" means "stored state unknown"; Pink then assumes you honoured the request.
public_urlThe page's absolute URL on your site.
indexableOptional. Whether the page currently allows indexing (index, follow). Omit it if your site has no such idea.

Errors

Return a real HTTP status (never 2xx on failure) with a JSON body:

{ "error": "validation_failed", "message": "title is required" }
StatuserrorWhen
400validation_failedA required field is missing or has the wrong type, or the slug breaks the rule above
401unauthorizedMissing or wrong X-API-Key
403site_mismatchA site_id in the body doesn't match the site your key belongs to
404site_not_foundThe key is well formed but bound to no site (may also be 401)
500internal_errorYour store failed

A failed publish reports your status and message back to the user and the model, so make the message say what to fix. A planner may retry a 5xx; it must not resend a 4xx unchanged.

Versioning

The protocol has a single integer version, sent in the X-CM-Protocol-Version header; version 1 is current. Adding optional fields or new block types is not a breaking change, so ignore fields you don't know and skip block types you don't render. Don't require the header: Pink sends it on the settings test, but not yet on every publish.

Next: the payloads and the blocks.

On this page