# Casa Digital — client site integration contract

Contract version: 4.1.0 (semver; compare the major).  
Platform origin: https://www.casadigital.pt  
Audience: developers and coding agents building or maintaining a client website on the Casa Digital platform.

This is the complete contract as seen from a client website: everything a site may do, and nothing else is available to it. If a capability is not described here, a client site does not have it.

## How the pieces fit

- **The platform (casadigital.pt) owns the data.** It hosts the multi-tenant backoffice where a business edits its own content and works its leads, and it is the only system that touches the database.
- **Your website is a view.** It reads content over HTTPS with a per-tenant API key and renders it with its own design. It stores no content of its own.
- **One tenant, one API key.** The key both authenticates the request and selects the tenant, so no site identifier is ever sent.
- **Reads are content; the only write is a lead.** Everything else — editing content, working the CRM, SMS notifications, AI seeding, billing — happens in the backoffice and has no public API.

## What the platform owns, and what your repository owns

The platform holds design-agnostic business data only: the business identity and contacts, the services it performs, the products it sells, the case studies it publishes, its locations, its company values, and the legal documents the client must be able to edit itself.

A service is work scoped per job and carries no price. A product is something sold at a stated price, delivered as an amount plus its currency and billing period so your site formats it in its own style. A case study is work already delivered, told as problem, solution and results, with links and attached files.

Everything about the website as a website belongs to your repository: which pages exist, how they are routed and laid out, section copy, headings, statistics, call-to-action labels, imagery the client did not upload, and all SEO metadata. That separation is deliberate — it is what lets every client have a bespoke site while the platform stays the same.

Available resources: `settings`, `services`, `products`, `caseStudies`, `locations`, `values`, `legal`. Full field tables are in the endpoint reference at the end of this document.

| Resource | Endpoint | Shape | What it holds |
| --- | --- | --- | --- |
| `settings` | `GET /api/v1/settings` | singleton | Business name, tagline, description, contact details, opening schedule and social links for the authenticated site. |
| `services` | `GET /api/v1/services` | collection | Services for the authenticated site in display order, each with a slug, title, icon key, display tier, copy and an optional image. |
| `products` | `GET /api/v1/products` | collection | Products for the authenticated site in display order, each with a slug, title, icon key, copy, an optional image and a price. |
| `caseStudies` | `GET /api/v1/caseStudies` | collection | Work the business has already delivered, in display order: the client it was for, then the problem, the solution and the results, plus related links. |
| `locations` | `GET /api/v1/locations` | collection | Locations for the authenticated site in display order, with address lines and optional Google Maps URLs. At most one is flagged primary. |
| `values` | `GET /api/v1/values` | collection | Company values for the authenticated site in display order. |
| `legal` | `GET /api/v1/legal` | singleton | Terms and privacy text the client maintains in the backoffice, keyed by document, each an ordered list of title and body sections. |

## Environment

| Variable | Required | Purpose |
| --- | --- | --- |
| `CASADIGITAL_API_URL` | no | Platform origin. Defaults to `https://casadigital.pt`; set it to a local platform instance during development. |
| `SITE_API_KEY` | yes | The tenant's Site API key (`sk_` + 48 hex chars), issued by Casa Digital. Server-side only — it grants read access to all of the tenant's content and the right to create leads. |
| `REVALIDATE_SECRET` | recommended | Shared secret the platform sends when it pushes cache invalidation to your site. Without it your site cannot accept pushes and only refreshes on its own interval. |

API keys and secrets are issued by Casa Digital when the site is provisioned; there is no self-service signup and no way to mint a key from an API. Ask for one, keep it server-side, and never ship it to the browser.

## Authentication

Send the key on every `/api/v1/*` request except the asset endpoint: `Authorization: Bearer sk_<48 hex chars>`. A missing, malformed or unknown key is a 401. Keys can be regenerated by Casa Digital, which immediately invalidates the old one.

## Response envelope and contract version

