← Docs
Developers

API Reference

Headless-CMS API v1

Overview

Bueno doubles as a headless CMS: the posts you publish in Content Factory are readable over a small JSON API, so you can render your own blog listing, category, guide, and post pages on any stack, and a key with the write scope can create and update posts and manage categories and series too.

Every read endpoint is a GET, returns JSON, and is scoped to the single project that owns the API key you send. Read responses carry permissive CORS headers (access-control-allow-origin: *) and each read route answers OPTIONS preflight, so calling them straight from the browser works, but note that a key in client-side code is public to anyone who reads it. Prefer calling from your server or build step.

Writes are server-side only, and enforced as such: no CORS headers, no OPTIONS preflight, and a request carrying an Origin header (which every browser sends and a server-to-server call doesn't) is rejected with 403. Keep write keys in your backend environment.

Base URL:

https://usebueno.com/api/v1

Only published posts are ever returned. Drafts and future-dated posts stay hidden.

Authentication

Create a key in Bueno under Settings → API Keys → API Access. The token is shown once, at creation. Only its hash is stored, so it can never be recovered. Keys look like bk_live_… and are project-scoped; delete and re-create one to rotate.

Send it on every request as a bearer token:

Authorization: Bearer bk_live_xxxxxxxx

Errors

Errors come back as JSON with an error string and a matching status code.

NameTypeDescription
401Missing bearer tokenNo Authorization header, or it isn't a Bearer token.
401Invalid API keyThe token doesn't match a live key. It may have been deleted.
403This API key is read-onlyThe key doesn't have the write scope. Turn on "Allow write" for it in Settings.
403Write endpoints are server-side onlyThe write request carried an Origin header, so it came from a browser page.
404Not foundNo published post (or, on the category endpoints, no category) in this project matches the requested slug.
409…already existsThe category name is taken among its siblings, or the slug is taken anywhere in the project.
400Invalid bodyThe JSON body is malformed or a field failed validation (the message names it).
{ "error": "Invalid API key" }

List posts

GET/api/v1/posts

Published posts, newest first (by publishedAt, falling back to createdAt), paginated. Post bodies are omitted here. Fetch a single post to get its Markdown.

NameTypeDescription
categorystringFilter by category slug (the slug returned by /categories). A parent slug also returns the posts in its subcategories. An unknown slug returns an empty page, not all posts.
tagstringFilter by exact tag name (case-sensitive).
limitnumberPage size, clamped to 1-100. Defaults to 20.
offsetnumberRows to skip. Defaults to 0.
curl "https://usebueno.com/api/v1/posts?category=engineering&limit=10" \
    -H "Authorization: Bearer $BUENO_API_KEY"
{
    "posts": [
      {
        "title": "Shipping faster with Bueno",
        "description": "How we cut our publishing loop in half.",
        "slug": "shipping-faster-with-bueno",
        "coverImageUrl": "https://usebueno.com/api/cms/assets/covers/abc123.jpg",
        "tags": ["product", "engineering"],
        "category": { "name": "Engineering", "slug": "engineering" },
        "author": {
          "name": "Ada Lovelace",
          "avatarUrl": "https://usebueno.com/api/cms/assets/authors/ada.jpg",
          "bio": "Writes about tooling."
        },
        "publishedAt": "2026-08-01 09:00:00",
        "createdAt": "2026-07-28 11:24:10"
      }
    ],
    "total": 42,
    "limit": 10,
    "offset": 0
  }

total is the count of posts matching the filters (not the page), so you can paginate until offset + limit ≥ total.

Get a post

GET/api/v1/posts/{slug}

One published post by slug, including its Markdown body. Returns 404 when the slug isn't a published post in this project.

Every response carries seriesList: the series this post belongs to, each with its position and total, so a page can render “Part 3 of 8” without a second call. Add ?series=<slug> to also get a series object with prev and next: everything a guide's “← Previous / Next →” footer needs, without pulling the whole series on every post view. It's absent when the post isn't a published member of that series.

curl "https://usebueno.com/api/v1/posts/shipping-faster-with-bueno" \
    -H "Authorization: Bearer $BUENO_API_KEY"
{
    "post": {
      "title": "Shipping faster with Bueno",
      "description": "How we cut our publishing loop in half.",
      "slug": "shipping-faster-with-bueno",
      "coverImageUrl": "https://usebueno.com/api/cms/assets/covers/abc123.jpg",
      "tags": ["product", "engineering"],
      "category": { "name": "Engineering", "slug": "engineering" },
      "author": { "name": "Ada Lovelace", "avatarUrl": null, "bio": null },
      "publishedAt": "2026-08-01 09:00:00",
      "createdAt": "2026-07-28 11:24:10",
      "body": "## The old loop\n\nWe used to...",
      "seriesList": [
        { "name": "Getting Started", "slug": "getting-started", "position": 2, "total": 8 }
      ],
      "series": {
        "name": "Getting Started",
        "slug": "getting-started",
        "position": 2,
        "total": 8,
        "section": "Part 2: Going deeper",
        "prev": { "title": "Your first project", "slug": "your-first-project" },
        "next": { "title": "Scheduling a post", "slug": "scheduling-a-post" }
      }
    }
  }

List categories

GET/api/v1/categories

Every category in the project with its published-post count. Empty categories are included with posts_count: 0, so you decide whether to show them. Subcategories come back in the same flat list, each carrying the parent_slug it hangs off. Group by it to render the tree. A parent's posts_count includes the posts in its subcategories.

curl "https://usebueno.com/api/v1/categories" \
    -H "Authorization: Bearer $BUENO_API_KEY"
{
    "categories": [
      {
        "name": "Engineering",
        "slug": "engineering",
        "description": "Build notes and postmortems.",
        "posts_count": 12
      },
      {
        "name": "Postmortems",
        "slug": "postmortems",
        "description": "What broke, and why.",
        "parent_slug": "engineering",
        "posts_count": 3
      }
    ]
  }

List series

GET/api/v1/series

A series is a curated, ordered run of posts: a guide, a multi-part write-up. Where a category groups posts by subject and leaves them unordered, a series is a hand-picked reading order, so it can mix categories freely.

posts_count counts only the published members (the ones GET /series/{slug} will actually return), so an all-draft guide reads as empty instead of looking full and then coming back with nothing.

curl "https://usebueno.com/api/v1/series" \
    -H "Authorization: Bearer $BUENO_API_KEY"
{
    "series": [
      {
        "name": "Getting Started",
        "slug": "getting-started",
        "description": "Everything you need in your first week.",
        "posts_count": 8
      }
    ]
  }

Get a series

GET/api/v1/series/{slug}

One series with its posts in reading order: the guide's index page. Render them in the order returned; don't re-sort.

Only published posts come back, and position is re-indexed over them, so “part 3 of 8” always matches what a reader can open. A draft parked mid-run doesn't leave a hole in the numbering. Each entry is a Post summary (no body) plus position and an optional section: a group heading, where consecutive entries sharing one belong under it. Returns 404 for an unknown slug.

curl "https://usebueno.com/api/v1/series/getting-started" \
    -H "Authorization: Bearer $BUENO_API_KEY"
{
    "series": {
      "name": "Getting Started",
      "slug": "getting-started",
      "description": "Everything you need in your first week.",
      "posts_count": 2,
      "posts": [
        {
          "title": "Your first project",
          "slug": "your-first-project",
          "description": "Set up in five minutes.",
          "position": 0,
          "section": "Part 1: Basics",
          "tags": [],
          "publishedAt": "2026-08-01 09:00:00",
          "createdAt": "2026-07-28 11:24:10"
        },
        {
          "title": "Scheduling a post",
          "slug": "scheduling-a-post",
          "description": "Publish on your own clock.",
          "position": 1,
          "tags": [],
          "publishedAt": "2026-08-03 09:00:00",
          "createdAt": "2026-08-02 10:00:00"
        }
      ]
    }
  }

Create a post

POST/api/v1/posts

Creates a post in this project. Requires a key with Allow write turned on (Settings → Project → API Access) and must be called from your server (see Overview). Only title is required; a post defaults to a draft, so it stays invisible to the read endpoints until you publish it.

NameTypeDescription
titlestringRequired. Also seeds the slug.
slugstring?Preferred slug. Derived from the title when omitted, and de-duplicated either way. Read the slug back off the response.
descriptionstring?Short summary (used for meta tags).
bodystring?Markdown body. Defaults to empty.
status"draft" | "hidden" | "published"Defaults to "draft". Only "published" posts appear on the read endpoints.
publishedAtstring?ISO datetime to schedule the post. Omit to go live as soon as status is "published".
categoryNamestring?Files the post under this category, creating it if the project doesn't have one by that name yet.
authorNamestring?Same, for the author byline.
tagsstring[]?Tag names. Unknown names are registered.
coverImageUrlstring?Absolute URL to a cover image.
curl -X POST "https://usebueno.com/api/v1/posts" \
    -H "Authorization: Bearer $BUENO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "title": "Shipping the new editor",
      "body": "## Why\nWe rebuilt it.",
      "categoryName": "Engineering",
      "tags": ["changelog"],
      "status": "published"
    }'

