Skip to main content

Public Center-Website API — Contract (Read)

This document is the binding reference contract for public read access to center data through the cockpitOS dashboard (Next.js API Routes). It serves:

  • as a specification for the backend (P0/P1),
  • as context for v0 / other frontends (standalone apps via fetchnot the ZIP workflow for website templates in the cockpit; see Templates — Intro),
  • as a basis for dogfooding (server-data-loader gradually via HTTP): including Page Content, Services, Shop Categories (GET …/website-categories, same logic as before /api/categories), Category Themes (GET …/category-themes-for-website — website payload, not the raw admin JSON from …/category-themes), Construction Diary, Single Offers, Current Events, Hot Picks (GET …/hotpicks), Shops, Office Themes, Offices, Center Plans, DOOH Playlists (public reader routes under …/dooh/public/… for v0/Vercel). Shared implementation: @mall-os/database (getWebsiteShopCategories, getWebsiteCategoryThemesPayload). The Center website’s /api/categories and /api/category-themes call the same helpers (BFF for browser). /api/centers/centerplan remains BFF for Slug→ID and combined response.

Machine-readable (OpenAPI, optional): /openapi/public-wayfinding-read.yaml (public routes GET …/wayfinding/floors and …/centerplan). For AgencyOS including GET …/context see /openapi/agencyos-integration.yaml.

See also: Gap Analysis & Priorities (CORS status, missing routes). Editorial — Guide A–Z (Reading vs. Writing, v0, IT handover): v0 Website A–Z (for editorial staff). Editorial — only Steps & Instructions D/A/B/C/E (10×5000-character limit): v0 + Cockpit (Here’s how). Step-by-step (even without a dev background): v0 with public API. Example JSON, media URLs (resolveMediaUrl), type hints: Examples & Media.


Basic

FieldValue
Production Base URLhttps://dashboard.cockpit-os.de
Code ConfigurationgetDashboardApiUrl() in packages/dashboard-api — Env: DASHBOARD_API_URL, NEXT_PUBLIC_DASHBOARD_URL or NEXT_PUBLIC_API_URL
Auth (this contract)No session, no API keys — only publicly permitted read data. Write access and website-config GET are not part of this contract.
Content-Typeapplication/json
ErrorTypically { success: false, error: string } or { error: string } with appropriate HTTP status (400, 404, 429, 500). Exact fields may vary slightly by route.

Status by Endpoint

  • Ready + CORS * — Browser from another origin (e.g., Vercel) can directly fetch (including OPTIONS preflight).
  • Planned (P1) — not yet or only partially; the contract describes the goal.

Security & Data Segregation

  • Not every dashboard API is public: Endpoints under /api/dashboard/…, session routes, website-config GET, and write operations without strong auth remain not intended for anonymous cross-origin clients.
  • public-visitor-surface only provides a whitelist (no secrets, no full themeOverrides). Extensions only after a brief privacy/security check.
  • page-content POST remains without broad CORS — public write access without a token is undesirable.
  • Chatbot & Routing POST: POST /api/ai/visitor-chatbot and POST /api/wayfinding/routing are Redis-based per client IP limited (time window via rate-limit.ts); response 429 including CORS * and headers Retry-After, X-RateLimit-*. On Redis failures, fail-open. Tests: COCKPIT_DISABLE_PUBLIC_VISITOR_RATE_LIMIT=1. Additional WAF remains optional (P2).

Process: Determine centerId

External apps often know the slug or the custom domain, not the UUID.

1) By Slug

GET /api/centers/by-slug/{slug}

CORSYes (*)
ResponseObject with e.g. id, name, slug, websiteTemplate, city, address, baseColor, logo, urls

Example (shortened):

{
"id": "34eca9c3-1ea7-4c5a-b83a-d2b6bfb0c9f2",
"name": "Example Center",
"slug": "example-center",
"city": "Berlin",
"address": "Sample Street 1",
"baseColor": "#3b82f6",
"secondaryColor": "#64748b",
"logo": "https://…",
"logoUrl": "https://…",
"coverImageUrl": null,
"websiteFavicon": null,
"theme": "light",
"websiteTemplate": "mec-template-a",
"organization": null,
"urls": {
"dashboard": "https://dashboard.cockpit-os.de/centers/34eca9c3-…",
"manager": "https://example-center.manager.cockpit-os.de",
"signage": "https://example-center.signage.cockpit-os.de",
"companion": "https://example-center.signage.cockpit-os.de/companion",
"main": "https://preview.cockpit-os.de/example-center"
}
}

2) By Custom Domain

GET /api/centers/by-domain?domain={host}

Parameterdomain — Hostname without path, e.g., www.palais-vest.de or palais-vest.de
CORSYes (*)
Response{ success, center: { id, slug, websiteTemplate, … } }websiteTemplate also on GET …/by-slug/{slug} (flat object, field websiteTemplate)

Example:

{
"success": true,
"center": {
"id": "…",
"name": "…",
"slug": "…",
"customDomain": "palais-vest.de",
"customDomains": ["palais-vest.de", "www.palais-vest.de"],
"websiteEnabled": true,
"domainVerified": true,
"baseColor": "#…",
"secondaryColor": "#…",
"logoUrl": "https://…",
"organizationId": "…",
"websiteTemplate": "mec-template-green",
"comingSoonEnabled": false
}
}

Theme & Website Configuration (Public)

Theme Configuration (SPA / Template)

GET /api/centers/by-slug/{slug}/theme-config