Every content response is wrapped identically: `{ version, siteKey, <resource> }`. `version` is the contract version this platform serves and `siteKey` is the tenant slug, useful for logging.

Compare the major of `version` against the one you built against (currently `4.1.0`) and log loudly on a mismatch. Within a major version, changes are additive: new fields may appear, so ignore unknown fields rather than failing. A major bump may rename, reshape or remove fields.

Validate the payload against your own local schema before rendering. The platform validates on the way out, but a site that parses what it receives fails in one obvious place instead of deep inside a component.

## Reading content

One resource per request, so each consumer fetches only what it renders and caches it on its own schedule. There is no combined content document and no partial-response or field-selection syntax.

- **Collections come complete and ordered.** No pagination, no filtering, no sort parameters: `services`, `products`, `caseStudies`, `locations` and `values` arrive in the display order the client arranged in the backoffice.
- **Prices are data, not presentation.** A product carries `price`, `currency` and `pricePeriod`; format them yourself. A `price` of 0 means the price is not published and `available: false` marks a product kept on record but not currently sold — decide what each means in your design instead of rendering them raw.
- **A case study's files come as one list.** `caseStudies[].assets` holds images, videos and documents together, in the order the client arranged them, each tagged with a `kind`. Split on `kind` rather than on the file extension: a gallery of the media and a download list of the documents is what the shape is built for. A case study with no files at all is normal.
- **Contact details are written for reading, and links are yours to build.** `settings.phone` is formatted for a human (`+351 912 345 678`); derive the `tel:` target by stripping everything but `+` and digits, in one place rather than per section. Same idea for a `wa.me` link if you want one. An empty `phone` means the business publishes no number, so render no phone link at all.
- **An empty site is a valid site.** Singletons come back fully defaulted (absent strings are empty strings) and collections come back as empty arrays, so a site whose content has not been authored yet still renders. Content problems are never signalled by a 404.
- **Freshness is yours.** Responses carry `Cache-Control: private, max-age=0, must-revalidate`; the platform deliberately does not cache them for you. Cache per resource on your side and invalidate on push (below).
- **Always set a request timeout** (5s is what the reference site uses) and degrade to the empty state on failure. A slow platform must not take the website down.
- **A save is live immediately.** There is no publish step or draft state: the API always returns the latest content.

### Quickstart

```ts
// lib/content.ts — one cached read per resource, on the server only.
import { unstable_cache } from "next/cache"

const BASE_URL = (process.env.CASADIGITAL_API_URL ?? "https://casadigital.pt").replace(/\/+$/, "")
const EXPECTED_CONTRACT_MAJOR = "4"

async function fetchResource<T>(resource: string): Promise<T> {
  const response = await fetch(`${BASE_URL}/api/v1/${resource}`, {
    headers: { Authorization: `Bearer ${process.env.SITE_API_KEY}` },
    // Freshness is owned by the cache wrapper below, not by fetch.
    cache: "no-store",
    // A hung platform must not hold the render open.
    signal: AbortSignal.timeout(5_000),
  })

  if (!response.ok) throw new Error(`${resource} responded ${response.status}`)

  const payload = await response.json()

  if (String(payload.version).split(".")[0] !== EXPECTED_CONTRACT_MAJOR) {
    console.error(`[content] ${resource} is contract ${payload.version}; this site expects ${EXPECTED_CONTRACT_MAJOR}.x`)
  }

  // Validate against your own local schema (zod, valibot, …) before rendering.
  return payload[resource] as T
}

// One cache tag per resource, plus a slow interval as a safety net in case an
// invalidation push is ever missed. An unavailable resource degrades to its
// empty value so the site stays up.
export const getServices = unstable_cache(
  async () => {
    try {
      return await fetchResource<unknown[]>("services")
    } catch (error) {
      console.error("[content] services unavailable, rendering empty state:", error)
      return []
    }
  },
  ["services"],
  { tags: ["services"], revalidate: 300 }
)
```

