Content API
Fetch your published blog posts from IndieBob and display them on any website. REST API with JSON responses, RSS/JSON feeds, and XML sitemaps.
Looking to drive IndieBob rather than read from it — publish posts, run outreach, ask what to do next? That is the agent API, with an MCP server and a machine-readable capability document at /agents.md.
Overview
The Content API gives you programmatic access to your published blog posts. Use it to build a blog on your own domain, populate your Next.js or Astro site, or syndicate content anywhere.
Authentication
All requests require a Content API key passed as a Bearer token. Create keys in your project's Settings page — one key on the free plan, more on Plus.
Keys use the ib_pk_ prefix. They are hashed before storage — the raw key is shown only once on creation.
Revoking a key is immediate and permanent.
Rate Limits
| Limit | 100 requests / minute / key |
| Exceeded | 429 Too Many Requests |
Endpoints
/api/content/v1/postsReturns a paginated list of published posts.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
| page | number | 1 | Page number |
| limit | number | 20 | Items per page (max 100) |
| sort | string | publishedAt | publishedAt, updatedAt, or title |
| order | string | desc | asc or desc |
Response
{
"data": [
{
"id": "uuid",
"slug": "my-first-post",
"title": "My First Post",
"excerpt": "A short description...",
"metaTitle": "My First Post | Blog",
"metaDescription": "...",
"ogImageUrl": "/api/og/proj/postId",
"publishedAt": "2026-03-01T12:00:00.000Z",
"updatedAt": "2026-03-01T12:00:00.000Z",
"writtenWithCredit": true,
"creditUrl": "https://www.indiebob.com/?ref=post-credit"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 42,
"totalPages": 3
}
}/api/content/v1/posts/:slugReturns a single published post by slug, including full content.
Response
{
"id": "uuid",
"slug": "my-first-post",
"title": "My First Post",
"bodyHtml": "<h2>Introduction</h2><p>...</p>",
"bodyMarkdown": "## Introduction\n\n...",
"excerpt": "...",
"metaTitle": "My First Post | Blog",
"metaDescription": "...",
"ogImageUrl": "/api/og/proj/postId",
"seoScore": 82,
"publishedAt": "2026-03-01T12:00:00.000Z",
"updatedAt": "2026-03-01T12:00:00.000Z",
"writtenWithCredit": true,
"creditUrl": "https://www.indiebob.com/?ref=post-credit"
}Returns 404 if the slug doesn't exist or the post isn't published.
The post credit
When writtenWithCredit is true, bodyHtml ends with a one-line footer linking to creditUrl. The block is marked data-indiebob-credit="1" so you can remove it and render your own — nothing is hidden in markup you don't control. The project owner turns it off in project settings → Post Credit; the change applies on the next publish.
/api/content/v1/feed.jsonJSON Feed 1.1 — 50 most recent posts. Content-Type: application/feed+json.
/api/content/v1/feed.xml RSS 2.0 feed — 50 most recent posts. Content-Type: application/rss+xml.
/api/content/v1/sitemap.xml XML sitemap of all published posts. Content-Type: application/xml. Cached for 1 hour.
Webhooks
Webhooks notify your server in real-time when content changes. Configure them in your project's Settings page.
Events
| Event | Trigger |
|---|---|
| content.published | A post is published |
| content.updated | An already-published post is published again. Saving an edit sends nothing. |
| content.unpublished | A post is reverted to draft. data.post carries id, slug and title only. |
Payload Format
{
"event": "content.published",
"timestamp": "2026-03-01T12:00:00.000Z",
"project": { "id": "uuid", "name": "My Project" },
"data": {
"post": {
"id": "uuid",
"slug": "my-first-post",
"title": "My First Post",
"excerpt": "...",
"ogImageUrl": "/api/og/proj/postId",
"publishedAt": "2026-03-01T12:00:00.000Z",
"writtenWithCredit": true,
"creditUrl": "https://www.indiebob.com/?ref=post-credit"
}
}
}Headers included with every delivery:
| X-IndieBob-Signature | HMAC-SHA256 signature |
| X-IndieBob-Event | Event type |
| X-IndieBob-Delivery | Unique delivery UUID |
Signature Verification
Verify the X-IndieBob-Signature header to ensure the payload was sent by IndieBob. The signature is computed as sha256=HMAC-SHA256(rawBody, secret) — over the exact bytes we sent, so verify before parsing. A re-serialised body will never match.
// Node.js verification import { createHmac, timingSafeEqual } from 'node:crypto' function verifySignature(rawBody: string, signature: string | undefined, secret: string) { if (!signature) return false const expected = Buffer.from( 'sha256=' + createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex'), ) const received = Buffer.from(signature) // Constant-time: a plain === leaks how much of a guess was right. if (expected.length !== received.length) return false return timingSafeEqual(expected, received) }
Deliveries retry up to 3 times (immediately, +1min, +5min) on 5xx, 429 or no response, reusing the same X-IndieBob-Delivery id — make your handler idempotent. Any other 4xx is treated as a deliberate refusal and is not retried. Each attempt times out after 10s. After 10 consecutive failures, the webhook is auto-disabled.
Caching
All responses include caching headers. Use conditional requests to minimize bandwidth.
| Cache-Control | public, max-age=60, stale-while-revalidate=300 |
| ETag | Content hash — send back in If-None-Match |
| Last-Modified | Send back in If-Modified-Since for 304 |
Integration Examples
Next.js
// app/blog/page.tsx
const API_KEY = process.env.INDIEBOB_API_KEY
async function getPosts() {
const res = await fetch('https://indiebob.com/api/content/v1/posts', {
headers: { Authorization: `Bearer ${API_KEY}` },
next: { revalidate: 60 },
})
return res.json()
}
export default async function BlogPage() {
const { data: posts } = await getPosts()
return (
<ul>
{posts.map(post => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
)
}Astro
--- // src/pages/blog/[slug].astro const { slug } = Astro.params const res = await fetch( `https://indiebob.com/api/content/v1/posts/${slug}`, { headers: { Authorization: `Bearer ${globalThis._importMeta_.env.INDIEBOB_API_KEY}` } } ) const post = await res.json() --- <article> <h1>{post.title}</h1> <Fragment set:html={post.bodyHtml} /> </article>
The whole site — Astro, hosted free on DigitalOcean, the blog built from this API at build time, RSS from the same posts — is walked step by step in the Astro on DigitalOcean guide.
Generic Fetch
const response = await fetch(
'https://indiebob.com/api/content/v1/posts?limit=10',
{ headers: { Authorization: 'Bearer ib_pk_your_key' } }
)
const { data, pagination } = await response.json()
console.log(`${pagination.total} posts, showing page ${pagination.page}`)
data.forEach(post => console.log(post.title))