Site API reference
Version 6.9.0 · Markdown version at /api/v1/docs
casadigital.pt is the single owner of all customer data. Client websites — and any other app built on this platform — never touch the database directly: they read and write through this HTTP API.
Every tenant (a Site) has its own API key, issued when the site is provisioned. The key both authenticates the request and identifies the tenant, so no site identifier needs to be sent.
Building a site on the platform?
This page documents the endpoints. The full integration contract — environment, caching, the invalidation endpoint a client site should expose, limits and error handling — is at /api/v1/integration.md, described as OpenAPI 3.1 at /api/v1/openapi.json and indexed for agents at /llms.txt.
- /api/v1/integration.md— the full integration contract, as Markdown
- /api/v1/openapi.json— OpenAPI 3.1 description of every endpoint
- /api/content-schema— JSON Schema per content resource
- /llms.txt— index for AI agents (/llms-full.txt for one fetch)
Authentication
All authenticated endpoints expect the key in the Authorization header: "Authorization: Bearer sk_<48 hex chars>".
Requests with a missing or unknown key receive 401. Keys are managed by Casa Digital; if a key leaks it can be regenerated, which immediately invalidates the old one.
Authorization: Bearer sk_0123abcd…Content resources
Content is served one resource at a time so each consumer fetches only what it renders and caches it on its own schedule. There is no combined content document.
Every resource response is wrapped in the same envelope: { version, siteKey, locale, defaultLocale, locales, <resource> }. Compare version against the contract version you built against and log loudly on a major mismatch.
Content can be published in several languages. Every content read takes an optional ?locale= naming one of the site's `locales`; the response keeps the same shape, each text translated where the tenant translated it and in the main language where not, with an `untranslated` list on the item naming the fields that fell back. A language the site does not publish is served as the main language rather than refused, and the envelope's `locale` and the Content-Language header say which one arrived. A site with one language can ignore all of this.
An empty site is a valid site. Singleton resources come back fully defaulted (absent strings are empty strings) and collections come back as empty arrays, so a website whose content has not been authored yet still renders. Content problems are never signalled by a 404. Block values are the one exception to defaulting: a block field with no value is absent, and the site renders its own fallback.
The platform serves design-agnostic business data only. Layout and section copy belong to each website's own design and are not part of these resources.
Website blocks
Website blocks are the exception a site opts into: some of its own copy, which it lets the business edit in the backoffice's Website screen. The site declares its blocks — named groups of typed fields — in a manifest it syncs with PUT /api/v1/blocks/definitions, reads the business's values from GET /api/v1/blocks, and renders them in its own markup with its own copy as the fallback.
An optional live preview lets the business edit beside the website itself: the site marks its rendered elements with data-cd-block, data-cd-field and data-cd-item attributes, loads the platform's preview hook with a one-line loader that does nothing outside the backoffice's frame, and allows that frame with Content-Security-Policy: frame-ancestors. Every part is optional and independent, block by block. The manifest format, the field types, the loader and a worked example are in /api/v1/integration.md, under "Website blocks".
Making a website editable, with or without the live preview
The manifest format, the field types, the sync command, the markup attributes, the preview loader and framing header, a worked example and what to check when the preview does not appear are all in the integration contract, under “Website blocks”.
/api/v1/integration.mdEndpoints
/api/v1/settings
API keyBusiness name, tagline, description, brand (SVG logo and colour palette), contact details, opening schedule and social links for the authenticated site.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| ?locale= | string | no | The content language to serve, one of the site's `locales` — from `pt`, `en`, `es`, `fr`, `de`, `it`, `nl` (a regional tag such as `en-GB` is read as its language). Omitted, unknown or not published by this site: the main language is served, never an error. Text the tenant has not translated falls back to the main language and is named in the item's `untranslated` list; everything else is identical in every language. |
Response
200 with { version, siteKey, locale, defaultLocale, locales, settings } — empty content included — and a Content-Language header naming the language served. 401 on bad key.
{
"version": "6.9.0",
"siteKey": "my-business",
"locale": "pt",
"defaultLocale": "pt",
"locales": ["pt", "en"],
"settings": {
"name": "…",
"tagline": "…",
"phone": "+351 912 345 678",
"schedule": [{ "days": "Seg – Sáb", "hours": "09h – 19h" }],
"social": { "instagram": "…", "facebook": "" }
}
}| Field | Type | Required | Description |
|---|---|---|---|
| version | string | yes | Contract version served by the platform (semver). Compare the major. |
| siteKey | string | yes | Slug of the tenant the API key resolved to. Useful for logging. |
| locale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The content language this response is in: the one asked for with `?locale=` when the site publishes it, the main language otherwise. |
| defaultLocale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The site's main content language, the one every text is written in first (`pt` unless the tenant chose another). |
| locales | array of string | yes | Every content language the site publishes, main language first. A single entry means a one-language site. |
| settings.name | string | no | Business name as displayed across the site. |
| settings.tagline | string | no | One-line slogan shown near the logo/footer. In the response language (`locale`). |
| settings.description | string | no | Short description of the business, 1-2 sentences. In the response language (`locale`). |
| settings.phone | string | no | Display phone number, formatted for reading (e.g. "+351 912 345 678"). Empty string if none. Derive a tel: URI from it by stripping everything but + and digits. |
| settings.email | string | no | Public contact email. Empty string if none. |
| settings.whatsapp | string | no | WhatsApp chat URL (e.g. "https://wa.me/351912345678"). Empty string if none. |
| settings.appUrl | string | no | Mobile app store URL if the business has an app. Empty string if none. |
| settings.schedule | array of object | no | Opening schedule rows, in display order. |
| settings.schedule[].days | string | no | Day range label (e.g. "Segunda – Sábado"). In the response language (`locale`), the main one included: derived from the weekly hours the business drew on every read. |
| settings.schedule[].hours | string | no | Opening hours label (e.g. "09h – 19h" or "Fechado"). |
| settings.social | object | no | Social media profile links. |
| settings.social.facebook | string | no | Facebook page URL. Empty string if none. |
| settings.social.instagram | string | no | Instagram profile URL. Empty string if none. |
| settings.social.linkedin | string | no | LinkedIn page URL. Empty string if none. |
| settings.social.x | string | no | X/Twitter profile URL. Empty string if none. |
| settings.social.youtube | string | no | YouTube channel URL. Empty string if none. |
| settings.brand | object | no | The brand: logo and colour palette. Language-neutral: identical in every `locale`. |
| settings.brand.logo | object | no | The company logo. |
| settings.brand.logo.url | string | no | Absolute URL of the logo, an SVG file on the blob store's CDN. Empty string when the business has uploaded none. Sanitised and with its background removed on upload, so it is safe to inline and sits on any colour; served as `image/svg+xml`. |
| settings.brand.logo.width | integer | no | Width of the logo's canvas (its viewBox), for its aspect ratio — an SVG has no pixel size. |
| settings.brand.logo.height | integer | no | Height of the logo's canvas (its viewBox). |
| settings.brand.palette | array of object | no | The brand's colours, in the order the business arranged them — any number, possibly none. The order is the business's own and carries no fixed meaning (primary, secondary…); use the names if it matters to your design. |
| settings.brand.palette[].name | string | no | What the business calls the colour (e.g. "Azul principal"). Free text, possibly empty, the same in every language. |
| settings.brand.palette[].hex | string | yes | The colour as six-digit lowercase hex, e.g. "#1a2b3c". Never has an alpha channel. |
| settings.untranslated | array of string | no | Only when the response's `locale` is not the site's main language: the fields of this item that have no translation yet (e.g. "description", "image.alt"), served in the main language instead. A list (`bullets`, `tags`) is served whole in one language: a translation with fewer entries than the main list is not served, the main list is, and the field is named here. An untranslated `image.alt` may carry a stand-in already in the response language instead (see `alt`). Absent when the item is fully translated. |
/api/v1/services
API keyServices for the authenticated site in display order, each with a slug, title, icon key, display tier, copy and an optional image.
An image arrives as an absolute `url` served from the blob store's CDN — usable as an image source as it stands, provided your image optimiser allows the host.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| ?locale= | string | no | The content language to serve, one of the site's `locales` — from `pt`, `en`, `es`, `fr`, `de`, `it`, `nl` (a regional tag such as `en-GB` is read as its language). Omitted, unknown or not published by this site: the main language is served, never an error. Text the tenant has not translated falls back to the main language and is named in the item's `untranslated` list; everything else is identical in every language. |
Response
200 with { version, siteKey, locale, defaultLocale, locales, services } — empty content included — and a Content-Language header naming the language served. 401 on bad key.
{
"version": "6.9.0",
"siteKey": "my-business",
"locale": "pt",
"defaultLocale": "pt",
"locales": ["pt", "en"],
"services": [
{
"slug": "mudancas",
"title": "Mudanças",
"icon": "truck",
"tier": "primary",
"short": "…",
"description": "…",
"bullets": ["…"]
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| version | string | yes | Contract version served by the platform (semver). Compare the major. |
| siteKey | string | yes | Slug of the tenant the API key resolved to. Useful for logging. |
| locale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The content language this response is in: the one asked for with `?locale=` when the site publishes it, the main language otherwise. |
| defaultLocale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The site's main content language, the one every text is written in first (`pt` unless the tenant chose another). |
| locales | array of string | yes | Every content language the site publishes, main language first. A single entry means a one-language site. |
| services[].slug | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. |
| services[].title | string | no | Service name. In the response language (`locale`). |
| services[].icon | "truck" | "wrench" | "packageCheck" | "hammer" | "boxes" | "warehouse" | "arrowDownToLine" | "zap" | "clock" | "shieldCheck" | yes | Icon key from the fixed icon set (Lucide-style names). |
| services[].image | object | no | Illustrative image stored in Vercel Blob. |
| services[].image.assetId | string | no | Asset document id, set by the backoffice after upload. |
| services[].image.url | string | no | Absolute URL of the image, served from the blob store's CDN. Use it directly as an image source; no API key is involved. Allow the host in your image optimiser. |
| services[].image.alt | string | no | Image alt text. In the response language (`locale`). When the alt has no translation yet it is named in `untranslated` (`image.alt`, `coverImage.alt`) and, rather than the main-language alt, the item's translated title — or a testimonial author's name — is served where there is one. |
| services[].image.width | integer | no | Image width in pixels. |
| services[].image.height | integer | no | Image height in pixels. |
| services[].tier | "primary" | "featured" | "secondary" | yes | Display tier: "primary" for the main highlighted services, "featured" for the secondary highlights, "secondary" for the rest. |
| services[].short | string | no | One-sentence summary shown on service cards. In the response language (`locale`). |
| services[].description | string | no | Full description. In the response language (`locale`). |
| services[].bullets | array of string | no | Bullet-point highlights. In the response language (`locale`), whole: a translation with fewer entries than the main list is not served — the main list is, and `bullets` is named in `untranslated`. |
| services[].untranslated | array of string | no | Only when the response's `locale` is not the site's main language: the fields of this item that have no translation yet (e.g. "description", "image.alt"), served in the main language instead. A list (`bullets`, `tags`) is served whole in one language: a translation with fewer entries than the main list is not served, the main list is, and the field is named here. An untranslated `image.alt` may carry a stand-in already in the response language instead (see `alt`). Absent when the item is fully translated. |
/api/v1/products
API keyProducts for the authenticated site in display order, each with a slug, title, icon key, copy, an optional image and a price.
The price is an amount plus its currency and billing period, so each website formats it in its own style. A price of 0 means it is not published, and `available: false` marks a product kept on record but not currently sold.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| ?locale= | string | no | The content language to serve, one of the site's `locales` — from `pt`, `en`, `es`, `fr`, `de`, `it`, `nl` (a regional tag such as `en-GB` is read as its language). Omitted, unknown or not published by this site: the main language is served, never an error. Text the tenant has not translated falls back to the main language and is named in the item's `untranslated` list; everything else is identical in every language. |
Response
200 with { version, siteKey, locale, defaultLocale, locales, products } — empty content included — and a Content-Language header naming the language served. 401 on bad key.
{
"version": "6.9.0",
"siteKey": "my-business",
"locale": "pt",
"defaultLocale": "pt",
"locales": ["pt", "en"],
"products": [
{
"slug": "caixa-cartao-60x40",
"title": "Caixa de cartão 60x40",
"icon": "boxes",
"sku": "CX-6040",
"price": 2.5,
"currency": "EUR",
"pricePeriod": "once",
"priceNote": "IVA incluído",
"featured": false,
"available": true
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| version | string | yes | Contract version served by the platform (semver). Compare the major. |
| siteKey | string | yes | Slug of the tenant the API key resolved to. Useful for logging. |
| locale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The content language this response is in: the one asked for with `?locale=` when the site publishes it, the main language otherwise. |
| defaultLocale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The site's main content language, the one every text is written in first (`pt` unless the tenant chose another). |
| locales | array of string | yes | Every content language the site publishes, main language first. A single entry means a one-language site. |
| products[].slug | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. |
| products[].title | string | no | Product name. In the response language (`locale`). |
| products[].icon | "truck" | "wrench" | "packageCheck" | "hammer" | "boxes" | "warehouse" | "arrowDownToLine" | "zap" | "clock" | "shieldCheck" | yes | Icon key from the fixed icon set (Lucide-style names). |
| products[].image | object | no | Illustrative image stored in Vercel Blob. |
| products[].image.assetId | string | no | Asset document id, set by the backoffice after upload. |
| products[].image.url | string | no | Absolute URL of the image, served from the blob store's CDN. Use it directly as an image source; no API key is involved. Allow the host in your image optimiser. |
| products[].image.alt | string | no | Image alt text. In the response language (`locale`). When the alt has no translation yet it is named in `untranslated` (`image.alt`, `coverImage.alt`) and, rather than the main-language alt, the item's translated title — or a testimonial author's name — is served where there is one. |
| products[].image.width | integer | no | Image width in pixels. |
| products[].image.height | integer | no | Image height in pixels. |
| products[].sku | string | no | Internal reference or article number. Empty string if none. |
| products[].price | number | no | Price amount in the currency below. 0 means the price is not published. |
| products[].currency | "EUR" | "USD" | "GBP" | no | Currency of the price amount. |
| products[].pricePeriod | "once" | "month" | "year" | no | Billing period the price refers to: "once" for a one-off price, "month" or "year" for a subscription. |
| products[].priceNote | string | no | Short qualifier shown next to the price (e.g. "IVA incluído"). In the response language (`locale`). |
| products[].short | string | no | One-sentence summary shown on product cards. In the response language (`locale`). |
| products[].description | string | no | Full description. In the response language (`locale`). |
| products[].bullets | array of string | no | Bullet-point highlights. In the response language (`locale`), whole: a translation with fewer entries than the main list is not served — the main list is, and `bullets` is named in `untranslated`. |
| products[].featured | boolean | no | True for a product the website should highlight. |
| products[].available | boolean | no | False for a product kept on record but not currently sold. |
| products[].untranslated | array of string | no | Only when the response's `locale` is not the site's main language: the fields of this item that have no translation yet (e.g. "description", "image.alt"), served in the main language instead. A list (`bullets`, `tags`) is served whole in one language: a translation with fewer entries than the main list is not served, the main list is, and the field is named here. An untranslated `image.alt` may carry a stand-in already in the response language instead (see `alt`). Absent when the item is fully translated. |
/api/v1/projects
API keyWork the business has already delivered, in display order: the client it was for, then the problem, the solution and the results, plus related links.
Every attached file arrives in one `assets` array with its absolute `url`, `kind` (`image`, `video` or `document`), MIME type, original filename, byte size and, for media, its pixel dimensions. Group by `kind` if your design shows media separately from documents; the `url` is ready to use as a source or a download link.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| ?locale= | string | no | The content language to serve, one of the site's `locales` — from `pt`, `en`, `es`, `fr`, `de`, `it`, `nl` (a regional tag such as `en-GB` is read as its language). Omitted, unknown or not published by this site: the main language is served, never an error. Text the tenant has not translated falls back to the main language and is named in the item's `untranslated` list; everything else is identical in every language. |
Response
200 with { version, siteKey, locale, defaultLocale, locales, projects } — empty content included — and a Content-Language header naming the language served. 401 on bad key.
{
"version": "6.9.0",
"siteKey": "my-business",
"locale": "pt",
"defaultLocale": "pt",
"locales": ["pt", "en"],
"projects": [
{
"slug": "projeto-mudanca-armazem",
"client": "Padaria Central",
"problem": "…",
"solution": "…",
"results": "…",
"links": [{ "title": "Website do cliente", "url": "https://exemplo.pt" }],
"assets": [
{
"kind": "image",
"url": "https://abc123xyz.public.blob.vercel-storage.com/sites/my-business/antes-abc123.webp",
"contentType": "image/webp",
"name": "antes.webp",
"alt": "",
"size": 184320,
"width": 1600,
"height": 1067
},
{
"kind": "document",
"url": "https://abc123xyz.public.blob.vercel-storage.com/sites/my-business/relatorio-def456.pdf",
"contentType": "application/pdf",
"name": "relatorio.pdf",
"alt": "",
"size": 240128
}
]
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| version | string | yes | Contract version served by the platform (semver). Compare the major. |
| siteKey | string | yes | Slug of the tenant the API key resolved to. Useful for logging. |
| locale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The content language this response is in: the one asked for with `?locale=` when the site publishes it, the main language otherwise. |
| defaultLocale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The site's main content language, the one every text is written in first (`pt` unless the tenant chose another). |
| locales | array of string | yes | Every content language the site publishes, main language first. A single entry means a one-language site. |
| projects[].slug | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. |
| projects[].title | string | no | Name of the project, as the website should title it. Read this as the heading; a project stored before 6.1.0 may carry only `client`, so fall back to that when this is empty. |
| projects[].client | string | no | Name of the client the work was delivered for. Empty when there is no client to name — the business's own product, or work it may not attribute. |
| projects[].problem | string | no | The situation the client was in. In the response language (`locale`). |
| projects[].solution | string | no | What the business did about it. In the response language (`locale`). |
| projects[].results | string | no | What the client got out of it. In the response language (`locale`). |
| projects[].links | array of object | no | Related links, in display order. |
| projects[].links[].title | string | no | Link label. In the response language (`locale`). May be empty — fall back to showing the URL. |
| projects[].links[].url | string | no | Absolute URL, including the scheme. |
| projects[].assets | array of object | no | Every attached file in one list, in display order. Group them by `kind` if your design shows media separately from documents. |
| projects[].assets[].kind | "image" | "video" | "document" | yes | What the file is, derived from its MIME type: "image" and "video" are media, everything else is a "document". Split a list on this rather than on the file extension. |
| projects[].assets[].url | string | yes | Absolute URL of the file, served from the blob store's CDN. Use it directly — as an image source, a video source, or the href of a download link — with no API key involved. |
| projects[].assets[].contentType | string | no | MIME type the file was stored with (e.g. "image/webp", "application/pdf"). |
| projects[].assets[].name | string | no | Original filename at upload time. Use it as the download label for a document. |
| projects[].assets[].alt | string | no | Alt text, when one was written. Often empty — fall back to the surrounding context. |
| projects[].assets[].size | integer | no | File size in bytes, when known. |
| projects[].assets[].width | integer | no | Intrinsic width in pixels. Present for media, absent for documents. |
| projects[].assets[].height | integer | no | Intrinsic height in pixels. Present for media, absent for documents. |
| projects[].untranslated | array of string | no | Only when the response's `locale` is not the site's main language: the fields of this item that have no translation yet (e.g. "description", "image.alt"), served in the main language instead. A list (`bullets`, `tags`) is served whole in one language: a translation with fewer entries than the main list is not served, the main list is, and the field is named here. An untranslated `image.alt` may carry a stand-in already in the response language instead (see `alt`). Absent when the item is fully translated. |
/api/v1/posts
API keyNews and articles the business writes itself, newest first by `publishedAt`. Only published posts are served: a draft is absent from this list entirely, so there is no state to check before rendering one.
`body` is an HTML fragment, like a block's `richText` fields and unlike every other text on this API. It is sanitised when the tenant saves it, against a fixed allowlist — `p`, `h2`–`h4`, `strong`, `em`, `u`, `s`, `code`, `pre`, `blockquote`, `ul`, `ol`, `li`, `a`, `img`, `hr`, `br` — so that is the whole set of elements your stylesheet has to cover. Headings start at `h2`, because the `h1` of the page is the post's `title`.
An `img` in a body is always a file from this tenant's own blob store, with the same absolute `url` shape as every other file on this API. It carries `alt`, its pixel dimensions and `data-align` (`left`, `center` or `right`) — the writer's intent, for your design to interpret. There is no inline CSS and no class attribute.
`publishedAt` and `updatedOn` are calendar dates, not instants: format them in UTC or the day will read as the one before for anyone west of Greenwich.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| ?locale= | string | no | The content language to serve, one of the site's `locales` — from `pt`, `en`, `es`, `fr`, `de`, `it`, `nl` (a regional tag such as `en-GB` is read as its language). Omitted, unknown or not published by this site: the main language is served, never an error. Text the tenant has not translated falls back to the main language and is named in the item's `untranslated` list; everything else is identical in every language. |
Response
200 with { version, siteKey, locale, defaultLocale, locales, posts } — empty content included — and a Content-Language header naming the language served. 401 on bad key.
{
"version": "6.9.0",
"siteKey": "my-business",
"locale": "pt",
"defaultLocale": "pt",
"locales": ["pt", "en"],
"posts": [
{
"slug": "novo-armazem-em-leiria",
"title": "Novo armazém em Leiria",
"excerpt": "Duplicámos a capacidade de armazenamento na zona centro.",
"body": "<p>A partir de <strong>setembro</strong> passamos a operar…</p><h2>O que muda para os clientes</h2><p>…</p><img src=\"https://abc123xyz.public.blob.vercel-storage.com/sites/my-business/armazem-abc123.webp\" alt=\"Interior do novo armazém\" width=\"1600\" height=\"1067\" data-align=\"center\" data-asset-id=\"6712f0a1b2c3d4e5f6a7b8c9\">",
"coverImage": {
"url": "https://abc123xyz.public.blob.vercel-storage.com/sites/my-business/capa-abc123.webp",
"alt": "Fachada do novo armazém",
"width": 1600,
"height": 900
},
"publishedAt": "2026-08-24",
"updatedOn": "",
"tags": ["armazenamento", "leiria"],
"author": "Ana Ferreira"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| version | string | yes | Contract version served by the platform (semver). Compare the major. |
| siteKey | string | yes | Slug of the tenant the API key resolved to. Useful for logging. |
| locale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The content language this response is in: the one asked for with `?locale=` when the site publishes it, the main language otherwise. |
| defaultLocale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The site's main content language, the one every text is written in first (`pt` unless the tenant chose another). |
| locales | array of string | yes | Every content language the site publishes, main language first. A single entry means a one-language site. |
| posts[].slug | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. |
| posts[].title | string | no | Headline of the post. In the response language (`locale`). |
| posts[].excerpt | string | no | Standfirst: one or two sentences summarising the post. Plain text, no markup — it is written for a card and for a meta description. In the response language (`locale`). |
| posts[].body | string | no | The post itself, as an HTML fragment (no <html> or <body> wrapper). Render it as HTML; it is sanitised on save against a fixed allowlist, so the only elements it can contain are p, h2, h3, h4, strong, em, u, s, code, pre, blockquote, ul, ol, li, a, img, hr, br. Headings start at h2, because the page's h1 is the title above. An <a> carries href, and one leaving the site also carries target and rel. An <img> is always a file from this tenant's own blob store and carries src, alt, width, height and data-align ("left", "center" or "right"); a <p>, <h2>, <h3> or <h4> the writer centred or right-aligned carries data-align ("center" or "right"), and a left-aligned one nothing — style the alignment yourself, there is no inline CSS. Empty string for a post with no body yet. |
| posts[].coverImage | object | no | Lead image for the post, shown on a card and at the top of the page. |
| posts[].coverImage.assetId | string | no | Asset document id, set by the backoffice after upload. |
| posts[].coverImage.url | string | no | Absolute URL of the image, served from the blob store's CDN. Use it directly as an image source; no API key is involved. Allow the host in your image optimiser. |
| posts[].coverImage.alt | string | no | Image alt text. In the response language (`locale`). When the alt has no translation yet it is named in `untranslated` (`image.alt`, `coverImage.alt`) and, rather than the main-language alt, the item's translated title — or a testimonial author's name — is served where there is one. |
| posts[].coverImage.width | integer | no | Image width in pixels. |
| posts[].coverImage.height | integer | no | Image height in pixels. |
| posts[].publishedAt | string | no | The day the post is dated, as "YYYY-MM-DD". Treat it as a calendar date, not an instant: format it in UTC or the day will read as the one before west of Greenwich. Posts arrive newest first. |
| posts[].updatedOn | string | no | The day the post was last revised, as "YYYY-MM-DD". Empty when it has not been revised since publishing — useful as dateModified in structured data. |
| posts[].tags | array of string | no | Free-text labels the business groups its posts by. Display order. Not a fixed vocabulary and not slugs — slugify them yourself if you route by them. In the response language (`locale`), whole: a translation with fewer tags than the main list is not served — the main list is, and `tags` is named in `untranslated`. |
| posts[].author | string | no | Who wrote it, as it should be credited. Empty when the business does not by-line its posts. |
| posts[].untranslated | array of string | no | Only when the response's `locale` is not the site's main language: the fields of this item that have no translation yet (e.g. "description", "image.alt"), served in the main language instead. A list (`bullets`, `tags`) is served whole in one language: a translation with fewer entries than the main list is not served, the main list is, and the field is named here. An untranslated `image.alt` may carry a stand-in already in the response language instead (see `alt`). Absent when the item is fully translated. |
/api/v1/testimonials
API keyWhat clients have said about the business, in display order. There is no draft state and nothing to filter: a testimonial the tenant no longer wants is deleted, and one created but not yet written is withheld, so everything here has words in it.
`rating` is a whole number of stars out of 5, and 0 means the testimonial came without one — show the words alone rather than an empty row of stars.
`author` may be empty (an anonymous testimonial) and `image`, the photograph of the person quoted, is usually absent. `date` is a calendar date: format it in UTC.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| ?locale= | string | no | The content language to serve, one of the site's `locales` — from `pt`, `en`, `es`, `fr`, `de`, `it`, `nl` (a regional tag such as `en-GB` is read as its language). Omitted, unknown or not published by this site: the main language is served, never an error. Text the tenant has not translated falls back to the main language and is named in the item's `untranslated` list; everything else is identical in every language. |
Response
200 with { version, siteKey, locale, defaultLocale, locales, testimonials } — empty content included — and a Content-Language header naming the language served. 401 on bad key.
{
"version": "6.9.0",
"siteKey": "my-business",
"locale": "pt",
"defaultLocale": "pt",
"locales": ["pt", "en"],
"testimonials": [
{
"slug": "padaria-central",
"author": "Ana Ferreira",
"role": "Gerente, Padaria Central",
"quote": "Entregam sempre à hora combinada. Em dois anos nunca falhámos uma abertura.",
"rating": 5,
"date": "2026-06-12",
"image": {
"url": "https://abc123xyz.public.blob.vercel-storage.com/sites/my-business/ana-abc123.webp",
"alt": "Ana Ferreira",
"width": 400,
"height": 400
}
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| version | string | yes | Contract version served by the platform (semver). Compare the major. |
| siteKey | string | yes | Slug of the tenant the API key resolved to. Useful for logging. |
| locale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The content language this response is in: the one asked for with `?locale=` when the site publishes it, the main language otherwise. |
| defaultLocale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The site's main content language, the one every text is written in first (`pt` unless the tenant chose another). |
| locales | array of string | yes | Every content language the site publishes, main language first. A single entry means a one-language site. |
| testimonials[].slug | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. |
| testimonials[].author | string | no | Who said it, as they should be credited. Empty for an anonymous testimonial. |
| testimonials[].role | string | no | Who the author is, in their own words (e.g. "Gerente, Padaria Central"). One line, meant to be read under the name. In the response language (`locale`). |
| testimonials[].quote | string | no | What they said. Plain text, no markup. In the response language (`locale`). |
| testimonials[].rating | integer | no | Stars out of 5. 0 means the testimonial came without a rating — show the words alone rather than zero stars. |
| testimonials[].date | string | no | The day it was given, as "YYYY-MM-DD". Empty when it is not dated. A calendar date, not an instant: format it in UTC. |
| testimonials[].image | object | no | Photograph of the author, when there is one. Often absent. |
| testimonials[].image.assetId | string | no | Asset document id, set by the backoffice after upload. |
| testimonials[].image.url | string | no | Absolute URL of the image, served from the blob store's CDN. Use it directly as an image source; no API key is involved. Allow the host in your image optimiser. |
| testimonials[].image.alt | string | no | Image alt text. In the response language (`locale`). When the alt has no translation yet it is named in `untranslated` (`image.alt`, `coverImage.alt`) and, rather than the main-language alt, the item's translated title — or a testimonial author's name — is served where there is one. |
| testimonials[].image.width | integer | no | Image width in pixels. |
| testimonials[].image.height | integer | no | Image height in pixels. |
| testimonials[].untranslated | array of string | no | Only when the response's `locale` is not the site's main language: the fields of this item that have no translation yet (e.g. "description", "image.alt"), served in the main language instead. A list (`bullets`, `tags`) is served whole in one language: a translation with fewer entries than the main list is not served, the main list is, and the field is named here. An untranslated `image.alt` may carry a stand-in already in the response language instead (see `alt`). Absent when the item is fully translated. |
/api/v1/faqs
API keyThe questions the business answers before they are asked, in display order, with the questions of a group together.
`category` is the tenant's own heading for a group and is free text, not a vocabulary: group on the exact string, and expect an ungrouped question (an empty `category`) beside grouped ones. In another language each question falls back on its own: one whose `category` is untranslated carries the main-language heading and names `category` in `untranslated`, so it groups apart until the tenant translates it.
`answer` is plain text — split it on its line breaks to lay out paragraphs, as with a legal section's body. `slug` is stable, so it works as the anchor of a question you can link to.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| ?locale= | string | no | The content language to serve, one of the site's `locales` — from `pt`, `en`, `es`, `fr`, `de`, `it`, `nl` (a regional tag such as `en-GB` is read as its language). Omitted, unknown or not published by this site: the main language is served, never an error. Text the tenant has not translated falls back to the main language and is named in the item's `untranslated` list; everything else is identical in every language. |
Response
200 with { version, siteKey, locale, defaultLocale, locales, faqs } — empty content included — and a Content-Language header naming the language served. 401 on bad key.
{
"version": "6.9.0",
"siteKey": "my-business",
"locale": "pt",
"defaultLocale": "pt",
"locales": ["pt", "en"],
"faqs": [
{
"slug": "fazem-entregas-ao-sabado",
"question": "Fazem entregas ao sábado?",
"answer": "Sim, entre as 9h e as 13h na área metropolitana de Lisboa.\n\nFora dessa área, apenas em dias úteis.",
"category": "Entregas"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| version | string | yes | Contract version served by the platform (semver). Compare the major. |
| siteKey | string | yes | Slug of the tenant the API key resolved to. Useful for logging. |
| locale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The content language this response is in: the one asked for with `?locale=` when the site publishes it, the main language otherwise. |
| defaultLocale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The site's main content language, the one every text is written in first (`pt` unless the tenant chose another). |
| locales | array of string | yes | Every content language the site publishes, main language first. A single entry means a one-language site. |
| faqs[].slug | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. Stable, so it works as the anchor of a linkable question. |
| faqs[].question | string | no | The question, as a client would ask it. In the response language (`locale`). |
| faqs[].answer | string | no | The answer. Plain text, no markup; split it on its line breaks to lay out paragraphs, like a legal section's body. In the response language (`locale`). |
| faqs[].category | string | no | Free-text heading this question is grouped under (e.g. "Entregas"). Empty for an ungrouped question. Not a fixed vocabulary and not a slug — group by the exact string, and expect an empty one alongside filled ones. In the response language (`locale`), falling back per question: one question whose category is untranslated arrives with the main-language heading (and `category` in its `untranslated`), so grouping by the exact string puts it under a heading of its own. |
| faqs[].untranslated | array of string | no | Only when the response's `locale` is not the site's main language: the fields of this item that have no translation yet (e.g. "description", "image.alt"), served in the main language instead. A list (`bullets`, `tags`) is served whole in one language: a translation with fewer entries than the main list is not served, the main list is, and the field is named here. An untranslated `image.alt` may carry a stand-in already in the response language instead (see `alt`). Absent when the item is fully translated. |
/api/v1/locations
API keyLocations for the authenticated site in display order, with address lines and optional Google Maps URLs. At most one is flagged primary.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| ?locale= | string | no | The content language to serve, one of the site's `locales` — from `pt`, `en`, `es`, `fr`, `de`, `it`, `nl` (a regional tag such as `en-GB` is read as its language). Omitted, unknown or not published by this site: the main language is served, never an error. Text the tenant has not translated falls back to the main language and is named in the item's `untranslated` list; everything else is identical in every language. |
Response
200 with { version, siteKey, locale, defaultLocale, locales, locations } — empty content included — and a Content-Language header naming the language served. 401 on bad key.
{
"version": "6.9.0",
"siteKey": "my-business",
"locale": "pt",
"defaultLocale": "pt",
"locales": ["pt", "en"],
"locations": [
{
"slug": "lisboa",
"city": "Lisboa",
"lines": ["Rua Exemplo 12", "1000-001 Lisboa"],
"primary": true
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| version | string | yes | Contract version served by the platform (semver). Compare the major. |
| siteKey | string | yes | Slug of the tenant the API key resolved to. Useful for logging. |
| locale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The content language this response is in: the one asked for with `?locale=` when the site publishes it, the main language otherwise. |
| defaultLocale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The site's main content language, the one every text is written in first (`pt` unless the tenant chose another). |
| locales | array of string | yes | Every content language the site publishes, main language first. A single entry means a one-language site. |
| locations[].slug | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. |
| locations[].city | string | no | City or locality name. |
| locations[].lines | array of string | no | Address lines, in display order. |
| locations[].mapsSearchUrl | string | no | Google Maps search/share URL for this address. Empty string if none. |
| locations[].mapEmbedUrl | string | no | Google Maps embed URL (iframe src). Empty string if none. |
| locations[].primary | boolean | no | True for the main location (at most one per site). |
/api/v1/values
API keyCompany values for the authenticated site in display order.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| ?locale= | string | no | The content language to serve, one of the site's `locales` — from `pt`, `en`, `es`, `fr`, `de`, `it`, `nl` (a regional tag such as `en-GB` is read as its language). Omitted, unknown or not published by this site: the main language is served, never an error. Text the tenant has not translated falls back to the main language and is named in the item's `untranslated` list; everything else is identical in every language. |
Response
200 with { version, siteKey, locale, defaultLocale, locales, values } — empty content included — and a Content-Language header naming the language served. 401 on bad key.
{
"version": "6.9.0",
"siteKey": "my-business",
"locale": "pt",
"defaultLocale": "pt",
"locales": ["pt", "en"],
"values": [{ "slug": "qualidade", "title": "Qualidade", "description": "…" }]
}| Field | Type | Required | Description |
|---|---|---|---|
| version | string | yes | Contract version served by the platform (semver). Compare the major. |
| siteKey | string | yes | Slug of the tenant the API key resolved to. Useful for logging. |
| locale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The content language this response is in: the one asked for with `?locale=` when the site publishes it, the main language otherwise. |
| defaultLocale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The site's main content language, the one every text is written in first (`pt` unless the tenant chose another). |
| locales | array of string | yes | Every content language the site publishes, main language first. A single entry means a one-language site. |
| values[].slug | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. |
| values[].title | string | no | Value name (e.g. Qualidade). In the response language (`locale`). |
| values[].description | string | no | Short explanation of the value. In the response language (`locale`). |
| values[].untranslated | array of string | no | Only when the response's `locale` is not the site's main language: the fields of this item that have no translation yet (e.g. "description", "image.alt"), served in the main language instead. A list (`bullets`, `tags`) is served whole in one language: a translation with fewer entries than the main list is not served, the main list is, and the field is named here. An untranslated `image.alt` may carry a stand-in already in the response language instead (see `alt`). Absent when the item is fully translated. |
/api/v1/legal
API keyTerms and privacy text the client maintains in the backoffice, keyed by document, each an ordered list of title and body sections.
This is the only page-shaped content the platform holds. Which pages a website has, what they say and how they are titled belongs to the website itself.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| ?locale= | string | no | The content language to serve, one of the site's `locales` — from `pt`, `en`, `es`, `fr`, `de`, `it`, `nl` (a regional tag such as `en-GB` is read as its language). Omitted, unknown or not published by this site: the main language is served, never an error. Text the tenant has not translated falls back to the main language and is named in the item's `untranslated` list; everything else is identical in every language. |
Response
200 with { version, siteKey, locale, defaultLocale, locales, legal } — empty content included — and a Content-Language header naming the language served. 401 on bad key.
{
"version": "6.9.0",
"siteKey": "my-business",
"locale": "pt",
"defaultLocale": "pt",
"locales": ["pt", "en"],
"legal": {
"terms": { "sections": [{ "title": "1. Objeto", "body": "…" }] },
"privacy": { "sections": [{ "title": "1. Dados recolhidos", "body": "…" }] }
}
}| Field | Type | Required | Description |
|---|---|---|---|
| version | string | yes | Contract version served by the platform (semver). Compare the major. |
| siteKey | string | yes | Slug of the tenant the API key resolved to. Useful for logging. |
| locale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The content language this response is in: the one asked for with `?locale=` when the site publishes it, the main language otherwise. |
| defaultLocale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The site's main content language, the one every text is written in first (`pt` unless the tenant chose another). |
| locales | array of string | yes | Every content language the site publishes, main language first. A single entry means a one-language site. |
| legal.<key> | object | no | One legal document. |
| legal.<key>.sections | array of object | no | Ordered sections, as the document should read. |
| legal.<key>.sections[].title | string | no | Section heading. In the response language (`locale`). |
| legal.<key>.sections[].body | string | no | Section body prose; plain text, may contain multiple sentences. In the response language (`locale`). |
| legal.<key>.untranslated | array of string | no | Only when the response's `locale` is not the site's main language: the fields of this item that have no translation yet (e.g. "description", "image.alt"), served in the main language instead. A list (`bullets`, `tags`) is served whole in one language: a translation with fewer entries than the main list is not served, the main list is, and the field is named here. An untranslated `image.alt` may carry a stand-in already in the response language instead (see `alt`). Absent when the item is fully translated. |
/api/v1/blocks
API keyThe site's own copy that the business edits in the backoffice, in the blocks the site's manifest declares (see PUT /api/v1/blocks/definitions). One entry per declared block, in the manifest's order, each with its `key` and its `values` by field id; a site that never synced gets an empty list.
Each value has its field type's shape: a string for `text` and `richText` (an HTML fragment sanitised to `p`, `br`, `strong`, `em`, `ul`, `ol`, `li` and `a` with `href` only), a number for `number`, `{ label, href }` for `link`, `{ assetId?, url, alt, width?, height?, focal: { x, y }, fit, aspect? }` for `image` (`assetId` may be missing on an older value: do not depend on it), and an array of item objects for `list`. A `collection` field never has a value: the site reads that resource itself.
A field with no value — never set, or cleared by the business — is absent, never null: render the site's own fallback. Cache this resource under the `blocks` tag; a save in the backoffice pushes it.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| ?locale= | string | no | The content language to serve, one of the site's `locales` — from `pt`, `en`, `es`, `fr`, `de`, `it`, `nl` (a regional tag such as `en-GB` is read as its language). Omitted, unknown or not published by this site: the main language is served, never an error. Text the tenant has not translated falls back to the main language and is named in the item's `untranslated` list; everything else is identical in every language. |
Response
200 with { version, siteKey, locale, defaultLocale, locales, blocks } — empty content included — and a Content-Language header naming the language served. 401 on bad key.
{
"version": "6.9.0",
"siteKey": "my-business",
"locale": "pt",
"defaultLocale": "pt",
"locales": ["pt", "en"],
"blocks": [
{
"key": "home.hero",
"values": {
"title": "Pão de fermentação lenta",
"intro": "<p>Cozido todas as manhãs em <strong>Alvalade</strong>.</p>",
"cta": { "label": "Encomendar", "href": "/contactos" },
"photo": {
"assetId": "6712f0a1b2c3d4e5f6a7b8c9",
"url": "https://abc123xyz.public.blob.vercel-storage.com/sites/my-business/forno-abc123.webp",
"alt": "O forno a lenha",
"width": 1600,
"height": 1067,
"focal": { "x": 0.5, "y": 0.3 },
"fit": "cover",
"aspect": "4:3"
}
}
},
{
"key": "home.how",
"values": {
"steps": [{ "title": "Encomende", "body": "Até às 18h da véspera." }]
}
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| version | string | yes | Contract version served by the platform (semver). Compare the major. |
| siteKey | string | yes | Slug of the tenant the API key resolved to. Useful for logging. |
| locale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The content language this response is in: the one asked for with `?locale=` when the site publishes it, the main language otherwise. |
| defaultLocale | "pt" | "en" | "es" | "fr" | "de" | "it" | "nl" | yes | The site's main content language, the one every text is written in first (`pt` unless the tenant chose another). |
| locales | array of string | yes | Every content language the site publishes, main language first. A single entry means a one-language site. |
| blocks[].key | string | yes | The block's key, as the manifest declares it. |
| blocks[].values | object | yes | The block's values by field id: what the owner saved, or the manifest's defaults where nothing was saved. A field with no value (never set, cleared by the owner, or a collection) is absent: render your own fallback. |
| blocks[].values.<key> | unknown | no | |
| blocks[].untranslated | array of string | no | Only when the response's `locale` is not the site's main language and something fell back: the paths of the values served in the main language for want of a translation (`title`, `cta.label`, `photo.alt`, `steps.2.label`, or a list's id when the whole list fell back). |
/api/v1/leads
API keyCreates a lead for the authenticated site — typically a quote or contact form submission. The lead enters the backoffice CRM pipeline with status "new".
If the tenant enabled SMS or Telegram notifications in the backoffice, they are notified. Unless they turned it off, the platform also emails the person who filled the form to confirm it arrived — so your site should not send a confirmation of its own. Notification failures never fail the request.
On a site in more than one language, send `locale` — the language the page with the form was read in. It is stored on the lead, and the confirmation email is written in it when the platform has that language.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | yes | Contact name. Required. |
| string (email) | yes | Contact email. Required. | |
| phone | string | no | Contact phone, free format. |
| formType | "quote" | "contact" | no | Which website form produced the lead. Defaults to "quote" for older client sites that do not send it. |
| serviceSlug | string | no | Slug of the service the visitor is interested in. |
| originDestination | string | no | Pickup / delivery description, for transport-style quote forms. |
| message | string | yes | The visitor's message. Required. |
| locale | string | no | The language of the page the form was on, e.g. the `locale` your content was read in ("en"). Read as its language (a regional tag such as "en-GB" is "en") and stored on the lead when it is one of the content languages (pt, en, es, fr, de, it, nl); any other value is accepted but not kept, and the lead is stored without a language. The confirmation email to the visitor is written in it when the platform has that language (pt, en), and in the account's language otherwise. Optional. |
Response
201 with { success: true, id }. 422 with field issues when the payload is invalid. 401 on bad key.
{
"success": true,
"id": "665f1c2ab8d3e0a1f0c4d5e6"
}/api/v1/blocks/definitions
API keyDeclares the site's blocks: the whole manifest, sent from the site's repository by hand or by its production deploy only (on Vercel, when `VERCEL_ENV` is `production`), never from a preview or branch build, whose manifest would hide the blocks it lacks and set aside the content of any field whose type it changes. The platform creates the blocks that are new and updates every block's label, page, fields and order. Blocks and fields the manifest no longer lists are hidden from the backoffice and from GET /api/v1/blocks, with their content kept; they return as they were when the manifest lists them again.
Idempotent, and it never overwrites what the business saved. `defaults` (text, rich text, numbers, links, and lists of those; never images) fill a field only while nobody has filled it. Changing a field's `id` or `type` makes it a new field; changing a `type` sets its content aside, and changing it back does not bring it back. No cache invalidation is pushed.
Every object is strict: an unknown property is refused. Keys are lowercase letters and digits joined by `.` or `-`, field ids letters and digits starting with a letter; a list's item fields may not be lists or collections. The field types, limits and a worked example are in /api/v1/integration.md.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| blocks | array of object | yes | Every block the site declares, up to 100. |
| blocks[].key | string | yes | Block key, unique in the manifest: lowercase letters and digits joined by "." or "-", max 80 (e.g. "home.hero"). |
| blocks[].label | string | yes | Text the owner sees in the backoffice, 1 to 80 characters. |
| blocks[].page | string | yes | Path of the site page the block appears on; starts with "/", max 200 characters. |
| blocks[].fields | array of object | object | object | yes | The block's fields, up to 40. |
| blocks[].defaults | object | no | Starting values, by field id, written once into fields nobody has filled yet; never over what the owner saved. For text, richText, number and link fields, in the shape of their value, and for a list field: an array of up to 50 items, each an object keyed by the list's text, richText, number and link item field ids. Never for images or collections. |
| blocks[].defaults.<key> | unknown | no |
Response
200 with { synced, hidden }: the number of blocks in the manifest and the keys this sync hid. 400 when the body is not JSON, 401 on bad key, 422 with { error, issues: [{ path, message }] } when the manifest breaks a rule (nothing is written), 409 when the blocks changed during the sync — a save in the backoffice or another sync — in which case some blocks may already be updated and running it again completes it, 500 when the platform could not write.
{
"synced": 2,
"hidden": []
}Content formats
The shape of each resource is documented separately: JSON Schema at /api/content-schema, human-readable reference at /docs/content-api, Markdown for AI agents at /api/content-schema/docs. Open the content format reference.