## Cache invalidation: the endpoint your site must expose

After a content save, the platform pushes the resources that changed to your site so edits appear immediately. Give Casa Digital your revalidation URL and a shared secret; they are registered per tenant on the platform side.

| Property | Value |
| --- | --- |
| Direction | platform → your site |
| Method | `POST` to the URL you registered |
| Header | `Authorization: Bearer <REVALIDATE_SECRET>` |
| Body | `{ "tags": ["services", …] }` |
| Valid tags | `settings, services, products, caseStudies, locations, values, legal` — exactly the resource names |
| Expected response | any 2xx; non-2xx is logged on the platform side |
| Timeout | 5s, then the platform gives up |
| Delivery | best effort, at most once — there are no retries and no signature |

Because delivery is best effort, treat the push as an optimisation and keep a slow revalidation interval as the safety net. A site with no registered URL is fully supported; it simply refreshes on its own schedule. Reject unknown tags with a 400 rather than ignoring them, so a mistake on either side surfaces instead of silently serving stale content.

```ts
// app/api/revalidate/route.ts — the endpoint the platform calls after a save.
import { revalidateTag } from "next/cache"

const TAGS = ["settings", "services", "products", "caseStudies", "locations", "values", "legal"] as const

export async function POST(request: Request) {
  const secret = process.env.REVALIDATE_SECRET

  if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
    return Response.json({ error: "Unauthorized" }, { status: 401 })
  }

  const { tags } = (await request.json()) as { tags?: unknown }

  if (!Array.isArray(tags) || tags.length === 0) {
    return Response.json({ error: `Send { "tags": [...] }` }, { status: 400 })
  }

  // Reject unknown tags instead of ignoring them: a typo on either side should
  // be loud, not a silent success while the site keeps serving stale content.
  const unknown = tags.filter((tag) => !TAGS.includes(tag as (typeof TAGS)[number]))
  if (unknown.length) {
    return Response.json({ error: `Unknown tag(s): ${unknown.join(", ")}` }, { status: 400 })
  }

  for (const tag of tags as (typeof TAGS)[number][]) revalidateTag(tag)

  return Response.json({ revalidated: true, tags })
}
```

## Submitting leads

`POST /api/v1/leads` is the only write a client site has. Every website form — quote request, contact form — funnels through it and lands in the tenant's CRM with status `new`. Set `formType` (`quote` or `contact`) so the backoffice can tell the forms apart.

- **Notifications are not your concern.** Whether an SMS reaches the tenant depends on their own backoffice settings and subscription. A lead that produced no SMS is still a successful lead; never surface that to the visitor or retry because of it.
- **Spam protection is your concern.** The endpoint is not rate limited and every accepted call creates a CRM record, so put a honeypot, a time trap or a captcha in front of your form.
- **Validation is strict:** `name` at least 2 characters, `email` a valid address, `message` at least 10 characters. A 422 lists the offending fields in `issues[]`.
- **A website cannot read leads back.** There is no list, get, update or delete; the CRM is backoffice-only.

## Files

Every file the client uploaded is fetched from the public `GET /api/v1/assets/{pathname}` endpoint — no API key needed. Proxy it under your own route (the reference site uses `/api/blob/<pathname>`) so URLs stay same-origin and your image optimiser can cache them.

| Where | What it holds |
| --- | --- |
| `services[].image`, `products[].image` | The single illustrative image of one service or product: `pathname`, `alt`, and intrinsic `width`/`height` when known. |
| `caseStudies[].assets[]` | Any number of files of mixed kinds: `kind`, `pathname`, `contentType`, `name` (the original filename), `alt`, `size` in bytes, and `width`/`height` for media. |

- **Reserve the space.** `width` and `height` are the intrinsic pixel dimensions, sent so you can size a box before the file arrives. They are absent for documents, and may be absent for a media file whose dimensions could not be read.
- **`alt` is often empty.** Fall back to the surrounding context — the service title, the case study's client — rather than rendering an empty attribute.
- **Documents are links, not embeds.** Use `name` as the label and `contentType` to pick an icon; let the browser decide what to do with a PDF or a spreadsheet.
- **Nothing else lives here.** Backgrounds, team photos and illustrations belong to your repository. The platform stores only what the client uploaded in their own backoffice.