Responds 201 with the created post, including the slug it was actually given:

{
    "post": {
      "id": "0b1c…",
      "title": "Shipping the new editor",
      "slug": "shipping-the-new-editor",
      "status": "published",
      "categoryName": "Engineering",
      "tags": ["changelog"]
    }
  }

Update a post

PATCH/api/v1/posts/{slug}

Edits a post in place, addressed by its current slug. Unlike the GET, this reaches drafts and hidden posts too, so a post you created as a draft can be filled in and then published. Requires a key with Allow write turned on and must be called from your server (see Overview).

It's a partial update: every field is optional and anything you omit keeps its current value, so sending just body won't wipe the category or tags. Fields that can be emptied take an explicit null to clear them: that's what distinguishes “leave it” from “remove it”. tags replaces the whole list; pass [] to strip them.

NameTypeDescription
titlestring?New title. Can't be empty.
slugstring?New slug, de-duplicated on the way in. Read it back off the response, and remember later calls must address the post by the new slug.
descriptionstring?Short summary. Pass "" to clear.
bodystring?Markdown body. Pass "" to clear.
status"draft" | "hidden" | "published"?Switch to "published" to make the post visible on the read endpoints; "draft" or "hidden" takes it back off them.
publishedAtstring | null?ISO datetime to schedule the post. null clears the schedule: it goes live as soon as status is "published".
categoryNamestring | null?Re-files the post, creating the category if the project doesn't have one by that name. null leaves it uncategorized.
authorNamestring | null?Same, for the byline.
tagsstring[]?Replaces the tag list wholesale. Unknown names are registered.
coverImageUrlstring | null?Absolute URL to a cover image. null removes the cover.
curl -X PATCH "https://usebueno.com/api/v1/posts/shipping-the-new-editor" \
    -H "Authorization: Bearer $BUENO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{ "status": "published", "tags": ["changelog", "editor"] }'

