# Delivery API

The public, unauthenticated surface that serves your published content.
Everything is keyed by your site's public key (`pk_…`) and works only for
[verified](/docs/verification) sites. `GET` and `HEAD` are supported
everywhere.

Base origin: `https://api.dailysmith.com`.

## Rendered pages

```
GET /v1/render/{publicKey}/{path}
```

`{path}` is the path **as it appears on your site** — e.g. `/blog` for the
index, `/blog/my-post-slug` for a post. baas resolves which blog owns the
path by its base path, so proxy, SDK, and prerender all share one URL
scheme.

| Query param | Meaning |
|---|---|
| `format=fragment` | JSON fragment for the SDK instead of a full HTML page |
| `page=N` | Index pagination (10 posts per page) |

A rendered page is always served in its blog's own
[language](/docs/content-pipeline) — there is no `locale` parameter here. A
blog holds one language, so the only thing another value could produce is an
empty page declaring a language it has nothing in, which is not worth handing
to a crawler. Run a second blog if you publish in a second language. The
structured listing below does take `locale`, because there an explicit empty
answer to "do you have a German edition?" is useful.

**Full pages** are complete HTML documents with title, meta description,
Open Graph and Twitter card tags (including `og:image` when the post has a
[featured image](/docs/rich-content)), canonical URL, JSON-LD, a readable
default style, and your [theme palette](/docs/theming) inlined.

**Fragments** (`format=fragment`) return:

```json
{
  "html": "<article class=\"baas-post\">…</article>",
  "kind": "post",
  "title": "Post title",
  "description": "Meta description",
  "canonical": "https://example.com/blog/my-post-slug",
  "json_ld": "{…}",
  "locale": "en",
  "og_locale": "en_US",
  "css": "/* the blog's theme palette, only when one is set */",
  "css_href": "/v1/sdk/md2.css",
  "hero_image_url": "https://api.dailysmith.com/v1/media/01a01789-959f-78f7-a5d0-78fc208903f6",
  "hero_image_alt": "Abstract illustration for Post title",
  "hero_image_width": 1376,
  "hero_image_height": 768
}
```

The four `hero_image_*` fields are **omitted entirely** for a post with no
[featured image](/docs/rich-content), which is every post written before you
turned them on. The picture is *also* already inside `html` as a
`<img class="baas-hero">` — one renderer serves every delivery mode, so you
get it for free by inserting the fragment. The separate fields exist so you
can build a social card or a listing thumbnail without parsing the markup;
our own SDK reads them to set `og:image` and clears those tags again when it
navigates to a post that has none. The image is served from
`https://api.dailysmith.com/v1/media/{id}` — permanent, immutable, and public by id, so
it needs no key and can be cached forever.