## Fixed vocabularies

| Field | Allowed values |
| --- | --- |
| `services[].icon`, `products[].icon` | `truck`, `wrench`, `packageCheck`, `hammer`, `boxes`, `warehouse`, `arrowDownToLine`, `zap`, `clock`, `shieldCheck` |
| `services[].tier` | `primary`, `featured`, `secondary` |
| `products[].currency` | `EUR`, `USD`, `GBP` |
| `products[].pricePeriod` | `once`, `month`, `year` |
| `caseStudies[].assets[].kind` | `image`, `video`, `document` |
| `legal` keys | `terms`, `privacy` |
| `formType` (lead) | `quote`, `contact` |

Map icon keys to your own icon components (the names follow Lucide). Treat an unknown value as a signal that the contract moved on: log it and fall back, rather than crashing the page.

## Limits and guarantees

- No rate limiting today, which is not a licence to fetch per request: cache per resource and rely on invalidation pushes.
- No pagination, filtering or sorting parameters. Collections are complete and pre-ordered.
- No write access to content. Content is editable only in the backoffice by the client.
- No CRM, account, subscription or analytics data is readable by a client site.
- No webhooks other than the invalidation push described above.
- No cross-tenant access: a key resolves to exactly one tenant, and asset pathnames are namespaced per tenant.
- No uptime or latency guarantee is published. Build so that an unreachable platform degrades to cached or empty content.

## Errors

| Status | Body | Meaning | What the site should do |
| --- | --- | --- | --- |
| 200 | `{ version, siteKey, <resource> }` | Content read succeeded. Empty content is also a 200. | Render it. Never treat empty values as an error. |
| 201 | `{ success: true, id }` | Lead created. | Show the visitor a confirmation. |
| 400 | `{ error }` | Body was not valid JSON. | Fix the request; this is a bug in the site. |
| 401 | `{ error }` | Missing, malformed or unknown API key. | Check `SITE_API_KEY`. Never retry in a loop; ask Casa Digital to reissue the key. |
| 404 | (empty) | Only for `GET /api/v1/assets/{pathname}` when the file does not exist. | Fall back to a placeholder image. A content resource never 404s. |
| 422 | `{ error, issues: [{ path, message }] }` | Lead payload failed validation. | Map `issues[].path` back onto your form fields and show the messages. |
| 500 | `{ error }` | The platform could not load the resource. | Serve the last cached copy, or the empty state, and log it. |

## Verify an integration

```bash
# Content read: expect 200 and a { version, siteKey, settings } envelope.
curl -s -H "Authorization: Bearer $SITE_API_KEY" https://www.casadigital.pt/api/v1/settings

# Wrong key: expect 401.
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer sk_wrong" https://www.casadigital.pt/api/v1/settings

# Lead: expect 201 and { success: true, id }. This creates a real CRM record.
curl -s -X POST https://www.casadigital.pt/api/v1/leads \
  -H "Authorization: Bearer $SITE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Teste","email":"teste@exemplo.pt","formType":"contact","message":"Integration self-test."}'

# Invalidation push, as the platform sends it, against your own site.
curl -s -X POST "$YOUR_SITE_URL/api/revalidate" \
  -H "Authorization: Bearer $REVALIDATE_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"tags":["settings"]}'
```

## Machine-readable references

- `https://www.casadigital.pt/api/v1/integration.md` — this document.
- `https://www.casadigital.pt/api/v1/openapi.json` — OpenAPI 3.1 description of every endpoint below.
- `https://www.casadigital.pt/api/content-schema` — JSON Schema per resource, for generating or validating local types.
- `https://www.casadigital.pt/api/v1/docs` — endpoint reference on its own (Markdown). Human version: `https://www.casadigital.pt/api-docs`.
- `https://www.casadigital.pt/llms.txt` — index of everything an agent should read. `https://www.casadigital.pt/llms-full.txt` is the whole set in one file.

