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

FieldNotes
titleRequired. The headline.
focusKeywordWhat the post should rank for. Drives the slug and every keyword test.
secondaryKeywordsUp to 8. Each must appear in the body, ideally in a subheading.
slugOptional. Defaults to the focus keyword, hyphenated. Never changed by a re-save.
seoTitleOptional <title> when it should differ from the headline. Under 60 characters.
metaDescription120–160 characters, keyword inside the first 120.
excerptShown in lists and as the standfirst. Defaults to the first paragraph.
bodyMarkdown. No raw HTML is rendered. Headings from ##; images as ![alt](url).
coverImageUrl, coverCardUrl, coverImageAltFrom an upload (below). The card is the 1200×630 social image.
authorName, category, tagsTags are strings; posts sharing a tag are related.
canonicalUrlOnly when the post is syndicated from elsewhere and that copy should rank.
noindexHide 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 ![alt](url); 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.

TestPasses when
Keyword in title, meta, URL, opening, bodyPresent; in the first half of the title; within the meta's first 120 characters; in the first 10% of the body
Content length2500+ words for full credit (2000: 70%, 1500: 60%, 1000: 40%, 600: 20%)
Keyword density1.0–1.5%; over 2.5% fails
Keyword in a subheading; secondaries in body and a headingAt least one H2/H3 each
Internal link; external linkAt least one of each; external links stay followed
Image ALT with keyword; mediaAt least one ALT carries it; four or more images for full credit
Title readabilityA number, a power word, a positive or negative word, 60 characters or fewer
Paragraphs; structureNone over 120 words; opens with prose not a heading; two to four intro paragraphs; no "Conclusion" heading
Table of contentsGenerated 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.