Skip to content

02 — Content Delivery API

Dev Guide — Savoy Signature Hotels
PRD refs: 08_API_Contracts.md, 06_Content_Modeling_Umbraco.md, 10_MultiLanguage_and_i18n.md


Umbraco 17 ships with the Content Delivery API v2, a read-only REST API that exposes published content as JSON. This is the primary data source for the Next.js frontend. This guide covers endpoint usage, headers, filtering, pagination, and the FE client contract.


{UMBRACO_API_URL}/umbraco/delivery/api/v2
EnvironmentBase URL
DEVhttps://savoy.dev-cms.wycreative.com/umbraco/delivery/api/v2
STAGEhttps://savoy.stage-cms.wycreative.com/umbraco/delivery/api/v2
QAhttps://qa-cms.savoysignature.com/umbraco/delivery/api/v2
PRODhttps://cms.savoysignature.com/umbraco/delivery/api/v2

  • Authentication: None — the API is public (read-only published content)
  • API Key: Optional, set via UMBRACO_API_KEY env var for rate-limiting/tracking
  • CORS: Configured to accept requests from Next.js origin only

Every API request MUST include these headers:

const headers = {
'Accept-Language': locale, // 'pt' or 'en'
'Start-Item': siteKey, // e.g., 'savoy-palace'
'Api-Key': process.env.UMBRACO_API_KEY, // optional
};

Critical: The Start-Item header scopes all queries to the requesting site’s content tree. Without it, queries may return content from other hotel sites.

Accept-Language determines which language variant is returned. If the requested locale is not published for a content item, the API returns 404.


GET /content/item/{path}

Primary endpoint for page rendering. The {path} is the URL slug (e.g., /accommodation/deluxe-room).

Example:

Terminal window
GET /umbraco/delivery/api/v2/content/item/accommodation/deluxe-room
Accept-Language: en
Start-Item: savoy-palace

Response shape:

{
"name": "Deluxe Room",
"route": {
"path": "/accommodation/deluxe-room",
"startItem": { "id": "guid-here", "path": "savoy-palace" }
},
"contentType": "roomDetailPage",
"cultures": {
"pt": { "path": "/alojamento/quarto-deluxe", "startItem": { "path": "savoy-palace" } },
"en": { "path": "/accommodation/deluxe-room", "startItem": { "path": "savoy-palace" } }
},
"properties": {
"roomName": "Deluxe Room",
"roomCategory": "Room",
"shortDescription": "...",
"modules": {
"items": [
{ "contentType": "pageHero", "properties": { ... } },
{ "contentType": "richTextBlock", "properties": { ... } }
]
}
}
}
GET /content

Used for listing pages (rooms, restaurants, news, etc.).

Query Parameters:

ParameterUsageExample
filterFilter by content type or propertycontentType:roomDetailPage
sortSort resultssortOrder:asc, updateDate:desc
skipPagination offset0, 12, 24
takePage size12
fetchFetch children of a pathchildren:/accommodations
expandExpand related contentproperties[relatedRooms]

Example — List all rooms for a site:

Terminal window
GET /umbraco/delivery/api/v2/content?filter=contentType:roomDetailPage&sort=sortOrder:asc&take=12&skip=0
Accept-Language: pt
Start-Item: savoy-palace
GET /content/{id}

Used when content references are stored as GUIDs (e.g., Content Picker values).

GET /media/{id} # Single media item by GUID
GET /media # Query media items

Media responses include url, name, width, height, mediaType, bytes.


The client lives in packages/cms-client/src/client.ts and provides typed methods:

class UmbracoClient {
// Fetch a single page by its URL path
async getContentByPath(siteKey: string, locale: string, path: string): Promise<PageContent | null>
// List content items by type (rooms, news, offers, etc.)
async getContentByType(
siteKey: string,
locale: string,
contentType: string,
options?: { skip?: number; take?: number; sort?: string }
): Promise<PaginatedResult<UmbracoContent>>
// Fetch navigation tree for a site
async getNavigation(siteKey: string, locale: string): Promise<NavItem[]>
// Fetch site root configuration
async getSiteRoot(siteKey: string, locale: string): Promise<SiteRootContent>
}

Key implementation details:

  • All fetches use cache: 'no-store' — Cloudflare handles caching, not Next.js
  • 404 responses return null (not an error)
  • Other error codes throw ApiError(status, body)

Defined in packages/cms-client/src/types.ts:

interface UmbracoContent {
name: string;
route: { path: string; startItem: { id: string; path: string } };
contentType: string;
cultures: Record<string, { path: string; startItem: { path: string } }>;
properties: Record<string, any>;
}
interface UmbracoElement {
contentType: string;
properties: Record<string, any>;
}
interface UmbracoBlockList {
items: UmbracoElement[];
}
interface UmbracoMedia {
url: string;
name: string;
width?: number;
height?: number;
mediaType: string;
bytes?: number;
}
interface PageContent {
name: string;
contentType: string;
locale: string;
path: string;
siteKey: string;
seo: {
metaTitle?: string;
metaDescription?: string;
noIndex?: boolean;
noFollow?: boolean;
canonicalUrl?: string;
ogTitle?: string;
ogDescription?: string;
ogImage?: UmbracoMedia;
};
modules: UmbracoElement[];
availableLocales: string[];
}

Standard pagination across all list pages:

const PAGE_SIZE = 12;
async function getRoomsList(siteKey: string, locale: string, page: number) {
const skip = (page - 1) * PAGE_SIZE;
return client.getContentByType(siteKey, locale, 'roomDetailPage', {
skip,
take: PAGE_SIZE,
sort: 'sortOrder:asc',
});
}

ScenarioAPI Behavior
Page published in requested localeReturns content in that locale
Page NOT published in requested localeReturns 404
Media with locale variantReturns locale-specific media if available
Media without locale variantReturns PT (default) media
Invariant properties (toggles, numbers)Same value regardless of Accept-Language

The cultures object in responses indicates which locales are available and their URL paths. Use this for:

  • Language switcher (link to alternate locale)
  • Hreflang meta tags

Enable the Content Delivery API in appsettings.json:

{
"Umbraco": {
"CMS": {
"DeliveryApi": {
"Enabled": true,
"PublicAccess": true,
"MemberAuthorization": { "Enabled": false }
},
"WebRouting": {
"DisableAlternativeTemplates": true,
"DisableFindContentByIdPath": true
},
"Global": {
"ReservedUrls": "~/.well-known"
}
}
}
}

Before any FE integration, validate API responses match the expected shape:

Checklist:

  • Content type alias matches FE registry key
  • All property aliases match .types.ts interface fields
  • Block List items array is present and correctly structured
  • Media URLs are absolute and accessible
  • cultures object includes all published locales
  • Start-Item correctly scopes to single site
  • Nested Element Types (e.g., heroSlide inside heroSlider) are properly serialized

Quick test with curl:

Terminal window
curl -s \
-H "Accept-Language: pt" \
-H "Start-Item: savoy-palace" \
"${UMBRACO_API_URL}/umbraco/delivery/api/v2/content/item/" \
| jq '.properties.modules.items[0]'

PitfallSolution
Missing Start-Item headerAlways include — without it, queries return cross-site results
Caching API responses in Next.jsUse cache: 'no-store' — Cloudflare is the cache layer
Assuming locale fallbackThere is NO cross-language fallback — missing locale = 404
Hardcoding API base URLAlways use UMBRACO_API_URL env var
Not expanding related contentUse expand parameter for Content Picker references
Property alias mismatch with FECoordinate aliases between BE model and packages/cms-client/src/types.ts