## Endpoint reference

Generated from the same schemas the routes validate with. Field tables list every field, its type and whether it is always present.

### GET /api/v1/settings

Business name, tagline, description, contact details, opening schedule and social links for the authenticated site.

Authentication: required (Bearer API key)

#### Response

200 with { version, siteKey, settings } — empty content included. 401 on bad key.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no | Business name as displayed across the site. |
| `tagline` | string | no | One-line slogan shown near the logo/footer. Portuguese. |
| `description` | string | no | Short description of the business, 1-2 sentences. Portuguese. |
| `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. |
| `email` | string | no | Public contact email. Empty string if none. |
| `whatsapp` | string | no | WhatsApp chat URL (e.g. "https://wa.me/351912345678"). Empty string if none. |
| `appUrl` | string | no | Mobile app store URL if the business has an app. Empty string if none. |
| `schedule` | array of object | no | Opening schedule rows, in display order. |
| `schedule[].days` | string | no | Day range label (e.g. "Seg – Sáb"). Portuguese. |
| `schedule[].hours` | string | no | Opening hours label (e.g. "09h – 19h" or "Fechado"). |
| `social` | object | no | Social media profile links. |
| `social.facebook` | string | no | Facebook page URL. Empty string if none. |
| `social.instagram` | string | no | Instagram profile URL. Empty string if none. |
| `social.linkedin` | string | no | LinkedIn page URL. Empty string if none. |
| `social.x` | string | no | X/Twitter profile URL. Empty string if none. |
| `social.youtube` | string | no | YouTube channel URL. Empty string if none. |

```json
{
  "version": "4.1.0",
  "siteKey": "my-business",
  "settings": {
    "name": "…",
    "tagline": "…",
    "phone": "+351 912 345 678",
    "schedule": [{ "days": "Seg – Sáb", "hours": "09h – 19h" }],
    "social": { "instagram": "…", "facebook": "" }
  }
}
```

### GET /api/v1/services

Services for the authenticated site in display order, each with a slug, title, icon key, display tier, copy and an optional image.

Image pathnames refer to GET /api/v1/assets/{pathname}; client sites usually proxy that under their own route.

Authentication: required (Bearer API key)

#### Response

200 with { version, siteKey, services } — empty content included. 401 on bad key.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].slug` | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. |
| `[].title` | string | no | Service name. Portuguese. |
| `[].icon` | "truck" \| "wrench" \| "packageCheck" \| "hammer" \| "boxes" \| "warehouse" \| "arrowDownToLine" \| "zap" \| "clock" \| "shieldCheck" | yes | Icon key from the fixed icon set (Lucide-style names). |
| `[].image` | object | no | Illustrative image stored in Vercel Blob. |
| `[].image.assetId` | string | no | Asset document id, set by the backoffice after upload. |
| `[].image.pathname` | string | no | Blob storage pathname; client sites serve it through their own /api/blob/ proxy. |
| `[].image.alt` | string | no | Image alt text. Portuguese. |
| `[].image.width` | integer | no | Image width in pixels. |
| `[].image.height` | integer | no | Image height in pixels. |
| `[].tier` | "primary" \| "featured" \| "secondary" | yes | Display tier: "primary" for the main highlighted services, "featured" for the secondary highlights, "secondary" for the rest. |
| `[].short` | string | no | One-sentence summary shown on service cards. Portuguese. |
| `[].description` | string | no | Full description. Portuguese. |
| `[].bullets` | array of string | no | Bullet-point highlights. Portuguese. |

```json
{
  "version": "4.1.0",
  "siteKey": "my-business",
  "services": [
    {
      "slug": "mudancas",
      "title": "Mudanças",
      "icon": "truck",
      "tier": "primary",
      "short": "…",
      "description": "…",
      "bullets": ["…"]
    }
  ]
}
```

