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
- Open Settings → Integrations.
- Type a name you will recognise later — the system you are wiring up, like “Sanity”.
- 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.
Related
- Environment variables — every variable in one place
- Rate limits — how the per-IP budget behaves
- HTTP API — the rest of the JSON API
Found a mistake?Edit this page on GitHub.