{{title}}
{{content | striptags | truncate:200}}
{{#if author}}By {{author}}{{/if}}
```
**After upload via the editor** the `src` is automatically replaced by the CDN URL
(`cdn.websitepublisher.ai/custom/wid{id}/images/...`).
#### Common placeholder dimensions
| Usage | Dimensions |
|---|---|
| Hero wide | 1200x675 |
| Photo 4:3 | 800x600 |
| Portrait | 600x800 |
| Nav logo | 240x48 |
| Team card | 600x520 |
#### ⚠️ Image performance — mandatory rules
Every `
```
**Impact:** A page with 11 images missing `loading="lazy"` fires 11 simultaneous CDN requests on page load. On mobile (slower network, in-app mail browsers), this causes blank pages and multi-second load delays. Adding lazy loading reduced this to 1-2 eager requests with the rest deferred.
### Assets — Images, CSS, JS, and Files
Assets are files stored on the WebsitePublisher CDN (`cdn.websitepublisher.ai/custom/wid{id}/...`).
Use `upload_asset` to add images, stylesheets, JavaScript, fonts, PDFs, and other static files
to a project. Assets are served globally with caching — fast and reliable.
**Three ways to provide content:**
| Parameter | Use for | Example |
|---|---|---|
| `source_url` | Import from a durably hosted public URL — the server fetches it | Images on an existing website, a stock-photo CDN, a client's current hosting |
| `content` | Base64-encoded binary data | Images generated locally or received as base64 |
| `content_text` | Plain text content (saves tokens vs base64) | CSS, JS, JSON, SVG, HTML, XML, MD files |
Always provide exactly **one** of the three. Never combine them.
#### Importing Images from External URLs
The `source_url` parameter is the easiest way to bring images into a project. The server
fetches the file, validates it (HTTPS only, no internal IPs), and stores it on the CDN.
This works for **any public HTTPS URL** — not limited to any specific platform.
**Works well:**
- Migrating images from an existing website (WordPress, Wix, Squarespace, any CMS)
- Importing stock photos from Unsplash, Pexels, or similar services
- Pulling logos or assets from a client's current hosting
**Does NOT work — use `content` (base64) instead:**
- AI-generated image URLs (DALL·E, Midjourney and similar). These are temporary and
signed; the signature is lost when the URL is passed along, so the fetch returns 404.
- Google Drive and Google Photos links (`drive.google.com`, `lh3.googleusercontent.com`).
These need an authenticated session — the server has none, so it gets a 404 even when
the file opens fine in your own browser.
- Any signed cloud-storage link with an expiring token in the query string.
- Files the user has on their own machine. Point them to the Files page in the dashboard,
then use `list_assets` to get the CDN URL.
In production these four categories account for the large majority of failed fetches, so
check the source before reaching for `source_url`.
**Example — import a single image:**
```
upload_asset(
project_id: 12345,
slug: "images/hero-photo.jpg",
source_url: "https://existing-site.com/wp-content/uploads/2025/hero.jpg"
)
→ CDN URL: cdn.websitepublisher.ai/custom/wid12345/images/hero-photo.jpg
```
**Example — batch import from an existing site:**
```
upload_asset(project_id: 12345, slug: "images/project-1.jpg", source_url: "https://old-site.nl/uploads/photo1.jpg")
upload_asset(project_id: 12345, slug: "images/project-2.jpg", source_url: "https://old-site.nl/uploads/photo2.jpg")
upload_asset(project_id: 12345, slug: "images/team-photo.jpg", source_url: "https://old-site.nl/uploads/team.jpg")
```
Then reference the new CDN URLs in your page HTML:
```html
```
**Rules:**
- `source_url` must be HTTPS — HTTP URLs are rejected
- Internal/private IP addresses are blocked (SSRF protection)
- Works for images (JPEG, PNG, WebP, GIF), PDF, fonts (.woff, .woff2, .ttf), and .ico files
- Set `overwrite: true` to replace an existing asset with the same slug
- The slug determines the CDN path — use descriptive names: `images/hero.jpg`, `images/team/jan.jpg`
- Alt text can be set via the `alt` parameter for images
**When migrating a website:** list all images on the old site first (via web fetch, sitemap,
or CMS tools), then upload each one with `source_url`. Update page HTML to reference the
new CDN URLs. The old site must remain accessible until all images have been imported.
#### Uploading Text-Based Assets
For CSS, JavaScript, JSON, SVG, and other text files, use `content_text` instead of base64
encoding. This is more token-efficient and easier to read:
```
upload_asset(
project_id: 12345,
slug: "//cdn.websumo.com/css/custom-styles.css",
content_text: "body { font-family: 'Inter', sans-serif; }"
)
```
#### Managing Existing Assets
| Action | Tool |
|---|---|
| List all assets | `list_assets(project_id: 12345)` |
| Read asset content | `get_asset(project_id: 12345, slug: "//cdn.websumo.com/js/app.js")` |
| Edit text asset in place | `patch_asset(project_id: 12345, slug: "//cdn.websumo.com/js/app.js", patches: [...])` |
| Replace asset | `upload_asset(project_id: 12345, slug: "images/old.jpg", source_url: "...", overwrite: true)` |
| Delete asset | `delete_asset(project_id: 12345, slug: "images/unused.jpg")` |
`get_asset` returns the current `version_hash` for optimistic concurrency on later edits. For binary assets larger than 1 MB, `content` is omitted — use the `url` field to download the file directly.
### Dynamic Data (MAPI) — When Entities Make Sense
**Use MAPI entities when content is managed independently of page design** — the
owner (or a different AI session) should be able to add, remove, or reorder items
without touching page HTML.
**Use MAPI + SSR for:**
| Content type | Entity name | Example fields |
|---|---|---|
| Menu items | `menuitems` | name, description, price, category, sort_order |
| Team members | `team` | name, role, bio, photo_url, sort_order |
| Services / offerings | `services` | title, description, icon, price, sort_order |
| Portfolio projects | `projects` | title, description, image_url, link, category, sort_order |
| Testimonials / reviews | `testimonials` | name, role, company, quote, photo_url |
| Blog posts | `posts` | title, slug, content, author, published_at, featured_image |
| FAQ items | `faq` | question, answer, category, sort_order |
| Events | `events` | title, date, location, description, registration_url |
| Products (showcase) | `products` | name, description, price, image_url, category |
**Use static HTML when:**
- Content is small and fixed (≤5 items that rarely change — e.g. 3 services on an about page)
- The page is a one-off (hero text, about narrative, single landing page)
- The owner will only update content through an AI session anyway
- It's page structure and layout (sections, containers)
**Don't over-engineer.** A restaurant with 8 menu items that change twice a year
does not need a MAPI entity + SSR template + admin panel. Static HTML with clear
structure is fine — the AI can update it in 30 seconds when the menu changes.
**The trigger for MAPI:** when you hear "I want to add/remove items myself" or when
items will grow beyond 10, or when multiple pages show the same data differently
(e.g. a shop overview AND a homepage featured section both pulling from products).
**How to build with MAPI — two tools, operation-based:**
Schema work goes through `entities` (operations: `list`, `create`, `update`, `delete`,
`schema`, `add_property`, `delete_property`). Data work goes through `records`
(operations: `list`, `get`, `create`, `update`, `delete`).
Property types: `varchar`, `text`, `int`, `datetime`, `tinyint`.
1. Define the entity:
```
entities(operation: "create", project_id: 12345, entity_name: "services",
properties: [
{ name: "title", type: "varchar", required: true },
{ name: "description", type: "text" },
{ name: "icon", type: "varchar" },
{ name: "price", type: "varchar" },
{ name: "sort_order", type: "int" }
],
public_read: true
)
```
⚠️ `public_read: true` makes the data **publicly readable** via
`/mapi/public/{projectId}/{entity}` — use it only for content that belongs on the
public site (menus, team, services). **Never** on personal or financial data
(customers, orders, loyalty). See **Data Access Control** below.
2. Create records:
```
records(operation: "create", project_id: 12345, entity_name: "services", data: {
title: "Web Design", description: "...", icon: "🎨", price: "From €499", sort_order: 1
})
```
`records(operation: "update", ...)` is partial — only provided fields change.
Add a column later with `entities(operation: "add_property", entity_name: "services",
property_name: "badge", type: "varchar")`; inspect the schema with
`entities(operation: "schema", entity_name: "services")`.
3. **Render with SSR (preferred — SEO-friendly):**
Use `` template tags in your HTML. The platform renders entity data
server-side before delivering the page, so search engines see full content immediately.
```html
{{description | truncate:150}}
{{#if price}} {{price}} {{/if}}No services available yet.
``` This is the **default choice** for rendering MAPI data. Always use SSR unless the page needs interactive features like client-side search, filtering, or live updates. 4. Render with JavaScript (only when interactivity is needed): Use client-side `fetch()` when the user needs to search, filter, or sort dynamically **in the browser**. SSR and JS can coexist on the same page. ```javascript fetch('/mapi/public/{project_id}/services') .then(r => r.json()) .then(data => { const container = document.getElementById('services-grid'); data.data .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)) .forEach(service => { container.innerHTML += `${service.description}
{{street}}, {{city}} {{zip}}
{{/with}} ``` **Repeat helper** — `#times` renders a block N times (useful for star ratings): ```html {{#times 5}}★{{/times}} ``` **Join helper** — concatenate array items with a separator: ```htmlTags: {{#join tags ", "}}
``` **Template comments** — invisible in rendered output: ```html {{!-- This comment won't appear in the HTML --}} ``` **Literal escaping** — prevent template processing: ```html \{{this will appear literally as curly braces\}} ``` **Empty attribute shorthand** — alternative to the `wps-mapi-empty` block: ```htmlNo products on sale right now.
``` #### Single Record Mode Render one specific record by ID or field match: ```html{{description}}
``` **URL-based slug matching** (a routed page — see below for how to create one): ```html{{{description}}}
Product not found.
``` When a visitor opens `/product/wireless-headphones`, the router recognises the page at `/product` as routed, takes `wireless-headphones` as the route segment, and resolves it against the entity. `record=":slug"` always takes the **last URL segment** as the match value. An unknown slug returns a real **404** on a `mapi`-routed page; on a `catalog` route it still falls into the `-empty` branch (see `route_mandatory` below). #### Creating a routed page A page becomes routed through the **`route_*` parameters on `create_page` / `update_page`**. There is no magic filename — the page slug you choose *is* the route prefix. ``` create_page( project_id: 12345, slug: "product", // → serves /product/{record} content: "…", route_entity: "products", // the only one you really need route_source: "catalog", // "catalog" or "mapi"; omit to auto-detect route_mandatory: true // 404 on unknown records and on the bare URL ) ``` | Parameter | Meaning | |---|---| | `route_entity` | Entity to resolve one record from. Setting it turns the route **on**; sending an empty string on `update_page` turns it **off**. | | `route_source` | `"mapi"` for your own entities, `"catalog"` for the built-in webshop. Omit to auto-detect — but catalog wins for the reserved names `products` and `categories`, so set it explicitly if your own MAPI entity has one of those names. | | `route_match` | Record field the URL segment matches. Defaults to `slug`. | | `route_mandatory` | See the trade-off below. Defaults to `false`. | > **⚠️ Name the page after the route, not after the template.** > `slug: "product"` gives you `/product/{record}`. A slug like > `products/_template.html` creates an ordinary page that literally lives at > `/products/_template.html` — the router never looks at it and no route is set. > Use a clean slug: no underscore prefix, no `.html`, no `/`. > **⚠️ `route_mandatory` no longer decides whether an unknown record 404s.** > Since the routing change of 2026-09-09, a URL segment that resolves to nothing > **always** returns a real 404 when `route_source` is `mapi` — regardless of this > flag. What is left for `route_mandatory` is narrower and simpler: > - `true` → the bare `/product` (no segment at all) is **404**. Use this for pure > detail pages that have no index of their own. > - `false` → the bare `/blog` renders the page with the `-empty` branch. That lets > **one** page serve both an index and its detail URLs, which is the pattern you > usually want: `/blog` lists, `/blog/{slug}` reads, unknown slugs 404. > > For `route_source: "catalog"` the old behaviour still applies — an unknown segment > falls into the `-empty` branch with a 200. That is a soft-404: the page returns > "found" for a URL that has no content of its own. Give the empty branch something > honest to say, and prefer `mapi` for anything with generated slugs. **Reading and updating a routed page.** Use the slug you created it with: `get_page(slug: "product")`, `patch_page(slug: "product", …)`. `get_page` returns the current `routing` block (`enabled`, `mandatory`, `source`, `entity`, `match`) alongside `version_hash`. **Routing changes are not versioned.** Sending only `route_*` parameters updates the route but leaves `version` and `version_hash` untouched — nothing about the content changed. Consequence: `rollback_page` restores content, never a routing configuration. Write the route down if it matters. #### Dynamic Routed Pages (detail + related list) A routed page can match a **parent record** (a category, a branch, a "zebra"…) and show a **related list** next to it that is filtered server-side on that parent. Fully indexable, no client-side JS required. One page template, two branches driven by routing (`source`, `entity`, `match="slug"` on the page): - **match branch** (slug found) → the matched parent + its filtered list - **empty branch** (no/unknown slug) → the overview (all items) ```htmlNo products in this category.
Nothing published yet.
| Product | Stock |
|---|---|
| {{name}} | {{stock}} |
| No products. | |
{{content | striptags | truncate:200}}
{{#if author}}By {{author}}{{/if}}{{role}}
{{#if bio}}{{bio | truncate:200}}
{{/if}}From: {{fields.name}} ({{fields.email}})
{{fields.message}}
" } }, max_submits_per_session: 5 ) ``` ### Step 2 — Add the CDN script + form handler to the page **Always use the CDN library.** Do not write inline session management code. The library handles sessions, CSRF tokens, stale session recovery, and all headers automatically. > ⚠️ **`WP.sapi()` covers visitor sessions *and* signed-in tenant members.** For a > member portal, hand the client the `wst_` token once per page load with > `setBearer()` and keep using `call()` / `callUpload()` — see **Calling SAPI as a > signed-in member** under Tenant-Protected Pages. > > The one exception is **admin authentication**. Admin login and admin-only IAPI calls > use direct `fetch()` to `/iapi/project/{id}/admin-auth/...` with `Authorization: > Bearer wsa_…`, because those routes carry no SAPI session at all. ```html ``` ### What the CDN library handles for you | Feature | How | |---|---| | Session creation + resume | `WP.sapi(PROJECT_ID)` pre-warms on init | | CSRF token management | Sent via `X-CSRF-Token` header + `_csrf` body (dual) | | Session ID header | `X-Session-Id` header on every request | | Stale session recovery | 401 response -> auto-clear -> fresh session -> retry (max 1) | | Per-project storage keys | `wp_{projectId}_sid` -- no cross-site conflicts | | Safari ITP compatibility | Uses sessionStorage (first-party, never blocked) | | Auth state preservation | After successful POST, only CSRF is cleared -- session ID survives | ### Key rules -- never forget these: | Rule | Why | |---|---| | Always include `website: ''` in the fields object | Honeypot field -- bots fill it in, humans leave it empty. Server silently drops the submission if non-empty | | Never pre-fill the honeypot field | An empty string is required -- any value triggers bot detection | | Replace `PROJECT_ID` with the actual numeric project ID | The library uses this to scope sessions and build API URLs | ### Forms with File Upload Forms can accept image uploads from visitors via the SAPI upload endpoint. Uploads are stored as project assets on the CDN -- no bearer token needed. > **Building an admin panel with image upload?** See "Image Upload in Admin Panels" > under the Admin-Protected Pages section — it shows how to combine admin auth > with SAPI upload on the same page. **Flow:** upload file(s) first -> collect CDN URLs -> include in form submit fields. ```javascript var sapi = WP.sapi(PROJECT_ID); async function uploadFile(file, onProgress) { // The library owns the session, the CSRF token and the multipart boundary, // picks up the replacement token this route hands back, and retries once if // the session died server-side. Do not rebuild any of that by hand. var res = await sapi.uploadFile('intake', file, onProgress); if (res.ok) { return res.data.data.asset_url; // CDN URL ready for use } throw new Error((res.data.error && res.data.error.message) || 'Upload failed'); } ``` `onProgress(percent, loaded, total)` is optional — pass it to drive a progress bar. **Upload rules:** | Rule | Value | |---|---| | Allowed types | JPEG, PNG, WebP only | | Max file size | 5 MB per file | | Max per session | 10 uploads | | CSRF | Single-use -- library handles refresh automatically for submitForm(), manual clear needed after raw fetch upload | | Response includes | `asset_url`, `filename`, `mime_type`, `size`, `width`, `height`, `uploads_remaining` | **Include uploaded URLs in form submit:** ```javascript // After uploading, pass CDN URLs as regular form fields sapi.submitForm('intake', { name: '...', email: '...', image_url_1: uploadedUrl1, // CDN URL from upload response image_url_2: uploadedUrl2, website: '', // honeypot }); ``` --- ## Step 4 — Go Live Checklist Before handing over to the user, verify: - [ ] Homepage has `landingpage: true` (or was created first) - [ ] All pages that should be findable have `seo_robots_index: true` - [ ] All pages have `seo_title` and `seo_description` - [ ] All `` comment tags are present in every page - [ ] Multi-page sites use **fragments** for header and footer (not copy-pasted HTML) - [ ] Repeating content uses **MAPI entities** (not hardcoded static HTML) - [ ] Every SAPI call goes through the CDN library (`sapi-client.js`) — no inline session code, on member pages either. A hand-written `fetch()` skips the library's stale-session recovery, and a session that dies server-side then breaks the page **permanently**: the visitor sees a broken page, refreshing does not help, and only clearing localStorage fixes it. Nobody's customer knows to do that. - [ ] Thank-you page exists if form redirects after submit - [ ] Terms / privacy page exists if form collects personal data - [ ] Design uses distinctive typography and cohesive color palette (not generic AI defaults) - [ ] Design context saved via `execute_integration(service: "site_context")` for future consistency - [ ] Website URL shared with user: `https://{subdomain}.websitepublisher.ai` - [ ] If the user wants their own domain: hand them the two `A` records and point them at Publish → Connect your own domain (see **Custom Domains**) — you cannot connect it - [ ] Contact form includes `website: ''` honeypot field in the fields object - [ ] Visual Editor session offered for image replacement and final tweaks - [ ] **Translate-safe:** client JS reads from state / `data-*` / input values, not visible text; `` accurate; no page-wide `notranslate` meta (framework crashes are already handled by the platform-injected guard — see **Translate-Safe JavaScript**) ### Mandatory Security Review (before going live) A site must **not** be presented as live until this review passes. Run it as the final gate — never skip it, even for a quick demo that the user intends to keep. - [ ] **No secrets in client code.** No API keys, tokens, or passwords in page HTML, inline JS, or assets. All credentials use `{{vault:...}}` references — resolved server-side, never delivered to the browser. - [ ] **Admin pages are auth-guarded.** Every admin/dashboard page enforces the IAPI Admin Auth guard server-side. No admin-only data or actions reachable without a valid `wsa_` session. No client-side-only "hidden" protection. - [ ] **Entity exposure is intentional.** `public_read` is enabled only on entities meant to be public. No personal data, leads, orders, or admin records exposed via public MAPI endpoints. Remember: `public_read` is a visibility flag, not protection — sensitive entities carry a `policy_json` or stay `public_read: false` (see **Data Access Control**). - [ ] **Money math is server-side.** Checkout totals, discounts, tier/volume pricing, and loyalty points are computed and validated **by the platform integrations** — never trusted from client-side JS, `localStorage`, or hidden form fields. A visitor must not be able to change what they pay or what they earn by editing the page. (Real exploits found pre-go-live: client-computed order totals and client-written loyalty points.) - [ ] **Admin is protected server-side.** No `showAdmin()`-style JS toggles, hidden DOM, or devtools-bypassable checks as the only barrier — every admin page and admin data call enforces the `wsa_` auth guard server-side. - [ ] **No sensitive files on the public CDN.** Exports, snapshots, or data files containing customer/order data are served through an authenticated route (admin auth / asset proxy), never as a world-readable CDN asset. - [ ] **Form input is validated.** Required fields set, honeypot present, file uploads (if any) restricted to expected types/sizes via SAPI upload. - [ ] **No customer-supplied HTML rendered unescaped.** Visitor/lead/form data shown back on a page is escaped — no raw injection into the DOM. - [ ] **Legal pages present where required.** Terms / privacy page exists whenever the site collects personal data (forms, auth, leads). If any item fails, fix it before declaring the site live. Log the outcome of this review in TAPI (`add_task_history`) so the security gate is traceable per project. --- ## Things Only the Project Owner Can Enable A few capabilities are switched on outside the API. There is **no MCP tool and no endpoint** for them, so retrying with different parameters will never succeed. When you hit one, stop and tell the user what to do. | What | How it fails | What to tell the user | |---|---|---| | **Custom domain** | The site stays reachable only on `{subdomain}.websitepublisher.ai` | Dashboard → the project → **Publish** → *Connect your own domain*. See **Custom Domains** | | **Email on a custom domain** | `email_account/list-domains` returns empty and `enable-email` refuses the domain | Only available when the domain is registered or transferred through WebsitePublisher. Otherwise: use the project's own Resend key in the vault. See **Custom Domains → Email** | | **Invoice checkout** (B2B "pay by invoice" instead of card/iDEAL) | `checkout-flow/create-payment` with `payment_provider: "invoice"` returns **403** | The project setting `allow_invoice_checkout` is off and there is no self-service toggle yet. Email **support@websitepublisher.ai** and ask for it to be enabled on the project | Say it plainly — "this is a setting only you can turn on, here is where" — and move on to the rest of the build. Do not file a capability request for these: they are known, and a request does not speed them up. ## Custom Domains — You Cannot Connect One Yourself A project is always reachable on `https://{subdomain}.websitepublisher.ai`. Connecting a customer's own domain is a **dashboard action performed by the project owner**. There is no MCP tool and no AI-callable endpoint for it. Your job is to hand the owner the right DNS record and tell them where to click. ### The DNS records Two `A` records, both pointing at the platform load balancer: | Type | Name / Host | Value | TTL | |---|---|---|---| | `A` | `@` | `206.189.242.68` | `3600` (or Auto) | | `A` | `www` | `206.189.242.68` | `3600` (or Auto) | That is the whole setup — the same record twice, once for the root and once for `www`. The platform routes on the requested hostname, so both land on the right project once the domain is saved in the dashboard. For a subdomain instead of the root (`shop.example.com`), use one `A` record with the subdomain label as the host — `shop` — and the same value. Delete any other `A`, `AAAA` or `CNAME` record on those same names. Two conflicting records for one host is the most common reason a domain keeps serving the old site. ### The owner's steps 1. Dashboard → the project → **Publish** → *Connect your own domain* 2. Enter the domain; the dashboard shows the exact record with copy buttons 3. Create that record at the DNS provider 4. **Validate & Save** in the dashboard SSL is then auto-provisioned via Let's Encrypt. Connecting a custom domain is a **paid plan feature**; on a plan that does not allow it the dashboard returns an upgrade prompt. ### Before pointing DNS at us If the domain currently points at another website platform, those records must be **replaced, not supplemented** — and the domain usually has to be released on that platform too, or it keeps answering for it. Leftover verification records from a previous provider are harmless but do nothing here. ### Email on a custom domain Email is only offered when the domain is **registered or transferred through WebsitePublisher**. On a domain we do not administer we cannot guarantee SPF, DKIM and DMARC alignment, so we do not send on its behalf — `email_account/list-domains` will not list it and `enable-email` will refuse. That is by design, not a bug. For transactional mail (OTP, order confirmations) from such a domain, the two working options are: transfer the domain to WebsitePublisher, or configure the project's own Resend key in the vault and send through that. The second also gives the owner their own delivery dashboard. ## Platform Knowledge ### What WebsitePublisher handles automatically | Feature | How it works | |---|---| | **Sitemap** | Auto-generated. Pages appear when `seo_robots_index: true` | | **robots.txt** | Auto-generated with "Allow all" + sitemap reference | | **SSL certificate** | Auto-provisioned via Let's Encrypt on custom domains | | **Canonical tags** | Injected by Optimizer when comment tag is present | | **Open Graph** | Injected by Optimizer (uses SEO title/description) | | **Static caching** | Pages are served as static files — extremely fast | | **CDN** | Assets served via cdn.websitepublisher.ai | ### What requires API calls | Feature | API | |---|---| | Pages and content | PAPI | | Reusable components (header, footer) | PAPI Fragments | | Dynamic data / entities | MAPI | | Contact forms | SAPI | | Third-party integrations | IAPI + VAPI | | Visual editing (browser) | WPE | | Clone a website | WAPI clone endpoint | --- ## Built-in Integrations — Composable Building Blocks WebsitePublisher's integrations are not a feature list — they are composable building blocks. Every integration speaks the same interface (`execute_integration(service, endpoint, input)`), authenticates the same way (the Vault), and is callable from any AI on any platform via MCP. This means the AI doesn't *build* a payment flow, an email pipeline, or a lead system — it *assembles* them from pieces that are already wired, secured, and maintained. Generating code gives you a draft; snapping integrations together gives you a working system. ### Don't reinvent the wheel WebsitePublisher includes pre-built integrations for common website needs. You do not need to build email sending, payment processing, or SMS from scratch. Each integration is a single tool call — credentials are stored securely in the Vault, the platform handles authentication, rate limiting, and error handling. ### Discover what's available — list it, don't guess You never have to guess which integrations or endpoints exist. Two tools read the **live manifest** for the current project — they are the source of truth, more current than this document: - `list_integrations(project_id)` — every integration, split into **configured** (vault secrets present, ready to call now) and **available** (needs setup), each with its full endpoint list and descriptions. A typical project already has dozens wired (asset upload, exports, payments, email, shipping, imports, analytics, and more). - `get_integration_schema(project_id, service)` — the exact input fields (name, required, type, limits) for every endpoint of one integration. Call this before `execute_integration` so you send the correct body the first time. If a task seems to need a capability you have no tool for, run `list_integrations` **first**. The endpoint almost always already exists. Inventing an HTTP route, guessing a hostname, or asking the user to build an endpoint is the wrong move — the manifest already tells you what is there and how to call it. ### Available Integrations All of these are called the same way — `execute_integration(project_id, service, endpoint, input)` — and `get_integration_schema(project_id, service)` gives the exact input fields per endpoint. The **"Reach for it when"** column is the part that matters most: it is the bridge from what the user actually says to the capability that already exists. If a request matches one of those phrases, the answer is never "I can't do that" — it is `list_integrations` followed by the call. **Built-in — no API key, no setup, ready on every project.** *Content, pages & media* | Service | What it does | Reach for it when the user says… | |---|---|---| | `blog` | Posts, categories, RSS feed generation | "blog", "news section", "articles", "RSS" | | `pages` | List pages from inside a page/integration context | "which pages exist", dynamic navigation | | `records` | Read entity records from a page context (read-only lane) | server-rendered lists without admin auth | | `data_grid` | Drop-in editable data grid for admin panels | "table I can edit", "spreadsheet view", "CRUD screen" | | `asset_proxy` | Upload/delete assets from a browser admin panel (no WPA key) | "upload images from the admin panel" | | `versioning` | Asset version history + rollback | "restore the previous version", "undo that CSS change" | | `site_context` | Store design tokens (colors, fonts, style, locale) across sessions | "keep the same style next time" | | `site-import` | Import an existing website into the project | "move my current site over", "rebuild what I have" | | `schema-org` | Generate/detect structured data (JSON-LD) | "rich snippets", "structured data", "SEO markup" | | `web-scraper` | Fetch and parse an external page (robots-aware) | "pull the content from this URL" | *Commerce — cart, checkout, order, fulfilment* | Service | What it does | Reach for it when the user says… | |---|---|---| | `product-catalog` | Products, variants, categories, bulk import | "webshop", "products", "catalog" | | `product-search` | Search, filter and autocomplete over the catalog | "search bar for products", "filters" | | `shopping-cart` | Server-side cart: add/update/remove, price, clear | "cart", "basket" | | `checkout-flow` | Checkout state machine incl. **invoice mode** (quote, no online payment) | "checkout", "order on account", "request a quote" | | `order-management` | Orders, statuses, line metadata, lookup by payment | "my orders", "order status" | | `invoice-generator` | Generate, fetch, list and credit invoices | "invoice", "credit note", "billing document" | | `inventory-tracker` | Stock levels, increment/decrement, low-stock report | "stock", "inventory", "sold out" | | `discount-engine` | Discount codes: create, validate, calculate, usage | "coupon", "promo code", "discount" | | `pricing-rules` | Tiered/volume pricing per product | "bulk pricing", "customer tiers" | | `multi-currency` | Exchange rates and price conversion | "sell in dollars too", "currency switcher" | | `loyalty` | Points config, per-product points, balances | "loyalty points", "rewards", "savings card" | | `wishlist` | Per-visitor wishlist | "save for later", "favourites" | | `abandoned-cart` | Detect abandoned carts + send recovery mail | "people leave without buying" | | `ecommerce-analytics` | Revenue, top products, funnel, AOV, customer stats | "how is the shop doing", "best sellers" | | `shipping-rates` | Own shipping rates and rate tables | "shipping costs", "delivery fees" | | `myparcel` | Labels, shipments and tracking via MyParcel | "print a shipping label", "track the parcel" | | `order_events` | Event subscriptions on the order lifecycle (webhooks, chained actions) | "email the invoice automatically when paid" | | `calendar` | Calendars, events, bookable resources (chair/table/room), availability, slots, bookings — 15 endpoints, see "Calendar & Booking" | "appointments", "reservations", "book a table", "hotel rooms", "availability" | *Email & messaging* | Service | What it does | Reach for it when the user says… | |---|---|---| | `email_archive` | **The user's own archived mail** — search, read, threads, attachments, drafts, contexts | "my inbox", "email", "newsletters", "what did X send me", "past correspondence", "did I reply to…" | | `resend` | Transactional email (contact forms, notifications) | "send an email when someone submits" | | `email-templates` | Named templates: render-and-send, preview, manage | "same layout for every mail" | | `email_layout` | Branded email layout/wrapper for the project | "our house style in emails" | | `email_account` | Real mailboxes on a custom domain (users, aliases, domains) | "info@mydomain.com", "give me an email address" | | `linkedin` | Post text/images to a LinkedIn organisation page | "post this to LinkedIn" | *Members, auth & access* | Service | What it does | Reach for it when the user says… | |---|---|---| | `admin_auth` | Password-protected admin areas (login, reset, sessions) | "admin panel behind a login" | | `tenant_auth` | Provisioned/paid member portal (codes, sessions, refresh) | "member area", "customer portal", "subscribers only" | | `account` | Signed-in member reads/updates their own record | "my account page", "profile page" | | `member-provisioning` | Map offers/purchases to member access, event log, simulation | "buying the course gives access" | | `gated-files` | Private file delivery to members | "paid PDF", "downloads for members only" | | `file-downloads` | Signed download tokens with stats and revocation | "expiring download link" | | `identity` | Change the key email on an identity | "customer changed their email address" | | `auth_keys` | Request a project API key (human-approved, vault-stored) | AI needs a key without seeing it | *Data, documents & flows* | Service | What it does | Reach for it when the user says… | |---|---|---| | `data-import` | File → schema mapping → validate → dry-run → import | "import this CSV/Excel", "migrate my data" | | `xlsx-export` | Generate an Excel export | "export to Excel", "download as spreadsheet" | | `pdf_document` | Branded PDF from blocks (`generate`) or your own HTML template (`render-template`) | "PDF", "printable invoice", "downloadable brochure" | | `pdf_layout` | Reusable PDF layout config (can copy from the email layout) | "same header on every PDF" | | `flow_framework` | Definition + instance state machines for multi-step processes | "multi-step application", "approval workflow" | | `leads` | Store and retrieve form submissions as leads | "collect leads", "who filled in the form" | | `lead-scoring` | Score leads, single or batch | "which leads are worth calling" | | `comment-system` | Comments with moderation | "let visitors comment" | | `review-system` | Product reviews + ratings with moderation | "star ratings", "customer reviews" | | `prediction_game` | Prediction/pool games: participants, outcomes, scores | "pool", "prediction competition" | | `oura_sync` / `strava_sync` / `oura` / `strava` | Sync personal health/activity data into the project | "pull in my Oura/Strava data" | | `offline_sync` | Ping/sync/pull for offline-capable clients | "keep working without internet" | | `api-proxy` | Register and proxy an external API through the platform | "call our own backend from the page" | | `anthropic` | Claude messages from inside the project (server-side) | "the site itself should use AI" | *Ops, debugging & platform* | Service | What it does | Reach for it when the user says… | |---|---|---| | `tracer` | Live request tracing for API + page requests | "it fails and I don't know why" | | `capability_requests` | Report a genuine platform gap — **last resort**, see "You Are the Builder" | nothing above fits and you verified it | **Task tracking (TAPI)** is not an integration but a first-class MCP tool: `tasks(operation: …)`. Reach for it when the user says "where were we", "continue the build", "what's left" — see "Task Tracking (TAPI)". **External services — available, need an API key via `setup_integration` first.** Roughly 50 more, addressed exactly the same way once configured: | Category | Services | |---|---| | Payments | `stripe`, `mollie`, `paypal` | | Email & marketing | `mailgun`, `sendgrid`, `smtp`, `brevo`, `mailchimp`, `convertkit` | | Messaging | `twilio`, `slack-webhook`, `discord-webhook`, `telegram` | | Shipping | `postnl`, `sendcloud`, `shopsunited` | | AI | `openai`, `gemini`, `mistral`, `groq`, `perplexity`, `replicate`, `elevenlabs`, `deepgram`, `stability`, `imagen` | | Media | `unsplash`, `pexels`, `cloudinary`, `imgur`, `giphy`, `youtube`, `vimeo` | | CRM & productivity | `hubspot`, `notion`, `linear`, `todoist`, `github`, `sentry` | | Data & database | `airtable`, `supabase`, `contentful`, `google-places`, `openweather`, `newsapi`, `overheid-io` | | Booking & health | `calcom`, `oura`, `strava` | | Social | `twitter` | > The two lists above describe what the platform ships. **`list_integrations(project_id)` remains > the source of truth** for what is actually reachable on this project right now — some > capabilities are account- or entitlement-scoped and only appear there. Check the tool, not > your memory of this table. #### Email Archive — searching the user's own mail This one deserves its own note because it is the capability models most often miss: when a user asks about **their own inbox, newsletters, senders or past correspondence**, that is not a job for web search or a third-party mail connector — the platform archives and indexes their mail itself. `search` is scoped to one archive, so `archive_id` is **required**. Always resolve it first: ``` 1. execute_integration(service: "email_archive", endpoint: "list-archives", input: {}) → pick the archive (list-archives also accepts owner_email to filter) 2. execute_integration(service: "email_archive", endpoint: "search", input: { archive_id: 7, query: "The Neuron", mode: "hybrid", // keyword (default) | semantic | hybrid (best recall) date_from: "2026-08-28", sort: "newest", limit: 20 }) → metadata + snippets, no bodies 3. execute_integration(service: "email_archive", endpoint: "get-message", input: { archive_id: 7, id: 12345 }) → full body_plain for the messages that matter ``` Other endpoints: `get-thread` (whole conversation), `list-attachments` + download by index, `get-stats` (counts, date range, per-folder), `set-state` (mark handled/kept/todo), `draft-reply` (suggested reply text — never sends), and the context layer (`context-list`, `context-get`, `context-feed`, `context-match`, `context-members`) for LLM-ready rolling summaries of a topic. Two behaviours worth knowing before you report "nothing found": - Handled messages are **hidden by default** (a reply in Sent marks them handled) — pass `include_done: true` to see everything. - Newsletters are frequently HTML-only, so `snippet` comes back `null` with `snippet_source: null`. That means *no plain-text body*, **not** an empty result — fetch the message with `get-message` instead of concluding there is nothing there. ### How integrations work 1. **Setup** — Store the API key: `setup_integration(service: "resend", secrets: {"resend_api_key": "re_..."})` 2. **Use** — Call the integration: `execute_integration(service: "resend", endpoint: "send-email", input: {...})` 3. **Done** — The platform resolves credentials, validates input, proxies the request, returns the result API keys are **never exposed** to the AI or the browser. The Vault encrypts them at rest and the integration proxy resolves them server-side at execution time. ### Vault References — `{{vault:...}}` > Written with `...` as placeholder throughout this document: examples containing a > literal key-shaped reference are redacted by the platform's secret filter when this > skill is delivered via `get_skill`. In real templates and integration inputs, write > the actual key name — no spaces, no dots: two opening braces, `vault:your_key_name`, > two closing braces. The IAPI proxy resolves `{{vault:...}}` references (two opening braces, then `vault:` + your key name, then two closing braces — no spaces) server-side before making API calls. This is the core security mechanism that keeps secrets out of AI conversations and browser code. **Where vault references work (server-side only):** | Context | Works? | Example | |---|---|---| | `execute_integration` input | ✅ | `"api_key": "{{vault:...}}"` (e.g. key `stripe_key`) | | Scheduled tasks (AAPI) | ✅ | Vault refs in task payload resolved at execution | | IAPI proxy calls | ✅ | Bearer token from vault | | Browser JavaScript | ❌ | Browser cannot access vault — use admin auth (`wsa_`) instead | | Page HTML source | ❌ | Would expose secrets to anyone viewing source | | MCP tool responses | ❌ | VaultSanitizer strips any leaked vault values | **Critical rule:** Never put vault keys in browser-facing code. If a browser page needs to call an authenticated API, use the **admin auth pattern** (`wsa_` token) for data operations and **SAPI upload** for file uploads. The vault exists for server-side integrations only. ### When to use integrations | User wants... | Use this | |---|---| | Contact form that sends email | SAPI form + Resend integration | | Accept payments on website | Stripe or Mollie integration | | Quote / offerte request via the cart, **no online payment** | Checkout-flow **invoice mode** (see note below) | | SMS confirmation after booking | Twilio integration | | Store leads from multiple forms | Built-in Lead Capture | | Password-protected admin dashboard | Admin Auth (IAPI admin session) | | Open member area (anyone with an email may enrol) | SAPI Visitor Auth | | Provisioned / paid / multi-tenant member portal | Tenant Auth (IAPI) — see "Tenant-Protected Pages" | | Private file delivery to members (ebooks, paid PDFs) | `gated-files` — see "Member File Downloads" | | Signed-in member reads/updates their own record ("My Account") | `account` — see "Member Self-Profile" | | Remember design choices across sessions | Site Context integration | | Import 50-500 products at once | `bulk-upsert-products` (Product Catalog) | | Upload images from admin panel (browser) | **Asset Proxy** (PAPI assets) or **SAPI upload** (form uploads) | | Request a project API key securely | Auth Keys (human-approved, vault-stored) | | Debug failing requests or slow pages | Request Tracer | | Search their own inbox / newsletters / past mail | `email_archive` — `list-archives` then `search` | | Summarise what a sender or newsletter covered recently | `email_archive` — `search` (mode `hybrid`) then `get-message` | | Draft a reply to a mail they received | `email_archive` — `draft-reply` (returns text, never sends) | | Real mailboxes on their own domain | `email_account` | | Remember where a multi-session build stands | `tasks` (TAPI) | > **Quote / offerte checkout (no online payment).** To let visitors request a full quote through the normal cart → checkout flow instead of paying, use the checkout-flow **invoice provider**: `initiate-checkout` → `set-customer` → `create-payment` with **`provider: "invoice"`** → `complete-checkout`. No payment is created ("op factuur"); the resulting order is created with status `pending` / `payment_status: unpaid`, and the confirmation email still fires. That order *is* the quote request (products, quantities, customer details). **Requires the project setting `allow_invoice_checkout`.** Combine with hidden prices (`price_cents: 0`) for a pure request-a-quote shop: the cart shows products + quantities only, the order total is €0, and you follow up with a real quote. Full cart/checkout wiring lives in the e-commerce cookbook. **Always check if an integration exists before building custom solutions.** The built-in integrations handle authentication, error handling, rate limiting, and security — reimplementing these is unnecessary and error-prone. ### Bulk Product Import For large catalogs, use `bulk-upsert-products` instead of looping `create-product`: ``` execute_integration( service: "product-catalog", endpoint: "bulk-upsert-products", input: { "items": [ {"sku": "TSH-001", "name": "Classic Tee", "price_cents": 2999, "status": "active"}, {"sku": "TSH-002", "name": "V-Neck Tee", "price_cents": 3499, "status": "active"}, {"sku": "TSH-001", "price_cents": 2799} ] } ) ``` Each item is matched by SKU: existing → update, new → create (needs `name` + `price_cents`). Max 500 items per call. Response includes per-item status and `summary.by_error_type`. **Always check `result.failed` and `result.summary.by_error_type`** — `success: true` means the call itself worked, not that every item succeeded. ### Debugging with Request Tracer When something isn't working — a page returns wrong data, an integration fails, or performance is slow — use the Request Tracer to see exactly what happened: 1. **Start a trace session:** ``` execute_integration( service: "tracer", endpoint: "start", input: { "ttl": 120, "include_optimizer": true } ) → returns hash (e.g., "tr_abc12345") ``` 2. **Perform the operation that's failing** — create a page, submit a form, call an integration 3. **Read the trace:** ``` execute_integration( service: "tracer", endpoint: "logs", input: { "hash": "tr_abc12345" } ) ``` The trace shows every API request and page render with HTTP method, path, status code, duration, SQL query summary, and which server handled the request. Integration failures include typed error data (`error_type`, `error_code`, and `error_field` / `recovery` when applicable) so you can see exactly what went wrong without guessing. **When to use the tracer:** - Page renders wrong content → trace optimizer request, check SQL queries - Integration call fails → trace API request, check `error_type` and `recovery` - Request is slow → check `duration_ms` and `db.total_ms` breakdown - "It works sometimes" → `server` field shows which node handled each request **Options:** - `include_optimizer: true` — also trace public page renders (default: off) - `include_sql: false` — skip SQL summary (default: on) - `ttl: 10-300` — session duration in seconds (default: 60) --- ## PDF from Your Own Template — `pdf_document/render-template` Two ways to make a PDF. `pdf_document/generate` takes content blocks and applies the project's branding — fast, zero layout work. `render-template` renders a **project-defined HTML template** with full data-binding — use it when the layout must be exact: invoices on pre-printed stationery, packing slips, quotes, certificates. The template controls 100% of the output; no platform branding is applied. ### The template is a PAPI asset Upload the template like any asset (`upload_asset`, e.g. `templates/invoice.html`), iterate with `patch_asset`. Rules: - A **complete HTML document** with its own CSS. Set page margins in the template via `@page { margin: ...; }` (A4 portrait). `letterhead_top_mm` exists as a convenience override for pre-printed stationery, but defining `@page` yourself is preferred. - **Layout + template tokens only. NEVER put customer data, order data, or secrets in a template** — assets are public on the CDN. Data arrives at render time via `data`. - DOMPDF renders it: use tables and inline styles for structure; `position:absolute` works for fixed placement (address blocks). Font is **DejaVu Sans** — full glyph set incl. `€`. - Caps: template ≤ 512 KB, rendered HTML ≤ 2 MB. Rate limit 60/hour. ### Template dialect — same engine as SSR pages `{{var}}` (HTML-escaped — customer strings can never inject markup), `{{{var}}}` (raw, only for values the template author controls), dot paths, `{{#if}}/{{#else}}/{{#unless}}` with operators (`==`, `!=`, `>`, `<`, `>=`, `<=`, `contains`, `starts_with`, `ends_with`) and full same-type nesting, `{{#each}}` **including nesting** (`{{this}}` for scalar items, `@index`, `{{../parent}}`), filter chains. Money is always **integer cents** on this platform. Format in the template, never in a chain: | Filter | In → out | Example | |---|---|---| | `money_eur` | cents → `€ 1.234,56` (NL) | `{{total_cents \| money_eur}}` | | `vat_incl:21` | VAT-inclusive cents → VAT cents (fiscal rounding) | `{{total_cents \| vat_incl:21 \| money_eur}}` | | `divide:N` | numeric division | `{{qty \| divide:2}}` | | `date` | date → `13-08-2026` (**default d-m-Y**, format arg optional) | `{{paid_at \| date}}` | | `number:2` / `currency:EUR` | NL notation | building blocks under `money_eur` | Invoice-shaped template fragment (lines with sub-lines, conditional discount): ```html {{#each lines}}Discount: {{discount | money_eur}}
{{/if}}Total: {{total_cents | money_eur}} — VAT (21%): {{total_cents | vat_incl:21 | money_eur}}
``` ### Calling it `data` is the **root context** — its keys become the template's top-level variables. ``` execute_integration(service: "pdf_document", endpoint: "render-template", input: { "template_slug": "templates/invoice.html", "data": { "total_cents": 16170, "paid_at": "2026-08-13", "lines": [ ... ] }, "store": false, // false → in-memory, base64-only (email attachments) "return_base64": true // default store=true → archived to the private documents }) // bucket + signed download URL (never on the public CDN) ``` `data` must be a real object — a JSON *string* is rejected with a 422. ### External images/CSS — strict by design, self-reporting - **Relative URLs are auto-rewritten to the project's own CDN**: `src="images/logo.png"` just works. - Allowed absolutes: `data:` URIs and `https://cdn.websitepublisher.ai/...`. Everything else (other hosts, `http:`, protocol-relative) is **stripped** before rendering and reported back as `data.blocked_assets` in the response. **Check that field after a test render** — if your logo URL shows up there, upload it as a project asset and reference it relatively. ### Automatic invoice printing — the `order_events` chain pattern Thread the full order object as **one raw token** (exact single tokens keep their type), then feed the PDF base64 into the mail step: This is the `create-subscription` **input** — `steps` is a TOP-LEVEL field. Do NOT wrap it in `config`: that is the *stored* shape you see back in `list-subscriptions`, and an input `config` field is rejected. ``` execute_integration(service: "order_events", endpoint: "create-subscription", input: { "event": "order.paid", "target_type": "iapi_chain", "steps": [ { "service": "order-management", "endpoint": "get-order", "input_template": { "order_id": "{{fields.order_id}}" } }, { "service": "pdf_document", "endpoint": "render-template", "input_template": { "template_slug": "templates/invoice.html", "data": { "order": "{{steps.0.result.order}}" }, "store": false, "return_base64": true } }, { "service": "resend", "endpoint": "send-email", "input_template": { "from": "shop@yourdomain.com", "to": "printer@yourdomain.com", "subject": "Invoice {{fields.order_id}}", "text": "Attached.", "attachments": [ { "filename": "invoice.pdf", "content_base64": "{{steps.1.result.data.base64}}" } ] } } ] }) ``` Watch the resend attachment field: it is **`content_base64`** (not `content`). Need per-line structured data (sizes, prescriptions, options)? Add an `order-management/get-line-meta` step and pass its result alongside the order (`"lens": "{{steps.1.result}}"`); omit `def_key` unless you have verified the stored definition key, and copy the real meta key names from one live `get-line-meta` call. The template then reads `{{order.total_cents | money_eur}}`, `{{#each order.lines}}`, etc. Note: iapi_chain retries default to **off** (steps have real side effects). ### Testing & replaying the chain — `order_events/fire-event` Never test a chain with a real payment. `fire-event` pushes ONE existing order through the exact same payload/queue/retry path as a real transition: ``` execute_integration(service: "order_events", endpoint: "fire-event", input: { "order_id": 42, "event": "order.paid", "dry_run": true }) ``` - **Always `dry_run:true` first** — it reports `would_fire` / `would_skip` per subscription without enqueueing anything. Real fires are REAL: chains send real mail and print real documents. - An order+event already delivered to a subscription is skipped (dedup). **A FAILED delivery blocks a new fire just the same** — the dedup row exists either way — so re-running a failed delivery always needs `force: true`. `force` writes a distinct replay key (`{order_id}:{event}:replay:{timestamp}`), keeping the original row and the audit trail intact. - Reprinting orders for a NEW subscription needs no `force` (no delivery rows exist yet): create the subscription, verify one order, then fire per order. - The response lists per subscription: `action` (`fired`/`skipped`/`would_fire`/ `would_skip`), `delivery_id`, and a `reason` on skips. Check the outcome afterwards in `list-deliveries` (filter by `order_id` or `status`). ### Build workflow 1. Upload the template asset. 2. Test-render with `store:false` + sample `data`; decode the base64 and check the PDF **and** `blocked_assets`. 3. Iterate via `patch_asset`. 4. Wire the chain. Errors are explicit: `TEMPLATE_NOT_FOUND`, `TEMPLATE_INVALID` (non-`.html` / traversal), `TEMPLATE_TOO_LARGE`, `RENDER_OUTPUT_TOO_LARGE`. --- ## Admin-Protected Pages — IAPI Admin Auth > **⚠️ Need to upload images from an admin panel?** Do NOT use `upload_asset`, > vault keys, or MAPI asset routes from the browser. Use **Asset Proxy** > (`/iapi/project/{id}/asset-proxy/upload` with your `wsa_` admin token) — it's > the simplest option. See "Image Upload in Admin Panels" below. When building dashboards, admin panels, or any page that requires a logged-in admin (not a public visitor), use the IAPI Admin Auth pattern. This is separate from SAPI Visitor Auth — they serve different purposes. | Feature | Admin Auth (IAPI) | Visitor Auth (SAPI) | |---|---|---| | **Use case** | Admin dashboards, CMS, internal tools | Member areas, gated content, loyalty portals | | **Login method** | Email + password | Magic link or verification code | | **Token storage** | `sessionStorage.admin_token` | Managed by sapi-client.js internally | | **API calls** | Direct `fetch()` to `/iapi/project/{id}/...` with `Authorization: Bearer` | `WP.sapi(id).call(...)` via CDN library | | **Token prefix** | `wsa_` (server-side) | Session ID (no token exposed to page) | ### Admin Login ```javascript const PROJECT_ID = 12345; // replace with actual project ID async function login(email, password) { const r = await fetch(`/iapi/project/${PROJECT_ID}/admin-auth/login`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({email, password}) }); const data = await r.json(); if (data.success && data.token) { sessionStorage.setItem('admin_token', data.token); localStorage.setItem('admin_token', data.token); document.cookie = `admin_token=${data.token}; path=/; max-age=28800; SameSite=Lax`; } return data; } ``` Triple storage (sessionStorage + localStorage + cookie) ensures the token survives page navigations, tab reopens, and server-side middleware checks. After login, redirect to **`/`** if your dashboard page is set as `landingpage: true`. See the note about `landingpage` under Page Metadata. ### Admin-Only IAPI Calls ```javascript async function callAdmin(service, endpoint, payload) { const token = sessionStorage.getItem('admin_token'); if (!token) { window.location.replace('/login'); return; } const r = await fetch(`/iapi/project/${PROJECT_ID}/${service}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify(payload) }); if (r.status === 401) { sessionStorage.removeItem('admin_token'); localStorage.removeItem('admin_token'); window.location.replace('/login'); return; } return r.json(); } // Usage: const leads = await callAdmin('leads', 'get-leads', { page: 1, per_page: 25 }); const msg = await callAdmin('anthropic', 'create-message', { prompt: '...' }); ``` **Important:** Use direct `fetch()` — not `WP.sapi().call()`. The SAPI client library is for visitor sessions. Admin calls use `Authorization: Bearer` headers on `/iapi/` routes. ### Page Rendering — Auth Guard ```html