PendiumDocs

Site Export

Read a brand's published pages, posts, profile, business details, and site preview images in one call, to render them on your own site.

GET /api/v1/site/export

Authentication: Required (API key or OAuth token)

Returns everything a brand has published in Pendium as one JSON document: site settings, company profile, business details, every published page with its content, published posts with their bodies, the categories, authors, and listing pages built from them, and the site preview images Pendium drew for the brand. Every page, post, category, author, and listing page carries the exact path Pendium serves it at. Use it when your own front end (Astro, Next.js, a static site generator) renders the content and Pendium is where it gets written.

The response carries an ETag. Send it back in If-None-Match and an unchanged brand answers 304 Not Modified with no body, so checking for new content on a schedule stays cheap.

Authentication

Send a Pendium API key as a bearer token or in the x-api-key header. OAuth access tokens (pendium_oauth_…) work the same way as a bearer token.

curl "https://pendium.ai/api/v1/site/export?syntheticId=123" \
  -H "Authorization: Bearer pendium_xxxxxxxxxxxx"

The key can read any brand your account can open in Pendium: brands you own and brands shared with your team. Keep it on a server or in your build environment. Never ship it in browser JavaScript, because anyone holding it can read every brand on your account.

See Authentication for creating a key.

Query parameters

ParameterTypeDescription
syntheticIdintegerRequired. The brand's agent ID. List yours with /api/visibility/account.
postsCursorstringContinue from a previous response's postsNextCursor. See Paging through posts.
perspectivestringpublished (default) or drafts. Drafts also need a preview token. See Drafts.

What's included

By default, only published content. Drafts, scheduled posts, and archived pages are left out. To show a reviewer unpublished work, request drafts.

The export doesn't depend on whether the brand's Pendium-hosted site is public or which sections it shows. A brand can switch off its Pendium site and render everything on its own domain from this endpoint. Those settings are still reported under site.isPublic and site.enabled if you want to honor them.

The export carries content, not styling. Your front end owns the design: Pendium sends no colors, fonts, logo, or style. The one design-adjacent field is previews, the two site preview images from the brand's Site page, which you can use as reference images for a build. Early builds of this endpoint had a design block that held the previews along with the brand's logo, colors, fonts, style, and a screenshot of its website. That block was removed while schemaVersion stayed 1, so treat it as absent if your code still reads it. The previews moved to the top-level previews field, and the rest isn't exported.

Response

