Skip to content
Omicron

Reference

Content webhook

Publish into your instance from an external CMS over a single authenticated POST.

An external system — Sanity, Contentful, a static-site build hook, a script — can publish straight into your blog by POSTing a document to one endpoint. The result is an ordinary post: it appears in your feeds, is searchable, and federates to your remote followers exactly like something written in the editor.

Every writer on an instance can use this. You do not need to be an admin, and you do not need anything from your instance’s operator.

Getting a token

  1. Open Settings → Integrations.
  2. Type a name you will recognise later — the system you are wiring up, like “Sanity”.
  3. Press Create.

The token is shown once. It is stored hashed, so nobody — not you, not your instance’s admin — can display it again. Copy it straight into the system that needs it. Lose it and you revoke it and mint another; that costs you nothing.

Tokens do not expire. An integration that publishes for years should not break on a timer. Revoking is explicit, immediate, and yours to do from the same page.

The request

POST /api/webhooks/content
X-Webhook-Secret: <your token>
Content-Type: application/json

The token may travel in either header — X-Webhook-Secret: <token> or Authorization: Bearer <token> — whichever your CMS makes easier.

Body

Field Type Required Notes
title string on create Up to 300 characters.
body string on create Markdown. Rendered to sanitized HTML on write.
description string no Up to 500 characters. Defaults to the first ~150 characters of the body.
banner string no Absolute http(s) URL of a cover image, hosted by you.
slug string no Stable key for this document — not the post’s URL. Defaults to a slug of the title.
tags string[] no Up to 5 after normalisation. Omit to leave existing tags alone; [] clears them.
language string no BCP-47 primary subtag, e.g. en, tr. Drives the reader’s language filter.
status string no published (default) or draft. Scheduling is not available here — see below.

“On create” means the first request that publishes a given document must carry a title and a body. Once the post exists, every field is optional — see Partial updates.

Re-sending is safe

The slug is stored on the post as its external key. Send the same slug again and Omicron updates that post instead of publishing a second copy — so an at-least-once webhook, a manual retry after a timeout, or a CMS republish all converge on one post.

Keys are scoped to you, not to the instance. Another writer publishing their own hello-world never collides with yours, and no token can address, read, or overwrite a post belonging to a different account.

A title with no ASCII characters derives to an empty key; those documents must send an explicit slug and are rejected with 400 otherwise.

Partial updates

Once a post exists, a request changes only the fields it carries. Anything you leave out keeps the value it already has, so a CMS that knows one field moved sends that field and nothing else — no need to resend an article body you did not touch.

# Unpublish, leaving the content untouched.
-d '{"slug": "eu-payments", "status": "draft"}'

# Retitle.
-d '{"slug": "eu-payments", "title": "Europe ditches the card networks"}'

# Replace the tags, and nothing else.
-d '{"slug": "eu-payments", "tags": ["payments", "eu"]}'

Because these requests carry no title, the slug is the only thing naming the post — send it, or there is nothing to address.

Clearing a field

Sending null clears a field. Leaving it out keeps it. The two are deliberately different:

# Drops the cover image.
-d '{"slug": "eu-payments", "banner": null}'

# Returns the summary to one derived from the body.
-d '{"slug": "eu-payments", "description": null}'

tags follows the same idea with its own spelling: omit it to keep the current tags, send [] to clear them.

Responses

Success returns the post’s id and the key it is stored under:

{ "id": "9e962281-…", "slug": "eu-payments", "status": "published", "created": true }
Status Meaning
201 A new post was created.
200 Your existing post with that slug was updated ("created": false).
400 Invalid payload. The message names the offending field.
401 Unknown, revoked, or missing credential.
403 The owning account is suspended.
413 Body larger than WEBHOOK_MAX_BODY_BYTES (512 KB by default).
429 Over RL_WEBHOOK_MAX. Retry-After says how long to wait.

Errors never echo the credential, and nothing logs it.

Example

curl -X POST https://your-domain/api/webhooks/content \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Secret: $OMICRON_TOKEN" \
  -d '{
    "title": "Europe is ditching Visa and Mastercard",
    "body": "## The short version\n\nIt is a **huge** step.",
    "description": "Why the EU payments shift matters.",
    "banner": "https://cdn.example.com/covers/eu-payments.jpg",
    "slug": "eu-payments",
    "tags": ["fintech", "europe"],
    "language": "en"
  }'

