Publishing posts
The blog's CMS API — write, score and publish posts from a script or an AI agent, with images stored in R2 and every post graded against a 100-point SEO checklist.
The blog at /blog is written through a small JSON API. An operator uses it through the console; an agent uses it directly with a key. Either way a post is markdown plus a handful of SEO fields, and it cannot be published until it clears an SEO score — the same score the editor shows as you type.
Authentication
This is the one authenticated part of the API. Send a CMS key in either header:
x-runitup-key: <key>
Authorization: Bearer <key>Keys are set on the server as CMS_API_KEYS, comma-separated, each label:key — the label is written on every post the key touches, so a change is always attributable. A key can write and publish; it cannot force a post past the SEO bar. That override is reserved for a signed-in operator.
CMS keys are not gate keys
A key that opens the invitation gate does nothing here, and a CMS key does not open the gate. Give a bot the narrower one.
The shape of a post
| Field | Notes |
|---|---|
title | Required. The headline. |
focusKeyword | What the post should rank for. Drives the slug and every keyword test. |
secondaryKeywords | Up to 8. Each must appear in the body, ideally in a subheading. |
slug | Optional. Defaults to the focus keyword, hyphenated. Never changed by a re-save. |
seoTitle | Optional <title> when it should differ from the headline. Under 60 characters. |
metaDescription | 120–160 characters, keyword inside the first 120. |
excerpt | Shown in lists and as the standfirst. Defaults to the first paragraph. |
body | Markdown. No raw HTML is rendered. Headings from ##; images as . |
coverImageUrl, coverCardUrl, coverImageAlt | From an upload (below). The card is the 1200×630 social image. |
authorName, category, tags | Tags are strings; posts sharing a tag are related. |
canonicalUrl | Only when the post is syndicated from elsewhere and that copy should rank. |
noindex | Hide from search engines. |
Every response carries seoScore and seoReport: the score, the word count, the keyword density and the target range, and each check with pass, warn or fail and a one-line reason.
Write a draft
curl -X POST https://runitup.gg/api/cms/posts \
-H "x-runitup-key: $CMS_KEY" -H "content-type: application/json" \
-d '{
"title": "7 Proven Steps to Launch a Token Safely",
"focusKeyword": "launch a token",
"secondaryKeywords": ["locked liquidity", "creator fees"],
"metaDescription": "Launch a token on Robinhood Chain in one transaction: pick a venue, pair it with ETH or a stock, keep 75% of every fee. The complete, honest guide.",
"body": "If you want to launch a token on Robinhood Chain, this is the whole process...",
"tags": ["guides", "robinhood chain"]
}'Answers 201 with the post, its id, its slug and its first seoReport. PATCH /api/cms/posts/:id takes any subset of the fields; GET reads one; DELETE removes it.
Score before you save
curl -X POST https://runitup.gg/api/cms/seo -H "x-runitup-key: $CMS_KEY" \
-H "content-type: application/json" -d '{ "title": "...", "metaDescription": "...", "body": "...", "focusKeyword": "..." }'The same function, without a save. An agent can loop on this until the report is clean, then create the post once.
Images
curl -X POST https://runitup.gg/api/cms/upload -H "x-runitup-key: $CMS_KEY" \
-F "file=@cover.png" -F "alt=launch a token on Robinhood Chain"or as JSON, { "data": "<base64>", "contentType": "image/png", "alt": "..." }. Every image is stored in R2 as WebP, at most 1600px wide, and answers { url, cardUrl, width, height, alt }. Put url in the markdown as ; use url and cardUrl as the cover. GET /api/cms/upload lists everything uploaded.
Publish
curl -X POST https://runitup.gg/api/cms/posts/42/status -H "x-runitup-key: $CMS_KEY" \
-H "content-type: application/json" -d '{ "action": "publish" }'Actions: publish, unpublish, schedule (with "at": "2026-10-01T09:00:00Z"), archive, draft. Publishing and scheduling need the SEO score at or above the bar (CMS_MIN_SEO_SCORE, 80 by default). Below it the answer is 422 with the full report, so you know exactly what to fix.
The score, in one table
A port of Rank Math's content tests. A post that passes everything scores 100; length is the one test with partial credit.
| Test | Passes when |
|---|---|
| Keyword in title, meta, URL, opening, body | Present; in the first half of the title; within the meta's first 120 characters; in the first 10% of the body |
| Content length | 2500+ words for full credit (2000: 70%, 1500: 60%, 1000: 40%, 600: 20%) |
| Keyword density | 1.0–1.5%; over 2.5% fails |
| Keyword in a subheading; secondaries in body and a heading | At least one H2/H3 each |
| Internal link; external link | At least one of each; external links stay followed |
| Image ALT with keyword; media | At least one ALT carries it; four or more images for full credit |
| Title readability | A number, a power word, a positive or negative word, 60 characters or fewer |
| Paragraphs; structure | None over 120 words; opens with prose not a heading; two to four intro paragraphs; no "Conclusion" heading |
| Table of contents | Generated automatically for any post with four or more headings |
Reading the blog
Public, no key: GET /api/blog/posts?limit=&offset=&tag=&since= lists live posts without bodies — for a bot that announces new articles, poll with since. The RSS feed is at /feed.xml, and every post is in the sitemap.
