Push and manage articles in Markdown with one API key. You never need to know Payload's Lexical JSON. Every write goes through Payload as you, so contributor / editor / admin rules still apply.

Machine-readable spec: OpenAPI 3.1. This page as Markdown: /developers.md.

Get an API key

Ask your editor for an account. An admin creates the user in this installation; user creation is restricted to admins:

  1. Email, name, password as usual.
  2. Role Contributor (the default). Contributors can create and edit their own drafts; they cannot publish.
  3. Tick Is agent so the audit trail marks the account as automated.
  4. Tick Enable API key and copy the key. Payload shows it once.

Editors and admins get the same header; they can publish.

Auth headers

Official Payload header:

Authorization: users API-Key <key>

Also accepted:

Authorization: Bearer <key>

Every /api/v1 route except /api/v1/openapi.json returns 401 when the caller is not a users account.

Editorial rules

  • Contributors (including agents) create and edit their own drafts only. A publish attempt is 403.
  • Editors and admins can publish, unpublish, and delete any article.
  • News should include sources. The API still saves without them, but the response warnings array will say no sources.
  • Drafts wait for editor review. Use status: "draft" (the default) unless your key belongs to an editor.
  • Updates write only the fields you send. Omit status and an edit to a live article is saved as a draft revision; the live version does not change until an editor publishes it (or an editor key sends status: "published").
  • Titles that contain no Latin letters or digits cannot produce a slug: send an explicit kebab-case slug.

Rate limits

Per user id, in memory, per process (single-instance v1):

  • 60 writes per minute (POST, PATCH, DELETE)
  • 600 reads per minute (GET)

On breach: 429 with a Retry-After header (seconds).

Error body

{
  "error": {
    "code": "validation_error",
    "message": "…",
    "details": {}
  }
}

Codes: validation_error (422), unauthorized (401), forbidden (403), not_found (404), rate_limited (429), conflict (409), internal (500).

The posts editor supports H2–H4 and links. Unsupported lists, tables and code retain their text as paragraphs. Body images become descriptive links; hero images import into media.

Article JSON

Required on create: title (≤160), excerpt (≤320), markdown.

{
  "title": "string (required on create, ≤160)",
  "slug": "optional kebab-case",
  "type": "news | analysis | guide | tutorial (default news)",
  "excerpt": "string ≤320 (required on create)",
  "markdown": "GitHub-flavoured Markdown body (required on create). Lead H1 is stripped if it equals the title.",
  "keyTakeaways": ["string ≤280"],
  "faqs": [{ "question": "…", "answer": "…" }],
  "sources": [{ "title": "…", "url": "https://…", "publisher": "…" }],
  "categories": ["slug or title"],
  "tags": ["slug or title"],
  "authors": ["author slug"],
  "heroImage": { "url": "https://…", "alt": "…" },
  "meta": { "title": "≤60 recommended", "description": "≤160 recommended", "canonicalUrl": "…", "noindex": false },
  "featured": false,
  "publishedAt": "ISO",
  "lastReviewedAt": "ISO",
  "status": "draft | published (default draft)"
}

heroImage may also be { "id": 12 }. Categories and tags are matched by slug, then by case-insensitive title; missing ones are created. Unknown author slugs return 422 listing the valid ones.

Write response:

{
  "id": 1,
  "slug": "my-article",
  "type": "news",
  "status": "draft",
  "url": "https://dotsbot.co/news/my-article",
  "previewUrl": "/next/preview?…",
  "adminUrl": "/admin/collections/posts/1",
  "created": true,
  "warnings": ["no sources", "no keyTakeaways", "fewer than 300 words"]
}

Non-fatal warnings: no sources, no keyTakeaways, meta.description missing or >160, excerpt >200, fewer than 300 words.

Endpoints

Base URL examples use https://dotsbot.co. Replace <key> with your API key.

GET /api/v1/me

curl -sS https://dotsbot.co/api/v1/me \
  -H "Authorization: users API-Key <key>"

Returns { id, name, email, roles, isAgent }.

GET /api/v1/articles

Query: status (draft / published / any; default any), type, category, tag, since (ISO date on updatedAt), limit (≤100), page.

curl -sS "https://dotsbot.co/api/v1/articles?status=draft&limit=20" \
  -H "Authorization: users API-Key <key>"

