Skip to content

06 — Custom API Endpoints

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


Beyond the built-in Content Delivery API, the platform requires custom .NET controllers in Umbraco for search, forms, redirects, sitemap, dictionary, and cache management. This guide documents each endpoint, its contract, and implementation requirements.


EndpointMethodPurpose
/api/searchGETFull-text search via Examine/Lucene
/api/forms/{id}/submitPOSTSubmit form data
/api/redirects/{path}GETCheck redirect configuration
/api/sitemap/{siteKey}GETGenerate sitemap XML data
/api/dictionaryGETFetch UI label translations
/api/cache/purgePOSTManual cache purge from dashboard
/api/webhooks/umbracoPOSTPublish webhook handler (see Guide 04)

GET /api/search?q={query}&site={siteKey}&locale={locale}&page={page}&pageSize={pageSize}
ParameterTypeRequiredDefault
qstringYes
siteSiteKeyYes
localept | enYes
pagenumberNo1
pageSizenumberNo10
{
"query": "ocean view",
"total": 15,
"page": 1,
"pageSize": 10,
"totalPages": 2,
"items": [
{
"id": "guid-here",
"name": "Deluxe Ocean Room",
"contentType": "roomDetailPage",
"path": "/en/accommodation/deluxe-ocean-room",
"excerpt": "...with stunning <mark>ocean view</mark> from every...",
"score": 0.95,
"imageDesktop": { "url": "...", "width": 600, "height": 400 },
"imageMobile": { "url": "...", "width": 375, "height": 250 }
}
]
}
  • Uses Examine/Lucene (Umbraco’s built-in search engine)
  • Index must be scoped per site (filter by siteRoot ancestor)
  • Results filtered by locale (only return content published in requested language)
  • Excerpt generation: highlight search terms with &lt;mark&gt; tags
  • Rate limit: 20 req/min per IP (Cloudflare WAF rule)

Create a custom Examine index scoped to published content:

  • Include: All page Document Types (exclude Element Types)
  • Fields: name, metaTitle, metaDescription, full-text content fields
  • Exclude: Draft content, unpublished nodes, archived items

FormPlacementAction
Contact UscontactPageEmail to hotel department
Newsletter SignupFooter (global)Subscribe to CRM list
Event EnquiryeventsPageRFP to Sales team
Wedding EnquirycelebrationsPageRFP to Events team
Spontaneous ApplicationCareers pageUpload CV
POST /api/forms/{id}/submit
Content-Type: application/json
{
"formId": "guid-here",
"siteKey": "savoy-palace",
"locale": "pt",
"fields": {
"name": "John Doe",
"email": "[email protected]",
"department": "reservations",
"message": "I'd like to enquire about..."
},
"honeypot": "",
"recaptchaToken": "token-from-recaptcha-v3"
}

Success:

{
"success": true,
"message": "Thank you for your enquiry. We will respond within 48 hours."
}

Validation error:

{
"success": false,
"message": "Please correct the errors below.",
"errors": {
"email": ["Invalid email address"],
"name": ["This field is required"]
}
}
LayerImplementation
1. HoneypotHidden field named website — reject if filled
2. Rate LimitingMax 5 submissions per IP per 5 minutes (Next.js API + Cloudflare WAF)
3. reCAPTCHA v3Invisible token — block if score < 0.5
4. Server ValidationVerify payload matches form field definitions in Umbraco
5. CSRF ProtectionNext.js + CORS — verify request origin is a Savoy domain
SettingValue
ProviderMailjet or SendGrid (configurable via Azure App Settings)
Sender[email protected]
DNS recordsSPF, DKIM, DMARC configured in Cloudflare DNS
Reply-ToSubmitted email address
RoutingBased on “Department” field or siteRoot config
  • Max size: 5MB
  • Allowed types: .pdf, .docx only
  • Storage: Azure Blob /form-uploads container (private)
  • Email: Contains secure download link (expiring SAS token), NEVER the file itself
  • Retention: Auto-delete after 90 days (GDPR)
  • Mandatory consent checkbox linking to Privacy Policy page
  • Umbraco Forms data auto-deleted after 90 days via scheduled task
  • Right to Erasure: DPO can delete individual entries via backoffice
  • Export: Data exportable to Excel/CSV for data subject requests

GET /api/redirects/{path}

Redirect found:

{
"found": true,
"statusCode": 301,
"destination": "/en/accommodation"
}

No redirect:

{
"found": false
}
  • Check Umbraco’s built-in URL Tracker for content moves/renames
  • Support custom redirect rules configured by editors in backoffice
  • proxy.ts checks this endpoint before rendering a 404

GET /api/sitemap/{siteKey}?locale={locale}

Returns sitemap data as JSON (Next.js generates the XML):