Responds 200 with the updated post, or 404 when no post in the project has that slug:

{
    "post": {
      "id": "0b1c…",
      "title": "Shipping the new editor",
      "slug": "shipping-the-new-editor",
      "status": "published",
      "categoryName": "Engineering",
      "tags": ["changelog", "editor"]
    }
  }

Create a category

POST/api/v1/categories

Creates a category, or a subcategory when you pass parentSlug. Requires a key with Allow write turned on and must be called from your server (see Overview). Nesting is one level deep: a subcategory can't have subcategories.

NameTypeDescription
namestringRequired. Also seeds the slug.
slugstring?Preferred slug. Derived from the name when omitted (suffixed -2, -3… if that's taken); a slug you pass explicitly is never renamed: it returns 409 if taken.
descriptionstring?Defaults to an empty string.
parentSlugstring?Slug of an existing top-level category. Makes the new one a subcategory of it. 404 when the slug is unknown.
curl -X POST "https://usebueno.com/api/v1/categories" \
    -H "Authorization: Bearer $BUENO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "Postmortems",
      "description": "What broke, and why.",
      "parentSlug": "engineering"
    }'

Responds 201 with the category in the same shape GET /categories returns, including the slug it was actually given:

{
    "category": {
      "name": "Postmortems",
      "slug": "postmortems",
      "description": "What broke, and why.",
      "parent_slug": "engineering",
      "posts_count": 0
    }
  }

