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), on a Starter or Pro plan (plan required; a key from a free account gets a 403 with code: "PLAN_REQUIRED"). A keyless draft read with a preview token isn't affected.

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
rowBusiness Info exists. Owner corrections are retained; imported fields use current stored listing observations. updatedAt is the save date.
listingsNothing saved yet, so these are read from the brand's Yelp or Google listing. updatedAt is null.
noneNeither exists. Every field is empty.

A record can contain both owner corrections and imported observations. An imported phone can update while a corrected email stays in place. Saving the record does not refresh the observation date of its imported fields. The export keeps its existing response shape; it does not expose per-field provenance.

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. A page built from the brand's own site can cite a fact's source page right after the sentence, as ([/pages/press](https://…/pages/press)). In a prose body that citation stays a Markdown link. In the plain-text fields (list item titles, descriptions and details, stat values and labels, FAQ questions and answers, section intros, the hero lines, the CTA body, gallery captions and table cells) it's removed and the sentence is kept. 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, or /{directory}/{slug} when the brand has a blog directory. 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} (under the blog directory when there is one). 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} (under the blog directory when there is one). 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/feed, or /{directory} with a blog directorysite.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.
/feed, /category/{slug}, /author/{slug}, and each post's addresses at the top levelthe same entity under the directoryPosts are on and the brand has a blog directory. A post's top-level address is left out where a page answers it.
An earlier directory, and every address under itthe post list, or the same entityPosts are on and the brand changed or removed its blog directory. A bare earlier directory is left out where a page answers it.

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.

tools

The brand's live tools, oldest first: each one Pendium built into the site, at the revision its last merged build made live. First response only. Empty until a build of a tool merges; absent in older documents, which means the same.

toolsInBuild has the same fields: each tool with a build in progress, at the revision being built. It is for the build workflow. A renderer serves tools only.

FieldTypeMeaning
idstringThe tool's id.
slugstringThe tool's slug, the last segment of path.
namestringThe tool's name.
taglinestring or nullA one-line pitch, for an index or a share card.
pathstringWhere the site serves the tool: /tools/{slug}. Serve it on every host, like a page.
revisionnumberThe revision live on the site (in toolsInBuild, the one being built). Send it with every run.
pausedbooleanThe owner paused visitor runs: keep the page up, say the tool is taking a break, and expect the run relay to answer 503.
publishedAtstring or nullWhen the tool first went live.
updatedAtstring or nullThe tool's last change (counted in the top-level updatedAt).
copyobjecttitle, intro and method: the text to print as real HTML around the frame, so crawlers read what the tool is and how it works without rendering it. method is empty when the page has no "How we built this" note.
contractobjectThe form's shape, the result's shape and the reveal: version, name, inputs[] (key, label, type of text, textarea, number, select or multiselect, required, help, placeholder, options, min, max, unit, maxLength), output (kind of report, checklist, score, plan or comparison, plus its sections, scale, bands or columns) and, when the tool collects leads, reveal (title, cta, optional note: the gate's words, written for this tool). Never the method or the model: those stay on Pendium.
pageobjecthtml: the page to put in an iframe's srcdoc; sandbox: the iframe sandbox attribute to mount it with, exactly; nonce: the value every bridge message of this page carries.

Rendering a tool. Put copy.title, copy.intro and copy.method on the page as normal HTML, then mount page.html in an <iframe sandbox="{page.sandbox}" srcdoc="{page.html}">. Inside the frame those three are hidden (a style in page.html), so they read once, on your page; the frame shows the form and the result. The page is a self-contained document with no network access: its own policy blocks every request, and it talks to the page that embeds it with postMessage. Listen for messages from the frame's contentWindow whose nonce equals page.nonce:

typeFrom the frameYour answer
pendium-tool:readyThe page loaded.Nothing.
pendium-tool:resizeheight: the document's height in CSS pixels.Set the iframe's height to it, so the page never scrolls inside a scroll.
pendium-tool:openhref: a link the visitor clicked (https:, mailto:, tel:).Show it as a real link outside the frame, or open it. The frame can open nothing itself.
pendium-tool:runrequestId and inputs, the visitor's form values.Run the tool (below) and post back { type: "pendium-tool:result", requestId, ok: true, output } or { type: "pendium-tool:result", requestId, ok: false, error, fieldErrors? }. The frame waits two minutes.
pendium-tool:leadrequestId and email: the visitor's address, given at the reveal over the finished result (only a page whose contract has a reveal sends it).Record the lead (below) against the last run you answered for this page, and post back { type: "pendium-tool:result", requestId, ok: true } or { type: "pendium-tool:result", requestId, ok: false, error }. The frame keeps its gate up until ok.

Running a tool. A run is a model call on Pendium, so it must come from the brand's real site. Your server posts to Pendium; the visitor's browser never does:

POST https://pendium.ai/api/site/tools/run
Content-Type: application/json
X-Pendium-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">

{ "siteKey": "ps_…", "slug": "fee-check", "revision": 3, "inputs": { … }, "clientIp": "203.0.113.9", "requestId": "req_…" }

A renderer that holds no secret, such as a site change's preview (it runs code an agent wrote, so it is given none), sends X-Pendium-Preview-Token: <the draft token of the page the run came from> instead of the signature. The token counts as it does for the keyless drafts export: valid, for this brand, under an hour old, and minted by a user who can still open the brand. A signed request is always judged on its signature, never on a token sent beside it.

siteKey is site.analytics.siteKey. revision is the tool's revision. inputs is the object the frame sent. clientIp is the visitor's address, so Pendium's per-visitor limit sees the person rather than your server. requestId is a string of your own (8 to 80 characters of letters, digits, - and _), stored once even if you send the request twice. The signature is the one /api/analytics/hit uses, with the secret Pendium gives your renderer for tool runs (brand-sites holds it as PENDIUM_TOOL_RUN_SECRET); it is never sent to a browser.

StatusBodyShow the visitor
200{ output, runId }: post output to the frame as the result.The result.
401The signature is missing, stale or wrong.A calm failure; fix the secret.
404No live tool at that slug for that brand.A calm failure; refetch the export.
409{ revision }: the page was built for an older revision. Without revision: a second request with the same requestId; the first one ran.Reload the page (the export has the new one). A duplicate: do not retry; the first answer stands.
422{ fieldErrors }: inputs did not fit the contract.Post ok: false with fieldErrors so the form shows them.
429{ reason: "rate_limited" } or { reason: "daily_cap" }One sentence: the visitor has used it a lot this hour, or the tool is taking a break today. Do not retry.
503{ reason: "unavailable" }: paused or a model failure.One sentence: the tool is taking a break. Do not retry.

Pendium limits one visitor to 10 runs an hour per tool and the owner caps runs per tool per day (200 unless they change it). Tool paths are not in the canonical path set below and not in Pendium's own sitemap: a renderer adds every tools[].path to its routes and its sitemap itself.

Recording a lead. A tool whose contract has a reveal shows its finished result behind a gate and asks the visitor's email there, in the gate's own words (reveal.title, reveal.cta, reveal.note). The frame sends it as pendium-tool:lead; a page you build yourself draws the same gate from reveal and sends the address once the result is on the page. Your server relays it the same way as a run, with the same signature or preview token:

POST https://pendium.ai/api/site/tools/lead
Content-Type: application/json
X-Pendium-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">

{ "siteKey": "ps_…", "slug": "fee-check", "requestId": "req_…", "email": "visitor@example.com", "clientIp": "203.0.113.9" }

requestId is the id of the run the result came from (the one you sent to /api/site/tools/run): Pendium sets the address on that run's row as the business's lead, never hands it to the model, and keeps nothing else the visitor typed. A second request for the same run with a corrected address replaces the first.

StatusBodyShow the visitor
200{ ok: true }Take the gate down: the result in full.
401The signature is missing, stale or wrong.A calm failure; fix the secret.
404No live tool at that slug, or no visitor run of it with that requestId.A calm failure; the gate stays.
422{ fieldErrors: { email } }: the address is not one.The message under the field; the gate stays.
503{ reason: "unavailable" }: the lead could not be kept.One sentence ("That did not go through. Try again."); the gate stays. Do not retry on your own.

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, tools, blog, the brand's username, api-catalog, and the files sitemap.xml, robots.txt, feed.xml, llms.txt, llms-full.txt
subpath_proxyfeed, tools, api-catalog, and the same files. blog isn't reserved here.
platformfeed, tools, 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.

tools is reserved on every host for the brand's published tools (tools, above), served at /tools/{slug}.

Blog directory

A brand can keep its posts in their own folder, such as tahoe-journal. Then every post path, the post list (indexes feed), and every category and author path start with /{directory}: /tahoe-journal/{slug}, /tahoe-journal, /tahoe-journal/category/{slug}. Pages, Home and the files above stay at the top level, and the directory is reserved on every host. Inside the directory a path is always blog content, so a post there never competes with a page. With no directory, paths are exactly as described elsewhere on this page. The top-level and earlier-directory addresses of the same posts, categories and authors are listed in aliases.

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 the post list (/feed, or the blog directory).
  • 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