CORSYes (*)
ResponseObject from SpaThemeManager.getSpaThemeConfig(centerId) — structure center and template dependent (Theme plugin, templateContent, colors, feature flags). For stable fields: Log live response of a center and defensively parse in the frontend.

Note: Full GET /api/centers/{centerId}/website-config is auth-required and not part of this contract.

Public Visitor Bundle (Whitelist)

GET /api/centers/{centerId}/public-visitor-surface

CORSYes (*)
PrerequisiteCenter exists (centerId). No websiteEnabled requirement (Bundle is field-safe; client can control release themselves).
Response{ success: true, data: { schemaVersion: 1, center, seo, chatbot, centerplan, wayfindingMap, pagesConfig, features, tracking, visitorPrivacy, **templatePublicContent**, **v0Integration**, apiHints } }
  • templatePublicContent: { templateId, rootKey, content } — visitor-safe subset of themeOverrides.templateContent.{rootKey} (all website templates: RGW, ILG, MEC, Goldbeck, …). Includes e.g. Hero slides, footer options, pageVisibility. Excluded: formRecipients, passwords, API keys. v0/Claude: never GET …/website-config without auth (401) — instead use this field.

  • v0Integration: Short reference to reading (public) vs. writing (MCP/AgencyOS) — identical to cockpit_website_config_schemav0Integration.

  • apiHints.pageContentGet / homepageTilesGet: relative paths to public CMS pages or homepage tiles.

  • data.center.openingHours / openingHoursNote / specialDays: center opening hours from master data (Cockpit “Edit center” / cockpit_update_center). JSON is parsed server-side; plain text remains a string. specialDays: separate column wins and is mirrored into structured openingHours when regularHours is present.

  • visitorPrivacy: bundled fields for cookie banner texts (from themeOverrides), chatbot consent and privacy link (from AIConfiguration.parameters), plus relative paths /datenschutz and /impressumsame source as dashboard "Privacy & Visitor Consent" and GET /api/wordpress/embed-config.

  • chatbot: only UI fields (colors, texts, consent links) — no apiKey / provider / model.

  • apiHints: relative paths to Visitor Chatbot, Wayfinding, and DOOH (public) — base URL = same host as API (see section DOOH).

  • tracking: typical analytics IDs (as generally often land in the client); never analyticsPagePassword.


DOOH (Digital Out-of-Home, public for v0)

Creation and maintenance of playlists and media only in the cockpit: Digital Experience → DOOH (not through these public routes).

Prerequisite for all routes in this section: websiteEnabled === true for the center — otherwise 404 (Center not found or website disabled). CORS: * including OPTIONS.

MethodPathPurpose
GET/api/centers/{centerId}/dooh/public/playlistsAll playlists of the center without item payloads: slug, name, itemCount, validity, currentlyBroadcasting (corresponds to active + now in date range).
GET/api/centers/{centerId}/dooh/public/playlist?slug={slug}One playlist with items to play — only if isActive and validFrom/validUntil cover the current time (as playing at kiosk). Query slug required (characters: letters, numbers, -, _, length 1–63).
GET/api/centers/{centerId}/dooh/public/activeShort form for slug idle (Idle advertising slot). Response: { success, playlist }.
GET/api/centers/{centerId}/dooh/public/local-heroLocal-Hero cards (resolved names/images): { success, items }.

Playlist Items: type typically VIDEO | IMAGE | SLIDESHOW | IFRAME; payload JSON with URL(s) as in the cockpit; durationSeconds (at 0 for video often "full length" in client). Media URLs may be resolved as in Examples & Media with CDN base.

Note Digital Signage: GET /api/centers/{centerId}/dooh/active (without public) remains for kiosk/signage without websiteEnabled requirement; for external websites / v0 use the …/dooh/public/… routes.


Shops

GET /api/centers/{centerId}/shops

QueryDefaultDescription
limit100Page size (max. 5000 per request)
offset0Offset
publicWebsite / websitePublictrue = same filters as Center website SSR: status (Default Active) + publishStartDate/publishEndDate on shop, branch, and chain
statusin publicWebsite: ActiveOnly relevant in combination with publicWebsite; without publicWebsite still "not Inactive"
includeWayfindingLinkagestrue = per shop/branch on this page (limit/offset) the field wayfindingLinkages: active MapLocation hits with mapLocationId, svgId, mapLocationName, floorId, floorName, floorNumber (for v0/plan if floor/location in master data is empty). An additional DB query; canonical remains GET …/wayfinding/floors.

| CORS | Yes (*) |

Example (structure):

{
"success": true,
"data": [
{
"id": "…",
"name": "Shop or Branch",
"category": "Fashion",
"isShopLocation": false,
"tags": [],
"logo": "https://…",
"slug": "…"
},
{
"id": "…",
"slug": "chain-slug",
"name": "Branch XY",
"category": "Food",
"isShopLocation": true,
"type": "location",
"chain": { "id": "…", "name": "Chain", "logo": "https://…" },
"floor": "EG"
}
],
"total": 42,
"pagination": {
"limit": 100,
"offset": 0,
"hasMore": false,
"nextOffset": null
},
"meta": {
"total": 42,
"limit": 100,
"offset": 0,
"returned": 42,
"centerId": "…",
"centerName": "…",
"breakdown": {
"standaloneShops": 10,
"shopLocations": 32,
"shopLocationsFiltered": 0
}
}
}

Provider Information (providerInfo)