Update a category

PATCH/api/v1/categories/{slug}

Renames, re-slugs, re-describes, or moves a category, addressed by its current slug. Partial like the post update: omitted fields keep their value. Posts filed under the category stay filed under it, whatever changes. Requires a key with Allow write turned on and must be called from your server.

NameTypeDescription
namestring?New display name. Can't be empty.
slugstring?New slug: rejected with 409 if taken. Renaming without passing a slug re-derives it from the new name (suffixed if that's taken), so the old slug stops resolving.
descriptionstring?Pass "" to clear.
parentSlugstring | null?Moves the category: a slug files it under that top-level category, null promotes a subcategory back to the top level. Omit to leave the nesting alone.

Moves respect the one-level nesting rule, so 400 comes back if the target parent is itself a subcategory, or if the category being moved has subcategories of its own.

curl -X PATCH "https://usebueno.com/api/v1/categories/postmortems" \
    -H "Authorization: Bearer $BUENO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{ "description": "Incidents, in full.", "parentSlug": null }'
{
    "category": {
      "name": "Postmortems",
      "slug": "postmortems",
      "description": "Incidents, in full."
    }
  }

Delete a category

DELETE/api/v1/categories/{slug}

Deletes a category or subcategory by slug. Deleting a parent deletes its subcategories too. Posts are never deleted: the ones filed under any of the removed categories are simply left uncategorized, keeping their body, slug, tags, and publish state. Requires a key with Allow write turned on and must be called from your server. 404 when no category in the project has that slug.

curl -X DELETE "https://usebueno.com/api/v1/categories/engineering" \
    -H "Authorization: Bearer $BUENO_API_KEY"

deleted lists every slug that went away (the category plus any subcategories), so you know which urls stopped resolving:

{ "ok": true, "deleted": ["engineering", "postmortems"] }

Create a series

POST/api/v1/series

Creates an empty series. Requires a key with Allow write turned on (Settings → Project → API Access) and must be called from your server (see Overview). Add its posts with PUT /series/{slug}.

NameTypeDescription
namestringRequired. Display name.
slugstring?URL-safe id. Derived from the name (suffixed on collision) when omitted; a slug you pass that is already taken is a 409, since renaming it silently would break your url.
descriptionstring?Blurb for the guide's index page.
curl -X POST "https://usebueno.com/api/v1/series" \
    -H "Authorization: Bearer $BUENO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"name":"Getting Started","description":"Everything you need in your first week."}'

Set a series' posts

PUT/api/v1/series/{slug}

Replaces the series' posts and their order in one call. Send the whole list: there are no insert or move operations, so the result is exactly what you sent, with no server-side merge to reason about. [] empties the series. Same write-key rules as above.

Posts are addressed by slug. An unknown slug is a 404 rather than a silent skip: a typo here would quietly truncate a published guide. Drafts may be added; they stay hidden until published. An entry can be a bare slug string, or an object with a section heading.

curl -X PUT "https://usebueno.com/api/v1/series/getting-started" \
    -H "Authorization: Bearer $BUENO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"posts":[
          {"slug":"your-first-project","section":"Part 1: Basics"},
          "scheduling-a-post"
        ]}'

Style prompts