{
  "schemaVersion": 1,
  "syntheticId": 123,
  "perspective": "published",
  "updatedAt": "2026-09-09T22:18:54.331Z",
  "site": {
    "brandName": "Acme Advisors",
    "username": "acme",
    "title": "Acme Advisors",
    "tagline": "Tax planning that happens all year",
    "description": "Proactive tax planning and outsourced accounting.",
    "isPublic": true,
    "hostingMode": "platform",
    "customDomain": null,
    "customDomainVerified": false,
    "subpath": null,
    "url": "https://pendium.ai/acme",
    "canonicalOrigin": null,
    "analytics": null,
    "enabled": { "pages": true, "posts": true },
    "postsEnabled": true,
    "pathMode": "platform",
    "home": "page",
    "postsLabel": null,
    "updatedAt": "2026-08-05T00:00:00.000Z"
  },
  "profile": {
    "name": "Acme Advisors",
    "tagline": "Tax planning that happens all year",
    "website": "https://acme.com",
    "description": "…",
    "productsOrServices": ["Tax planning", "Outsourced accounting"],
    "aiReadyAnswers": [{ "question": "…", "answer": "…", "group": "…" }],
    "updatedAt": "2026-08-03T00:00:00.000Z"
  },
  "facts": {
    "source": "row",
    "phone": "(530) 555-0100",
    "email": "team@acme.com",
    "address": "555 Main St, Chico, CA 95928",
    "hours": [{ "day": "Monday", "hours": "8 AM–5 PM" }],
    "serviceAreas": [],
    "services": ["Tax preparation"],
    "testimonials": [{ "quote": "…", "author": "Dana", "source": "google" }],
    "rating": { "value": 4.8, "count": 52, "source": "google" },
    "bookingUrl": null,
    "updatedAt": "2026-09-01T00:00:00.000Z"
  },
  "pages": [
    {
      "id": "7e031df2-…",
      "pageType": "custom",
      "category": "resource",
      "status": "published",
      "slug": "tax-advice-for-home-care-businesses",
      "title": "Tax Advice for Home Care Businesses",
      "metaDescription": "…",
      "ogImageUrl": null,
      "isHome": false,
      "url": "https://pendium.ai/acme/tax-advice-for-home-care-businesses",
      "path": "/tax-advice-for-home-care-businesses",
      "publishedAt": "2026-08-02T00:00:00.000Z",
      "updatedAt": "2026-08-04T00:00:00.000Z",
      "content": {
        "title": "Tax Advice for Home Care Businesses",
        "heroImageUrl": null,
        "heroImageAlt": null,
        "sections": [
          { "id": "hero", "type": "hero", "headline": "…", "subheadline": "…" },
          { "id": "intro", "type": "prose", "heading": "…", "body": "Markdown…" },
          { "id": "faq", "type": "faq", "items": [{ "question": "…", "answer": "…" }] }
        ]
      }
    }
  ],
  "categories": [
    { "slug": "tax", "name": "Tax", "description": "Planning and filing.", "path": "/category/tax" }
  ],
  "authors": [
    {
      "slug": "dana-lee",
      "name": "Dana Lee",
      "bio": "CPA, 15 years with owner-run businesses.",
      "avatarUrl": "https://…/dana.png",
      "isDefault": true,
      "path": "/author/dana-lee"
    }
  ],
  "indexes": [
    {
      "kind": "feed",
      "path": "/feed",
      "title": "Acme Advisors",
      "intro": "Tax planning that happens all year"
    },
    {
      "kind": "resources",
      "path": "/resources",
      "title": "Resources",
      "intro": "Explore what Acme Advisors has to offer.",
      "items": [
        {
          "name": "Tax Advice for Home Care Businesses",
          "description": "…",
          "path": "/tax-advice-for-home-care-businesses"
        }
      ]
    }
  ],
  "aliases": [
    { "from": "/profile", "to": "/" },
    { "from": "/blog", "to": "/feed" },
    { "from": "/5-things-owners-get-wrong-about-tax-planning-06069b", "to": "/5-things-owners-get-wrong-about-tax-planning" }
  ],
  "redirects": [
    { "from": "/payroll-checklist-for-new-clinics", "to": "https://blog.example.com/payroll-checklist-for-new-clinics/" }
  ],
  "previews": {
    "postImageUrl": "https://…/post.jpg",
    "resourcesImageUrl": "https://…/resources.jpg",
    "generatedAt": "2026-09-20T12:00:00.000Z"
  },
  "posts": [
    {
      "id": "06069b31-…",
      "slug": "5-things-owners-get-wrong-about-tax-planning",
      "title": "5 things owners get wrong about tax planning",
      "status": "published",
      "metaDescription": "A breakdown of the most common tax planning myths.",
      "tags": ["tax planning", "small business"],
      "categories": [{ "slug": "tax", "name": "Tax" }],
      "body": "Markdown…",
      "imageUrl": "https://…/hero.jpg",
      "url": "https://pendium.ai/acme/5-things-owners-get-wrong-about-tax-planning",
      "path": "/5-things-owners-get-wrong-about-tax-planning",
      "categorySlugs": ["tax"],
      "authorSlug": "dana-lee",
      "heroImageUrl": "https://…/hero.jpg",
      "publishedAt": "2026-08-10T00:00:00.000Z",
      "updatedAt": "2026-08-12T00:00:00.000Z"
    }
  ],
  "postsNextCursor": null
}

Timestamps are ISO 8601. Every entity carries its own updatedAt, and the top-level updatedAt is the latest of them. The response has no _pendium block, unlike the Visibility endpoints.

site

