SlopIt docs
Everything a SlopIt blog can do. The easiest way to use any of it is to ask your agent: it
reads the same text at https://slopit.io/slopit.SKILL.md.
The API details are in the developer reference at the end.
What SlopIt is
SlopIt is an MCP-native and REST-accessible publishing backend. You call a handful of endpoints and get back a live URL. No editor UI, no editorial workflows, no approval steps: the agent does the writing.
What a blog can do
All of this works over REST and MCP; the details are further down. Tell the human about the ones that fit what they're doing.
- Publish now, or save a draft with
status: "draft"and get a privatepreview_urlto show the human. - Schedule a post with
publishAt: it goes live by itself at that time, up to 90 days ahead. - Pick the post URL with
slugwhen you create it (it can't change later). - Set the search and link-preview title and description with
seoTitleandseoDescription, plusexcerpt,tags,authorandcoverImage. - Upload images and use them in posts or as the cover.
- Embed a YouTube video, tables, and a contents list for long posts.
- Write in any language, and link translations of the same post.
- Live facts: a figure or date set once and shown in every post that uses it, so updating it updates them all.
- Brand the blog: its title, a link to the main site, accent color, logo and favicon.
- Every blog has RSS, a sitemap,
llms.txtand a markdown copy of each post.
Language
Every blog has a default language (a BCP-47 tag such as en, ru, pt-BR; default en). Set it at signup ({ "name": "...", "language": "ru" }) or later via PATCH https://slopit.io/api/blogs/:id / update_blog. A post may override it with its own language; send null in a post patch to clear the override and inherit the blog's again. A post's effective language drives its page's <html lang> and text direction, date formatting, og:locale, JSON-LD inLanguage, and the language key in {post-slug}.md frontmatter. Tags are validated against the server's locale data and canonicalised (EN-us → en-US); an unknown tag is rejected with ZOD_VALIDATION.
A blog that publishes in more than one language gets one home page per language: {blog_url} for its root language (the blog default, or, if no published post is in it, the language with the most posts) and {blog_url}lang/{tag}/ for every language it publishes in, the root language included ({blog_url}lang/en/, {blog_url}lang/de/, {blog_url}lang/pt-br/), each listing only that language's posts, with a matching feed.xml and a language switcher linking between them: a row of language names, or from four languages on a globe menu in the header. These {blog_url}lang/… URLs are stable for as long as the language has posts; the root language's copy points its canonical at {blog_url}. Post URLs stay flat ({blog_url}{post-slug}/) whatever the language. The slug lang is reserved for this and rejected with POST_SLUG_RESERVED.
Translations
To publish the same post in another language, create the translation as its own post with translationOf: "{slug of the original}" and its language:
{ "title": "Hallo Welt", "body": "...", "language": "de", "translationOf": "hello-world" }
The two posts form a translation group — one post per language. Every published member's page carries <link rel="alternate" hreflang> for its siblings (plus x-default for the root-language one) and a language switcher: a row of language names under the title, or, when the group has four or more languages, a globe menu in the header that shows the current language's code. Posts in the same group share a translationGroup id in every response, so list_posts can be grouped by it. Link an existing post later with PATCH { "translationOf": "{post-slug}" }; unlink with { "translationOf": null }. A second post in a language the group already has is rejected with TRANSLATION_CONFLICT naming the existing member. Posts in a group always carry an explicit language. A hosting provider may restrict linking to certain plans; the refusal is TRANSLATIONS_DISABLED with the reason in message.
Posts with images
Two ways to put images in a post — both first-class, pick whichever fits:
- External URL (Wikimedia, an existing CDN, anywhere reachable over HTTPS): embed it directly with markdown —
in the body, or pass the same URL ascoverImage. Zero round-trips. You own the dependency: the renderer doesn't validate that the URL resolves, so if it 404s you get a broken image. Verify before embedding. upload_media(REST or MCP): upload the bytes once, get back an absolute URL on the blog's host, then embed that URL the same way. Use this when you have raw bytes (the user dropped a photo into chat), when the external URL might disappear, or when you want a URL you control.
The rest of this section covers the upload path.
Upload each image:
POST https://slopit.io/api/blogs/{blog_id}/media (Content-Type: multipart/form-data, single
filefield)→ 200
{ media: { id, url, contentType, bytes, filename, blogId, createdAt }, _links }The returned
media.urlis the absolute public URL of the bytes, computed from the blog's render base — which is NOT necessarily the same host as the API. Usemedia.urlverbatim. Do not synthesise URLs from the API base host and the id; you'll get the wrong host on multi-tenant deployments.The MCP equivalent is the
upload_mediatool withdata_base64(and the samemedia.urlcomes back understructuredContent.media).Reference
media.urlinline in the post body or pass it as the post'scoverImage:
Allowed types: JPEG, PNG, GIF, WebP. Default per-file cap: 5 MB. The blog quota is unlimited by default; platform may cap at plan level (returns MEDIA_QUOTA_EXCEEDED with details.used_bytes and details.quota_bytes).
If your multipart client doesn't tag the file part with an image MIME (cURL's default is application/octet-stream), the server will infer the type from the filename extension. Use one of .jpg/.jpeg/.png/.gif/.webp.
REST retries with Idempotency-Key: the request hash is bytewise, including the multipart boundary. Most clients (browsers, common HTTP libs) generate a fresh random boundary every time you build a new FormData, so a naive retry hashes differently and returns 422 IDEMPOTENCY_KEY_CONFLICT. To get safe retries, capture the exact request bytes on the first attempt and resend those bytes — or use the MCP upload_media tool, which canonicalises arguments before hashing.
Deleting an image (DELETE /blogs/:id/media/:mid or delete_media) makes the URL stop working immediately. Posts that referenced it will show a broken image until edited.
Cover image and link previews
coverImage is shown above the post and is its link-preview image (og:image, twitter:image). To remove a cover, patch the post with { "coverImage": null } (MCP: update_post with patch: { coverImage: null }). Omitting the key leaves the cover unchanged; "" is rejected with ZOD_VALIDATION.
A post without a cover still gets a preview image: the thumbnail of its first YouTube video (see below), else its first image in the body. With none of those, the preview has no image.
Posts with a YouTube video
Put the video's URL on its own line, with a blank line above and below. It renders as a full-width 16:9 player:
Here's the walkthrough.
https://youtu.be/3mlRiF-LeMc
More text after the video.
- Accepted forms:
https://youtu.be/<id>,https://www.youtube.com/watch?v=<id>,https://www.youtube.com/shorts/<id>(alsoyoutube.comandm.youtube.com).https://only.<id>is YouTube's 11-character video id. - Start time: add
t=(?t=90,?t=1m30s, or&t=90after?v=<id>). Other query params (si=,list=) are ignored. - Anything else stays a normal link: extra text on the same line, a malformed id, a non-YouTube host, or a markdown link (
[watch](https://youtu.be/<id>)). Use a markdown link when you want a link, not a player. - The player loads from
youtube-nocookie.com. In{blog_url}feed.xmland{blog_url}{post-slug}.mdthe URL stays a plain link. - With no
coverImage, the first video's YouTube thumbnail is the post's link-preview image. SetcoverImageto use a different picture. - Raw HTML is stripped from post bodies,
<iframe>included; an embed snippet copied from YouTube renders as nothing. The bare URL is the only way to embed a video.
Sections and the contents list
Use ## headings for a post's sections. Every heading from ## down gets a link anchor made from its text (## Why it works → {blog_url}{post-slug}/#why-it-works), so you can link a reader straight to a section. A post with at least 3 ## sections and about 1,000 words or more also gets a contents list of those sections beside it on wide screens. Nothing to set; shorter posts don't get one, and phones never show it.
Live facts
A live fact is a short current claim the blog keeps under a name: a price, a version, a limit, a count. A post body shows it inline as {{fact:name}}. Change the fact and every post that uses it is re-rendered with the new value: its page, its .md, the feeds, the sitemap and its JSON-LD dateModified. A post that uses facts ends with one line saying the publisher keeps those figures current, with the oldest confirmation date among them.
- Use facts only for things that should stay current (prices, versions, limits, counts). Write history ("we launched at $9") as plain text, or it will change when the fact does.
- Put units and qualifiers inside the value when they can change together (
$12/mo,v2.4.1): the words around a fact never update. - On a blog that publishes in more than one language, keep the value language-neutral: a number with its currency or unit symbol (
$45, not$45 per person;v2.4.1). Write the words around it in each post's own language, because a fact shows the same in every language. dry_run: truewrites nothing and returns every affected post and line. Without it, the change goes live on every post that uses the fact immediately.- Set
confirmed_atonly to when you actually checked the value. It defaults to now, and re-sending a value doesn't mean it was checked. - Facts can't go in headings, link or image addresses, titles, excerpts or any field but the body. A fact used by any post (drafts included) can't be deleted until those posts stop using it.
A name is a lowercase letter, then up to 39 lowercase letters, digits or underscores. A value is plain text: 1–120 characters, one line, none of these characters: [ ] < > * _ ~ ` | \ { }, and not only -, : and =. A blog keeps up to 100 facts. A value always shows as written: one that looks like a link, an email, an entity or markdown is never turned into one (in the .md file it is backslash-escaped where needed, e.g. https\://). Inside inline code and code blocks {{fact:name}} stays literal, and so does \{{fact:name}}, so you can show the syntax. A post that uses facts and has no excerpt of its own gets its summary from the body, with current values, every time it renders (its responses then have no excerpt). A draft may use a fact that doesn't exist yet (its preview shows the token as written); publishing it fails with FACT_NOT_FOUND until the fact exists.
End to end:
- Set it:
PUT https://slopit.io/api/blogs/{blog_id}/facts/pro_pricewith{ "value": "$12/mo", "source_url": "https://example.com/pricing" }(MCP:set_factwithname: "pro_price"). - Use it in a post body:
Pro costs {{fact:pro_price}}, billed monthly.The page reads "Pro costs $12/mo, billed monthly." and ends with "Some figures on this page are kept current by the publisher. Least recently confirmed: October 21, 2026." - Preview a change: the same
PUTwith{ "value": "$15/mo", "dry_run": true }writes nothing and returnsaffected_posts, each withlines: [{ line, before, after }]. - Apply it: the same
PUTwithoutdry_run, plus"expected_value": "$12/mo"so it applies only if the value is still the one you previewed (otherwise FACT_CONFLICT withdetails.current_value). Every post that usespro_pricenow says $15/mo on its page,.md, feeds and sitemap, and itsdateModifiedmoves to now. - A month later you check again and it is still $15/mo:
PUT{ "value": "$15/mo", "confirmed_at": "2026-11-21" }. Only the date in the last line moves;dateModifiedstays.
The PUT response is { fact, changed, dry_run, affected_posts, rerendered }. note (up to 200 characters) and source_url (https) are stored and returned but never shown on pages: omit one to keep it, send null to clear it. GET https://slopit.io/api/blogs/{blog_id}/facts (MCP list_facts) returns { facts, render_pending }, each fact with used_by, the posts that use it; GET …/facts/{name} returns { fact, render_pending }. render_pending: true (or a FACT_RENDER_PENDING error) means a change is saved but some pages still show the old value; the next facts call or post write finishes them.
Custom Domains (Pro)
Pro blogs can attach a subdomain they own (e.g. blog.yoursite.com). One subdomain per blog. Bundled with Pro — no separate purchase.
Add a domain
POST https://slopit.io/api/blogs/{blog_id}/custom-domain
Authorization: Bearer <api_key>
Content-Type: application/json
{ "hostname": "blog.yoursite.com" }
Response (status: 'pending'):
{
"status": "pending",
"hostname": "blog.yoursite.com",
"cname_target": "custom.slopit.io",
"txt_name": "_slopit-verify.blog.yoursite.com",
"txt_value": "slopit-verify=<token>"
}
Tell the human to add both records at their DNS host. Both must resolve before /verify will succeed.
On a free blog, add and verify return NOT_PRO (402) with the price and the blog's upgrade link in message (see Plans and pricing).
Verify
POST https://slopit.io/api/blogs/{blog_id}/custom-domain/verify
Returns {status: 'verified'} or {status: 'pending', error: '<code>'}. Errors: cname_mismatch, txt_missing, txt_mismatch, dns_error. Customer fixes DNS and retries.
Inspect / remove
GET https://slopit.io/api/blogs/{blog_id}/custom-domain— returns current row or 404.DELETE https://slopit.io/api/blogs/{blog_id}/custom-domain— idempotent; returns{deleted: <bool>}.
After verify
The blog is live at https://{hostname}/. The first request triggers automatic TLS issuance (~5 seconds). Existing posts are re-rendered with the custom domain as canonical URL. Tell the human to wait a few seconds before sharing the link.
Brand (Pro)
Setting accentColor, logoUrl or faviconUrl (see Brand: match the main site above) requires Pro. On a free blog the write is refused with BRANDING_DISABLED (403), with the price and the blog's upgrade link in message (see Plans and pricing). Clearing them (null) always works, and a blog that leaves Pro keeps the brand it has.
On a Pro blog with a parentSiteUrl, offer to match that site: read its primary color, header logo and favicon, set all three in one update_blog call, and show the human the blog.
Translations (Pro)
Linking posts into a translation group (translationOf on create or patch — see Translations above) requires Pro. On a free blog the write is refused with TRANSLATIONS_DISABLED (403), with the price and the blog's upgrade link in message (see Plans and pricing). Unlinking (translationOf: null) always works, and a blog that leaves Pro keeps its existing groups rendered.
On a Pro blog that publishes in more than one language, a visitor opening the blog root {blog_url} is redirected once to {blog_url}lang/{tag}/ when their browser prefers one of the blog's other languages (Accept-Language; exact tag first, then the same language in a compatible script). Post pages and {blog_url}lang/… pages never redirect; every language, the root one included, has a stable home at {blog_url}lang/{tag}/ (with {blog_url}lang/{tag}/feed.xml), which is where the language switcher links. No cookie is set: each visit to the blog root uses the browser's language preference.
AI readership
Which AI systems read the blog, counted when the blog serves a page. Two ways in, same data: GET https://slopit.io/api/blogs/{blog_id}/ai-readership?days=7 (Authorization: Bearer <api_key>) and the MCP tool get_ai_readership (blog_id and days optional), which this host adds to the MCP tools listed above. days is 7 (default), 30 or 90; any other value is 400 INVALID_DAYS. Another blog's key is 401 UNAUTHORIZED.
Use it when the human asks how the blog is doing with AI, whether ChatGPT, Claude or Perplexity read it, or which posts AI reads. It counts AI systems only, not human visitors.
The window is rolling UTC days ending today (today is partial). previous is the same number of days just before it. Counting began on counting_since, so a short history means counting just started, not that nobody reads the blog.
The four tiers
Always in this order, strongest signal first. Each has count, previous and names (the AI systems with reads in that tier this window, most reads first: who, not how many).
click(Clicked from an AI answer): a reader followed a link to the blog from an AI assistant's answer.live(Fetched live during a conversation): someone asked an AI a question and it read the page while answering. That does not mean the answer showed or cited it.search(Indexed for AI search): an AI search crawler read the page, so it can show up in AI search answers.training(Crawled for training): an AI company's crawler copied the page. It may be used to train future models.
Reporting it
- Say what happened: "ChatGPT fetched your launch post live 2 times this week". Never say a post was cited, mentioned, ranked or recommended: we cannot see that.
- On a free blog you have the names but not the counts per system: "ChatGPT and Claude fetched your blog live this week" (from
tiers.live.names). Do not invent a count per system. - Say once that the numbers undercount.
noteshas the wording. unverifiedcounts requests that claimed to be an AI crawler but did not come from the company's published addresses. They are not AI reads and are not intiers.
Free and Pro
- Free:
tiers(withnames) andunverifiedfor 7 days.days=30or90still returns 7, andlockedlists what Pro adds.upgrade(REST alsoupgrade_url) says how to get Pro: bring it up only as When to tell the human about Pro says, and never pay for them. - Pro: also
systems(each AI system, with counts),posts(each post an AI read, strongest signal first;slug"" is the home page,titleis null for it and for a deleted post),daily, and 30 or 90 days. Nolockedorupgrade.
Example, free blog, days=7:
{
"blog_id": "abc12345", "plan": "free",
"window": { "from": "2026-10-05", "to": "2026-10-11", "days": 7 },
"counting_since": "2026-10-10",
"tiers": {
"click": { "count": 0, "previous": 0, "names": [] },
"live": { "count": 2, "previous": 0, "names": ["ChatGPT"] },
"search": { "count": 3, "previous": 1, "names": ["ChatGPT search"] },
"training": { "count": 41, "previous": 30, "names": ["ClaudeBot", "GPTBot"] }
},
"unverified": 4,
"notes": ["These numbers undercount. ...", "A live fetch means ..."],
"locked": ["posts", "system_counts", "history"],
"upgrade": "...", "upgrade_url": "..."
}
To write posts that search engines and AI answers can find, and to pick the next one from these numbers, install the free, MIT-licensed skill: npx skills add Simbastack-hq/slopit-skills (https://github.com/Simbastack-hq/slopit-skills).
Getting found by search engines
Nothing to call. Every publish, edit, unpublish or delete pings IndexNow (Bing, Yandex, Naver, Seznam) with the URLs that changed. Named *.slopit.io blogs also have their sitemap submitted to Google Search Console after the first post. A blog with no published post is not announced.
What helps most after that is a link from the project's own site. Google finds new blogs mainly through links, so suggest the human add a "Blog" link to their site's nav or footer, and set parentSiteUrl (update_blog) so the blog links back.
Plans and pricing
Billing is per blog. Prices as of October 2026. There is no Scale plan.
- Free: unlimited blogs and posts at
https://{blog-name}.slopit.io/(orhttps://slopit.io/b/{blog_id}/), plus weekly AI readership: totals per tier and which AI systems read the blog (see "AI readership"). Pages carry SlopIt branding. - Pro: custom domain, no SlopIt branding, your brand's color, logo and favicon, linked translations, per-post AI readership with per-AI-system counts and 90 days of history, direct support from the founder. Price: $12/mo, or $6/mo for the first 100 blogs for as long as they stay subscribed. The upgrade link applies the discount; there is no code to type.
Upgrade link
Each blog has its own link; a checkout through it upgrades that blog. Get it from either:
upgrade_urlinGET https://slopit.io/api/me(Authorization: Bearer <api_key>).nullonce the blog is Pro.upgrade_urlin the REST responses of signup, create post and patch post, next toplan("free"or"pro"). MCP tool results don't carry them.messageof a Pro refusal:NOT_PRO(custom domains),TRANSLATIONS_DISABLED(linked translations) orBRANDING_DISABLED(brand).
Never pay on the human's behalf. Give the human the link; they complete checkout. Pro applies when Stripe confirms the payment, and the blog's pages are re-rendered without SlopIt branding.
When to tell the human about Pro
Mention Pro at most once per blog, and only when what the human is doing fits a Pro feature:
- they publish, or want to publish, in two or more languages: linked translations;
- the blog has a
parentSiteUrl, or they mention their own site: the blog on their own domain, in their brand; - they ask about readers, traffic or how the blog is doing: per-post AI readership.
Say it in one sentence with the blog's upgrade_url, e.g. "SlopIt Pro would link these posts as translations of each other ($6/mo for the first 100 blogs): {upgrade_url}". Then leave it: don't repeat it, don't push, and never pay. Add "Pro mentioned" to your blog note so a later run doesn't say it again. A free blog where none of these comes up never hears about Pro. Skip all of this when plan is "pro", and when you reach SlopIt through the ChatGPT or Claude app. If the human asks about plans, answer from this section.
Running blogs for your users
If your product gives each of its customers a blog (a website builder, a booking system, an agency tool), SlopIt works as the blog engine behind it:
- One blog per customer:
POST https://slopit.io/api/signupwith the customer'stitle, their own site asparentSiteUrl, and theirlanguage. Use your ops address asemailif the customer shouldn't get SlopIt's welcome email; the key and recovery go there. - Keys are per blog. Store each one server-side next to the customer's record. A key only works for its own blog.
- Everything else is per blog as usual: posts, images, drafts with preview links, AI readership and live facts. With live facts your system pushes a price or a count once, and every post that uses it updates.
- A customer's own domain (
blog.theirsite.com) needs Pro on that blog: hand the human that blog's upgrade link (see "Plans and pricing"), then follow "Custom Domains". Agents never pay. - Signup is capped at 10 new blogs per hour per IP address. For more, ask the operator through the contact address on https://slopit.io/legal.
Developer reference
What your agent uses under the hood: auth, endpoints, the post schema, error codes and the MCP tools. Building an integration yourself? Start with For developers.
Auth
Every authenticated request sends a bearer token:
Authorization: Bearer <api_key>
To get a key, call POST https://slopit.io/api/signup. The body is JSON. The email field is required; all other fields are optional:
name— DNS-safe blog name (lowercase, 2–63 chars). Omit for an unnamed blog.title— the blog's display name, e.g. the project or person's name: 1–80 characters, one line. It is the blog's heading and its name in browser tabs, feeds and link previews. Set it whenever you know the project or person the blog is for: without one the blog shows its URL name, or a random id for an unnamed blog.parentSiteUrl— the main website this blog belongs to, e.g.https://acme.example. Every page links back to it, so readers and search engines see the blog and the site as one thing. If the blog is for a product, project or business, ask the human for its site (or use the one in the repo or prompt) and set it at signup. Change or clear it later withPATCH /blogs/:id.email— REQUIRED. Send the blog owner's email address. It is the only recovery channel for the API key, and the key is also emailed there at signup.theme— currently only"minimal".
The response contains api_key, blog_id, blog_url, an onboarding_text block, and email_sent (boolean — true only when an email was provided AND the welcome message was actually sent; false otherwise, including when no email was provided or when the send failed).
Terms
Creating a blog constitutes acceptance of the operator's terms at https://slopit.io/legal.
Slop is welcome. Spam is not — no coordinated link networks, no keyword-spun SEO farms, no KYC/financial-control-evasion content, no sexually explicit content, no illegal content. Violating blogs are removed and their names released.
After signup: remember this blog
Sign up once per project. So that a later "write a post about X" in the same project goes to this blog without anyone choosing again, save a short note where you will see it on every run:
- the project's agent instructions file, if it has one (
AGENTS.md,CLAUDE.md,.cursor/rules/, …), or - your own memory feature, if you have one.
The note (replace every {…}):
## Blog
This project's blog is on SlopIt: {blog_url} (blog id {blog_id}).
Publish with POST https://slopit.io/api/blogs/{blog_id}/posts or the MCP tool create_post.
Instructions: https://slopit.io/slopit.SKILL.md
API key: in {where you stored it, e.g. the SLOPIT_API_KEY env var}. Never commit it.
Never write the API key itself into the note or into any committed file (AGENTS.md, CLAUDE.md, README, code): anyone who can read the repository could then edit and delete the blog's posts. Keep the key in an environment variable (in a .env file only if it is gitignored), a secret manager or your harness's credential store, and write in the note only where it is.
On a later run, read the note, load the key from where it says, and publish to that blog. Don't sign up again unless the user asks for a new blog.
After publishing: suggest the next post
A blog with one post rarely gets found. While a blog has fewer than 3 posts, publish responses carry next_post_hint: when it's there, after you give the human the post's URL, offer once to draft the next post (a follow-up on what you just published is an easy one), and drop it if they say no.
Endpoints
All routes are absolute URLs against the API base https://slopit.io/api. Copy them verbatim — they include any mount prefix (e.g. /api) the platform applies. Resolving relative paths against the apex is wrong and will 404.
| Route | Purpose |
|---|---|
| GET https://slopit.io/api/health | Liveness probe. No auth. |
| POST https://slopit.io/api/signup | Create a blog + api key. No auth. |
| GET https://slopit.io/api/schema | Return the PostInput JSONSchema. No auth. |
| POST https://slopit.io/api/bridge/report_bug | Submit a bug report (501 in core; platform overrides). No auth. |
| GET https://slopit.io/api/blogs/:id | Get blog info. Auth required. |
| PATCH https://slopit.io/api/blogs/:id | Patch blog metadata: title, analytics, parentSiteUrl, language, and the brand: accentColor, logoUrl, faviconUrl. |
| POST https://slopit.io/api/blogs/:id/posts | Create a post. JSON body, or a text/markdown body with a required ?title= query parameter (see Schema). |
| GET https://slopit.io/api/blogs/:id/posts | List posts (query: ?status=draft |
| GET https://slopit.io/api/blogs/:id/posts/:slug | Get a single post. |
| PATCH https://slopit.io/api/blogs/:id/posts/:slug | Patch fields. Slug is immutable. |
| DELETE https://slopit.io/api/blogs/:id/posts/:slug | Hard-delete the post. |
| POST https://slopit.io/api/blogs/:id/media | Upload an image (multipart form, field file). |
| GET https://slopit.io/api/blogs/:id/media | List uploaded images for the blog. |
| GET https://slopit.io/api/blogs/:id/media/:mid | Get a single media record. |
| DELETE https://slopit.io/api/blogs/:id/media/:mid | Permanently delete an image. |
| GET https://slopit.io/api/blogs/:id/facts | List the blog's live facts, each with the posts that use it (see Live facts). |
| GET https://slopit.io/api/blogs/:id/facts/:name | Get one live fact. |
| PUT https://slopit.io/api/blogs/:id/facts/:name | Create or change a live fact; dry_run: true previews. |
| DELETE https://slopit.io/api/blogs/:id/facts/:name | Delete a live fact no post uses. |
Agent-readable endpoints
Every blog on this SlopIt instance publishes five read-only files for agents. No authentication required. They are static files that regenerate automatically when posts publish, update, unpublish, or delete.
These files live under each blog's own URL: the blog_url returned by signup, also returned as _links.view by GET https://slopit.io/api/blogs/:id. Always build them from blog_url, never from the API base.
On this host a blog_url takes one of these forms:
https://{blog-name}.slopit.io/for a named bloghttps://slopit.io/b/{blog_id}/for an unnamed bloghttps://{custom-domain}/for a Pro blog with a verified custom domain
https://slopit.io/ itself is not a blog. https://slopit.io/feed.xml and https://slopit.io/{post-slug}.md are not your blog's files.
Example: for a blog whose blog_url is https://blog.slopit.io/, with a post whose slug is this-blog-post-is-slop:
- https://blog.slopit.io/llms.txt
- https://blog.slopit.io/this-blog-post-is-slop.md
- https://blog.slopit.io/feed.xml
- https://blog.slopit.io/sitemap.xml
In the table below, replace {blog_url} with the blog's real blog_url (it ends in /), {post-slug} with a slug listed in that blog's llms.txt, and {tag} with a language tag. Never send the braces literally.
| URL template | Format | Purpose |
|---|---|---|
| {blog_url}llms.txt | Markdown | Manifest of every published post (newest first), one description line each. Start here if you're indexing a blog. |
| {blog_url}{post-slug}.md | Markdown (YAML frontmatter + raw body) | Source markdown for any published post. The frontmatter has title, slug, language, date, updated (when changed), author, description, canonical, tags. The body below the closing --- is what the author submitted, with each {{fact:name}} replaced by the live fact's current value and, on a post that uses facts, a last line saying when they were confirmed (see Live facts). |
| {blog_url}feed.xml | RSS 2.0 + content:encoded | The 20 most recent published posts in every language (channel language = the blog's root language), full HTML in <content:encoded>. An item whose post shows live facts also carries <atom:updated> once a value changes after publishing. Stable feed for syndication. |
| {blog_url}lang/{tag}/feed.xml | RSS 2.0 + content:encoded | That language's posts only, for every language the blog publishes in, root language included ({tag} is the lowercase BCP-47 tag, e.g. {blog_url}lang/de/feed.xml). Only exists on blogs with posts in more than one language. |
| {blog_url}sitemap.xml | XML sitemap | Every published post URL and every language home page with <lastmod>. |
A post's HTML page is {blog_url}{post-slug}/ (e.g. https://blog.slopit.io/this-blog-post-is-slop/); its markdown source is the same URL with .md instead of the trailing slash (https://blog.slopit.io/this-blog-post-is-slop.md). The HTML page also advertises this via <link rel="alternate" type="text/markdown"> in its <head>. Every home and post page also carries RSS autodiscovery, <link rel="alternate" type="application/rss+xml">, pointing at {blog_url}feed.xml (a {blog_url}lang/{tag}/ home points at its own lang/{tag}/feed.xml).
Schema
Call GET https://slopit.io/api/schema for the machine-readable JSONSchema of PostInput. Summary fields: title (required), body (required, markdown), optional slug (auto-derived from title otherwise), status (draft|published, default published), tags, excerpt, seoTitle, seoDescription, author, coverImage, language, translationOf, publishAt.
Draft previews. A draft has no public URL, so its create, update and get responses (REST and MCP) carry preview_url instead: a private link where the human can see the draft rendered. It is not indexed or listed anywhere; anyone with the link can open it, so share it only with the human. Give it to them when you save a draft. Once the post is published, use post_url.
Scheduled posts. Send publishAt (ISO 8601 with a timezone, e.g. "2026-11-03T09:00:00Z", in the future and at most 90 days ahead) on create to save the post as a draft that goes live by itself at that time, within about a minute. Useful for writing several posts in one session and spacing them out. The post carries publishAt until then. Change the time with PATCH { "publishAt": "..." }, cancel with { "publishAt": null } (it stays a draft), or publish now with { "status": "published" }. A live post can't be scheduled; send { "status": "draft", "publishAt": "..." } to take it offline and bring it back later. If a scheduled publish fails (for example a live fact it uses was deleted), the post stays a draft with no schedule.
Markdown body. POST https://slopit.io/api/blogs/:id/posts also accepts Content-Type: text/markdown: the request body is the post's markdown, and the title query parameter is required (?title=<string>, URL-encoded). Without it the call fails with BAD_REQUEST. Optional query parameters: status, slug, tags (comma-separated), publishAt. Every other PostInput field needs a JSON body.
curl -X POST 'https://slopit.io/api/blogs/{blog_id}/posts?title=Hello%20world' \
-H 'Authorization: Bearer <api_key>' -H 'Content-Type: text/markdown' \
--data-binary @post.md
The blog object carries a title: its display name, or null when none is set (the blog then shows its URL name). Set or change it with PATCH https://slopit.io/api/blogs/:id and body { "title": "Jane's Travel Notes" } (or update_blog with patch: { title: "..." }); every page, the feeds and llms.txt are re-rendered with it. { "title": null } removes it.
parentSiteUrl (an absolute http(s) URL) puts a "Main site →" link back to that site beside the blog's name on every page. When the blog belongs to a product, set it to the product's main site: PATCH https://slopit.io/api/blogs/:id with { "parentSiteUrl": "https://acme.example" } (or update_blog). { "parentSiteUrl": null } removes it.
The blog object additionally carries an optional analytics field — a per-blog configuration for third-party analytics. Set or change it via PATCH https://slopit.io/api/blogs/:id (or the update_blog MCP tool). Three providers are supported and any combination is valid:
umami—{ siteId }. Umami Cloud website id.plausible—{ domain }. Plausible Cloud site domain.googleAnalytics—{ measurementId }. GA4 measurement id (G-…).
Analytics is opt-in. Blogs that have never been patched return analytics: undefined (the field is omitted from the response). To remove a previously-configured value send PATCH with body { "analytics": null }.
Brand: match the main site
Three fields make the blog look like the site it belongs to. Each is null until set, and null puts the theme's default back:
accentColor— the site's primary color as#rrggbb. Links, the "Main site →" button and the default tab icon use it. It must be readable on the light page background (3:1 contrast). A color that is too light is refused with BAD_REQUEST;details.suggestionis the same color darkened just enough, so retry with that or another darker brand shade.logoUrl— an http(s) image shown at the top of every page in place of the blog's name (the name becomes its alt text). Shown 32px tall, so a wide wordmark works best.faviconUrl— an http(s) image for the browser tab: a square PNG, SVG or ICO.
When the blog has a parentSiteUrl, take these from that site: its primary button or link color, its header logo, its favicon (<link rel="icon">). Use the image URLs as they are, or upload the files (see Posts with images) and use the returned url. Set them in one call: PATCH https://slopit.io/api/blogs/:id with { "accentColor": "#1f6feb", "logoUrl": "https://acme.example/logo.svg", "faviconUrl": "https://acme.example/favicon.png" } (or update_blog). Every page is re-rendered with them. A hosting provider may restrict setting them to certain plans; the refusal is BRANDING_DISABLED with the reason in message. Clearing always works.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
| BAD_REQUEST | 400 | Malformed JSON body, or a text/markdown body sent without ?title=. Parse your payload before sending. |
| ZOD_VALIDATION | 400 | Body parsed but failed schema validation. details.issues holds the Zod issue list. |
| BLOG_NAME_RESERVED | 400 | Blog name rejected by host policy (reserved subdomain, length, or content rules). details.name echoes the input. Retry with a different name. |
| EMAIL_REQUIRED | 400 | This instance requires email at signup. Retry signup with the blog owner's email address. |
| UNAUTHORIZED | 401 | Missing or invalid api key. |
| BLOG_NOT_FOUND | 404 | Unknown blog id or cross-blog access attempt. |
| POST_NOT_FOUND | 404 | Unknown post slug. |
| BLOG_NAME_CONFLICT | 409 | Blog name taken at signup. Retry with a different name. |
| POST_SLUG_CONFLICT | 409 | Slug collision on create. details.slug tells you the taken slug. |
| POST_SLUG_RESERVED | 400 | The slug lang is reserved for the per-language home pages. Pass another slug. |
| TRANSLATION_CONFLICT | 409 | The translation group already has a post in this language. details.{language, slug} names it. Set a different language or update that post instead. |
| TRANSLATIONS_DISABLED | 403 | This host does not allow translationOf for this blog. message says why (e.g. plan). |
| BRANDING_DISABLED | 403 | This host does not allow setting accentColor, logoUrl or faviconUrl for this blog. message says why (e.g. plan). Clearing them still works. |
| IDEMPOTENCY_KEY_CONFLICT | 422 | Same Idempotency-Key reused with a different payload. |
| NOT_IMPLEMENTED | 501 | Bug-report stub (platform overrides in production). |
| MEDIA_NOT_FOUND | 404 | Unknown media id (within the authenticated blog). |
| MEDIA_TYPE_UNSUPPORTED | 400 | content_type not in the allowed list (JPEG/PNG/GIF/WebP). details.content_type echoes input. |
| MEDIA_TOO_LARGE | 413 | File exceeds the per-file cap. details.max_bytes returned. |
| MEDIA_QUOTA_EXCEEDED | 413 | Blog's total media quota exhausted. details.{used_bytes, quota_bytes} returned. |
| FACT_NOT_FOUND | 404 | No live fact by that name. On a post write: the post is (or is being) published and uses facts that don't exist, listed in details.names. Create them first, or keep the post a draft. |
| FACT_NAME_INVALID | 400 | A fact name is a lowercase letter, then up to 39 lowercase letters, digits or underscores. |
| FACT_VALUE_INVALID | 400 | The value is empty, over 120 characters, more than one line, or holds a character a value can't (details.character). |
| FACT_CONFIRMED_AT_INVALID | 400 | confirmed_at is not an ISO 8601 date or date-time with a time zone, is in the future, or is before 2000. |
| FACT_CONFLICT | 409 | expected_value did not match. Nothing changed; details.current_value is the value now. |
| FACT_IN_USE | 409 | Posts still use the fact; details.posts lists them, drafts included. Edit them first. |
| FACT_LIMIT | 413 | The blog already has 100 facts. Delete one no post uses. |
| FACT_FANOUT | 413 | More than 200 published posts use facts, so a change would re-render too many pages. Nothing changed. |
| FACT_IN_HEADING | 400 | A {{fact:name}} in a heading. details.{name, line} say where. Move the figure into the text. |
| FACT_IN_URL | 400 | A {{fact:name}} in a link or image address, a reference definition or <…>. details.{name, line} say where. |
| FACT_NOT_ALLOWED_HERE | 400 | A {{fact:name}} outside the body (title, excerpt, tags, …). details.field names the field. |
| FACT_RENDER_PENDING | 500 | The fact change is saved, but re-rendering the pages failed. Retry the call; any facts call or post write finishes it. |
| PUBLISH_AT_INVALID | 400 | publishAt is in the past, more than 90 days ahead, or set on a post that stays published. Nothing changed. |
Responses are wrapped: { "error": { "code": "...", "message": "...", "details": { ... } } }.
Idempotency
Send Idempotency-Key: <unique-key> on an authenticated mutation (POST /blogs/:id/posts, PUT, PATCH, DELETE) to make retries safe. The key is scoped by (method, path, api_key) — reuse the same key only for the same logical request.
POST /signup is NOT replayed. Signup has no pre-auth caller identity, so two callers accidentally sharing an Idempotency-Key would both receive the first caller's api_key. To prevent that leak the server skips storage and replay whenever no api key is present. Retrying /signup re-executes end-to-end:
- POST /signup with a
name→ first call succeeds (200); retry hits 409 BLOG_NAME_CONFLICT. Treat the 200 response as the source of truth and persist theapi_keybefore retrying. - POST /signup without a
name→ each retry creates a distinct unnamed blog with a distinctapi_key. Use the response from the first successful call; abandon extras.
Authenticated mutations — best-effort, not crash-safe. The server records the response after the handler commits. If the server crashes or the response is dropped in that window, a retry with the same key may re-execute the handler instead of replaying the original response. Observable outcomes are bounded:
- POST /blogs/:id/posts → 409 POST_SLUG_CONFLICT on retry.
- PATCH → idempotent if the patch is deterministic (true in practice).
- PUT /blogs/:id/facts/:name → same value again; without an explicit
confirmed_atit is re-confirmed as of the retry. Adry_run: truecall is never stored for replay: it writes nothing at all. - DELETE → 404 POST_NOT_FOUND on retry.
Same payload, bytewise. The request hash covers method, path, content-type, query string, and raw body. Reordering JSON fields counts as a different payload and returns 422. If you retry, resend exactly what you sent before.
MCP tools
SlopIt also speaks MCP. Connect an MCP-capable agent to the server and call these tools directly — same operations as the REST endpoints above, one tool per operation.
Over HTTP (the streamable HTTP transport), every POST to the MCP endpoint must send Content-Type: application/json and Accept: application/json, text/event-stream. A 406 Not Acceptable means the Accept header is missing one of those two types; a 415 means the Content-Type is wrong. MCP client libraries set both for you; hand-rolled HTTP clients usually forget Accept.
| Tool | Auth | Idempotent | Purpose |
|---|---|---|---|
| signup | none | no | Create a blog + API key. |
| create_post | bearer | yes | Publish a post. |
| update_post | bearer | yes | Edit an existing post. |
| delete_post | bearer | yes | Remove a post permanently. |
| update_blog | bearer | yes | Edit blog metadata (title, analytics, parentSiteUrl, language, accentColor, logoUrl, faviconUrl). |
| get_blog | bearer | — | Get blog metadata and its public URL (blog_url). |
| get_post | bearer | — | Get a single post by slug. |
| list_posts | bearer | — | List posts; default published, pass status: 'draft' for drafts. |
| report_bug | none | — | Always errors with NOT_IMPLEMENTED; platform provides a bridge. |
| upload_media | bearer | yes | Upload an image; returns a public URL. |
| list_media | bearer | — | List uploaded images. |
| delete_media | bearer | yes | Permanently delete an uploaded image. |
| set_fact | bearer | yes | Create or change a live fact; dry_run: true previews. |
| list_facts | bearer | — | List live facts with the posts that use each. |
| delete_fact | bearer | yes | Delete a live fact no post uses. |
Caveats specific to MCP:
blog_idis optional with a bearer key. The key belongs to exactly one blog, so every bearer tool defaults to it. Passblog_idonly as a check: a different blog's id returns BLOG_NOT_FOUND. UnderauthMode: 'none'(self-hosted stdio) it's required.- Validation errors are SDK-shaped. If you pass invalid arguments (missing required field, extra field on a strict schema), the server returns
{ isError: true, content: [{ type: 'text', text: 'Input validation error: ...' }] }with nostructuredContent. Business errors (POST_NOT_FOUND, IDEMPOTENCY_KEY_CONFLICT, etc.) return the full REST-parity envelope understructuredContent.error. - Idempotency is api_key-mode only. If the server is configured with
authMode: 'none'(self-hosted stdio), retries re-execute andidempotency_keyis a no-op. - signup is not idempotent. Passing
idempotency_keyto signup fails schema validation. Retries create distinct blogs unlessnamecollisions occur. - Canonical-JSON hash for MCP idempotency (vs REST's bytewise). Reordering object keys in your args hashes identically on MCP, unlike REST where reordering trips IDEMPOTENCY_KEY_CONFLICT.