Substitute your own instance’s domain — this works on any Omicron instance.

Hand-escaping Markdown into JSON is miserable, so for real content let jq build the payload from a plain .md file:

jq -n --arg t "My post" --arg s "my-post" --rawfile b post.md \
      '{title:$t, slug:$s, body:$b}' \
| curl -X POST https://your-domain/api/webhooks/content \
    -H "Content-Type: application/json" \
    -H "X-Webhook-Secret: $OMICRON_TOKEN" \
    --data-binary @-

Managing tokens over the API

The same endpoints the Settings page uses. These are ordinary signed-in API calls authenticated by the session cookie, not by a webhook token — a token cannot mint or revoke tokens.

Method Path Purpose
GET /api/webhooks/tokens Your live tokens (never the values)
POST /api/webhooks/tokens Mint one; the plaintext is in this response only
DELETE /api/webhooks/tokens/:id Revoke one

A maximum of 10 live tokens per account.

What happens to the content

The Markdown is rendered on the way in, not on the way out. Omicron stores the sanitized HTML, the same as a post written in the editor. Every tag outside the allowlist — <script>, event handler attributes, inline styles, javascript: links — is dropped before anything is stored. This is why the endpoint can accept content from a machine safely.

The practical consequence: what you send is what gets published, but exotic raw HTML in your Markdown may not survive. Standard Markdown, tables, code fences, images and links all do.

Maths

TeX maths renders: $…$ inline, $$…$$ on its own line.

The bound holds for $\varepsilon > 0$:

$$\dot{\Sigma} \ge \frac{k_{\mathrm{B}} \, \mathcal{I}(\varepsilon)}{\tau}$$

Formulas are typeset on the way in, like the rest of the Markdown, and stored as MathML — which every current browser draws natively. Readers download no maths engine, and a remote instance showing your Article gets the finished formula rather than raw TeX.

A $ that is not maths stays a $: a delimiter has to sit flush against its formula, so prices (“it costs $5, they charge $6”) are left alone. A formula that does not parse is shown as the TeX you typed, in red, rather than breaking the post.

What federates

A published post is delivered to your remote followers as a Create(Article). On top of the usual title, body, and tags:

Field Federates as Seen by remote instances as
description Article summary The preview line above a long-form link
banner Article image The link card’s image

Re-sending an already-published post delivers an Update(Article) so remote copies refresh. Flipping a published post to "status": "draft" tombstones the copies already delivered.

What readers see

description and banner are not federation-only metadata — they shape the post on your own instance too:

Field In the feed On the post In a link preview
description The preview line under the title Not shown — the body is right there og:description
banner A thumbnail beside the title A full-width image above the body og:image

A post written in the editor has neither, and its feed preview is clipped from the body instead. A banner that fails to load is dropped rather than left as a broken image, since it is hosted by you rather than by the instance.

For operators: the instance-wide secret

Setting WEBHOOK_SECRET in .env enables a second credential that publishes as WEBHOOK_AUTHOR (or, unset, the oldest account). It is a fallback for publishing without a user token — a migration script, an operator-run pipeline — and needs a restart to change.

Per-user tokens are the better default for anything ongoing: they identify who published, they are revocable from the UI without a restart, and losing one affects one account instead of the instance.

Variable Default Purpose
WEBHOOK_SECRET unset Instance-wide fallback credential. Minimum 16 characters.
WEBHOOK_AUTHOR oldest account Username that WEBHOOK_SECRET publishes as.
WEBHOOK_MAX_BODY_BYTES 512000 Reject a request body larger than this.
RL_WEBHOOK_MAX 30 Requests / min per source IP.

Limits and what this is not

  • No signature verification. Authentication is the bearer credential, not an HMAC of the payload. If your CMS signs its webhooks, that signature is not checked.
  • No scheduling. A document is published when it arrives.
  • No deletes. There is no webhook path that removes a post; set "status": "draft" to unpublish, or delete it from the UI.
  • Posts only. A token cannot read, comment, follow, moderate, change your settings, or touch posts you wrote in the editor.

Found a mistake?Edit this page on GitHub.