PurposeOptional Plaintext field for transparency/mandatory information about the provider (e.g., labeling on the website). On the Center website, it will be displayed subtly in shop detail views if a non-empty value arises after resolution.
Data ModelShop.providerInfo, ShopLocation.providerInfo, ShopChain.providerInfo — each nullable (no requirement when creating).

Telephone (Shop, Branch, Chain)

Data ModelShop.phone (individual shop), ShopLocation.phone (branch), ShopChain.phone (chain-wide, optional) — each nullable.
ResponseFor individual shops with shopChainId, the route returns shopChain: { id, phone } (compact). For branches, phone is at the location, the full or compact chain including phone in shopChain or chain.
Center Website (Resolution)Public display: first location/shop, else ShopChain.phone, else optionally a number of other shops of the same chain in the same center (Peer-fallback), if nothing else is filled out.

GET /api/centers/{centerId}/shops (Dashboard, CORS *):

  • Standalone Shop (isShopLocation: false): providerInfo corresponds to the stored shop field.
  • Branch (isShopLocation: true): Top-level providerInfo is resolved: first non-empty text of the branch, else that of the shop chain. In object chain, providerInfo of the chain is also included (for clients that need to differentiate between raw and fallback data).

Center Website App (apps/center-website): SSR (loadShops) calls this route with publicWebsite=true. The public shop API and loaders apply the same branch before chain logic for display; templates with their own shop detail page consistently integrate the text.

  • Branch (isShopLocation: true): Top-level slug corresponds to ShopChain.slug (like in Center website /api/shops), so that shop detail URLs /…/shops/{slug} align with SSR resolutions. Additionally type: "location" to distinguish from individual shops.

Shop Categories

GET /api/centers/{centerId}/website-categories

QueryDefaultDescription
includeGlobaltruefalse = only center-specific categories
statusActiveStatus filter for shop/branch counting
minShopCount0Only categories with ≥ this number of shops

| CORS | Yes (*) |

Response: { success, categories, meta }categories includes e.g. slug, coverImage, shopCount, shopNames, cardSettings (like previously Center website /api/categories).

Legacy / Global Catalog

GET /api/categories — still for dashboard/lists; query includes centerId, forServices, includeGlobal (see route).


Category Themes (Website Payload)

GET /api/centers/{centerId}/category-themes-for-website

| CORS | Yes (*) |

Response: JSON array of theme objects (name, slug, image, shopCount, shopIds, shops, …) — same semantics as Center website /api/category-themes.
Note: GET …/category-themes (without -for-website) delivers the raw admin JSON for the cockpit (including theme id, nested mappings) — do not use this endpoint for public sites.


News, Events, Offers, Jobs

All under GET /api/centers/{centerId}/… with CORS *.

Content Categories (News / Events / Offers)

GET /api/content-categories

Editorial ContentCategory entries (not shop categories under …/website-categories).

QueryDescription
typenews, event, or offer
centerIdCenter UUID
includeGlobaltrue with centerId: center-specific and global categories

Response: JSON array with id, name, slug, type, color, icon, …

Filters in Lists: News/Events/Offers under …/centers/{centerId}/news|events|offers accept contentCategorySlug (recommended), contentCategoryId or contentCategory (name/slug). News additionally legacy category (free text field). Each list entry optionally includes contentCategory.

MCP: cockpit_public_content_categories, filters in cockpit_public_news|events|offers. AgencyOS context: include=content_categories or contentCategory on each news/event/offer entry.

v0 Example Cinema Advertising (website section): …/news?published=true&contentCategorySlug=cinema-advertising instead of filtering by title substring.

v0 Stele (touchscreen): …/news?published=true&forSignage=true — only news with cockpit toggle Show on stele (showOnSignage: true in the response).

News

GET /api/centers/{centerId}/news

QueryDescription
limitDefault 20
offsetDefault 0
publishedtrue: like Center website / current bundle (newsVisibleInCenter, publishedNewsStatusFilter, websiteNewsDateFilter including publishEndDate); without published and without constructionDiary: only centerId (legacy for internal readers with no website filter). Ignored in Construction Diary (constructionDiary has its own path logic).
forToGotrue like bundle: also include toGoExclusive: true news (by default excluded)
forSignagetrue = only news with Show on stele in the dashboard (showOnSignage); for v0 touchscreen steles
categoryLegacy: free text field News.category (still supported)
contentCategoryIdUUID of the content category (ContentCategory)
contentCategorySlugSlug of the content category (recommended, e.g., cinema-advertising)
contentCategoryName or slug (contains/equals, case-insensitive)
constructionDiarytrue = only Construction Diary (isConstructionDiary). Like current SSR for news: newsVisibleInCenter, status default Published, websiteNewsDateFilter, toGoExclusive: false, including linkedShops
featuredtrue = only news marked as Highlight in the cockpit (homepage/current in v0); without parameter: all visible news, highlights first (orderBy featured desc)
statusOptional; in diary: default via ActivePublished like Center website
{
"success": true,
"data": [ { "id": "…", "title": "…", "slug": "…", "excerpt": "…", "image": "…", "featured": false, "showOnSignage": true, "publishDate": "2026-03-01T10:00:00.000Z", "status": "…", "category": "…", "contentCategory": { "id": "…", "name": "Cinema Advertising", "slug": "cinema-advertising", "type": "news" }, "center": { "id": "…", "name": "…", "slug": "…" } } ],
"meta": { "total": 0, "limit": 20, "offset": 0, "returned": 0, "centerId": "…" }
}

Events

GET /api/centers/{centerId}/events