GET/api/v1/content-settings

Reads the project's persistent Content Factory style prompts — the ones edited in the app under Content Settings → Style Prompt. These steer every generated post and cover image, distinct from a one-off style field a caller might pass for a single generation. Auth: Authorization: Bearer $BUENO_API_KEY.

curl "https://usebueno.com/api/v1/content-settings" \
    -H "Authorization: Bearer $BUENO_API_KEY"
{
    "blogPrompt": "Write like a founder explaining a decision to a peer. No em dashes.",
    "coverPrompt": "Flat vector illustration, muted pastel palette, no gradients.",
    "coverReferences": ["https://example.com/brand/cover-ref-1.png"]
  }
PATCH/api/v1/content-settings

Updates the style prompts. Partial: an omitted field keeps its stored value; an empty string clears it. Requires a key with Allow write turned on (Settings → Project → API Access) and must be called server-side (see Overview).

NameTypeDescription
blogPromptstring?House style for every generated post. Capped at 2000 characters (truncated, not rejected).
coverPromptstring?House style for every generated cover image. Capped at 2000 characters.
coverReferencesstring[]?http(s) image URLs the cover generator conditions on. Non-http(s) entries are dropped; capped at 4.
curl -X PATCH "https://usebueno.com/api/v1/content-settings" \
    -H "Authorization: Bearer $BUENO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"blogPrompt":"Short paragraphs. Never open with a question."}'

Responds with the full updated state, same shape as the GET above.

Memory

GET/api/v1/content-memory

Lists the project's Content Factory memory: durable style rules, either added by hand or learned from an operator's edits to a generated draft. Only active rules are folded into a generation prompt — learned rules land as suggested and are never applied until approved in the app. Auth: Authorization: Bearer $BUENO_API_KEY.

NameTypeDescription
scopestring?"blog" or "cover". Omit for both.
statusstring?"active", "suggested", or "dismissed". Omit for all.
curl "https://usebueno.com/api/v1/content-memory?scope=blog&status=active" \
    -H "Authorization: Bearer $BUENO_API_KEY"
{
    "memory": [
      {
        "id": "c1b2...",
        "scope": "blog",
        "rule": "Never open a post with a rhetorical question.",
        "source": "learned",
        "status": "active",
        "postId": "8f3e...",
        "createdAt": "2026-08-02 10:14:03"
      }
    ]
  }
POST/api/v1/content-memory

Adds a rule. source is always manual for rules added this way — only the app's own learn loop can mint a learned row. Requires a key with Allow write turned on and must be called server-side.

NameTypeDescription
scopestringRequired. "blog" or "cover".
rulestringRequired. One short imperative constraint.
statusstring?Defaults to "active". Pass "suggested" to file it for review instead of applying it immediately.
curl -X POST "https://usebueno.com/api/v1/content-memory" \
    -H "Authorization: Bearer $BUENO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"scope":"cover","rule":"Never include people in the illustration."}'

Responds 201 with the created rule. A duplicate (case-insensitive, same scope, not dismissed) or an empty rule responds 400 instead of creating a second copy.

PATCH/api/v1/content-memory

Edits a rule's text and/or status by id — this is how an API caller approves a suggested rule ({ "status": "active" }) or dismisses one. Same write-key rules as above. 404 when the id doesn't belong to this project.

curl -X PATCH "https://usebueno.com/api/v1/content-memory" \
    -H "Authorization: Bearer $BUENO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"id":"c1b2...","status":"active"}'
DELETE/api/v1/content-memory

Deletes a rule by ?id=. Same write-key rules as above. 404 when the id doesn't belong to this project.

curl -X DELETE "https://usebueno.com/api/v1/content-memory?id=c1b2..." \
    -H "Authorization: Bearer $BUENO_API_KEY"

Track a page view

POST/api/v1/hits

Reports one page view of your site so Bueno can chart who is actually reading your content: search crawlers, AI crawlers, people arriving from an AI answer, or plain human traffic. Bueno is headless, so the visitor hits your server and never ours; this endpoint is how those visits get here.