{
"urls": [
{
"loc": "/pt/",
"lastmod": "2026-03-05",
"changefreq": "weekly",
"priority": 1.0,
"alternates": [
{ "hreflang": "pt", "href": "https://www.savoysignature.com/pt/" },
{ "hreflang": "en", "href": "https://www.savoysignature.com/en/" },
{ "hreflang": "x-default", "href": "https://www.savoysignature.com/pt/" }
]
}
]
}
  • Include all published pages for the requested site
  • Exclude pages with noIndex: true
  • Include <xhtml:link rel="alternate" hreflang="..."> annotations for multi-language
  • x-default always points to the PT version
  • lastmod is the Umbraco updateDate

GET /api/dictionary?locale={locale}
{
"ReadMore": "Read More",
"BookNow": "Book Now",
"ViewAll": "View All",
"BackToTop": "Back to Top",
"Close": "Close",
"Loading": "Loading...",
"Menu": "Menu",
"Search": "Search",
"Language": "Language",
"SkipToContent": "Skip to Content",
"CheckIn": "Check-in",
"CheckOut": "Check-out",
"Guests": "Guests",
"Adults": "Adults",
"Children": "Children",
"PromoCode": "Promo Code",
"SearchAvailability": "Search Availability",
"Submit": "Submit",
"RequiredField": "Required field",
"InvalidEmail": "Invalid email",
"SuccessMessage": "Thank you for your submission",
"From": "From",
"PerNight": "/night",
"SqMeters": "sqm",
"MaxGuests": "Max Guests",
"Amenities": "Amenities",
"PageNotFound": "Page Not Found",
"ServerError": "Server Error",
"TryAgain": "Try Again"
}
CategoryKeys
GlobalReadMore, BookNow, ViewAll, BackToTop, Close, Loading
NavigationMenu, Search, Language, SkipToContent
BookingCheckIn, CheckOut, Guests, Adults, Children, PromoCode, SearchAvailability
FormsSubmit, RequiredField, InvalidEmail, SuccessMessage
RoomsFrom, PerNight, SqMeters, MaxGuests, Amenities
ErrorsPageNotFound, ServerError, TryAgain
  • Uses Umbraco Dictionary Items
  • Cached in Next.js with use cache + cacheLife('minutes') (~15 min TTL)
  • Fetched with cache: 'no-store' at the API level

POST /api/cache/purge
Authorization: Bearer {ADMIN_TOKEN}
{
"type": "all" | "site" | "urls",
"siteKey": "savoy-palace",
"urls": ["/pt/alojamento", "/en/accommodation"]
}
  • Only accessible from Umbraco backoffice (authenticated admin)
  • Calls Cloudflare purge API
  • Logs all purge operations for audit trail
  • Three purge types: all (purge everything), site (all URLs for a site), urls (specific URLs)

9. Form Schema Endpoint (for Frontend Dynamic Rendering)

Section titled “9. Form Schema Endpoint (for Frontend Dynamic Rendering)”

Editors build forms in Umbraco Forms (drag-and-drop). The frontend needs the form definition to render dynamically.

GET /api/forms/{id}
{
"formId": "guid-here",
"name": "Contact Us",
"submitLabel": "Send Message",
"successMessage": "Thank you for contacting us.",
"fields": [
{
"alias": "name",
"label": "Full Name",
"type": "text",
"required": true,
"cssClass": "col-span-12 md:col-span-6",
"validation": { "maxLength": 100 }
},
{
"alias": "email",
"label": "Email Address",
"type": "email",
"required": true,
"cssClass": "col-span-12 md:col-span-6",
"validation": { "pattern": "^[^@]+@[^@]+\\.[^@]+$" }
},
{
"alias": "department",
"label": "Department",
"type": "dropdown",
"required": true,
"cssClass": "col-span-12",
"options": [
{ "value": "reservations", "label": "Reservations" },
{ "value": "events", "label": "Events" },
{ "value": "general", "label": "General Enquiry" }
]
},
{
"alias": "message",
"label": "Your Message",
"type": "textarea",
"required": true,
"cssClass": "col-span-12",
"validation": { "maxLength": 2000 }
},
{
"alias": "consent",
"label": "I agree to the Privacy Policy",
"type": "checkbox",
"required": true,
"cssClass": "col-span-12"
}
]
}

The frontend uses react-hook-form + zod to:

  1. Fetch form schema JSON
  2. Dynamically generate a Zod validation schema from field rules
  3. Render form fields based on type and cssClass
  4. Submit via POST to /api/forms/{id}/submit

EndpointLimitEnforced By
/api/search20 req/min per IPCloudflare WAF
/api/forms/*/submit5 req/5min per IPCloudflare WAF + Next.js API
All other /api/*60 req/min per IPCloudflare WAF

PitfallSolution
Search index includes draft contentConfigure Examine index to only include published nodes
Form submissions without reCAPTCHAAlways validate reCAPTCHA token server-side (score >= 0.5)
File attached directly in emailNever attach files — use secure download links
Dictionary not cachedUse use cache with ~15 min TTL in Next.js
Sitemap includes noIndex pagesFilter out pages where noIndex === true
Missing CORS headers on custom endpointsConfigure CORS for Next.js origin only
Search results cross-siteAlways filter search index by siteRoot ancestor