### GET /api/v1/products

Products 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.

Authentication: required (Bearer API key)

#### Response

200 with { version, siteKey, products } — empty content included. 401 on bad key.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].slug` | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. |
| `[].title` | string | no | Product name. Portuguese. |
| `[].icon` | "truck" \| "wrench" \| "packageCheck" \| "hammer" \| "boxes" \| "warehouse" \| "arrowDownToLine" \| "zap" \| "clock" \| "shieldCheck" | yes | Icon key from the fixed icon set (Lucide-style names). |
| `[].image` | object | no | Illustrative image stored in Vercel Blob. |
| `[].image.assetId` | string | no | Asset document id, set by the backoffice after upload. |
| `[].image.pathname` | string | no | Blob storage pathname; client sites serve it through their own /api/blob/ proxy. |
| `[].image.alt` | string | no | Image alt text. Portuguese. |
| `[].image.width` | integer | no | Image width in pixels. |
| `[].image.height` | integer | no | Image height in pixels. |
| `[].sku` | string | no | Internal reference or article number. Empty string if none. |
| `[].price` | number | no | Price amount in the currency below. 0 means the price is not published. |
| `[].currency` | "EUR" \| "USD" \| "GBP" | no | Currency of the price amount. |
| `[].pricePeriod` | "once" \| "month" \| "year" | no | Billing period the price refers to: "once" for a one-off price, "month" or "year" for a subscription. |
| `[].priceNote` | string | no | Short qualifier shown next to the price (e.g. "IVA incluído"). Portuguese. |
| `[].short` | string | no | One-sentence summary shown on product cards. Portuguese. |
| `[].description` | string | no | Full description. Portuguese. |
| `[].bullets` | array of string | no | Bullet-point highlights. Portuguese. |
| `[].featured` | boolean | no | True for a product the website should highlight. |
| `[].available` | boolean | no | False for a product kept on record but not currently sold. |

```json
{
  "version": "4.1.0",
  "siteKey": "my-business",
  "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
    }
  ]
}
```

### GET /api/v1/caseStudies

Work 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 `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; fetch each file from `GET /api/v1/assets/{pathname}`.

Authentication: required (Bearer API key)

#### Response

200 with { version, siteKey, caseStudies } — empty content included. 401 on bad key.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].slug` | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. |
| `[].client` | string | no | Name of the client the work was delivered for. Doubles as the case study's title. |
| `[].problem` | string | no | The situation the client was in. Portuguese. |
| `[].solution` | string | no | What the business did about it. Portuguese. |
| `[].results` | string | no | What the client got out of it. Portuguese. |
| `[].links` | array of object | no | Related links, in display order. |
| `[].links[].title` | string | no | Link label. Portuguese. May be empty — fall back to showing the URL. |
| `[].links[].url` | string | no | Absolute URL, including the scheme. |
| `[].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. |
| `[].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. |
| `[].assets[].pathname` | string | yes | Blob storage pathname; fetch the file from GET /api/v1/assets/{pathname}, or proxy it through your own route. |
| `[].assets[].contentType` | string | no | MIME type the file was stored with (e.g. "image/webp", "application/pdf"). |
| `[].assets[].name` | string | no | Original filename at upload time. Use it as the download label for a document. |
| `[].assets[].alt` | string | no | Alt text, when one was written. Often empty — fall back to the surrounding context. |
| `[].assets[].size` | integer | no | File size in bytes, when known. |
| `[].assets[].width` | integer | no | Intrinsic width in pixels. Present for media, absent for documents. |
| `[].assets[].height` | integer | no | Intrinsic height in pixels. Present for media, absent for documents. |

```json
{
  "version": "4.1.0",
  "siteKey": "my-business",
  "caseStudies": [
    {
      "slug": "caso-mudanca-armazem",
      "client": "Padaria Central",
      "problem": "…",
      "solution": "…",
      "results": "…",
      "links": [{ "title": "Website do cliente", "url": "https://exemplo.pt" }],
      "assets": [
        {
          "kind": "image",
          "pathname": "sites/my-business/antes-abc123.webp",
          "contentType": "image/webp",
          "name": "antes.webp",
          "alt": "",
          "size": 184320,
          "width": 1600,
          "height": 1067
        },
        {
          "kind": "document",
          "pathname": "sites/my-business/relatorio-def456.pdf",
          "contentType": "application/pdf",
          "name": "relatorio.pdf",
          "alt": "",
          "size": 240128
        }
      ]
    }
  ]
}
```

### GET /api/v1/locations

Locations for the authenticated site in display order, with address lines and optional Google Maps URLs. At most one is flagged primary.

Authentication: required (Bearer API key)

#### Response

200 with { version, siteKey, locations } — empty content included. 401 on bad key.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].slug` | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. |
| `[].city` | string | no | City or locality name. |
| `[].lines` | array of string | no | Address lines, in display order. |
| `[].mapsSearchUrl` | string | no | Google Maps search/share URL for this address. Empty string if none. |
| `[].mapEmbedUrl` | string | no | Google Maps embed URL (iframe src). Empty string if none. |
| `[].primary` | boolean | no | True for the main location (at most one per site). |