Call it from your server, not the browser. GPTBot and Googlebot don't execute JavaScript, so a client-side beacon measures precisely the audience this endpoint exists to look past, and a request carrying an Origin header is rejected with 403 either way. Any valid key works: reporting a page view doesn't need the write scope.

Rate limited to 100 calls per 10 seconds per project. Each call carries up to 200 hits, so batching keeps you well clear of it. Over the limit you get 429.

NameTypeDescription
pathstringRequired. Pathname or a full URL. Send the query string — campaign params (utm_*, ref, gclid, …) are read off it for the UTM breakdown. The stored path is the pathname alone; the fragment is dropped.
uastring?The visitor's User-Agent. Classification runs on this field: omit it and the hit is filed as a bot.
refererstring?The visitor's Referer. Absent is normal and meaningful: it's how direct traffic looks.
atstring | number?ISO datetime or epoch ms. Defaults to now; a value over a day ahead or a year behind falls back to now.
countrystring?ISO-3166 alpha-2: this is what puts geography on your dashboard. On Cloudflare, the cf-ipcountry header.
ipstring?Visitor IP, used to verify that a self-declared crawler really is one. On Cloudflare, the cf-connecting-ip header.
titlestring?The page's own title. Shown under the path in Top Content for URLs Bueno holds no post for: a post's own title always wins.
hitsobject[]?Send up to 200 of the above objects under this key instead of one at the top level.

ip and country are independent: a country is never derived from the IP, so send country if you want geography on the dashboard. The IP is used only to check a claimed crawler against the address ranges search and AI companies publish: a User-Agent saying “Googlebot” is a claim anyone can make. Both fields are optional.

curl -X POST "https://usebueno.com/api/v1/hits" \
    -H "Authorization: Bearer $BUENO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "path": "/blog/shipping-the-new-editor",
      "ua": "Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)",
      "referer": "https://chatgpt.com/"
    }'

The response reports how each hit was classified, which is the quickest way to confirm a new integration is filing traffic the way you expect:

{ "accepted": 1, "bySegment": { "ai_crawler": 1 } }

Batch when you can. An edge middleware that opens a write per page view costs more than the page does:

{ "hits": [
      { "path": "/blog/a", "ua": "…" },
      { "path": "/blog/b", "ua": "…" }
    ] }

Hits are attributed to a post by matching path against the post URL pattern set under Settings → Indexing (for example /blog/{slug}). Hits on other paths are still recorded, with no post attached: finding out an AI crawler spends its time on your pricing page is worth knowing.

Next.js on Cloudflare, via middleware, so crawlers are counted too:

// middleware.ts
  import { getCloudflareContext } from "@opennextjs/cloudflare"
  import { NextResponse, type NextRequest } from "next/server"

  export const config = { matcher: ["/blog/:path*"] }

  export function middleware(req: NextRequest) {
    const { ctx } = getCloudflareContext()
    ctx.waitUntil(
      fetch("https://usebueno.com/api/v1/hits", {
        method: "POST",
        headers: {
          authorization: `Bearer ${process.env.BUENO_API_KEY}`,
          "content-type": "application/json",
        },
        body: JSON.stringify({
          path: req.nextUrl.pathname,
          ua: req.headers.get("user-agent") ?? "",
          referer: req.headers.get("referer") ?? undefined,
          country: req.headers.get("cf-ipcountry") ?? undefined,
          ip: req.headers.get("cf-connecting-ip") ?? undefined,
        }),
      }).catch(() => {}),
    )
    return NextResponse.next()
  }

Never await the call in your render path, and never put the key in a NEXT_PUBLIC_ variable: that ships it to the browser.

One caveat worth checking early: middleware only runs on requests your server actually handles. A page served straight from a CDN edge cache never reaches it, and the resulting flat chart looks identical to nobody reading you. If you cache pages aggressively, compare your recorded hits against your CDN's request count once, in the first few days.

