# Delivering content

Show blogs, docs, FAQs and other content in any stack, fetched on your server or at build time and cached, never from the browser.

Content is read with an org API token, and that token is a secret: it must stay
on your server, in a build step, or in CI. There is no browser read path for
blogs, docs, FAQs, testimonials, rich content or collection data, and that is
on purpose. A page that fetched from BuildBase on every visit would put its
traffic on us instead of on your cache.

There are three supported ways to show content. None of them is tied to a
framework or a language.

| Approach                        | Use it when                                                   |
| ------------------------------- | ------------------------------------------------------------- |
| Server-side fetch, with caching | Your app has a backend: any framework, any language           |
| Build-time pull                 | A static site, or a single-page app with no backend           |
| Webhooks                        | You cache content and want it refreshed the moment it changes |

The token is `orgId:secret`, created under **Settings → API keys**, and sent as
`Authorization: Bearer <token>`. Give it the built-in **content-reader** role:
it can read blogs, docs, FAQs, testimonials, rich content, collections and
forms, and nothing else. A token created without a role has full admin access,
which a site that only renders content never needs.

## Server-side fetch, with caching

Fetch from a route, a server component, a controller, a loader: anywhere that
runs on your server. Then cache the result, so one fetch serves many page
views.

Content responses carry these headers:

| Header          | Value                                             |
| --------------- | ------------------------------------------------- |
| `ETag`          | `W/"<hash of the body>"`                          |
| `Cache-Control` | `private, max-age=60, stale-while-revalidate=300` |
| `Vary`          | `Authorization`                                   |

Keep the ETag with what you cached and send it back as `If-None-Match`. If
nothing changed, the answer is an empty `304 Not Modified`: you keep serving
your copy. BuildBase answers repeated reads from its own cache, so a 304 does
no database work at all, and any write to that content type clears the cache
immediately. `private` means the response answered a secret: keep it in your
server's cache, not a shared CDN.

That `Cache-Control` value is what an org API token receives. The console,
signed in as a person who has just edited the content, gets
`private, no-cache` instead, so it always revalidates and never shows a list
from before the edit.

```bash
# First fetch: keep the body and the ETag.
curl -i "https://api.console.buildbase.app/api/rich-content/slug/refund-policy" \
  -H "Authorization: Bearer $BUILDBASE_API_TOKEN"

# Later: ask only whether it changed.
curl -i "https://api.console.buildbase.app/api/rich-content/slug/refund-policy" \
  -H "Authorization: Bearer $BUILDBASE_API_TOKEN" \
  -H 'If-None-Match: W/"5f2c…"'
```

The same in Python with `requests`, caching in memory for five minutes and
revalidating after that:

```python
import os, time, requests

BASE = "https://api.console.buildbase.app"
TOKEN = os.environ["BUILDBASE_API_TOKEN"]
_cache = {}  # path -> (fetched_at, etag, body)

def content(path, max_age=300):
    hit = _cache.get(path)
    if hit and time.time() - hit[0] < max_age:
        return hit[2]
    headers = {"Authorization": f"Bearer {TOKEN}"}
    if hit:
        headers["If-None-Match"] = hit[1]
    r = requests.get(BASE + path, headers=headers, timeout=5)
    if r.status_code == 304:
        _cache[path] = (time.time(), hit[1], hit[2])
        return hit[2]
    r.raise_for_status()
    body = r.json()
    _cache[path] = (time.time(), r.headers.get("ETag", ""), body)
    return body

policy = content("/api/rich-content/slug/refund-policy")
```

Any HTTP client can do this: `fetch`, Go's `net/http`, PHP, Ruby. In
JavaScript and TypeScript, the SDK's server client does it for you, with a
pluggable cache (memory, Redis, KV) and webhook handling. It runs on Node,
Bun, Deno, Workers and edge runtimes, and refuses to load in a browser so the
token cannot end up in a bundle. It is a convenience, not a requirement:
everything on this page works without it.

```ts
import { createServerClient } from '@buildbase/sdk/server';

const content = createServerClient({
  serverUrl: 'https://api.console.buildbase.app',
  apiToken: process.env.BUILDBASE_API_TOKEN!,
});

const posts = await content.blogs.list();
const policy = await content.richContent.get('refund-policy');
const faq = await content.faqs.questions('help-center');
```

### Read routes for sites

| Content             | Route                                                              |
| ------------------- | ------------------------------------------------------------------ |
| Blog or doc by path | `GET /api/blogs/path?path=folder/slug` (docs the same)             |
| Blog or doc tree    | `GET /api/blogs/folders/tree`                                      |
| FAQ group by slug   | `GET /api/faqs/collections/slug/:slug` (`/faqs` for its questions) |
| Testimonial         | `GET /api/testimonials` or `GET /api/testimonials/:id`             |
| Rich-content block  | `GET /api/rich-content/slug/:slug`                                 |
| Collection data     | `GET /api/collections/data/:slug?latest=true`                      |