Returns { docs, page, totalPages, totalDocs }.

GET /api/v1/articles/:slug

Add ?draft=true to read the latest draft.

curl -sS "https://dotsbot.co/api/v1/articles/my-article?draft=true" \
  -H "Authorization: users API-Key <key>"

POST /api/v1/articles

Upsert by slug (derived from title when omitted). 201 when created, 200 when updated.

curl -sS -X POST https://dotsbot.co/api/v1/articles \
  -H "Authorization: users API-Key <key>" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "OpenAI Dots in Slack",
    "type": "news",
    "excerpt": "What shipped this week.",
    "markdown": "## What changed\n\nDots can now…",
    "sources": [{ "title": "OpenAI", "url": "https://openai.com" }],
    "categories": ["dots"],
    "status": "draft"
  }'

PATCH /api/v1/articles/:slug

Partial update: only the fields in the body are written. Without status, changes to a published article become a draft revision awaiting publication.

curl -sS -X PATCH https://dotsbot.co/api/v1/articles/my-article \
  -H "Authorization: users API-Key <key>" \
  -H "Content-Type: application/json" \
  -d '{ "excerpt": "Updated dek." }'

POST /api/v1/articles/:slug/publish

Sets _status: published. Contributors get 403.

curl -sS -X POST https://dotsbot.co/api/v1/articles/my-article/publish \
  -H "Authorization: users API-Key <key>"

POST /api/v1/articles/:slug/unpublish

Editors only.

curl -sS -X POST https://dotsbot.co/api/v1/articles/my-article/unpublish \
  -H "Authorization: users API-Key <key>"

DELETE /api/v1/articles/:slug

Editors only.

curl -sS -X DELETE https://dotsbot.co/api/v1/articles/my-article \
  -H "Authorization: users API-Key <key>"

POST /api/v1/media

JSON { url, alt } imports from a URL. multipart/form-data with file + alt uploads a file. Returns { id, url, alt, width, height }.

URL imports only allow http:/https: on ports 80/443. Private, loopback, link-local, CGNAT, multicast, reserved, IPv4-mapped private, and ULA addresses are rejected (SSRF). Max 10 MB, 15s timeout, image magic bytes required.

curl -sS -X POST https://dotsbot.co/api/v1/media \
  -H "Authorization: users API-Key <key>" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/hero.png", "alt": "Dots in Slack" }'

GET /api/v1/taxonomy

curl -sS https://dotsbot.co/api/v1/taxonomy \
  -H "Authorization: users API-Key <key>"

Returns { categories:[{slug,title}], tags:[{slug,title}], authors:[{slug,name}] }.

GET /api/v1/openapi.json

Public. No API key.

curl -sS https://dotsbot.co/api/v1/openapi.json

MCP

The official Payload MCP server is at /api/mcp. MCP keys are separate from users API keys: an admin creates one under Agents → MCP API keys, bound to a user. Custom tools: upsert_article, get_article, list_articles, import_image. They run as that user with overrideAccess: false.

Claude Code

claude mcp add --transport http dotsbot https://dotsbot.co/api/mcp --header "Authorization: Bearer <key>"

Cursor

{
  "mcpServers": {
    "dotsbot": {
      "url": "https://dotsbot.co/api/mcp",
      "headers": {
        "Authorization": "Bearer <key>"
      }
    }
  }
}

VS Code

Place this in .vscode/mcp.json:

{"servers":{"dotsbot":{"type":"http","url":"https://dotsbot.co/api/mcp","headers":{"Authorization":"Bearer <key>"}}}}

Codex

Set DOTSBOT_MCP_KEY in your environment. In ~/.codex/config.toml:

[mcp_servers.dotsbot]
url = "https://dotsbot.co/api/mcp"
bearer_token_env_var = "DOTSBOT_MCP_KEY"

Markdown importer CLI

DOTSBOT_API_KEY=<key> bun scripts/push-articles.ts ./articles [--api https://dotsbot.co] [--publish] [--dry-run]

YAML frontmatter fields: title, slug, type, excerpt, metaTitle, metaDescription, categories[], tags[], keyTakeaways[], faqs[{question,answer}], sources[{title,url,publisher}], authors[], heroImage, heroImageAlt.