Traffic segments

Every hit is classified on arrival, User-Agent first: a visit counts as human only if its User-Agent matches no known bot.

NameTypeDescription
search_crawlerbotGooglebot, Bingbot, Applebot, YandexBot, DuckDuckBot… Your page is indexable.
ai_crawlerbotGPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, PerplexityBot, CCBot… Your page is being ingested into answers.
ai_referralhumanA person whose referrer is chatgpt.com, perplexity.ai, claude.ai, gemini.google.com… An AI cited you and someone clicked through.
humanhumanEveryone else: organic search, direct, internal navigation, or another site.
other_botbotSEO suites, link unfurlers, uptime monitors, HTTP clients.

AI crawlers and AI referrals are counted separately on purpose. A month where GPTBot fetched four thousand pages and sent no readers is a very different month from the reverse, and a single “AI” number can't tell you which one you had.

other_bot is separate for the same reason: AhrefsBot is not a search engine, and letting SEO tools and Slack link previews pile into the search-crawler count would make a page Google has never fetched look thoroughly discovered.

A visit with no referrer is not a bot. Typed URLs, bookmarks, links from a chat app or an email client, and any https → http hop all arrive without one: that is ordinary direct traffic, and it is counted as human.

MCP

Bueno is also an MCP server, so an AI client (Claude, Cursor, anything that speaks the Model Context Protocol) can read your traffic, audit a post and publish the fix without you writing any glue. It talks to the same tables as this API and the dashboard.

Connect a client to:

https://mcp.usebueno.com/mcp/<project-handle>

The handle in the path picks the project. A client that only lets you paste a bare https://mcp.usebueno.com/mcp works too: it falls back to your personal project, whose handle is your username.

No API key. Authentication is OAuth against your ordinary Bueno account: the client opens a sign-in page, you enter the same email and password you use for the app, and it receives a token. One sign-in covers every project you belong to, and each tool call is re-checked against your membership: pointing a client at a project handle you aren't a member of returns an error, never data.

Tools return evidence, not verdicts. None of them calls a language model: they hand back what your data says and leave the judgment to the agent you are talking to. auditContent is the clearest case: it runs four mechanical checks and gives your agent the body, the failures and the rubric, so the rewrite is written where the context is.

MCP tools

ToolArgumentsWhat it does
listContentlimit?, offset?, category?, tag?, status?Posts, newest first: title, slug, description, category, tags, publishedAt. Never bodies. Defaults to live posts; `status` reaches drafts and hidden posts too.
getContentslugOne post by slug, whatever its status, including its full Markdown body.
listCategoriesnoneEvery category with its published-post count. Subcategories come back flat, tagged with parent_slug.
publishContenttitle, slug?, description?, body?, status?, publishedAt?, categoryName?, authorName?, tags?, coverImageUrl?Create a post. Same schema and same behaviour as POST /posts: status defaults to draft, and the slug is de-duplicated on the way in.
updateContentslug, newSlug?, title?, description?, body?, status?, publishedAt?, categoryName?, authorName?, tags?, coverImageUrl?Edit a post by slug, drafts included. Omitted fields keep their current value; tags replaces the whole list.
getContentPerformancefrom?, to?, limit?Daily traffic by segment, window totals, top posts and publish dates. Visits = human + ai_referral; crawler fetches are counted separately and never summed into visits.
getCrawlerActivityfrom?, to?Which bots actually fetched the site and how often, one row per bot, split into ai_crawler and search_crawler.
getOpportunitieslimit?, from?, to?Findings with a next step, from seven deterministic rules over your own rows, not model output.
auditContentslugFour mechanical answer-shaping checks on a post, drafts included, plus its body and the rubric. No model runs; the rewrite is the calling agent's to write.
getDomainRatingnoneAhrefs Domain Rating for your domain over the last 30 days, sampled daily, with Ahrefs' license string.
submitForIndexingslugsPush post URLs to IndexNow: Bing (and so ChatGPT search), Yandex, Seznam, Naver, Yep. Not Google, which doesn't participate.
getIndexingStatuslimit?The submission log: what was pushed, to which provider, and whether it was accepted. Accepted is not indexed.
getTopicLandscapefrom?, to?What you have published, grouped by category and tag, against what each topic earned, including AI referrals per post, the column worth writing against. Plus what is cited, what is crawled but never cited, and your existing titles as a dedup guard.
checkVisibilityqueries, engines?, brandAliases?, competitors?, domain?, locationName?Costs credits. Per query and engine: are you cited, at what position, which competitors were cited instead, and the raw answer text. Matching is deterministic domain comparison: no model interprets anything.
getSeedBriefdomain?, seedKeywords?, locationName?, dryRun?Costs credits. The cold-start brief for a project with no posts: what the domain already ranks for, volume and intent for the seed terms, who owns page 1, and whether any AI cites you today. Pass dryRun to see the price first.

