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
- Implement the receiver endpoints below on your site, over HTTPS.
- Choose a shared secret, for example with
openssl rand -hex 32, and configure your receiver to accept it. - 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/articlesand/questions. - Publish API key: the same secret.
- Publish URL: the base of your receiver, e.g.
- Press Test in Settings. Pink posts an empty article to
{publish_url}/articleswith your key. Your receiver should check the key first, then refuse the article with400because it has no title or body, so nothing is stored:400passes, and401or403means 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.
| Method | Path | Purpose | Required |
|---|---|---|---|
POST | {publish_url}/articles | Create or update one article | Yes |
POST | {publish_url}/questions | Create or update one Q&A page | Yes |
GET | {publish_url}/articles/{slug} | Return the article as you hold it now | Strongly recommended |
GET | {publish_url}/components-condensed | Declare the blocks your site renders | Optional |
And one call the other way, from your site to Pink:
| Method | URL | Purpose |
|---|---|---|
POST | https://app.edit.pink/api/content/notify | Tell 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:
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:
| Field | Meaning |
|---|---|
id | Your primary key for the row, opaque to Pink. Your notify calls quote it. |
status | What 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_url | The page's absolute URL on your site. |
indexable | Optional. 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:
| Status | error | When |
|---|---|---|
400 | validation_failed | A required field is missing or has the wrong type, or the slug breaks the rule above |
401 | unauthorized | Missing or wrong X-API-Key |
403 | site_mismatch | A site_id in the body doesn't match the site your key belongs to |
404 | site_not_found | The key is well formed but bound to no site (may also be 401) |
500 | internal_error | Your 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.