```json
{
  "version": "4.1.0",
  "siteKey": "my-business",
  "locations": [
    {
      "slug": "lisboa",
      "city": "Lisboa",
      "lines": ["Rua Exemplo 12", "1000-001 Lisboa"],
      "primary": true
    }
  ]
}
```

### GET /api/v1/values

Company values for the authenticated site in display order.

Authentication: required (Bearer API key)

#### Response

200 with { version, siteKey, values } — empty content included. 401 on bad key.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `[].slug` | string | yes | URL-safe unique identifier, lowercase, hyphen-separated. |
| `[].title` | string | no | Value name (e.g. Qualidade). Portuguese. |
| `[].description` | string | no | Short explanation of the value. Portuguese. |

```json
{
  "version": "4.1.0",
  "siteKey": "my-business",
  "values": [{ "slug": "qualidade", "title": "Qualidade", "description": "…" }]
}
```

### GET /api/v1/legal

Terms 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.

Authentication: required (Bearer API key)

#### Response

200 with { version, siteKey, legal } — empty content included. 401 on bad key.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `<key>` | object | no | One legal document. |
| `<key>.sections` | array of object | no | Ordered sections, as the document should read. |
| `<key>.sections[].title` | string | no | Section heading. Portuguese. |
| `<key>.sections[].body` | string | no | Section body prose; plain text, may contain multiple sentences. Portuguese. |

```json
{
  "version": "4.1.0",
  "siteKey": "my-business",
  "legal": {
    "terms": { "sections": [{ "title": "1. Objeto", "body": "…" }] },
    "privacy": { "sections": [{ "title": "1. Dados recolhidos", "body": "…" }] }
  }
}
```

### POST /api/v1/leads

Creates 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 notifications in the backoffice, an SMS is sent to them. Notification failures never fail the request.

Authentication: required (Bearer API key)

#### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | Contact name. Required. |
| `email` | 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. |

#### Response

201 with { success: true, id }. 422 with field issues when the payload is invalid. 401 on bad key.

```json
{
  "success": true,
  "id": "665f1c2ab8d3e0a1f0c4d5e6"
}
```

### GET /api/v1/assets/{pathname}

Streams a file from the tenant asset store (anything uploaded through the backoffice). Pathnames come from the content itself: services[].image.pathname, products[].image.pathname and caseStudies[].assets[].pathname.

Public and heavily cached; client sites usually proxy it under their own /api/blob/ route to keep same-origin URLs. Serve a document from a download link rather than inline.

Authentication: none

#### Response

200 with the file body and its Content-Type. 404 when the pathname does not exist.

```json
(binary file body)
```