Dates are ISO YYYY-MM-DD in UTC and default to the last 30 days, so the numbers a tool returns match the ones on your dashboard.

Every tool above is free except the two marked costs credits, which call a paid provider. Those two meter to the same ledger as the rest of the product (Cost Control → Usage) and are charged only on success: a reading served from cache lands a row at zero cost.

On checkVisibility, treat the engines as two different instruments. google_ai_overview is the AI Overview Google actually rendered; the four model engines are that vendor's API with web search enabled, which is a strong proxy for the consumer product rather than a capture of it. Every row carries isProxy so the two never get averaged together.

Objects

Post

NameTypeDescription
titlestringPost title.
descriptionstringShort summary (good for meta tags).
slugstringURL-safe id, unique within the project.
coverImageUrlstring?Absolute URL to the cover image, or omitted when there is none.
tagsstring[]Tag names. Empty array when untagged.
categoryobject?{ name, slug }. Omitted when the post is uncategorized.
authorobject?{ name, avatarUrl?, bio? }. Omitted when no author is set. avatarUrl is absolute.
publishedAtstring?UTC datetime the post went live, when set.
createdAtstringUTC datetime the post was created.
bodystringMarkdown body. Only on the single-post endpoint.

Category

NameTypeDescription
namestringDisplay name.
slugstringURL-safe id, unique within the project. Pass to ?category=.
descriptionstringEmpty string when unset.
parent_slugstring?Slug of the parent category (present only on a subcategory).
posts_countnumberPublished posts filed under this category, including its subcategories.

Series

NameTypeDescription
namestringDisplay name.
slugstringURL-safe id, unique within the project. Pass to /series/{slug} and ?series=.
descriptionstringEmpty string when unset.
posts_countnumberPublished posts in the series. Drafts and future-dated posts are not counted.
postsobject[]Only on the single-series endpoint. Post summaries in reading order, each with position and an optional section.
positionnumber0-based index within the published members, not a stored value, so it never has gaps.
sectionstring?Group heading for this entry (e.g. “Part 1: Basics”). Omitted when ungrouped; consecutive entries sharing one belong under it.

Examples

Fetching a listing page from a Next.js server component:

const res = await fetch("https://usebueno.com/api/v1/posts?limit=20", {
    headers: { Authorization: `Bearer ${process.env.BUENO_API_KEY}` },
    next: { revalidate: 300 },
  })
  if (!res.ok) throw new Error(`Bueno API: ${res.status}`)
  const { posts, total } = await res.json()

Paging through every post:

const all = []
  for (let offset = 0; ; offset += 100) {
    const res = await fetch(`https://usebueno.com/api/v1/posts?limit=100&offset=${offset}`, {
      headers: { Authorization: `Bearer ${process.env.BUENO_API_KEY}` },
    })
    const { posts, total } = await res.json()
    all.push(...posts)
    if (all.length >= total || posts.length === 0) break
  }

Questions or a missing endpoint? support@usebueno.com.