# Getting started URL: /docs/getting-started > 60-second integration. Get an API key, install the SDK, fetch your first posts. This is the fastest path from zero to a working integration in a TypeScript/JavaScript project. For a deeper dive on either side, see [the API reference](/docs/api/authentication) or [the SDK section](/docs/sdk/installation). ## 1. Get your API key [#1-get-your-api-key] In the Brandfine CMS, open your workspace's settings → API keys → **Reveal**. The key scopes to that single workspace — every API call uses it to identify which tenant's content to return. ## 2. Install the SDK [#2-install-the-sdk] ```sh npm install @brandfine/client ``` If you're on Astro, also install the adapter: ```sh npm install @brandfine/client-astro ``` ## 3. Create the client [#3-create-the-client] ```ts // src/lib/bf.ts import { createBrandfineClient } from '@brandfine/client' export const bf = createBrandfineClient({ baseUrl: process.env.BRANDFINE_API_URL!, // e.g. https://api.brandfine.co apiKey: process.env.BRANDFINE_API_KEY!, }) ``` ## 4. Fetch your first posts [#4-fetch-your-first-posts] ```ts import { bf } from './bf' const posts = await bf.posts.list({ type: 'blog' }) console.log(posts.map((p) => p.title)) ``` That's it. From here: * [Add server-side caching](/docs/sdk/caching) so you don't hit the API on every page load. * [Wire up the publish webhook](/docs/sdk/webhooks) so cache invalidation happens automatically when editors save. * [Resolve navigations](/docs/sdk/resolvers) into per-locale URL trees ready to render. --- # Welcome URL: /docs > Brandfine is a multi-tenant headless CMS. Pick your integration path. Brandfine ships every workspace's content through one stable HTTP surface: `/external/*`. Consumer sites — Astro, Next.js, Remix, anywhere with `fetch` — read posts, navigations, categories, and workspace metadata from that surface and render them however they like. Two integration paths: ## REST API [#rest-api] Plain HTTP. Use it directly from any language with a JSON HTTP client. Auth is a single workspace-scoped `X-Api-Key` header. [Start with authentication →](/docs/api/authentication) ## SDK (TypeScript) [#sdk-typescript] `@brandfine/client` wraps the REST surface with typed methods, server-side caches with stale-while-revalidate semantics, locale helpers, navigation resolvers, and a framework-agnostic webhook handler. `@brandfine/client-astro` adds an Astro `APIRoute` shape on top. ```sh npm install @brandfine/client @brandfine/client-astro ``` [Start with the SDK quickstart →](/docs/sdk/quickstart) ## Core concepts [#core-concepts] Whichever path you take, you'll work with the same domain model: **workspaces** own **posts** (grouped by **post types**), **categories**, and **navigations**. **Translation groups** tie locale variants of the same article together. [Read the concepts overview →](/docs/concepts/workspaces) ## Using AI to integrate? [#using-ai-to-integrate] These docs ship with [`llms.txt`](/llms.txt) and [`llms-full.txt`](/llms-full.txt) — markdown-formatted, AI-friendly versions of everything you see here. Drop the URL into ChatGPT, Claude, or Cursor (`@docs https://docs.brandfine.co/llms-full.txt`) and the model has the full reference in context. --- # Authentication URL: /docs/api/authentication > Workspace-scoped X-Api-Key header. One key per workspace. Every `/external/*` request is authenticated with a single HTTP header: ```http X-Api-Key: bk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` The key resolves to exactly one workspace. Brandfine identifies which tenant's content to return from the key alone — no workspace ID or tenant slug in the URL. ## Getting a key [#getting-a-key] 1. Sign in to the CMS at `cms.brandfine.co`. 2. Open your workspace → **Settings** → **API keys**. 3. Click **Reveal** on the existing key, or **Rotate** to mint a new one. (Rotation revokes the old key immediately.) ## Storing the key safely [#storing-the-key-safely] * **Server-side only.** Never ship the key to a browser. The SDK refuses to construct a client without one because all requests pass through SSR / server functions, not the client. * **One key per environment.** Use separate workspaces for staging vs. production rather than reusing the same key. * **Environment variable.** Convention: `BRANDFINE_API_KEY`. ## What the key can do [#what-the-key-can-do] Workspace API keys are **read-only over the external surface**. They can: * `GET /external/workspace` * `GET /external/posts/*` * `GET /external/categories` * `GET /external/navigations/:key` They cannot: * Write any data (creates/edits/deletes happen via the CMS UI). * Read other workspaces' data. * Hit internal `/ws//*` endpoints (those use session auth, not API keys). ## Errors [#errors] | Code | Meaning | | ----- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `401` | Missing / invalid / revoked API key. | | `404` | The resource exists for some workspace but not yours, OR doesn't exist at all. (Returned uniformly to avoid leaking workspace existence.) | | `429` | Rate limited. Back off and retry. | | `5xx` | Brandfine server issue. The SDK serves stale cache on these; you should too. | --- # GET /external/categories URL: /docs/api/categories > Workspace categories filtered to those with published content. ```http GET /external/categories?locale=pt X-Api-Key: bk_live_xxx ``` Returns only categories that have **at least one published post in the requested locale**. This prevents the consumer site from rendering "dead" category chips that link to empty pages. | Query | Type | Default | | -------- | ------ | ------------------------- | | `locale` | string | workspace `defaultLocale` | Pass `locale=*` to ignore the filter and return every category. ## Response [#response] ```json { "items": [ { "id": "c_1", "slug": "visas", "name": "Visas" }, { "id": "c_2", "slug": "guides", "name": "Guides" } ] } ``` ## SDK [#sdk] ```ts const cats = await bf.categories.list({ locale: 'pt' }) ``` --- # GET /external/navigations/:key URL: /docs/api/navigations > Workspace navigation by key, with POST items pre-resolved per locale. ```http GET /external/navigations/header X-Api-Key: bk_live_xxx ``` Returns the navigation identified by `:key` (e.g. `'header'`, `'footer-services'`). Items are nested via `parentId` — top-level items have `parentId: null`. ## Item types [#item-types] | Type | Renders as | Required fields | | ------------ | ----------------------------------------------------- | --------------------------- | | `CUSTOM_URL` | `` | `customUrl`, `labels` | | `POST` | `` | `post`, optional `labels` | | `HEADING` | non-link label (dropdown title, footer column header) | `labels`, may have children | POST items are pre-resolved — the `post.locales` array contains the per-locale slug+title for every published translation. ## Response [#response] ```json { "key": "header", "name": "Main Header", "items": [ { "id": "i_1", "parentId": null, "position": 0, "type": "HEADING", "customUrl": null, "labels": { "en": "Get My Visa", "pt": "Obter o meu visto" }, "hiddenLocales": [], "post": null }, { "id": "i_2", "parentId": "i_1", "position": 0, "type": "POST", "customUrl": null, "labels": null, "hiddenLocales": [], "post": { "id": "p_2N4r", "canonicalSlug": "schengen-visa", "postTypeId": "pt_services", "postTypeSlug": "services", "locales": [ { "locale": "en", "slug": "schengen-visa", "title": "Schengen Visa" }, { "locale": "pt", "slug": "visto-schengen", "title": "Visto Schengen" } ] } } ] } ``` 404 when no navigation with that key exists. ## SDK [#sdk] ```ts const nav = await bf.navigations.get('header') // → BrandfineNavigation | null (null on 404) ``` For rendering, pass through the SDK's resolver to get a ready-to-render tree: ```ts import { resolveNavigation } from '@brandfine/client/resolvers' const hydrated = resolveNavigation(nav!, 'pt', { defaultLocale: 'en' }) // hydrated.items: [{ href, label, type, children }, ...] ``` See [SDK → Resolvers](/docs/sdk/resolvers). --- # Posts URL: /docs/api/posts > GET /external/posts and GET /external/posts/:slug Two endpoints — paginated list and single-post-by-slug. ## List [#list] ```http GET /external/posts?type=services&locale=en&page=1&limit=50 X-Api-Key: bk_live_xxx ``` | Query | Type | Default | Notes | | ------------- | ------ | ----------- | ----------------------------------------------------------- | | `type` | string | `blog` | Post type slug. `*` for every type. | | `locale` | string | (no filter) | BCP47 code. Omit to fetch every locale. | | `page` | int | `1` | 1-indexed. | | `limit` | int | `50` | Max page size. | | `force_limit` | int | — | Opt past the 50-cap. Up to 1000. | | `include` | string | (none) | Pass `content` to inline `contentHtml` / `contentMarkdown`. | | `q` | string | — | Case-insensitive substring on title + slug. | ### Response [#response] ```json { "items": [ { "postId": "p_2N4r", "slug": "schengen-visa", "canonicalSlug": "schengen-visa", "title": "Schengen Visa", "metaDescription": "...", "imageUrl": "...", "tags": ["europe"], "category": { "id": "c_1", "slug": "visas", "name": "Visas" }, "authorName": "...", "customConfig": { /* free-form */ }, "publishedAt": "2026-03-12T08:00:00.000Z", "contentHtml": null, "contentMarkdown": null, "jsonLd": null } ], "pageInfo": { "page": 1, "limit": 50, "total": 54, "totalPages": 2, "hasNext": true, "hasPrev": false } } ``` ### SDK [#sdk] ```ts const posts = await bf.posts.list({ type: 'services', locale: 'en' }) // Pagination handled internally; caller gets a flat array. ``` ## Get by slug [#get-by-slug] ```http GET /external/posts/schengen-visa?locale=en X-Api-Key: bk_live_xxx ``` Returns the single post + a `translations` array listing every published locale variant. Each entry includes `publishedAt` so consumers can compute a cross-locale sitemap `` (`max(publishedAt)` across siblings) when one URL represents the whole article family. ```json { "postId": "...", "slug": "schengen-visa", "title": "Schengen Visa", "...": "...", "translations": [ { "locale": "en", "slug": "schengen-visa", "publishedAt": "2026-03-12T08:00:00.000Z" }, { "locale": "pt", "slug": "visto-schengen", "publishedAt": "2026-04-02T10:15:00.000Z" } ] } ``` 404 when the slug doesn't exist in the requested locale. ### SDK [#sdk-1] ```ts const post = await bf.posts.getBySlug('schengen-visa') // → BrandfinePost | null (null on 404) ``` ## `publishedAt` semantics — read before building a sitemap [#publishedat-semantics--read-before-building-a-sitemap] `publishedAt` is **the time of the last publish that actually changed content**, not the time of first publish. Brandfine re-stamps it whenever an editor republishes a post with a different contentHash, and skips the bump on no-op republishes (clicking Publish without edits). That makes it the right value to feed into a sitemap ``: ```xml https://example.com/services/schengen-visa {post.publishedAt} ``` Two corollaries worth noting: * **Draft edits don't move `publishedAt`.** Saving a typo fix to the draft does nothing to the public URL or to this timestamp — the change only becomes visible (and `publishedAt` only bumps) when the editor hits Publish. * **Unpublish → republish bumps it.** Even with identical content, re-publishing after an unpublish bumps `publishedAt` since consumers may have dropped the URL during the gap. --- # Webhooks URL: /docs/api/webhooks > Publish notifier — POST to your URL whenever workspace content changes. Each workspace has a single configurable webhook URL. Whenever content changes (post publish/unpublish, navigation save, workspace config edit), Brandfine fires a POST to that URL so consumer sites can invalidate caches. ## Configuration [#configuration] 1. CMS → Workspace → **Settings** → **Publish notifier**. 2. URL format: ``` https://your-site.example/api/brandfine-webhook?secret= ``` 3. The `secret` query param is shared with Brandfine. The consumer's webhook handler verifies it with constant-time comparison. ## Payload [#payload] ```http POST /your-webhook-url?secret= Content-Type: application/json User-Agent: Brandfine-Notifier/1 { "event": "post.published", "workspaceId": "ws_2N4r", "postId": "p_2N4r", "slug": "schengen-visa", "title": "Schengen Visa", "publishedAt": "2026-03-12T08:00:00.000Z", "at": "2026-03-12T08:00:01.123Z" } ``` ## Event types [#event-types] | Event | Fired when | | --------------------------- | ----------------------------------------------------- | | `post.published` | A post is published (either first-time or unarchive). | | `post.unpublished` | A post is unpublished or deleted. | | `navigation.created` | A new navigation is created. | | `navigation.updated` | A navigation's metadata (name, key) changes. | | `navigation.deleted` | A navigation is removed. | | `navigation.items.replaced` | A navigation's item tree is saved. | Future event names are added without versioning the payload — consumers should accept unknown `event` strings (the SDK's `BrandfineWebhookEvent` type uses `string` with the known union for autocomplete). ## Response semantics [#response-semantics] | HTTP code | Brandfine retries? | | --------- | --------------------------------------- | | `200` | No — delivered. | | `400` | No — bad body (consumer's fault). | | `401` | No — secret mismatch. | | `429` | Yes (with backoff). | | `5xx` | Yes (with backoff, up to \~6 attempts). | ## SDK [#sdk] ```ts import { createBrandfineWebhookHandler } from '@brandfine/client/webhook' export const POST = createBrandfineWebhookHandler({ secret: process.env.BRANDFINE_WEBHOOK_SECRET!, onEvent: ({ event, key }) => { postsCache.invalidate() if (event.startsWith('navigation.')) navigationsCache.invalidate(key ?? undefined) }, }) ``` The handler verifies the secret, parses the body, calls your `onEvent`, and returns the right HTTP code automatically. See [SDK → Webhooks](/docs/sdk/webhooks). --- # GET /external/workspace URL: /docs/api/workspace > Workspace metadata, customConfig, and schemaOrg. Returns metadata + free-form config the consumer site uses to configure itself (theme, feature flags, schema.org payload). ```http GET /external/workspace X-Api-Key: bk_live_xxx ``` ## Response [#response] ```json { "id": "ws_2N4rDQk", "slug": "olavisa-pt", "name": "Olavisa", "locales": ["en", "pt"], "defaultLocale": "pt", "customConfig": { "theme": "purple", "featureFlags": { "newApply": true } }, "schemaOrg": { "Organization": { "@type": "Organization", "name": "Olavisa", "legalName": "Olavisa, Lda." } } } ``` | Field | Type | Notes | | --------------- | -------------- | ----------------------------------------------------------- | | `id` | string | Internal workspace ID. | | `slug` | string | URL-safe workspace identifier. | | `name` | string | Human-readable name. | | `locales` | string\[] | BCP47 codes the workspace serves. First entry is canonical. | | `defaultLocale` | string | The locale served at the bare URL. | | `customConfig` | object \| null | Free-form JSON. Consumer interprets. | | `schemaOrg` | object \| null | Keyed by `@type`. Merged into per-page JSON-LD. | ## SDK [#sdk] ```ts const ws = await bf.workspace.get() ``` You can narrow the two free-form payloads' types if you have a known shape: ```ts type MyConfig = { theme: string; featureFlags: Record } type MySchema = { Organization: { '@type': 'Organization'; name: string } } const ws = await bf.workspace.get() ``` --- # Locales URL: /docs/concepts/locales > BCP47 locale codes, default-locale fallback, hreflang. Each workspace declares a set of BCP47 locale codes it serves and one default locale. Posts pin to one locale at a time; [translation groups](/docs/concepts/translation-groups) tie locale variants of the same article together. ## How locale routing works [#how-locale-routing-works] Two conventions for URLs: * **Default locale = bare path.** `defaultLocale: 'en'` → `/about`, `/services/uk-eta`. * **Non-default locales = prefixed.** `pt` → `/pt/about`, `/pt/services/uk-eta`. The SDK provides `localizePath` and `stripLocalePrefix` helpers that do this conversion for you. ## The fallback chain [#the-fallback-chain] When a consumer requests a post in a locale that doesn't have a translation, the SDK's navigation resolver falls back to the default-locale URL automatically. Custom URL items in navigations behave the same way. ## Hreflang [#hreflang] The `/external/posts/:slug` response includes a `translations: [{ locale, slug }]` array — every published sibling of the requested post. Use this to render hreflang alternates without a second API call. --- # Post types URL: /docs/concepts/post-types > How Brandfine groups posts into families. A **post type** is a slug-identified family of posts inside a workspace. Every workspace ships with the default `blog` type; custom types are added from the CMS (workspace settings → Post types). Typical examples: | Workspace | Post types | | ------------------- | ------------------------------------- | | Travel agency | `blog`, `services`, `visas`, `guides` | | SaaS marketing site | `blog`, `case-studies`, `changelog` | | Documentation hub | `blog`, `tutorials`, `reference` | ## Filtering by post type [#filtering-by-post-type] REST: pass `type=` on `/external/posts`. ```http GET /external/posts?type=services&locale=en ``` SDK: ```ts const services = await bf.posts.list({ type: 'services', locale: 'en' }) ``` Omit `type` to default to `blog`. Pass `type=*` to fetch every post type in one call. ## Custom config per type [#custom-config-per-type] Each post type stores a `defaultConfig` JSON payload that the CMS applies to newly created posts. Useful for boilerplate fields the consumer site expects but doesn't want to author manually for every post. --- # Translation groups URL: /docs/concepts/translation-groups > How Brandfine ties locale variants of the same article together. Every post has a `translationGroupId`. Posts in the same group are translations of one another. The cms uses this to: * Auto-link translations on MDX import (matching by `canonicalSlug`). * Surface "translations available" UI in the CMS. * Build hreflang alternates in the external API responses. ## Two ways to identify a post [#two-ways-to-identify-a-post] * **`slug`** — per-locale URL slug. Unique within `(workspace, locale)`. Used in the consumer URL: `/services/visto-schengen` (PT) vs `/services/schengen-visa` (EN). * **`canonicalSlug`** — stable cross-locale identifier. Shared by every translation of the same article. Drives the auto-linking on MDX import. ## In the SDK [#in-the-sdk] Navigation `POST` items return the full sibling array: ```ts nav.items[0].post.locales // [ // { locale: 'en', slug: 'schengen-visa', title: 'Schengen Visa' }, // { locale: 'pt', slug: 'visto-schengen', title: 'Visto Schengen' }, // ] ``` The SDK's `resolveNavigation` helper picks the right sibling for the active locale and falls back to the default locale's slug when a translation is missing. --- # Workspaces URL: /docs/concepts/workspaces > The unit of multi-tenancy. One workspace = one site = one API key. A **workspace** is the unit of multi-tenancy in Brandfine. Each customer site (each consumer integration) maps to exactly one workspace; that workspace's API key is what `/external/*` endpoints accept. ## What a workspace owns [#what-a-workspace-owns] * **Posts** — articles, blog entries, service pages, visa guides. Grouped by [post type](/docs/concepts/post-types). * **Post types** — generic taxonomy of post families. The default is `blog`; you can define custom types per workspace (`services`, `visas`, `guides`, etc.). * **Categories** — workspace-scoped tags. Optional grouping layer over posts. * **Navigations** — named tree structures (`header`, `footer-services`) the consumer site renders. Each item is either a link to a post, a custom URL, or a heading. * **Locales** — BCP47 codes the workspace serves. The first one is the canonical fallback. * **Settings** — `customConfig` (free-form JSON the consumer interprets) and `schemaOrg` (workspace-level structured data merged into every page's JSON-LD). ## What a workspace doesn't have access to [#what-a-workspace-doesnt-have-access-to] A workspace's API key can only read its own data. It can't list other workspaces, can't read other workspaces' posts, and can't write anywhere (the external API is read-only — writes happen via the CMS UI). --- # Astro adapter URL: /docs/sdk/astro-adapter > @brandfine/client-astro — thin layer for Astro projects. The Astro adapter is two convenience helpers around the core SDK. The actual client + caches + resolvers live in [`@brandfine/client`](/docs/sdk/installation) and are framework-agnostic. ```sh npm install @brandfine/client @brandfine/client-astro ``` ## What's in it [#whats-in-it] ### `createBrandfineWebhookRoute` [#createbrandfinewebhookroute] Re-shapes the SDK's `(Request) => Response` webhook handler into Astro's `APIRoute` `(ctx) => Response` shape so you can export it directly: ```ts // src/pages/api/brandfine-webhook.ts import { createBrandfineWebhookRoute } from '@brandfine/client-astro' export const prerender = false export const POST = createBrandfineWebhookRoute({ secret: import.meta.env.BRANDFINE_WEBHOOK_SECRET, onEvent: (...) => { /* ... */ }, }) ``` That's a one-line route file — no middleware, no body parsing, no manual response shaping. ### `readBrandfineEnv` [#readbrandfineenv] Reads the three standard env vars (`BRANDFINE_API_URL`, `BRANDFINE_API_KEY`, `BRANDFINE_WEBHOOK_SECRET`) from Vite's `import.meta.env` with a `process.env` fallback. Handles the dual-source quirk Astro has between dev (`astro dev` via Vite) and SSR (Node adapter). ```ts // src/lib/bf.ts import { createBrandfineClient } from '@brandfine/client' import { readBrandfineEnv } from '@brandfine/client-astro' const env = readBrandfineEnv() export const bf = createBrandfineClient({ baseUrl: env.apiUrl, apiKey: env.apiKey, }) ``` Returns `{ apiUrl, apiKey, webhookSecret }` — all empty strings when unset (you decide how to react: warn, throw, fall back to hardcoded defaults). ## Required Astro setup [#required-astro-setup] For SSR (which the webhook handler requires) you need an adapter — typically `@astrojs/node`: ```ts // astro.config.mjs import { defineConfig } from 'astro/config' import node from '@astrojs/node' export default defineConfig({ output: 'static', // OR 'server' for full SSR adapter: node({ mode: 'standalone' }), }) ``` The webhook route uses `export const prerender = false` to force SSR on a per-route basis when most of the site is prerendered. Static pages can still be cached via the SDK's in-memory caches; they just refetch on the next request after the webhook fires. ## What's not in it (and why) [#whats-not-in-it-and-why] * **React hooks for client-side fetches** — Astro renders on the server. Client-side hydration uses your framework of choice (React, Vue, Svelte). The SDK's server-side caches are what you want. * **Vite plugin / build-time tooling** — Astro's MDX content collections cover this domain. The SDK stays out of build internals. --- # Caching URL: /docs/sdk/caching > createCache and createKeyedCache — server-side SWR primitives. The SDK ships two server-side cache primitives. They live in the consumer site's memory (per SSR process) and refresh via the publish webhook. Stale-while-revalidate semantics out of the box. ```ts import { createCache, createKeyedCache } from '@brandfine/client/cache' ``` ## Single-slot — `createCache` [#single-slot--createcache] For resources that don't vary by any key (workspace metadata). ```ts const workspaceCache = createCache({ label: 'workspace', ttl: 24 * 60 * 60 * 1000, // 24h safety net fetch: () => bf.workspace.get(), }) const ws = await workspaceCache.get() workspaceCache.invalidate() // called from webhook ``` ## Keyed — `createKeyedCache` [#keyed--createkeyedcache] For per-key dimensions: per-locale posts, per-nav-key navigations, anything where the same fetcher returns different data for different inputs. ```ts const postsCache = createKeyedCache({ label: 'posts', ttl: 60 * 60 * 1000, fetch: (locale) => bf.posts.list({ type: 'blog', locale }), }) await postsCache.get('en') await postsCache.get('pt') postsCache.invalidate('en') // one key postsCache.invalidate() // all keys ``` ## The contract [#the-contract] | Condition | Behavior | | --------------------------------- | -------------------------------------------------------- | | Fresh (not dirty + within TTL) | Serve cached. | | Cold start (no data) | Block on fresh fetch. | | Invalidated (webhook) | **Block** on fresh fetch — *not* stale-while-revalidate. | | TTL-expired with cached data | Stale-while-revalidate. Serve stale + bg refresh. | | Refetch error with cached data | Log via `onError`, serve stale (stale-while-error). | | Concurrent reads during in-flight | Dedupe — all await the same promise. | The invalidate-blocks-fresh rule is the important one: when the webhook fires, the next page render must see the new data, not the old. Otherwise editors would need to refresh twice to see their changes. ## Custom error logger [#custom-error-logger] ```ts const cache = createCache({ label: 'workspace', ttl: 24 * 60 * 60 * 1000, fetch: () => bf.workspace.get(), onError: (err) => logger.warn({ err }, 'workspace refetch failed'), }) ``` Defaults to `console.warn` if omitted. ## Caching adapted shapes [#caching-adapted-shapes] When the API's `BrandfinePost` doesn't match what your page templates expect, adapt at the cache boundary: ```ts type Service = { slug: string title: string applyUrl: string | null } function adapt(post: BrandfinePost): Service { return { slug: post.slug, title: post.title, applyUrl: post.customConfig?.apply?.url ?? null, } } const servicesCache = createKeyedCache({ label: 'services', ttl: 60 * 60 * 1000, fetch: async (locale) => (await bf.posts.list({ type: 'services', locale })).map(adapt), }) ``` --- # createBrandfineClient URL: /docs/sdk/client > The SDK's entry point. Typed HTTP client over the /external/* surface. ```ts import { createBrandfineClient } from '@brandfine/client' const bf = createBrandfineClient({ baseUrl: 'https://api.brandfine.co', apiKey: process.env.BRANDFINE_API_KEY!, }) ``` Returns a stateless, multi-instance-safe handle. Construct one per `(baseUrl, apiKey)` pair — multi-tenant consumers create several side-by-side. ## Options [#options] | Option | Type | Required | Notes | | ----------- | ------------ | -------- | ------------------------------------------------------ | | `baseUrl` | string | ✓ | No trailing slash (the client trims one if present). | | `apiKey` | string | ✓ | Workspace-scoped key. | | `fetch` | typeof fetch | — | Override for tests / edge runtimes / tracing wrappers. | | `userAgent` | string | — | Defaults to `@brandfine/client`. | Empty `baseUrl` or `apiKey` throws at construction — fail loud, not silent. ## Methods [#methods] ```ts bf.posts.list({ type, locale, forceLimit }) // paginated, returns flat array bf.posts.getBySlug(slug) // null on 404 bf.workspace.get() // narrow customConfig + schemaOrg bf.navigations.get(key) // null on 404 bf.categories.list({ locale }) bf.get(path, opts?) // escape hatch for new endpoints ``` ## Error handling [#error-handling] Non-2xx responses (except 404 on `nullable404` endpoints) throw `BrandfineApiError`: ```ts import { BrandfineApiError } from '@brandfine/client' try { await bf.posts.list() } catch (err) { if (err instanceof BrandfineApiError) { console.error(err.status, err.statusText, err.body, err.url) if (err.status === 429) await sleep(1000) // backoff } throw err } ``` ## `publishedAt` for sitemap `` [#publishedat-for-sitemap-lastmod] Every `BrandfinePost` carries a `publishedAt` ISO timestamp. It's the time of the **last publish that changed content** — re-stamped on every republish with a new contentHash, untouched on no-op republishes. Use it directly for sitemap entries: ```ts const posts = await bf.posts.list({ type: 'services', locale: 'en' }) const sitemap = posts.map((p) => ({ url: `https://example.com/services/${p.slug}`, lastmod: p.publishedAt, })) ``` The singular `bf.posts.getBySlug(slug)` response includes a `translations[]` array with per-locale `publishedAt`, so you can compute a cross-locale lastmod when one URL represents the whole article family: ```ts const post = await bf.posts.getBySlug('schengen-visa') if (post) { const familyLastmod = post.translations ?.reduce((max, t) => (t.publishedAt > max ? t.publishedAt : max), post.publishedAt) ?? post.publishedAt } ``` Draft edits don't affect `publishedAt` — only publishes do — so this is also the right field for "fresh content" badges or chronological feeds, not just sitemaps. ## Typed customConfig [#typed-customconfig] When you have a known shape for `customConfig` (per post type), parameterize the methods: ```ts type ServiceConfig = { apply: { visaSlug: string; destinationCode: string } | null faq: Array<{ question: string; answer: string }> } const services = await bf.posts.list({ type: 'services' }) // services[0].customConfig is now typed as ServiceConfig | null ``` The default is `unknown` — opt into typing incrementally as the consumer's needs solidify. --- # Installation URL: /docs/sdk/installation > Two packages — the core SDK and an optional Astro adapter. ```sh npm install @brandfine/client # Optional, only on Astro projects: npm install @brandfine/client-astro ``` Both packages ship to public npm with [provenance attestations](https://docs.npmjs.com/generating-provenance-statements) — installs are verified against this repo's CI runs. ## What's in each [#whats-in-each] ### `@brandfine/client` [#brandfineclient] Framework-agnostic. Works in any environment with `fetch` (Node 18+, browser, Bun, Cloudflare Workers, Deno). | Module | What it exports | | ----------------------------- | ---------------------------------------------------------------------------------- | | `@brandfine/client` | `createBrandfineClient`, `BrandfineApiError`, all types | | `@brandfine/client/cache` | `createCache`, `createKeyedCache` | | `@brandfine/client/resolvers` | `pickLocale`, `localizePath`, `stripLocalePrefix`, `isLocale`, `resolveNavigation` | | `@brandfine/client/webhook` | `createBrandfineWebhookHandler`, `verifyWebhookSecret`, `parseWebhookPayload` | ### `@brandfine/client-astro` [#brandfineclient-astro] Astro adapter. Re-shapes the SDK's `Request → Response` webhook handler into Astro's `APIRoute` shape, plus a `readBrandfineEnv()` helper for the `import.meta.env` / `process.env` dual-source quirk. ## Versioning [#versioning] Pre-1.0 (`0.x.y`) is the unstable phase — minor bumps can contain breaking changes (per npm's pre-1.0 semver convention). Pin precisely with `~0.1.0` if you want patches only; use `^0.1.0` to accept all `0.1.x` patches automatically. After `1.0`, normal semver applies — breaking changes in major, additive in minor, fixes in patch. --- # Quickstart URL: /docs/sdk/quickstart > From npm install to rendered nav in under 2 minutes. This walks through a minimal Astro integration end-to-end. For Next.js or other frameworks, the same patterns apply — swap `@brandfine/client-astro`'s `readBrandfineEnv()` for `process.env.*` reads. ## 1. Install [#1-install] ```sh npm install @brandfine/client @brandfine/client-astro ``` ## 2. Construct the client [#2-construct-the-client] ```ts // src/lib/bf.ts import { createBrandfineClient } from '@brandfine/client' import { readBrandfineEnv } from '@brandfine/client-astro' const env = readBrandfineEnv() export const bf = createBrandfineClient({ baseUrl: env.apiUrl, apiKey: env.apiKey, }) export function isBrandfineConfigured() { return Boolean(env.apiUrl && env.apiKey) } ``` ## 3. Wire up the webhook receiver [#3-wire-up-the-webhook-receiver] ```ts // src/pages/api/brandfine-webhook.ts import { createBrandfineWebhookRoute, readBrandfineEnv, } from '@brandfine/client-astro' export const prerender = false export const POST = createBrandfineWebhookRoute({ secret: readBrandfineEnv().webhookSecret, onEvent: ({ event }) => { // Cache invalidations go here as you add caches. console.log(`[brandfine] ${event}`) }, }) ``` ## 4. Add a cache [#4-add-a-cache] ```ts // src/lib/posts-cache.ts import { createKeyedCache } from '@brandfine/client/cache' import { bf, isBrandfineConfigured } from './bf' export const postsCache = createKeyedCache({ label: 'posts', ttl: 60 * 60 * 1000, // 1h fetch: async (locale) => { if (!isBrandfineConfigured()) return [] return bf.posts.list({ type: 'blog', locale }) }, }) ``` Hook the invalidator into the webhook handler: ```ts // src/pages/api/brandfine-webhook.ts import { postsCache } from '@/lib/posts-cache' export const POST = createBrandfineWebhookRoute({ secret: readBrandfineEnv().webhookSecret, onEvent: ({ event }) => { if (event.startsWith('post.')) postsCache.invalidate() }, }) ``` ## 5. Render [#5-render] ```astro --- // src/pages/blog/index.astro import { postsCache } from '../../lib/posts-cache' const posts = await postsCache.get('en') --- ``` That's the loop. Cache reads are cheap, webhook drives invalidation, and your pages stay fresh without polling. Repeat the cache+invalidate pattern for navigations, categories, workspace metadata. --- # Resolvers URL: /docs/sdk/resolvers > Locale helpers and the navigation resolver. ```ts import { pickLocale, localizePath, stripLocalePrefix, isLocale, resolveNavigation, } from '@brandfine/client/resolvers' ``` All pure functions — no I/O, no side effects, no module-level state. Each takes the consumer's locale config as an option. ## Locale helpers [#locale-helpers] ```ts const opts = { locales: ['en', 'pt'], defaultLocale: 'en' } as const // Coerce unknown input to a known locale, fallback to default. pickLocale(Astro.currentLocale, opts) // → 'en' | 'pt' // Add the locale prefix to a path. localizePath('/services/uk-eta', 'pt', opts) // → '/pt/services/uk-eta' localizePath('/services/uk-eta', 'en', opts) // → '/services/uk-eta' (default = bare) // Strip a non-default locale prefix. stripLocalePrefix('/pt/about', opts) // → '/about' stripLocalePrefix('/about', opts) // → '/about' // Type guard. if (isLocale(value, opts.locales)) { /* value: string */ } ``` ## Navigation resolver [#navigation-resolver] Turns the API's locale-agnostic shape into a per-locale render-ready tree: ```ts const nav = await bf.navigations.get('header') if (!nav) {/* render hardcoded fallback */} const hydrated = resolveNavigation(nav, 'pt', { defaultLocale: 'en', }) // hydrated.items: HydratedNavItem[] // Each item: { href: string | null, label: string, type, children } ``` ### What the resolver does [#what-the-resolver-does] * **POST items** — picks the sibling for the active locale, builds `//` via `localizePath`. Falls back to the default-locale URL when no translation exists. * **CUSTOM\_URL items** — runs paths through `localizePath`; external URLs (`http://`, `mailto:`) pass through unchanged. * **HEADING items** — `href: null`, label-only. * **Labels** — picks the active-locale label, falls back to default-locale, then to any populated label (last resort). * **Hidden locales** — items with the active locale in `hiddenLocales` are dropped from the tree. ### Custom URL conventions [#custom-url-conventions] Override `urlForPost` when your consumer doesn't use the default `//` URL shape: ```ts const hydrated = resolveNavigation(nav, 'pt', { defaultLocale: 'en', urlForPost: ({ post, sibling }) => `/blog/${sibling.slug}`, // ignore post type, always under /blog }) ``` Most consumers won't need this — the default convention matches what `localizePath` expects. --- # Webhook handler URL: /docs/sdk/webhooks > Framework-agnostic POST handler. Works on Astro, Next, Remix, Bun, Workers. ```ts import { createBrandfineWebhookHandler } from '@brandfine/client/webhook' const handler = createBrandfineWebhookHandler({ secret: process.env.BRANDFINE_WEBHOOK_SECRET!, onEvent: ({ event, key }) => { postsCache.invalidate() if (event.startsWith('navigation.')) navigationsCache.invalidate(key ?? undefined) }, }) ``` `handler` is a `(Request) => Promise` function — the standard Web Fetch API shape. ## Wiring per framework [#wiring-per-framework] ### Astro [#astro] Use the adapter (handles the `APIRoute` shape): ```ts import { createBrandfineWebhookRoute } from '@brandfine/client-astro' export const POST = createBrandfineWebhookRoute({ secret: import.meta.env.BRANDFINE_WEBHOOK_SECRET, onEvent: (...) => { /* ... */ }, }) ``` ### Next.js (App Router) [#nextjs-app-router] The handler is already in Next's expected shape: ```ts // app/api/brandfine-webhook/route.ts import { createBrandfineWebhookHandler } from '@brandfine/client/webhook' export const POST = createBrandfineWebhookHandler({ secret: process.env.BRANDFINE_WEBHOOK_SECRET!, onEvent: (...) => { /* ... */ }, }) ``` ### Remix [#remix] ```ts // app/routes/api.brandfine-webhook.tsx import { createBrandfineWebhookHandler } from '@brandfine/client/webhook' const handler = createBrandfineWebhookHandler({ /* ... */ }) export const action = ({ request }: { request: Request }) => handler(request) ``` ## Status codes the handler returns [#status-codes-the-handler-returns] | Code | When | Brandfine retries | | ---- | ---------------------------------- | ------------------ | | 200 | Authenticated + `onEvent` returned | — | | 400 | Missing/malformed body | No | | 401 | Missing/wrong secret | No | | 500 | `onEvent` threw | Yes (with backoff) | Throwing from `onEvent` is the right move when invalidation itself failed (e.g., a downstream cache layer was down). The 500 tells Brandfine to retry the delivery. ## Lower-level primitives [#lower-level-primitives] If you need custom routing: ```ts import { verifyWebhookSecret, parseWebhookPayload } from '@brandfine/client/webhook' // Constant-time string compare: verifyWebhookSecret(providedFromUrl, expectedFromEnv) // → boolean // Parse + validate the JSON envelope: const payload = await parseWebhookPayload(request) // throws on bad body — caller turns that into 400 ```