QueryDescription
limit / offsetPagination (Default 100 / 0)
featuredtrue = only highlight events; highlights first (orderBy)
statusDefault Active (like current bundle); other values only if consciously needed.
forToGotrue like bundle: also include toGoExclusive events (standard: excluded)
contentCategoryIdUUID of the content category
contentCategorySlugSlug of the content category
contentCategoryName or slug (contains/equals)

Filters: align with current-bundle under the same status/forToGo: eventVisibleInCenter, websiteEventPublicFilter (endDate ≥ now, publishStartDate/publishEndDate, isActive: true).

Each entry includes among others featured (boolean) and optionally contentCategory (id, name, slug, type, color, icon).

Offers (List & Single)

GET /api/centers/{centerId}/offers

QueryDescription
limit / offsetPagination of the list (Default 100 / 0)
featuredtrue = only highlight offers; without parameter: all visible offers, highlights first
contentCategoryIdUUID of the content category
contentCategorySlugSlug of the content category
contentCategoryName or slug (contains/equals)
offerIdIf set: one public offer (same rules as Center website: status Active, websiteOfferPublicFilter, no To-Go exclusive). Value can be the UUID (id), the field slug or (multi-center publication) OfferCenter.slugKey for this center. Response { success, data } with flat shop object.

Without offerId: List as before ({ success, data, meta }). List entries optionally include contentCategory.

Jobs

GET /api/centers/{centerId}/jobs

QueryDescription
limit / offsetPagination (Default 100 / 0)
statusOptional. Default (empty or Active): Active | Published | Published — like current-bundle. Otherwise exact cockpit status string.

Filters: also websiteJobPublishWindowFilter like bundle (publishDate/publishEndDate; empty start date = "immediately" in terms of the helper logic).

Current Events (Bundle, one request)

GET /api/centers/{centerId}/current-bundle

CORSYes (*)
QueryDefaultMaxDescription
forToGotrue = include To-Go exclusive news/events/offers
statusActiveLike Center website loader: at Active use News publishedNewsStatusFilter; Offers publishedOfferStatusFilter (Active | Published | Published); Jobs Active | Published | Published; Events still status: Active.
newsLimit30500Protection against extremely large JSON responses
eventsLimit30500
offersLimit30500
jobsLimit20500

Response: { success, data: { news, events, offers, jobs }, meta: { limits, returned, … } } — filter logic like loadCurrent in the Center website (Center website SSR calls the bundle with *Limit=500 up to the Max limit). News, Events, and Offers are deemed related to the center if they either have the primary field centerId or are associated with this center in each respective linkage table (newsCenters, eventCenters, offerCenters) — analogous to the public list APIs (newsVisibleInCenter / eventVisibleInCenter / offerVisibleInCenter in @mall-os/database). Jobs remain bound to the center via centerId.

Jobs in the bundle can optionally include attachmentPdfUrl (URL to the job PDF, e.g., Bunny); the Center website links to it in the job detail view if set.

Offers in the bundle (and single fetch GET …/offers?offerId=) can optionally include attachmentPdfUrl (brochure/weekly offer PDF); the Center website shows a download link on the offer detail page when set.

News, Events, and Offers in the bundle contain, if necessary, videoUrl, heroVideoUrl (as well as News hasVideo, Events/Offers gallery where in the schema present) for the public detail page; values are raw URLs from the CMS (YouTube/Vimeo or media URL), resolution on CDN/embed takes place in the Center website.

On status=Active (default): News with publishedNewsStatusFilterPublished, Published and Active (the latter: historically e.g. after approval of a content draft, until all records are set to Published); Offers with publishedOfferStatusFilterActive, Published, Published, plus websiteOfferPublicFilter (validity period of the offer and optional publication window; the public list does not rely on the DB field isActive, but on editorial status and the date fields); Events with status: Active plus websiteEventPublicFilter; Jobs with status in Active | Published | Published and websiteJobPublishWindowFilter (without excluding empty publishDate; empty = immediately visible). The Center website filters additionally by these status rules on public calls (Defense in Depth).

Hot Picks (Curated Highlights)

GET /api/centers/{centerId}/hotpicks

CORSYes — like other public GET /api/centers/… via middleware (allowed origins, no auth)
PurposeHighlights maintained by the center in the cockpit related to a shop, event, news, offer, service, or reserve campaign. The frontend (e.g., v0/Vercel) decides freely on layout (slider, grid, teaser bar); the API only provides data. Do not confuse with Homepage Tiles (…/homepage-tiles) — different endpoint and different data.
Notechannels (JSON array in the record) controls playback per channel in the cockpit; for a pure website, the client can evaluate or ignore this as needed.

Response: { success: true, hotPicks: [ … ] } (max. 50 entries, status: active, ordering among others by priority, position).

Per entry e.g.: id, contentType (shop | event | news | offer | service | reserve_campaign), type (e.g., teaser type), title, description, image, priority, position, startDate, endDate, channels, content (resolved entity or teaser data), optionally reserveCampaignId / brochurePdfUrl for relevant types.

Discovery: Relative path also under GET …/public-visitor-surfacedata.apiHints.hotPicksGet (with centerId already in use).


Services

GET /api/centers/{centerId}/services

QueryDefaultDescription
limit100max. 5000
offset0Pagination
publicWebsite / websitePublictrue = like Center website SSR: status: Active, isActive: true, publication window + categoryRef (id, name, icon, color)

| CORS | Yes (*) |

Response: { success, data, meta }data is the services list.


Offices & Practices

GET /api/offices

QueryDescription
centerIdrecommended
typeType filter or all
statuse.g. Active or all

