08 — API Contracts
PRD Document · Savoy Signature Hotels — Multi-Site Headless Platform
Version: 1.0 · Date: 2026-03-04
Related docs:01_General_Architecture.md,06_Content_Modeling_Umbraco.md,04_Frontend_Architecture.md,A02_API_Contracts.md
1. Purpose
Section titled “1. Purpose”This document defines the API contracts between the Next.js frontend and Umbraco 17 backend. It covers the Content Delivery API usage, custom API endpoints, request/response formats, authentication, error handling, and webhook contracts.
2. API Architecture Overview
Section titled “2. API Architecture Overview”3. Umbraco Content Delivery API v2
Section titled “3. Umbraco Content Delivery API v2”3.1 Base Configuration
Section titled “3.1 Base Configuration”| Setting | Value |
|---|---|
| Base URL | {UMBRACO_API_URL}/umbraco/delivery/api/v2 |
| Authentication | None (public API) |
| Format | JSON |
| Rate Limiting | None (internal Azure VNet traffic) |
| CORS | Configured for Next.js origin only |
3.2 Key Endpoints
Section titled “3.2 Key Endpoints”| Endpoint | Method | Purpose |
|---|---|---|
/content/item/{path} | GET | Fetch a single content item by route path |
/content | GET | Query/filter/sort content items |
/content/{id} | GET | Fetch by GUID |
/media/{id} | GET | Fetch media item by GUID |
/media | GET | Query media items |
3.3 Common Request Headers
Section titled “3.3 Common Request Headers”const headers = { 'Accept-Language': locale, // 'pt' or 'en' 'Start-Item': siteKey, // Root node path (e.g., 'savoy-palace') 'Api-Key': process.env.UMBRACO_API_KEY, // Optional: if API key auth enabled};3.4 Content Item Response Contract
Section titled “3.4 Content Item Response Contract”// GET /content/item/{path}// Example: /content/item/pt/accommodations/rooms
interface ContentItemResponse { name: string; createDate: string; // ISO 8601 updateDate: string; // ISO 8601 route: { path: string; // e.g., "/pt/accommodations/rooms" startItem: { id: string; // GUID path: string; // e.g., "savoy-palace" }; }; id: string; // GUID contentType: string; // e.g., "roomsListPage" cultures: Record<string, { path: string; startItem: { id: string; path: string }; }>; properties: Record<string, unknown>; // Content type-specific properties}3.5 Content Query Response Contract
Section titled “3.5 Content Query Response Contract”// GET /content?filter=contentType:roomDetailPage&sort=sortOrder:asc&skip=0&take=12
interface ContentQueryResponse { total: number; items: ContentItemResponse[];}3.6 Media Item Response Contract
Section titled “3.6 Media Item Response Contract”interface MediaItemResponse { id: string; name: string; mediaType: string; // "Image", "File", "Video" url: string; // Relative URL to media file extension: string; // "jpg", "png", "pdf", etc. width?: number; // For images height?: number; // For images bytes: number; properties: Record<string, unknown>;}3.7 Content Filtering, Pagination, and Module-Specific Queries
Section titled “3.7 Content Filtering, Pagination, and Module-Specific Queries”The Content Delivery API v2 supports server-side filtering, sorting, and pagination — essential for modules like paginated lists, search results, and filtered grids.
Query Parameters
Section titled “Query Parameters”| Parameter | Description | Example |
|---|---|---|
filter | Filter by content type, property value, or ancestor | filter=contentType:roomDetailPage |
sort | Sort results by field | sort=sortOrder:asc, sort=updateDate:desc |
skip | Number of items to skip (for pagination) | skip=0 |
take | Number of items to return (page size) | take=12 |
fetch | Fetch strategy (ancestors, children, descendants) | fetch=children:/accommodations |
expand | Expand related content (nested content pickers) | expand=properties[relatedRooms] |
Example: Rooms List with Pagination
Section titled “Example: Rooms List with Pagination”// Fetch rooms for Savoy Palace, page 2 (items 13–24)const rooms = await fetch( `${UMBRACO_API}/umbraco/delivery/api/v2/content` + `?filter=contentType:roomDetailPage` + `&sort=sortOrder:asc` + `&skip=12` + `&take=12`, { headers: { 'Accept-Language': 'pt', 'Start-Item': 'savoy-palace', // Scoped to this site only }, cache: 'no-store', });
// Response:// {// "total": 24, ← Total rooms across all pages// "items": [ ... ] ← 12 items (page 2)// }Example: Fetch Children of a Specific Section
Section titled “Example: Fetch Children of a Specific Section”// Get all restaurants under "Restaurants & Bars" sectionconst restaurants = await fetch( `${UMBRACO_API}/umbraco/delivery/api/v2/content` + `?fetch=children:/restaurantes-e-bares` + `&filter=contentType:diningDetailPage` + `&sort=sortOrder:asc` + `&take=50`, { headers: { 'Accept-Language': 'pt', 'Start-Item': 'savoy-palace', }, cache: 'no-store', });Example: Multiple Filters
Section titled “Example: Multiple Filters”// Fetch rooms of category "Suite" onlyconst suites = await fetch( `${UMBRACO_API}/umbraco/delivery/api/v2/content` + `?filter=contentType:roomDetailPage` + `&filter=roomCategory:Suite` + `&sort=sortOrder:asc` + `&take=12`, { headers: { 'Accept-Language': 'en', 'Start-Item': 'savoy-palace', }, cache: 'no-store', });Frontend Pagination Pattern
Section titled “Frontend Pagination Pattern”const PAGE_SIZE = 12;
export default async function RoomsListPage({ params, searchParams }: PageProps) { const { locale } = await params; const page = Number((await searchParams).page) || 1; const skip = (page - 1) * PAGE_SIZE;
const headersList = await headers(); const siteKey = headersList.get('x-site-key')!;
const client = new UmbracoClient(); const result = await client.getContentByType(siteKey, locale, 'roomDetailPage', { skip, take: PAGE_SIZE, sort: 'sortOrder:asc', });
const totalPages = Math.ceil((result?.total || 0) / PAGE_SIZE);
return ( <> <CardGrid cards={result?.items || []} siteKey={siteKey} locale={locale} /> <Pagination currentPage={page} totalPages={totalPages} basePath={`/${locale}/accommodations`} /> </> );}[!NOTE] The
Start-Itemheader is critical — it ensures that queries only return content from the requesting site’s content tree. Without it, a rooms query would return rooms from all 8 hotels.
4. Frontend API Client
Section titled “4. Frontend API Client”4.1 Client Architecture
Section titled “4.1 Client Architecture”export class UmbracoClient { private baseUrl: string; private apiKey?: string;
constructor() { this.baseUrl = process.env.UMBRACO_API_URL!; this.apiKey = process.env.UMBRACO_API_KEY; }
private async request<T>( endpoint: string, options: { locale: string; siteKey: string; params?: Record<string, string>; } ): Promise<T | null> { const url = new URL(`${this.baseUrl}/umbraco/delivery/api/v2${endpoint}`);
if (options.params) { Object.entries(options.params).forEach(([k, v]) => url.searchParams.set(k, v)); }
const response = await fetch(url.toString(), { headers: { 'Accept-Language': options.locale, 'Start-Item': options.siteKey, ...(this.apiKey && { 'Api-Key': this.apiKey }), }, cache: 'no-store', // Cloudflare handles caching, not Next.js });
if (!response.ok) { if (response.status === 404) return null; throw new ApiError(response.status, await response.text()); }
return response.json(); }
// ── Public Methods ──
async getContentByPath(siteKey: string, locale: string, path: string) { return this.request<ContentItemResponse>( `/content/item/${path}`, { locale, siteKey } ); }
async getContentByType( siteKey: string, locale: string, contentType: string, options?: { skip?: number; take?: number; sort?: string } ) { return this.request<ContentQueryResponse>( '/content', { locale, siteKey, params: { 'filter': `contentType:${contentType}`, 'sort': options?.sort || 'sortOrder:asc', 'skip': String(options?.skip || 0), 'take': String(options?.take || 12), }, } ); }
async getNavigation(siteKey: string, locale: string) { return this.request<ContentQueryResponse>( '/content', { locale, siteKey, params: { 'filter': 'contentType:homePage', 'fetch': 'children:/', 'sort': 'sortOrder:asc', 'take': '50', }, } ); }
async getSiteRoot(siteKey: string, locale: string) { return this.request<ContentItemResponse>( '/content/item/', { locale, siteKey } ); }}4.2 Error Handling
Section titled “4.2 Error Handling”export class ApiError extends Error { constructor( public status: number, public body: string ) { super(`API Error ${status}: ${body}`); }}
// Usage in componentstry { const content = await client.getContentByPath(siteKey, locale, slug); if (!content) notFound();} catch (error) { if (error instanceof ApiError) { console.error(`Umbraco API error: ${error.status}`, error.body); // Log to Application Insights } throw error; // Let error.tsx handle it}5. Custom API Endpoints
Section titled “5. Custom API Endpoints”Beyond the built-in Content Delivery API, custom endpoints are needed for specific functionality:
5.1 Custom Endpoints List
Section titled “5.1 Custom Endpoints List”| Endpoint | Method | Purpose | Implementation |
|---|---|---|---|
/api/redirects/{path} | GET | Check if a path has a redirect configured | Umbraco custom controller |
/api/search | GET | Full-text search across site content | Umbraco Examine/Lucene |
/api/forms/{id}/submit | POST | Submit form data (contact, newsletter) | Umbraco Forms or custom |
/api/sitemap/{siteKey} | GET | Generate sitemap XML data for a site | Umbraco custom controller |
/api/cache/purge | POST | Trigger cache purge (from Umbraco dashboard) | Custom + Cloudflare API |
/api/dictionary | GET | Fetch UI label translations (Umbraco Dictionary items) | Umbraco custom controller |
5.2 Search API Contract
Section titled “5.2 Search API Contract”// GET /api/search?q=ocean+view&site=savoy-palace&locale=en&page=1&pageSize=10
interface SearchRequest { q: string; // Search query site: string; // Site key locale: string; // Language page?: number; // Page number (default: 1) pageSize?: number; // Results per page (default: 10) contentType?: string; // Filter by content type}
interface SearchResponse { query: string; total: number; page: number; pageSize: number; totalPages: number; items: SearchResultItem[];}
interface SearchResultItem { id: string; name: string; contentType: string; path: string; excerpt: string; // Highlighted text snippet score: number; // Relevance score imageDesktop?: string; // Thumbnail URL (desktop) imageMobile?: string; // Thumbnail URL (mobile)}5.3 Form Submit Contract
Section titled “5.3 Form Submit Contract”// POST /api/forms/{id}/submit
interface FormSubmitRequest { formId: string; siteKey: string; locale: string; fields: Record<string, string>; // Field name → value website?: string; // Honeypot anti-spam field — named to look like a real field; must be empty recaptchaToken?: string; // reCAPTCHA v3 token}
interface FormSubmitResponse { success: boolean; message: string; errors?: Record<string, string[]>; // Field-level validation errors}6. Webhook Contracts
Section titled “6. Webhook Contracts”6.1 Publish/Unpublish Webhook
Section titled “6.1 Publish/Unpublish Webhook”Umbraco sends webhooks on content publish/unpublish events to trigger cache purge:
// Umbraco → Next.js Webhook Handler
// POST /api/webhooks/umbraco// Headers:// X-Webhook-Signature: {HMAC-SHA256 hex digest of body}
interface UmbracoWebhookPayload { event: 'ContentPublished' | 'ContentUnpublished' | 'MediaSaved'; timestamp: string; content: { id: string; contentType: string; cultures: string[]; // ['pt', 'en'] urls: string[]; // All affected URLs }; dependencies?: string[]; // URLs of dependent pages (e.g., listing page when child published)}6.2 Cache Purge Flow
Section titled “6.2 Cache Purge Flow”import { NextRequest, NextResponse } from 'next/server';import { createHmac } from 'crypto';
export async function POST(request: NextRequest) { // 1. Validate webhook signature (HMAC SHA-256) const signature = request.headers.get('x-webhook-signature'); const body = await request.text(); const expectedSignature = createHmac('sha256', process.env.REVALIDATE_SECRET!) .update(body) .digest('hex');
if (signature !== expectedSignature) { return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); }
// 2. Parse payload const payload: UmbracoWebhookPayload = JSON.parse(body);
// 3. Collect all URLs to purge const urlsToPurge = [ ...payload.content.urls, ...(payload.dependencies || []), ];
// 4. Purge from Cloudflare await fetch( `https://api.cloudflare.com/client/v4/zones/${process.env.CLOUDFLARE_ZONE_ID}/purge_cache`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ files: urlsToPurge }), } );
// 5. Warmup: request fresh pages to re-populate cache await Promise.allSettled( urlsToPurge.map(url => fetch(url, { cache: 'no-store' })) );
return NextResponse.json({ purged: urlsToPurge.length });}7. Acceptance Criteria
Section titled “7. Acceptance Criteria”- Content Delivery API returns correct data for all content types across all 8 sites
-
Start-Itemheader correctly scopes queries to the requesting site -
Accept-Languagecorrectly returns language-specific content - 404 returned for unpublished content (not fallback content)
- Search API returns relevant results scoped to the requesting site
- Form submission endpoint validates data, sends email, and returns structured errors
- Webhook handler authenticates via secret, purges correct URLs, and warms up cache
- API client in
packages/cms-clienthas full TypeScript types for all responses - All API calls use
cache: 'no-store'(Cloudflare handles caching) - Custom endpoints are rate-limited where applicable (search, form submit)
Next document: 09_Cache_and_Performance.md