Skip to content

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


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.


External

Umbraco 17 (Provider)

Next.js (Consumer)

REST

REST

POST

POST

Read config

React Server Components

proxy.ts

Content Delivery API v2

Custom API Endpoints

Webhook Dispatcher

Cloudflare API

Navarino API


SettingValue
Base URL{UMBRACO_API_URL}/umbraco/delivery/api/v2
AuthenticationNone (public API)
FormatJSON
Rate LimitingNone (internal Azure VNet traffic)
CORSConfigured for Next.js origin only
EndpointMethodPurpose
/content/item/{path}GETFetch a single content item by route path
/contentGETQuery/filter/sort content items
/content/{id}GETFetch by GUID
/media/{id}GETFetch media item by GUID
/mediaGETQuery media items
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
};
// 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
}
// GET /content?filter=contentType:roomDetailPage&sort=sortOrder:asc&skip=0&take=12
interface ContentQueryResponse {
total: number;
items: ContentItemResponse[];
}
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.

ParameterDescriptionExample
filterFilter by content type, property value, or ancestorfilter=contentType:roomDetailPage
sortSort results by fieldsort=sortOrder:asc, sort=updateDate:desc
skipNumber of items to skip (for pagination)skip=0
takeNumber of items to return (page size)take=12
fetchFetch strategy (ancestors, children, descendants)fetch=children:/accommodations
expandExpand related content (nested content pickers)expand=properties[relatedRooms]
// 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" section
const 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',
}
);
// Fetch rooms of category "Suite" only
const 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',
}
);
apps/web/src/app/[locale]/accommodations/page.tsx
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-Item header 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.


packages/cms-client/src/client.ts
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 }
);
}
}
packages/cms-client/src/errors.ts
export class ApiError extends Error {
constructor(
public status: number,
public body: string
) {
super(`API Error ${status}: ${body}`);
}
}
// Usage in components
try {
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
}

Beyond the built-in Content Delivery API, custom endpoints are needed for specific functionality:

EndpointMethodPurposeImplementation
/api/redirects/{path}GETCheck if a path has a redirect configuredUmbraco custom controller
/api/searchGETFull-text search across site contentUmbraco Examine/Lucene
/api/forms/{id}/submitPOSTSubmit form data (contact, newsletter)Umbraco Forms or custom
/api/sitemap/{siteKey}GETGenerate sitemap XML data for a siteUmbraco custom controller
/api/cache/purgePOSTTrigger cache purge (from Umbraco dashboard)Custom + Cloudflare API
/api/dictionaryGETFetch UI label translations (Umbraco Dictionary items)Umbraco custom controller
// 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)
}
// 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
}

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)
}
apps/web/src/app/api/webhooks/umbraco/route.ts
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 });
}

  • Content Delivery API returns correct data for all content types across all 8 sites
  • Start-Item header correctly scopes queries to the requesting site
  • Accept-Language correctly 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-client has 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