| CORS | Yes (*) |

{
"success": true,
"data": [
{
"id": "…",
"name": "…",
"type": "Practice",
"floor": "1st Floor",
"logo": "https://…",
"center": { "id": "…", "name": "…", "slug": "…" },
"officeType": { "id": "…", "name": "…" }
}
],
"count": 1
}

Page Content (CMS Pages, e.g. Opening Hours)

GET /api/centers/{centerId}/page-content

| CORS | Yes (*) — only GET; POST without broad CORS |

Center Website SSR (loadPageContent) uses GET and applies the same page selection as before (including alias service/services, oeffnungszeiten/opening-hours).

{
"pageContents": [
{
"id": "…",
"centerId": "…",
"pageType": "opening-hours",
"pageTitle": "Opening Hours",
"pageSubtitle": null,
"pageDescription": "…",
"metaTitle": "…",
"metaDescription": "…",
"metaKeywords": null,
"customContent": {},
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
]
}

pageType values are center-dependent (including shops, gastronomy, opening-hours, contact, …). Additionally, there are embedded-iframe-pages: one line per center with customContent.pages (list of entries with id, slug, title, description, iframeUrl, enabled, optional meta fields). Public route of the center website: /{centerSlug}/{slug} (short name directly under the center path; e.g. embedded Tally form). The previous URL /{centerSlug}/form/{slug} permanently redirects to the new address. Saving/validation through the same POST …/page-content as with other CMS pages (dashboard).


Homepage Tiles

Distinction: Homepage tiles are the tile configurations maintained in the cockpit (website tab) for the homepage (title, link, layout size, background). These are not Hot Picks — Hot Picks come exclusively from GET …/hotpicks (data.apiHints.hotPicksGet). Use the term "tiles" only for this endpoint, do not mix it with hot-pick data.

GET /api/centers/{centerId}/homepage-tiles

| CORS | Yes (*) |

{
"tiles": [
{
"id": "…",
"centerId": "…",
"title": "…",
"subtitle": null,
"href": "/shops",
"icon": "shopping-bag",
"order": 0,
"mobileSize": "1x",
"desktopSize": "1x1",
"backgroundType": "gradient",
"backgroundConfig": null,
"contentType": null,
"contentConfig": null,
"enabled": true,
"gridColumnSpan": 1,
"gridRowSpan": 1
}
]
}

Themes: Gastronomy, Categories, Offices

RouteCORS (according to analysis)
GET /api/centers/{centerId}/gastronomy-themesYes (*)
GET /api/centers/{centerId}/category-themesYes (*)
GET /api/centers/{centerId}/office-themesYes (*)

Response: each array/object with theme and shop/office associations — use live response as a reference (extensive includes).


Wayfinding / Center Plan

Center ID vs. Floor ID (Dashboard URL)

The route /dashboard/centerplaene/{id}/edit in the cockpit uses {id} = MapFloor.id (floor ID / floorId), not ShoppingCenter.id. The public endpoints, on the other hand, expect centerId = UUID of the center (typically from GET …/centers/by-slug/{slug} → field id).

If the floorId is accidentally passed as centerId to GET /api/wayfinding/floors?centerId=…, the API usually returns no matching floors or no mapLocations — it appears as if there are missing mappings. Correct: first resolve center, then set centerId.

RouteMethodCORS
GET /api/wayfinding/centerplan?centerId={uuid}GETYes (*)
GET /api/wayfinding/floors?centerId={uuid}GETYes (*)
POST /api/wayfinding/routingPOSTYes (*) — Body: Start/End waypoints or location IDs, see route
GET /api/wayfinding/routing?centerId=…GETYes (*) — Touchscreen locations (see route)
GET /api/centers/{centerId}/entrancesGETYes (*)

Center plan GET returns among others id, centerId, name, floors (ordered) — nested structure see Prisma select in the route.

Floors GET (/api/wayfinding/floors?centerId=…): Response { success, floors }. Each floor includes among others mapSvg (SVG markup) and mapLocations — this is the mapping table between SVG and entities (comparable to "Units"/areas in the plan):

Query Parameter (v0 / AI generators)

Only centerId is evaluated. Additional query parameters like includeMapLocations, includeShops, etc. are not present at this route and will be silently ignored by the server. mapLocations will always be fully supplied (for active entries per floor), provided they are maintained in the cockpit. Empty mapLocations[] thus indicate no markings in the database or a wrong center UUID (floorId from the Center plan edit URL is a common error).

Field (mapLocation)Meaning
svgIdCorresponds to the id attribute of the element in the SVG (e.g. shop-56).
typee.g. shop, service, …
shop / shopLocation / service / officeLinked entity including UUID (shop.id etc.) and display fields.

Hybrid Center Plan (Grid in the SVG)

Some plans are delivered as a single SVG file, combining a grid background (<image xlink:href="…" /> or data URL) and transparent vector click areas (path/polygon …). The public interface remains floors[].mapSvg (a string) — there is no separate "PNG-only" endpoint for the interactive map.

Field / TopicNote for v0 / external apps
mapSvgCan be very large (especially with base64 embedded images). In case of CDN URL in <image>, the payload is usually smaller.
mapImageAdditional URL for the background image (as stored in the cockpit); can match href in the SVG.
shopViewBoxesOptional: JSON string on the floor (MapFloor.shopViewBoxes) — mapping svgId{ viewBox, padding? } for sharp crop on shop detail pages. If null/empty: Frontend typically uses bounding box from the DOM (fallback).
InteractionClick areas are the vector elements with id; the background image should have pointer-events: none in the client (otherwise, clicks will be captured). Reference: @mall-os/wayfinding / InteractiveFloorPlan in the Center website.
AgencyOS ContextWith bearer: GET …/agencyos/v1/centers/{centerId}/context?include=floors_summarycompressed floor without SVG text (mapSvgChars, hybrid heuristic, mapping counter). Full SVG continues via GET …/wayfinding/floors. See AgencyOS Integration.
DecorativePolygons in <g id="decorative"> or IDs with prefix decorative- are intentionally not clickable on the live site.

Editorial Workflow: Hybrid Center Plan (PNG + Polygons) · AI Master Prompt for plan images.

The route also provides per floor shopSvgIdsWithOffers: SVG IDs of locations whose shop has an active offer (for highlighting in the plan).

Important for v0 / external websites: Without includeWayfindingLinkages=true the shop list does not contain svgId. Clicks on SVG areas primarily through GET …/wayfinding/floorsmapLocations (hit: svgId === clicked DOM id). With includeWayfindingLinkages=true, each shop/branch optionally provides wayfindingLinkages[] — practical if floor/location is often empty and still should be linked to svgId/floor. If svgId is missing everywhere, the mapping in the cockpit is still not set — not reliably match by name equality.

Routing: Dashboard API (POST …/wayfinding/routing)

Calculation via waypoints and edges in the database (maintained by the cockpit). Typical flow for a website:

  1. Start: GET /api/centers/{centerId}/entrances → list of entrance waypoints; entrances[].id as startWaypointId (or alternatively startLocationId = UUID of a MapLocation, if the start hangs on a location).
  2. Goal: endLocationId = mapLocations[].id (UUID of the MapLocation from GET …/wayfinding/floors), not the SVG area id (svgId). The API searches for an active waypoint with locationId = this UUID.
  3. Body (minimal): e.g. { "startWaypointId": "…", "endLocationId": "…" } — complete fields and options (avoidStairs, preferElevator, …) see apps/dashboard/src/app/api/wayfinding/routing/route.ts.

Rate Limit: approx. 90 requests / minute / IP; on 429 respond user-friendly (CORS remains set).

Route Network in the SVG (#routes / #Routes)

Many center plans contain an additional SVG group with id="routes" or id="Routes". They typically contain line, path, polyline, polygon — describing the walkable network (edges) in SVG coordinates.

AspectNote
PurposeData source for routing without or beside the routing API (e.g., legacy plans, Mapplic).
VisibilityThe layer is not intended as a "finished visitor route": in reference apps, #routes is hidden (display: none or similar); the actual route is drawn as a separate line/polyline over it.
Algorithm (Idea)Form a graph from all segment endpoints, calculate shortest path (e.g., Dijkstra) from a starting point (e.g., "You are here" / entrance in SVG coordinates) to the target point (e.g., shop area or anchor).
Anchor (optional)In the same or nearby layer, elements with IDs like p-shop-…, p-service-…, p-stand-… etc. can serve as connection points (more precise than just the area center). Details and naming scheme: internal doc docs/CENTERPLAN-SINGLE-SOURCE.md.
Reference CodeMonorepo: @mall-os/wayfinding — among others parseSvgRoutes, findPathThroughNetwork (as Center website in hybrid fallback).

v0 / external repo: Either use POST routing (if the cockpit graph is maintained), or rebuild the #routes logic according to the above idea; do not confuse both: API works with Waypoint/Location UUIDs, SVG network with geometry in mapSvg.


Chatbot (Visitor AI)

POST /api/ai/visitor-chatbot

CORSYes (*)
Rate Limitapprox. 45 requests / minute / IP (PUBLIC_VISITOR_CHATBOT); 429 with German-speaking message
Body (short)centerId and/or WordPress apiKey; conversation over messages: [{ role, content }] or legacy query
AuthNo user login; execution only server-side in the dashboard (OpenAI key from Env/Center config).

UI configuration (colors, consent texts, enabled): GET …/public-visitor-surfacedata.chatbot.

WordPress Embed (Center Website /embed/chat)

For the iframe/Embed view of the Center website app (…/embed/chat), the API key should not land in the browser URL. Instead:

StepEndpoint / Action
1. Get Token (server-side, e.g. from WordPress after saving the connection)POST /api/wordpress/embed-token with Authorization: Bearer <apiKey> (same key as for the WordPress website). Response: { success, token, expiresAt }.
2. Embed URLhttps://<center-website-host>/embed/chat?et=<token> — the token is short-lived (default approx. 24 hours).
3. Resolution (optional, usually server-side in the Center website)GET /api/wordpress/embed-resolve?et=…{ success, centerId, slug }.

Prerequisite in the dashboard: Environment variable COCKPIT_WP_EMBED_TOKEN_SECRET (strong secret); without secret, embed-token and embed-resolve return 503 — then legacy ?apiKey= on /embed/chat remains possible (not recommended).

Embed configuration for WordPress plugin (slug, chatbot display): GET /api/wordpress/embed-config with Authorization: Bearer <apiKey> — the query parameter ?apiKey= is no longer needed.

Response (excerpt) when success: true:

FieldMeaning
centerId, slugCenter association
chatbotDisplayamong others primaryColor, enabled, variant, position — from the Chatbot tab
wordPressWidgetTexts and quick themes for the WordPress plugin (Single Source of Truth, no manual duplicate on the WP page)
wordPressWidget.greetingMessageGreeting (Chatbot tab)
wordPressWidget.popularTopicsArray { label, query } — one line per entry from "Rotating Placeholders"
wordPressWidget.consentTitle / consentDescription / privacyPolicyUrlConsent UI in the widget

The plugin merges these fields server-side into the shortcode/ambient configuration (shortcode attributes take precedence when explicitly stated) and caches the API response briefly (transient, TTL e.g. 5 minutes). After disconnecting, the cache is invalidated.

Note: The chat in WordPress shortcode runs via the PHP proxy (admin-ajax.php), not via /embed/chat; there, a WordPress nonce protects the proxy route.


Optional / Later (P1 Rest)

TopicNote
OpenAPI / Schema FileMachine-readable contract
Dedicated …/construction-diaryIf desired alias without news query — currently: …/news?constructionDiary=true

Copy-Paste: Context for v0 / AI

v0 Custom Instructions are limited to 10×5000 characters — slot plan and partial boxes (A–J): v0 + Cockpit (Here’s how).
The entire block below remains for a long chat, cursor, or other tools without this limit sensible.

Everything in one text (for chat & Co.)

Build a public center website (Next.js, Server Components where possible).

API Base: https://dashboard.cockpit-os.de (Staging: adjust base URL)

Process:
1) Resolve center: GET /api/centers/by-slug/{slug} → id is centerId (UUID).
Alternatively Custom Domain: GET /api/centers/by-domain?domain={hostname}
IMPORTANT: The UUID in the browser URL …/dashboard/centerplaene/…/edit is the floor ID (floorId), NOT centerId — never use for ?centerId= or /centers/{id}/.
2) Field-safe bundle (branding, chatbot UI, wayfinding hints): GET /api/centers/{centerId}/public-visitor-surface
3) Theme/UI data (full, template-dependent): GET /api/centers/by-slug/{slug}/theme-config
4) Content (insert centerId):
- Shops (like Center website SSR): GET /api/centers/{centerId}/shops?publicWebsite=true&status=Active&limit=5000&offset=0 — optional &includeWayfindingLinkages=true for plan hints per shop
- Shop Categories (count/overrides): GET /api/centers/{centerId}/website-categories?status=Active&includeGlobal=true&minShopCount=1
- Category Themes (Website): GET /api/centers/{centerId}/category-themes-for-website
- News: GET /api/centers/{centerId}/news?published=true&limit=20
- Highlights (Homepage): GET /api/centers/{centerId}/news?published=true&featured=true&limit=6 (similarly …/events?featured=true, …/offers?featured=true)
- Or get Current Events once: GET /api/centers/{centerId}/current-bundle
- Note Visibility: `news?published=true`, `events`, `jobs` and `current-bundle` evaluate the same `@mall-os/database` time window logic — content falls from the list when start/end in the cockpit are reached (for Next.js ISR/tRPC etc. still consider revalidation).
- Hot Picks (curated highlights, free layout): GET /api/centers/{centerId}/hotpicks — also data.apiHints.hotPicksGet after step 2
- Construction diary: GET /api/centers/{centerId}/news?constructionDiary=true
- Single offer: GET /api/centers/{centerId}/offers?offerId={uuid|slug|slugKey}
- Events: GET /api/centers/{centerId}/events
- Offers: GET /api/centers/{centerId}/offers
- Jobs: GET /api/centers/{centerId}/jobs
- Services (like Center website SSR): GET /api/centers/{centerId}/services?publicWebsite=true&limit=5000&offset=0
- Offices: GET /api/offices?centerId={centerId}&status=Active
- CMS Pages (opening hours, etc.): GET /api/centers/{centerId}/page-content
- Homepage Tiles: GET /api/centers/{centerId}/homepage-tiles
- Center plan metadata: GET /api/wayfinding/centerplan?centerId={centerId} (404 possible, then only use Floors)
- Floors/map including mapLocations: GET /api/wayfinding/floors?centerId={centerId} — for media URLs possibly resolve with same CDN resolution as Center website (`resolveMediaUrl`)
- SVG id (e.g. shop-56) → Shop UUID: only via floors[].mapLocations (svgId + shop.id / shopLocation / service), not via shops list alone
- Entrances: GET /api/centers/{centerId}/entrances — entrances[].id = startWaypointId for routing
- Route (DB graph): POST /api/wayfinding/routing with startWaypointId (from entrances) and endLocationId = mapLocations[].id (UUID), not svgId; 429 possible
- Route (SVG network): hide in floors[].mapSvg group id routes or Routes (line/path/polyline/polygon), graph from segments, calculate shortest path in SVG coordinates, draw own overlay line; details docs/CENTERPLAN-SINGLE-SOURCE.md
- Chat: POST /api/ai/visitor-chatbot (centerId in body; no OpenAI key in frontend)
- DOOH (externally playing playlists created in the cockpit, only if website active):
- GET /api/centers/{centerId}/dooh/public/playlists — slugs & metadata
- GET /api/centers/{centerId}/dooh/public/playlist?slug={slug} — items for player
- GET /api/centers/{centerId}/dooh/public/active — short form slug idle
- GET /api/centers/{centerId}/dooh/public/local-hero — Local-Hero cards optional

