Ballad
Reference

The Ballad API

Every endpoint: the read-only content API, the revalidate and capability endpoint your site answers, the release and commit webhooks, and the agent API for everything that writes.

~10 min11 sectionsHTTP · JSON · bearer

Everything Ballad publishes is available over plain HTTP, and everything you want Ballad to know can be sent the same way. This is the reference; if you just want a Next.js blog, the package does all of this for you.

Which API you want

You want to…Use
Render articles, changelog entries or landing pages on your siteThe content API, below
Have Ballad refresh your pages when it publishesA revalidate endpoint your site exposes
Tell Ballad about a release or a batch of commitsThe release and commit webhooks
Draft, decide, record events, read the planThe agent API over MCP

Authentication

KeyWhere it comes fromWhat it opens
blc_…Settings → Site → Content API keyRead-only access to published content
bak_…Agents → New agent keyThe agent API, limited to the scopes you grant

Both are bearer tokens:

curl https://app.balladlabs.com/api/content/collections \
  -H "Authorization: Bearer $BALLAD_CONTENT_KEY"

Keep both server-side. The content key reads everything you've published — not secret, but not something to hand a browser either.

GET /api/content/collections

What this workspace has, so a site can tell a feed from a set of landing pages:

{
  "collections": [
    { "slug": "blog", "name": "Blog", "kind": "blog" },
    { "slug": "changelog", "name": "Changelog", "kind": "changelog" },
    { "slug": "for", "name": "For", "kind": "pages", "pageType": "segment" }
  ]
}

kind is blog, changelog or pages. A pages collection's slug is the route prefix: the "For" collection above renders at /for/<slug>.

GET /api/content/collections/{slug}

A collection and a page of its published items, newest first.

ParameterMeaning
limitPage size, 1–100. Default 20.
cursorThe previous response's nextCursor.
{
  "slug": "blog",
  "name": "Blog",
  "kind": "blog",
  "theme": "Inbound for technical founders",
  "tagline": "Payroll a founder can run in ten minutes.",
  "items": [
    {
      "slug": "your-best-posts-are-dying-in-notion",
      "title": "Your best posts are dying in Notion",
      "shape": "essay",
      "publishedAt": "2026-09-11T09:00:00.000Z",
      "excerpt": "…",
      "author": "Maya Reyes",
      "url": "https://northwind.com/blog/your-best-posts-are-dying-in-notion",
      "image": "https://app.balladlabs.com/api/content/assets/…",
      "artImage": null
    }
  ],
  "nextCursor": null
}

The feed is always paginated: nextCursor can be non-null even when you didn't pass limit. Keep following it until it's null.

Everything an RSS feed needs is in the summary, so one call builds a feed.

GET /api/content/items/{slug}

One item as ordered blocks plus its SEO:

{
  "slug": "pay-runs-you-can-take-back",
  "title": "Pay runs you can take back",
  "shape": "release",
  "collectionSlug": "changelog",
  "publishedAt": "2026-09-12T18:13:02.000Z",
  "blocks": [
    { "type": "prose", "position": 0, "payload": { "markdown": "…" } }
  ],
  "seo": {
    "title": "Pay runs you can take back",
    "description": "…",
    "canonical": "https://northwind.com/changelog/pay-runs-you-can-take-back",
    "ogImage": null,
    "jsonLd": { "@context": "https://schema.org", "@type": "Article" }
  }
}

Two fields matter for landing pages: page carries the type and subject, and noindex is true for a page built to receive sent traffic — emit a noindex robots tag and leave it out of your sitemap.

Block types

type is a plain string so a block added later still parses. Render what you know and skip the rest:

typepayload
prose{ markdown }
hero_image{ url, alt, width, height }
pull_quote{ text }
cta{ text, … }
code_embed{ code, lang, caption }
stat_callouta number worth pulling out
key_takeawaysthe summary list at the top
faqquestion and answer pairs

Never render Markdown as raw HTML. Run it through a Markdown renderer.

Images are served from /api/content/assets/<key> — public, immutable, no auth, safe to put behind your CDN.

What your site answers

Ballad calls your site when something publishes, so a page refreshes in seconds rather than on a timer. Expose one endpoint that handles both:

  • POST /api/revalidate with { secret, collection, slug }. Compare the secret against the one in Settings → Site, then revalidate that collection and item.
  • GET /api/revalidate answering with what you render. Ballad reads it before proposing a landing page, so it never proposes a page at a route your site can't serve:
{
  "ok": true,
  "package": "@balladlabs/next",
  "version": "0.3.0",
  "collections": ["blog", "changelog"],
  "pages": true
}

A hand-rolled site can answer the same shape without the package: name the collections you render and whether a pages catch-all is mounted.

Sending things in

Release and commit intake are signed webhooks, one per product, with the product's secret from Settings → Changelogs:

curl https://app.balladlabs.com/api/webhooks/releases/<productId> \
  -H "Authorization: Bearer $RELEASE_SECRET" \
  -H "content-type: application/json" \
  -d '{"title":"Undo a pay run","summary":"…","size":"minor"}'

GitHub can post to the same URLs instead: point a release or push webhook at them and set the product secret as the webhook secret — Ballad verifies the X-Hub-Signature-256 header. See Release notes and changelog.

Writes: the agent API

Everything that changes your workspace — drafting, deciding, recording events and contacts, proposing plan changes — goes through the agent API, which is an MCP server at /api/mcp speaking JSON-RPC. Use an agent key as a bearer token, or connect Claude with OAuth and skip keys entirely.

curl https://app.balladlabs.com/api/mcp \
  -H "Authorization: Bearer $BALLAD_AGENT_KEY" \
  -H "content-type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"track_contact_event",
                 "arguments":{"email":"ada@example.com","event":"signup"}}}'

Every call is scoped and audited. See Agents and Connect Claude.

Caching, errors and change

  • Content responses carry s-maxage=60, stale-while-revalidate=300. Cache them; the revalidate hook is what makes a publish appear immediately.
  • 401 means the key is wrong or revoked; 404 means no such published item. A 5xx is a 5xx — don't turn it into an empty page, or a bad minute becomes a deindexed article.
  • Fields are added, not repurposed. tier is deprecated and still present; read shape instead. Unknown block types and unknown shape values should degrade rather than throw.

Next

Next guideSet up your Next.js blog