Help Center
help.berchtold.ai/en/sites/content-api-developer-guide
Browse topics

Websites

Content API developer guide

For developers. Read a Berchtold website's published content with GraphQL, and build its pages with your own code.

Workspace settingAvailability is controlled by your workspace. Ask your administrator about access.

This guide is for the developer who builds a website's front-end. The people who run the website write its content in Berchtold. Your code reads the published content and draws the pages. For their side of the setup, read Show your website with your own front-end.

The address and the key

Send every request to one address:

https://mcp.berchtold.ai/content/v1/graphql

Put the key in the Authorization header: Bearer bck_live_…. The website's owner makes keys in the Hosting and delivery card, under Content API keys.

  • A server key works only from a server. A request with an Origin header is refused.
  • A browser key works only from the websites named on it. Use it for requests from a visitor's browser, such as a post search.
  • A key reads one website, and only its published content. To read a draft, use a preview token.

Your first query

Send a POST with a JSON body, or a GET with query and variables in the address.

curl https://mcp.berchtold.ai/content/v1/graphql \
  -H "Authorization: Bearer $BERCHTOLD_CONTENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ site { name } route(path: \"/about\") { __typename ... on Page { title } } }"}'

What you can ask for

type Query {
  site: Site!
  route(path: String!, locale: String): Route!
  menu(location: MenuLocation!, locale: String): Menu
  posts(first: Int = 10, after: String, category: String, tag: String, author: String, locale: String): PostConnection!
  categories(locale: String): [Term!]!
  tags(locale: String): [Term!]!
  authors: [Author!]!
  sitemap: [SitemapUrl!]!
  redirects: [RedirectRule!]!
  preview(token: String!): Route
  page(path: String!, locale: String): Page
  page_list(first: Int = 10, after: String, locale: String): PageConnection!
  post(path: String!, locale: String): Post
  post_list(first: Int = 10, after: String, locale: String): PostConnection!
  # One pair for each content type, for example:
  neighborhood(path: String!, locale: String): Neighborhood
  neighborhood_list(first: Int = 10, after: String, locale: String): NeighborhoodConnection!
}
union Route = Page | Post | Neighborhood | Archive | LegalPage | Redirect | NotFound

Introspection works, so your GraphQL tools can read the full schema.

  • route(path) answers what is at an address. Use it for a catch-all route. The answer is one of these:
    • an entry: a page, a post or a content type entry;
    • an archive: the posts page, or a category, tag, date or author list;
    • a legal page, a redirect, or not found.
  • Names come from Berchtold. A content type neighborhood is the type Neighborhood. Its fields keep their keys, like median_price. The schema changes when someone adds a content type or a field.
  • Languages. Add locale, like "es", to get that language version of an entry. alternates lists the other versions.
  • Search engines. Each entry has seo { title description noindex canonical }. canonical uses your front-end's address. sitemap and redirects help you build a sitemap and redirect rules.

Field types

In BerchtoldIn GraphQL
Text, Paragraphs, ChoiceString (a choice with more than one answer: [String!]!)
Formatted textString, as clean HTML
NumberFloat
DateString, as YYYY-MM-DD
Yes or noBoolean
Image, ImagesImage { url width height alt }, or a list of them
FileFile { url name }
LinkLink { href label }
Linked entries[Entry!]!, published entries only

Each entry also lists all its values in fields { key label type value }.

Sections

Pages are built from sections. Each section is { id type props }. props holds its settings as JSON, such as a heading, text, images and buttons. Your code draws each section type it uses.

  • A form section gives form { embed_url }. Show the form in a frame with that address.
  • A listings section gives home_search { code loader }. MLS listings never come through the API. Place the home search frame from code on the page, and add loader once on every page. The website needs home search set up first. Without it, home_search.enabled is false.
  • A blog section gives its posts, categories, tags or months in blog.

Pages of results

Lists take first (1 to 100) and after. Each answer gives the cursor for the next page.

Limits and errors

  • A query can be 20,000 characters and 8 levels deep. Large lists inside lists cost more. A query that costs too much is refused before it runs.
  • Each key can make 1,200 requests a minute. Over that, the answer is 429 with Retry-After.
  • Errors come in GraphQL's shape, with a code in extensions.code:
    • missing_key, unknown or revoked: send a valid key.
    • server_key_in_browser or origin_not_allowed: use the right kind of key in the right place.
    • front_end_off: the website is not set to use its own front-end.
    • rate_limited or too_large: wait, or ask for less.

Caching

Each answer has an ETag and a Berchtold-Release header. Send If-None-Match with the ETag, and an answer that did not change comes back as 304. The best way to stay current is the publish notice.

Publish notices

When someone publishes, Berchtold sends a POST to your publish notice address:

{ "site_id": "site-8c62f47b3e", "release_id": "r_93", "paths": ["/about", "/"], "menus": true, "settings": false }
  • paths are the addresses whose content changed. Lists, such as the posts page, can change with any publish, so rebuild them on every notice.
  • settings: true with no paths means: rebuild every page.
  • A scheduled post is published at its time without a notice. Rebuild at least once a day.

Check every notice. The Berchtold-Signature header is t=…,v1=…. v1 is the HMAC-SHA256, in hex, of t, a dot and the raw body, with the signing secret. Refuse a notice older than 5 minutes.

// app/api/revalidate/route.ts (Next.js): the publish notice address.
import crypto from "node:crypto"
import { revalidateTag } from "next/cache"

export async function POST(request: Request) {
  const body = await request.text()
  const parts = Object.fromEntries((request.headers.get("berchtold-signature") ?? "").split(",").map((p) => p.trim().split("=")))
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
  const expected = crypto.createHmac("sha256", process.env.BERCHTOLD_SIGNING_SECRET!).update(`${parts.t}.${body}`).digest("hex")
  const valid = /^[0-9a-f]{64}$/.test(parts.v1 ?? "") && crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(parts.v1, "hex"))
  if (!fresh || !valid) return new Response("Bad signature", { status: 401 })
  const notice = JSON.parse(body) as { paths: string[]; settings: boolean }
  if (notice.settings || !notice.paths.length) revalidateTag("berchtold")
  else for (const path of notice.paths) revalidateTag(`path:${path}`)
  return new Response(null, { status: 204 })
}

Answer with a 2xx status. Berchtold tries a failed notice 4 times. After 3 failed notices in a row, it tells the website's owner.

Previews

The website's owner enters your preview address with {token} and {path} in it, like https://www.yourwebsite.com/api/preview?token={token}&path={path}. Preview in the editor opens it. Turn on your draft mode, then ask preview(token: "…") instead of route(path: "…"). A token lasts 30 minutes and works only with your website's key.

A Next.js example

// lib/berchtold.ts (server only). BERCHTOLD_CONTENT_KEY is a server key.
export async function berchtold<T>(query: string, variables?: Record<string, unknown>, tags: string[] = ["berchtold"]): Promise<T> {
  const response = await fetch("https://mcp.berchtold.ai/content/v1/graphql", {
    method: "POST",
    headers: { "content-type": "application/json", authorization: `Bearer ${process.env.BERCHTOLD_CONTENT_KEY}` },
    body: JSON.stringify({ query, variables }),
    next: { tags, revalidate: 86400 },
  })
  const json = await response.json()
  if (json.errors?.length) throw new Error(json.errors[0].message)
  return json.data as T
}
// app/[[...path]]/page.tsx
import { notFound, permanentRedirect } from "next/navigation"
import { berchtold } from "@/lib/berchtold"

const ROUTE = `query Route($path: String!) {
  route(path: $path) {
    __typename
    ... on Entry { title seo { title description canonical noindex } sections { id type props form { embed_url } home_search { code } } }
    ... on Archive { title entries { title path excerpt } page pages }
    ... on Redirect { to code }
  }
}`

export default async function Page({ params }: { params: Promise<{ path?: string[] }> }) {
  const path = `/${(await params).path?.join("/") ?? ""}`
  const { route } = await berchtold<{ route: any }>(ROUTE, { path }, ["berchtold", `path:${path}`])
  if (route.__typename === "Redirect") permanentRedirect(route.to)
  if (route.__typename === "NotFound") notFound()
  return <Sections sections={route.sections ?? []} />
}

Sections is your own component. It draws each section by its type.

  • Tracking. Add the tracking script from Sites, on the Installation tab, to every page. Berchtold already tracks your front-end's domain.
  • Forms. Show a form section's embed_url in a frame. Submissions go to the website's CRM.
  • Home search. Use the frames from the listings sections. Read Show home search on your own website.

Market reports, listing alerts and sign-in emails link to three pages on your front-end. The website's owner enters their addresses in the Hosting and delivery card:

  • Market page, with {market} where the market's web address (its slug) goes, like /markets/{market}. Listing cards in emails open it too. Show the market with the home search frames.
  • Visitor account page. If the website has home search, sign-in links open its search page instead, and the frame signs the visitor in. Otherwise the link opens this page with #t= and a one-time code.
  • Contact page. The market report's home value link opens it.

Berchtold opens each page once to check it. Each must answer with a 2xx status on your front-end's own host, after at most 3 redirects. Reports for the website wait until every page answers.

What is not in the API

  • MLS listings, market numbers and listing photos. Use the home search frames.
  • Visitor accounts and saved homes. The home search frames have them.
  • Form answers, contacts and other CRM data.
  • Visit and analytics data.
  • Writing content. People write in Berchtold, or in an AI app through the MCP.
  • The standard text of the legal pages. A legal page gives the website's legal details and its own sections.

Updated November 11, 2026

Content API developer guide · Berchtold Help