Response forms (important for correct parsing):
- GET by-slug: flat JSON WITHOUT { success, data } — fields e.g. id, name, slug, logo, logoUrl (logo and logoUrl often identical), baseColor, secondaryColor, urls.
- GET public-visitor-surface: { success: true, data: { schemaVersion, center, seo, chatbot, centerplan, features, apiHints, … } }.
Branding: data.center (logo, logoUrl, baseColor, secondaryColor, …). Chat UI colors/texts: data.chatbot (e.g. primaryColor, enabled, consentTitle).
apiHints contains among others doohPublicPlaylistsListGet, doohPublicPlaylistBySlugGet (append slug to URL), doohPublicActiveGet, doohPublicLocalHeroGet — relative paths, base = API host.
- GET …/dooh/public/playlists: { success, playlists: [ { slug, name, itemCount, currentlyBroadcasting, … } ] }.
- GET …/dooh/public/playlist?slug=…: { success, playlist: { id, name, slug, items: [ { type, payload, durationSeconds, sortOrder } ] } | null } — playlist null if inactive or out of validity.
- GET …/dooh/public/active | local-hero: { success, playlist? } or { success, items }.
- GET …/shops: { success, data: [ … ], total, pagination }. Shop media: fields logo, coverImage (no uniform imageUrl across all endpoints). Optional meta.wayfindingLinkagesIncluded + per entry wayfindingLinkages[] when includeWayfindingLinkages=true.
- GET …/wayfinding/floors: { success, floors: [ { mapSvg, mapLocations, shopSvgIdsWithOffers?, … } ] }. mapLocations: svgId, type, shop?, shopLocation?, service?, office? — Mapping SVG DOM ↔ entity UUID. Parameter centerId = Center UUID (by-slug), not floorId from Center plan edit URL.
- Many other reads: { success, data } or own keys — check live response.