The brand's site settings and where its site lives. url is the public address of the site, and each page and post url is that address plus the entity's path. A post's url is null whenever the site doesn't serve the post: its path is null, or postsEnabled is false (the post keeps its path then, but nothing serves it). Which address depends on who serves the site:

  • A site Pendium serves: its Pendium address, following the brand's custom domain or subpath setup once verified. These URLs only load when Pendium is serving that surface: if isPublic is false, or enabled.pages / enabled.posts is false, Pendium doesn't serve them.
  • A site built for the brand and served outside Pendium: the address it is live at (the brand's domain or subpath, or its preview address once that is public). Until it has one, every url is null.
  • A brand with no site yet: url is null on the site and on every page and post. path is still set, so a renderer can place each entity on its own host.

If you're building the brand's site, take absolute URLs from canonicalOrigin plus each path. Don't derive them from site.url or any page or post url: those point at wherever the content is served today, which can be Pendium's own address.

FieldTypeMeaning
canonicalOriginstring or nullThe address to build absolute URLs on: canonical tags, og:url, sitemap and feed links. The brand's verified custom domain or subpath (a subpath keeps its folder, e.g. https://acme.com/agents), else, for a site served outside Pendium, its routing address (its preview address until a domain verifies), else null. Set before the site is public, unlike url. No trailing slash, so a canonical URL is canonicalOrigin followed by path.
analytics{ siteKey } or nullPendium analytics for this site. With a siteKey, add <script defer src="https://pendium.ai/a.js" data-site="{siteKey}"></script> to every public page (never to draft previews). Use a classic script tag, not type="module". null, or missing in older responses, means analytics is off: add no tag. The key is public and names exactly one brand.
postsEnabledbooleanWhether the site serves its post routes: every post's path, /category/*, /author/*, and /feed. Same value as enabled.posts. When false, posts, categories, and authors are still in the response, but none of their paths is served.
pathMode"custom_domain", "subpath_proxy", or "platform"The host every path and alias is computed for: the one url points at. See Paths.
home"page", "feed", or nullWhat the site root / shows. page: exactly one page has isHome: true, and it is the only entity whose path is /. feed: the post list. null: nothing, and Pendium redirects / to /feed. With Pages off, Home is never a page.

profile

The company profile: who the brand is, what it offers, who it serves, and its prepared answers to common questions. null if the brand hasn't set one up.

facts

Contact and business details. source tells you where they came from:

sourceMeaning
rowSaved by the brand in Pendium. updatedAt is when they last changed.
listingsNothing saved yet, so these are read from the brand's Yelp or Google listing. updatedAt is null.
noneNeither exists. Every field is empty.

pages

Every published page, in the brand's page order. pageType is one of homepage, profile, services_index, resources_index, faq, or custom. Custom pages also have a category: product, resource, or service_area. isHome marks the page the site root renders, so its path is / and its url is the site root. That is usually the page the brand chose as Home, with one exception: if the chosen page is stored under the offerings slug (a custom products page while the services index is labelled Products), the root renders the services index, so the services index has isHome: true and the chosen page has path: null.

path (string or null) is where the site serves the page, relative to the site root: / for the page with isHome: true, otherwise / plus its public slug. The services index answers at the brand's offerings slug (/services, /products, or /features), not its stored slug. path is null when Pendium serves no page at that address: Pages is switched off (enabled.pages), the slug is a route reserved on this host (a page stored as feed is never reachable there), or another page owns the address. See Paths.

A page's updatedAt is the latest edit to the page or to its content.

content.sections holds the page as structured data rather than markup:

typeFields
heroheadline, subheadline, eyebrow, cta, secondaryCta, imageUrl, imageAlt
proseheading, body (Markdown), imageUrl, imageAlt
listheading, intro, items[] (title, description, details[], href, imageUrl)
faqheading, intro, items[] (question, answer)
statsheading, intro, items[] (value, label)
galleryheading, images[] (url, alt, caption)
ctaheading, body, cta (label, href)
tableheading, columns[], rows[][]
embedheading, provider, url

When a prose body names another of the brand's pages, or a page on its main site, the name is a real Markdown link to it. Links to the brand's own site, in prose bodies and in every href, are site-relative paths from this response (see Links in content). Other text fields (FAQ answers, list item descriptions, CTA text) are plain text and normally carry the name only. When one does hold a Markdown link, it's rewritten the same way: a link that resolves gets its new target, and one that doesn't is replaced by its text. In the external-links section (the brand's main-site pages), title is the page's name without the site-name suffix.

Every section also has an id, unique within the page, and may have a variant layout hint you're free to ignore. New section types may be added over time, so skip any type your renderer doesn't recognize.

Two list variants change what the item fields hold. In a quotes list (customer reviews), each item's title is the quote, verbatim, and description is who wrote it, when known. In a contact list, title is the label (Phone, Address, Hours) and description is the value.

posts

Published posts, newest first, up to 100 per response. body is Markdown, cleaned the same way the brand's public post page is, with links to the brand's own site rewritten to site-relative paths (see Links in content). metaDescription and tags are separate fields, and imageUrl is the post's hero image, or null.

FieldTypeMeaning
pathstring or null/ plus the post's slug. null when a page, a listing page, or a reserved route owns that address, because Pendium serves that instead and the post can't be reached there, or when the post route can't reach the post at that slug. See Paths.
categorySlugsstring[]The slug of each entry in categories whose category page loads, in the same order. Each one matches an entry in the top-level categories.
authorSlugstring or nullThe post's writer. A post with no writer belongs to the brand's default writer. null when there's neither, or the writer has no author page. Matches an entry in the top-level authors.
heroImageUrlstring or nullThe post's hero image, the one the sitemap lists: the generated image first, then an external one. Only http(s) URLs.

categories

Categories with at least one post and a category page that loads, in the brand's category order. First response only; the list covers every post, not just the first 100.

FieldTypeMeaning
slugstring
namestring
descriptionstring or null
pathstring/category/{slug}. The category page lists that category's posts.

authors

Writers with at least one post and an author page that loads, in the brand's writer order. First response only; counted over every post.

FieldTypeMeaning
slugstring
namestring
biostring or null
avatarUrlstring or nullOnly http(s) URLs.
isDefaultbooleanThe brand's default writer. It also owns every post with no writer, so it can be listed with posts that name nobody.
pathstring/author/{slug}. The author page lists that writer's posts.

indexes

Listing pages the site serves that have no entry in pages. First response only. Each has a kind, a path, a title and an intro, and every kind but feed has items:

FieldTypeMeaning
titlestringThe page heading Pendium shows: the brand's offerings label (Services when unset), Resources, Service Areas, or for feed the site title, else the posts label.
introstring or nullThe line under the heading. For feed, the site tagline.
itemsarrayWhat the page lists, in order. Each has name, description (string or null) and path (string or null). A page's path is null when the site serves the page nowhere (the same rule as pages[].path), so render it as text too. Absent on feed: its entries are posts.

The offerings index lists each offering page (the meta description, else the page's subheadline, as description), then each entry in profile.productsOrServices that no page covers, with path: null. Render those as text, not links. A page covers an entry when, ignoring case, spaces, punctuation and ™/®, the entry (or its name before -, – or : ) matches the page's title or slug, or one contains the other and the shorter is at least 4 characters. So the entry "Email Archiving" isn't listed next to the page "Email Archiving and Discovery". Resources and service areas list their pages.

kindpathPresent when
feed/feedsite.postsEnabled is true. The page lists every post.
offerings/services, /products, or /features (the brand's offerings label)At least one custom page isn't a resource, and the brand has never published its own services index page. Lists those pages.
resources/resourcesAt least one custom page is a resource, and the brand has never published its own resources index page.
service_areas/service-areasAt least one custom page is a service_area.

The offerings, resources, and service-areas indexes need Pages on (enabled.pages). An index is left out when a page in pages already owns its address. A brand that published a services or resources index page and later unpublished it gets no listing page there.

Post bodies and page content can link to the brand's own site in several forms: its Pendium address (https://pendium.ai/{username}/…, also on www.pendium.ai), its own domain, subpath or preview address, a root-relative link, or an older /{username}/{old blog name}/{post} shape. In Markdown links ([text](url)), reference definitions, and page section hrefs (and in Markdown links inside list, FAQ and stats intros, list item descriptions, FAQ answers and the CTA body), each of these is rewritten to the canonical path of its target in this response, keeping any query string and #fragment, so you can serve the links as they are on any host. An old post address under the brand's own name or host resolves the way Pendium resolves it: by its last segment, through aliases, or by the six-character id at the end of an older post slug. A bare root-relative link (/something) resolves only when it's exactly a path or an alias in this response, because on Pendium it could have pointed at another brand. One that isn't was copied from the brand's main website, so it becomes the absolute URL on that site (the host of profile.website), as written: /services/jar-replay/ becomes https://jarpodcasts.com/services/jar-replay/. When the brand has no usable main website (none; a directory, social, link-in-bio, ordering or encyclopedia page; a profile on a publishing platform such as YouTube or Medium; or a Pendium address), or the main-site address would be the site's own (the path is under its subpath folder on that host), it's handled like any other link that doesn't resolve, below.

A Markdown link to the brand's site that resolves to nothing the site serves (a deleted post, a page that's switched off) is removed and its text kept, and an optional page section href (a list item, a hero button) is dropped. Some links are left exactly as written, so check them yourself if your build rejects dead links:

  • a cta section's href, which is required, when it doesn't resolve;
  • a reference definition ([label]: url) that doesn't resolve, since it has no text to fall back to;
  • autolinks (<https://…>), bare URLs in the text, and raw HTML links, which aren't rewritten at all.

Links to anywhere else, including other Pendium pages, are left exactly as written. Image URLs are never changed.

aliases

Addresses the site answers today that aren't anyone's canonical path, each with the canonical path a visitor lands on. First response only. Serve a 301 from each from to its to, so every link that works on Pendium keeps working on your site.

FieldTypeMeaning
fromstringThe old or extra address. Never equal to a canonical path in the same response.
tostringThe canonical path it lands on: /, a page, an index, or a post. Always in the response.

The list holds:

AliasLands onWhen
The Home page's own slug, e.g. /profile/A page is Home. Pendium renders Home at its slug too; its canonical is /. When the services index is Home, that's its offerings slug (e.g. /products) and /services.
/servicesthe offerings index, e.g. /productsThe offerings label isn't Services. The old address still shows the offerings index.
/blog/feedPosts are on, and site.pathMode isn't subpath_proxy.
The old blog slug/feedsite.pathMode is platform and the brand renamed its blog.
/{username}/site.pathMode is custom_domain and something is Home.
A post's earlier slugthe postPosts are on and the post's slug was changed. One entry per earlier slug.
A post's legacy {title}-{id} slugthe postPosts are on. Pendium's first URL shape for posts, for example /5-tips-for-tax-season-06069b.

An alias is left out when a page, a post, or a route reserved on this host answers that address first. A few address families redirect by pattern rather than one by one, so they aren't listed. Handle them with a rule if you need them:

  • any single segment ending in - plus four or more characters that begin a post's id, which redirects to that post (/anything-06069b)
  • on a custom domain, /{username}/{rest}, /ap/{username}/{rest}, and /page-doc/{username}/{rest}, which redirect to /{rest} (/ when rest is empty or profile), and /blog/{rest}, which redirects to /{rest}

redirects

Posts the brand moved from Pendium to its own WordPress or Webflow blog. Each old address of a moved post, with the absolute URL of the post's new home. First response only. Serve a permanent redirect (308) from each from to its to, as Pendium does, so the post's links and search ranking follow it.

FieldTypeMeaning
fromstringThe old address: the post's slug, an earlier slug, or its legacy {title}-{id} slug.
tostringAn absolute http/https URL on another site.

A moved post isn't in posts, the canonical paths, or the sitemap. A from never equals a canonical path or an alias from: a live post or page always owns its address. The list is empty while site.postsEnabled is false. Moving a post back to Pendium removes its entries and returns it to posts.

previews

The latest set of site preview images Pendium drew for the brand, the same two images its Site page shows. First response only. null until a first set has drawn.

FieldTypeMeaning
postImageUrlstring or nullA preview of a post page.
resourcesImageUrlstring or nullA preview of the resources index.
generatedAtstringWhen this set finished drawing.

While a new set is drawing, or after one fails, you get the previous set. The new one replaces it once it finishes, with a later generatedAt. Either image can be null if only one of the two drew. Image URLs are public http(s) addresses.

The previews are reference images, not a design spec: they show one take on the brand's site, not its colors, fonts, or logo. A finished set changes the ETag. Starting a new set, or a set that fails, doesn't. previews isn't counted in the top-level updatedAt.

Paths

Every path is relative to the site root: no host, no /{username} prefix, and no trailing slash. It's the address Pendium serves the entity at today on the host in site.pathMode, so a site that serves every canonical path and redirects every alias needs no other redirects when the domain moves.

site.pathMode is the host the brand's canonical URLs use (site.url): custom_domain for the brand's own domain, subpath_proxy for a folder on its main site behind a reverse proxy, platform for pendium.ai/{username}. Paths follow that host's rules, because a few single-segment addresses are answered before any page or post, and the set differs by host:

pathModeReserved single segments
custom_domainfeed, blog, the brand's username, api-catalog, and the files sitemap.xml, robots.txt, feed.xml, llms.txt, llms-full.txt
subpath_proxyfeed, api-catalog, and the same files. blog isn't reserved here.
platformfeed, blog, the brand's old blog slug, and the same files. api-catalog goes to a page that has that slug, else to the catalog.

The canonical addresses the site serves are:

  • /, when site.home isn't null (served by the page with isHome: true when site.home is page)
  • every non-null page path
  • every indexes[].path
  • when site.postsEnabled is true: every non-null post path, every categories[].path, and every authors[].path

Add every aliases[].from and redirects[].from to get the full set of addresses the site answers. No two entities share a canonical path, and no alias shares one with anything.

The canonical set is the same as the brand's Pendium sitemap (/sitemap.xml), with these differences:

  • The sitemap lists the RSS file /feed.xml where this list has /feed.
  • The sitemap stops at the 500 newest posts, where the export includes every post.
  • The sitemap still lists a page or post that another route answers first (below). The export gives it no path.
  • The sitemap lists a category or writer whose slug the category or author route can't match. The export leaves it out of categories and authors, because that page doesn't load.

When two things claim one address, Pendium serves the first of these and the export gives the others no path:

  1. a segment reserved on this host (table above)
  2. a page, including the offerings, resources, and service-areas listing pages. Between two pages, the services index owns the offerings slug and /services, and otherwise the page stored under that slug wins. The same rule decides /: if the page chosen as Home is stored under the offerings slug (a custom products page while the services index is labelled Products), the root shows the services index, so the services index gets path: "/" and isHome: true, its offerings slug becomes an alias of /, and the chosen page gets path: null.
  3. a post

Switches:

  • site.isPublic doesn't change paths. They say where each thing is served once the site is public.
  • With enabled.pages off, Pendium serves no page: every page path is null, no page is Home, the offerings, resources, and service-areas indexes are left out, and a post can have an address a page would otherwise take. On a custom domain or subpath proxy the offerings, resources, and service-areas slugs still aren't served to posts.
  • With site.postsEnabled off, the post list can't be Home, /feed isn't in indexes, and the post aliases and /blog are left out.

A post's path is also null when the post route can't reach it: its slug isn't one the route accepts (lowercase letters, digits, and hyphens), or the post has no saved slug and its generated one can't be matched to it (a title with no letters or digits, or another post whose id starts with the same six characters). A category or writer is left out when its slug isn't one the category or author route accepts (letters, digits, -, and _, starting with a letter or digit).

Paging through posts

A brand with more than 100 published posts returns the first 100 and a postsNextCursor. Pass it back to get the next batch:

curl "https://pendium.ai/api/v1/site/export?syntheticId=123&postsCursor=eyJrIjoi…" \
  -H "Authorization: Bearer pendium_xxxxxxxxxxxx"

A follow-up response holds only schemaVersion, syntheticId, perspective, posts, and postsNextCursor. Keep going until postsNextCursor is null. Each batch has its own ETag.

Checking for changes

curl -i "https://pendium.ai/api/v1/site/export?syntheticId=123" \
  -H "Authorization: Bearer pendium_xxxxxxxxxxxx" \
  -H 'If-None-Match: "GsTtJ6fo93cW93hxaJhXhualYI0Z4GI5"'

If nothing changed, you get 304 Not Modified and can keep your copy. The response is marked Cache-Control: private, no-cache: you can store it, but check with If-None-Match before reusing it.

Edits show up on the next request. The ETag covers the whole brand, not only the posts in that response: if a post in a later batch changes, the first response's ETag changes too. So a single conditional request for the first document tells you whether anything changed.

Drafts

perspective=drafts returns the site as it will look once everything written is published: published content plus draft pages, draft posts, and scheduled posts. Pages still being generated and archived pages stay out. Each page and post carries its status (draft, scheduled, or published), so your preview can label unpublished work.

Paths, site.home, categories, authors, indexes, and aliases are computed over that same set. A category or writer whose only posts are drafts is listed, a draft page can become Home, and a draft page takes its address from a post with the same slug. The published response never counts drafts.

Drafts need two credentials: your API key, as usual, and a preview token for that brand in the X-Pendium-Preview-Token header. Your key alone never returns drafts, so a site that shows drafts only when a reviewer brings a token can't expose them to the public by accident.

curl "https://pendium.ai/api/v1/site/export?syntheticId=123&perspective=drafts" \
  -H "Authorization: Bearer pendium_xxxxxxxxxxxx" \
  -H "X-Pendium-Preview-Token: pendium_preview_xxxxxxxxxxxx"

A preview token lasts 30 days. A Pendium user creates one with the create_site_preview_token MCP tool, or Pendium creates one when they open a site Pendium routes from its "View site" button: they sign in, their access to the brand is checked, and they land on the site with a fresh token in the link. It's tied to the brand and to the person who created it, and stops working if they lose access to the brand. A typical setup: your site takes the token on a preview link, stores it in a short-lived cookie for that reviewer, and sends it with each export request while the cookie lasts.

Draft responses are marked Cache-Control: private, no-store and have their own ETag. A draft post's slug is the one it will publish at, unless someone changes it before publishing. A draft's url is the address it will have once published; Pendium doesn't serve it before then.

When you send an API key, a 401 always means that key; a missing or expired preview token is a 403, so your site can end the reviewer's preview without treating its key as broken.

Drafts without an API key

A site that renders code it doesn't fully trust, such as a preview of a requested change, can read drafts with the preview token alone. Send perspective=drafts and the X-Pendium-Preview-Token header, and no Authorization or x-api-key header:

curl "https://pendium.ai/api/v1/site/export?syntheticId=123&perspective=drafts" \
  -H "X-Pendium-Preview-Token: pendium_preview_xxxxxxxxxxxx"

The token then works as the credential, for that one brand's drafts and nothing else. Without a key, a token works for one hour after it's created, not 30 days, so a link that leaks stops working quickly. When it's refused, send the reviewer back through Pendium (the /sites/preview link) for a fresh one. Its creator's access to the brand is checked on every request. Paging with postsCursor works the same way. Without a key, the published perspective and every other endpoint stay closed. If the token is invalid, over an hour old, for a different brand, or its creator lost access, the answer is 401 with INVALID_PREVIEW_TOKEN. If you send a key and it's invalid, the token is never tried in its place.

Errors

StatuscodeWhen
400INVALID_SYNTHETIC_IDsyntheticId is missing or not a positive integer.
400UNSUPPORTED_PERSPECTIVEperspective is something other than published or drafts.
400INVALID_CURSORpostsCursor wasn't issued by this endpoint.
401NO_API_KEYNo key or token was sent.
401INVALID_API_KEYThe key or token is invalid or revoked.
403PREVIEW_TOKEN_REQUIREDperspective=drafts without an X-Pendium-Preview-Token header.
403INVALID_PREVIEW_TOKENThe preview token is invalid, expired, for a different brand, or its creator lost access.
401INVALID_PREVIEW_TOKENSame, when the token is the only credential (drafts without an API key).
404NOT_FOUNDThe brand doesn't exist, or your account can't open it.
429RATE_LIMITEDToo many requests. See Rate limiting.

Error bodies look like { "error": "…", "code": "NOT_FOUND" }.

On this page