### The content-reads quota

Content reads answered with a body count toward your plan's monthly
**content reads**. A `304 Not Modified` is never counted and never refused, so
an app that revalidates costs almost nothing, however much traffic it serves.
An app that refetches the full body on every page load is the one that uses
up its allowance.

## Build-time pull

A static site, or a single-page app that has no server of its own, cannot hold
a token at runtime, so it should not fetch at runtime. Pull the content while
the site builds instead: the token lives in CI, the content is written into the
build as files, and visitors never reach BuildBase.

In JavaScript projects the SDK ships a command for this:

```bash
BUILDBASE_SERVER_URL=https://api.console.buildbase.app \
BUILDBASE_API_TOKEN=$BUILDBASE_API_TOKEN \
npx @buildbase/sdk content pull --out content/buildbase.json
```

It writes one JSON file with every published blog, doc, FAQ group,
testimonial and rich-content block; add `--collections slug1,slug2` for
collection data and `--modules blogs,docs` to pull less. Once the SDK is a
dependency, the same command is `npx buildbase content pull`.

In any other stack, a build step that calls the same endpoints with `curl` and
writes the JSON is the whole technique. Pair it with a
[deploy hook](/integrations/deploy-hooks), which rebuilds the site when you
start a deploy from the console, or with a webhook that triggers a rebuild so
the site updates when content changes.

## Webhooks: refresh on change

Rather than polling, subscribe a webhook endpoint to the content events and
clear your cache, or trigger your site's rebuild, when one arrives.

| Event                                                                                 | When                                                  |
| ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `blog.published`                                                                      | A blog post is published                              |
| `blog.updated`                                                                        | A published post is edited (draft edits send nothing) |
| `blog.unpublished`                                                                    | A post goes back to draft                             |
| `blog.deleted`                                                                        | A post is deleted                                     |
| `doc.published`, `doc.updated`, `doc.unpublished`, `doc.deleted`                      | The same, for docs                                    |
| `faq.updated`                                                                         | A question is created, edited or deleted              |
| `faq_collection.updated`                                                              | A group is created, edited, archived or reordered     |
| `testimonial.updated`, `testimonial.deleted`                                          | A testimonial changes; archiving counts as deleted    |
| `rich_content.updated`, `rich_content.deleted`                                        | A rich-content block changes                          |
| `collection.version_published`                                                        | A new collection version goes live                    |
| `collection.record_created`, `collection.record_updated`, `collection.record_deleted` | A record changes, including a public form submission  |

Deliveries use the same headers and signature as every other BuildBase
webhook: `x-buildbase-signature: sha256=<hex>`, the HMAC-SHA256 of
`{timestamp}.{body}` with your endpoint's signing secret, and
`x-buildbase-timestamp`. See [Webhooks](/webhooks/overview) for verifying it
in any language.

Every content event's `data` names what changed, plus ready-made cache tags:

```json
{
  "module": "blog",
  "id": "66f1…",
  "slug": "launch-week",
  "path": null,
  "tags": [
    "buildbase:blog",
    "buildbase:blog:launch-week",
    "buildbase:blog:id:66f1…"
  ]
}
```

`buildbase:<module>` covers every list of that module; the slug and id tags
cover a single item. `path` is reserved and `null` for now. If you tag your
cache entries the same way, a webhook handler only has to delete the tags it
is given. In JavaScript, `handleContentWebhook` from `@buildbase/sdk/server`
verifies the delivery and clears those tags from the client's cache, and
`toNodeHandler` mounts it in Express, Fastify or NestJS (it needs the raw
body, hence `express.raw`):

```ts
import {
  createContentWebhookHandler,
  createServerClient,
  toNodeHandler,
} from '@buildbase/sdk/server';
import express from 'express';

const content = createServerClient({
  serverUrl: 'https://api.console.buildbase.app',
  apiToken: process.env.BUILDBASE_API_TOKEN!,
});

const app = express();
app.post(
  '/webhooks/buildbase',
  express.raw({ type: 'application/json' }),
  toNodeHandler(
    createContentWebhookHandler({
      secret: process.env.BUILDBASE_WEBHOOK_SECRET!,
      cache: content.cache,
    })
  )
);
```

## Next Steps

- [Content overview](/content/overview) - the content types and their routes.
- [Webhooks](/webhooks/overview) - endpoints, events, and signature checks.