Opening hours (openingHours) — parsing & display:
- **Center master data:** `GET …/public-visitor-surface` → `data.center.openingHours`, `data.center.openingHoursNote`, `data.center.specialDays` (primary source for v0/external sites).
- **Shops/branches/services:** field `openingHours` on the respective list endpoints. May be missing (null), may be a JSON string, or (after parse) may be an object.
- Typical weekly object (keys in English, lowercase): monday … sunday. Per day not uniform:
- Open with text range: { "hours": "10:00 - 20:00" } or similar.
- Closed e.g.: { "open": "closed", "close": "closed" } — or only hours with "Closed"; always check defensively.
- Example structure (simplified):
{"monday":{"hours":"10:00 - 20:00"},"tuesday":{"hours":"10:00 - 20:00"},"wednesday":{"hours":"10:00 - 20:00"},"thursday":{"hours":"10:00 - 20:00"},"friday":{"hours":"10:00 - 20:00"},"saturday":{"hours":"10:00 - 20:00"},"sunday":{"open":"closed","close":"closed"}}
- Mandatory for UI: Never output raw JSON to visitors. Always translate into readable lines (e.g. table or list: "Mon–Sat 10:00–20:00", "Sun closed").
- Implementation: If string → JSON.parse in try/catch; if not an object or unknown form → fallback ("Opening hours see on-site" or display string if sensible).
- Weekdays in UI may be labeled in German (Monday … Sunday), keys in data remain English.