`locale` is the [blog's language](/docs/content-pipeline) as a plain tag —
put it on the element you insert the fragment into, or the article is
announced by a screen reader in whatever language your own page declares.
`og_locale` is the same language in the `language_TERRITORY` form Open Graph
wants; set it as `<meta property="og:locale">` or a share card is assumed to
be `en_US`. Our SDK always puts `lang` on the fragment it injects, and sets
`og:locale` too unless you opted out of head management.

`css` and `css_href` are both **omitted** when they don't apply, rather than
sent empty: `css` carries only the blog's [theme palette](/docs/theming) —
never the component stylesheet — so it's absent with no active theme;
`css_href` points at the shared, cacheable stylesheet below and is present
only when the content actually uses MD2 components. Full pages inline both
instead of linking them. For the index (`kind: "index"`), `description` and
`json_ld` are always empty strings rather than omitted.

## Structured listings

```
GET /v1/delivery/{publicKey}/blogs
GET /v1/delivery/{publicKey}/posts?base_path=/blog&locale=en&page=1
```

`blogs` lists the site's blogs (name, base path, default locale). `posts`
lists published posts for one blog — slug, title, excerpt, site path,
publish date, reading minutes — paginated (the response also carries `page`
and `total_pages`) — for building your own index UI or feeding other
systems.

## Sync feed

```
GET /v1/delivery/{publicKey}/posts/updated?base_path=/blog&since=2026-08-19T09:30:00Z
GET /v1/delivery/{publicKey}/posts/updated?base_path=/blog&since=…&cursor=MjAyNi0…
```

The polling feed for systems that keep their own copy of your posts — it is
what the [WordPress plugin](/docs/wordpress) syncs from. Entries are ordered
by `updated_at` ascending and carry everything a mirror needs in one
request: `id` (the stable post identifier — the same value the WordPress
push writes as the `_baas_post_id` marker), `slug`, `title`, `excerpt`,
`path`, `published_at`, `updated_at`, `reading_minutes`, the rendered
`body_html`, a `seo` object (`title_tag`, `meta_description`), the brief's
`focus_keyword`, and `hero_image_url` / `hero_image_alt` when the post has a
featured image. The envelope carries the blog-level styling once —
`theme_css` (the palette, already scoped to `.baas-wrap`) and `css_href`
(resolve it against this API's origin, like the fragment field) — plus
`next_cursor`, `page`, `total_pages`, `base_path`, and `locale`.

`since` is the entry point and is compared with `>=`: remember the newest
`updated_at` you saw, and send it back next time. The boundary post is
re-listed on the next poll by design — skip entries whose `updated_at` you
already hold. An absent or unparseable `since` returns everything, so a lost
checkpoint means a slow poll, never a stuck one.

**`next_cursor` is how you get the rest of one walk.** It is an opaque
position — send it back as `cursor`, unchanged, and the next response picks
up immediately after the last entry you were handed. Keep following it until
a response arrives without one; that absence means you have reached the end,
and the newest `updated_at` you collected is your next checkpoint. An
unreadable `cursor` is ignored rather than refused: the walk restarts from
`since`, which re-lists more than you need and stays correct.

:::callout{severity=warning title="Walk by cursor, not by page"}
`page` and `total_pages` are still served, and still work, but this feed is
ordered by the one field an edit rewrites. If a post is republished between
two of your requests it jumps to the end of the ordering and shifts
everything behind it one place forward — a page-number window then reads the
next slice at the old offset and steps straight over whichever post moved
into the place it already read. Advance your checkpoint afterwards and that
post stays unmirrored until someone edits it again. `cursor` is a position
in the ordering rather than a count into it, so the same edit costs you a
re-listed entry at worst.
:::

Archived posts drop out of the feed like every other endpoint here; a mirror
that also prunes should periodically walk the feed without `since` and
withdraw local copies whose `id` no longer appears.

## Feeds

```
GET /v1/delivery/{publicKey}/sitemap.xml
GET /v1/delivery/{publicKey}/rss.xml
```

The sitemap covers blog indexes and every published post with last-modified
dates; RSS carries the 50 most recent posts. Reference the sitemap from
your `robots.txt`:

```
Sitemap: https://api.dailysmith.com/v1/delivery/pk_YOUR_KEY/sitemap.xml
```

Each blog also serves an [llms.txt](/docs/ai-visibility) index for AI
crawlers through the render path — `{base_path}/llms.txt`, e.g.
`GET /v1/render/{publicKey}/blog/llms.txt` — listing every published post
with its canonical URL and a one-line description.

## SDK script

```
GET /v1/sdk/blog.js
GET /v1/sdk/md2.css
```

The [JS SDK](/docs/install-sdk) and the MD2 rich-content stylesheet a
fragment's `css_href` points at — both served with long-lived cache headers.

## Errors

Every error is `problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)):

```json
{ "type": "about:blank", "title": "Not found", "status": 404, "detail": "…" }
```

| Where | Status | `detail` | Meaning |
|---|---|---|---|
| Any endpoint | 404 | `unknown or unverified site key` | The key doesn't exist, or the site hasn't passed [verification](/docs/verification) — indistinguishable on purpose, so a key can't be used to probe which sites exist. |
| `/v1/render/{publicKey}/{path}` | 404 | `no blog is mounted at this path` | No blog's base path matches `{path}`. |
| `/v1/render/{publicKey}/{path}` | 404 | `no such post` | The path resolved to a blog, but the slug doesn't. |
| `/v1/delivery/{publicKey}/posts` | 404 | `no blog at that base path` | The `base_path` query param doesn't match any blog. |
| `/v1/sdk/md2.css` | 503 | `stylesheet temporarily unavailable` | The rendering sidecar is unreachable — retry. |

Nothing on this surface ever returns 402 or 422 — quota and validation
errors only apply to the authenticated content API that creates posts.

## Caching semantics

- Every response carries a **strong ETag** (a content hash) and honors
  `If-None-Match` with `304 Not Modified`.
- `Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600`
  — friendly to CDNs and your edge cache; content changes (a new post, an
  edited theme) change the ETag.
- CORS is wide open (`Access-Control-Allow-Origin: *`): the content is
  public by definition, which is what lets the SDK fetch it from your
  domain.

:::callout{severity=warning title="Public key, not secret key"}
The `pk_…` key identifies your site and is safe to ship in HTML — it is the
only key this API takes. Your site also carries a secret key (`sk_…`), which
no endpoint currently accepts: the dashboard API authenticates with your
signed-in session, not with a key. Treat `sk_…` as a credential anyway —
keep it out of client-side code, and rotate it from the site's **API keys**
page if it ever leaks.
:::
