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.
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 site | The content API, below |
| Have Ballad refresh your pages when it publishes | A revalidate endpoint your site exposes |
| Tell Ballad about a release or a batch of commits | The release and commit webhooks |
| Draft, decide, record events, read the plan | The agent API over MCP |
Authentication
| Key | Where it comes from | What it opens |
|---|---|---|
blc_… | Settings → Site → Content API key | Read-only access to published content |
bak_… | Agents → New agent key | The 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.
| Parameter | Meaning |
|---|---|
limit | Page size, 1–100. Default 20. |
cursor | The 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:
type | payload |
|---|---|
prose | { markdown } |
hero_image | { url, alt, width, height } |
pull_quote | { text } |
cta | { text, … } |
code_embed | { code, lang, caption } |
stat_callout | a number worth pulling out |
key_takeaways | the summary list at the top |
faq | question 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/revalidatewith{ secret, collection, slug }. Compare the secret against the one in Settings → Site, then revalidate that collection and item.GET /api/revalidateanswering 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. 401means the key is wrong or revoked;404means 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.
tieris deprecated and still present; readshapeinstead. Unknown block types and unknownshapevalues should degrade rather than throw.
Next
- Set up your Next.js blog — all of this, in five files.
- Agents — scopes and the tool catalogue.
- Landing pages — what the
pageskind renders.