Visitor chatbot (same data logic as cockpit visitor AI, without OpenAI key in browser):
- First GET public-visitor-surface → data.chatbot: enabled, primaryColor, consentTitle, consentDescription, privacyPolicyUrl, … If enabled=false → do not display chat widget.
- Before the first user message: obtain consent (texts from data.chatbot); only afterward send to API.
- Conversation: POST /api/ai/visitor-chatbot — JSON among others centerId (UUID from GET by-slug → id), messages: [{ "role": "user"|"assistant", "content": "…" }]. No secrets/OpenAI keys in client.
- Optional stream: true — response as Server-Sent Events (text/event-stream); otherwise compact JSON response — both are supported in the route.
- HTTP 429: brief notice for users (e.g., try again later), no technical text.

Media URLs (logos, images):
- API often delivers relative paths (/uploads/…, centers/…, global/…) or absolute URLs.
- For consistent images in the browser: prefix relative paths with CDN base — reference like production:
Base URL Default https://cockpitos.b-cdn.net (Env: NEXT_PUBLIC_BUNNY_CDN_URL or BUNNY_CDN_URL).
Example: /uploads/x.png → https://cockpitos.b-cdn.net/uploads/x.png
- Code reference (for porting): Center website resolveMediaUrl in lib/url-resolver.ts

Types / OpenAPI:
- No OpenAPI file. Strongest schema source for public-visitor-surface: TypeScript type PublicVisitorSurfaceV1 in apps/dashboard/src/lib/build-public-visitor-surface.ts

Auth:
- This contract = only public reads (+ visitor-chatbot POST without user session). website-config and dashboard write routes: session/cookies, **NOT** for anonymous cross-origin clients.

Notes:
- Responses use sometimes { success, data }, sometimes flat object, or { tiles }, { pageContents } — check each.
- Public read routes set CORS * for browser from another origin; write routes (e.g., page-content POST) cannot be called without auth from cross-origin.
- Chatbot and routing POST can yield 429 — observe Retry-After or throttle requests.
- No secrets in the client; website-config GET is only for logged-in dashboard users.
- Detailed example JSON and tables: Cockpit documentation "public-center-website-api-beispiele-und-medien".

Maintenance of this Document

  • After API/CORS changes: Match tables, public-visitor-surface whitelist, and DOOH …/dooh/public/… in the code.
  • After P1 implementation: add new URLs and examples.
  • Upon schema changes in Prisma: adjust example JSON or refer to "Live Response".

Nutzungsstatistik: Seitenaufrufe werden anonymisiert erfasst. Im Umami-Dashboard nach diesem Pfad filtern: /en/developer-guide/public-center-website-api-vertrag