Appearance
Vanity URLs
Vanity URLs let content creators associate memorable, campaign-friendly paths (e.g. /spring-sale) with a content item without changing the canonical slug. They are delivered to consuming frontends as a single, org-scoped manifest that an SSR middleware consults on every page load to decide whether to redirect or rewrite the incoming path to the canonical content route.
Vanity URLs augment the existing Quick Links system: Quick Links are location-configured link mappings authored separately, while Vanity URLs are authored on the content item itself and flow through the same draft/publish, distribution, and org-scoping rules as the content they are attached to.
When to use which behavior
Each vanity URL entry declares a behavior:
redirect— A 301 permanent redirect from the vanity path to the canonical content URL (/content/{type}/{slug}). Best for campaign URLs that should feed analytics through the canonical path and for SEO consolidation onto a single canonical URL.render_in_place— The consuming frontend renders the content directly at the vanity path without changing the browser URL. Best when the vanity URL is itself the "brand" URL the user should see (e.g. a permanent/about-our-bakerysurfacing content whose slug is something more generic).
Public manifest endpoint
GET /api/v1/public/vanity-urls
Returns every active vanity URL visible to the requesting location, ready for the consuming frontend to cache and resolve against during SSR.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
location_id | string | no | Organization hashkey. Defaults to the authenticated client's effective location. |
platform_key | string | no | Consumer platform identifier (e.g., shophero-ecom-v2). When supplied, the manifest filters out entries whose path collides with that platform's reserved paths. When omitted, only universal (*) reserved paths are enforced — defense-in-depth at the consumer becomes the only guard against platform-specific collisions. Recommend always supplying this. |
Response
json
{
"success": true,
"data": {
"vanity_urls": [
{
"path": "/spring-sale",
"behavior": "redirect",
"content_type": "landing",
"slug": "spring-savings-2026"
},
{
"path": "/about-our-bakery",
"behavior": "render_in_place",
"content_type": "page",
"slug": "bakery-department"
}
],
"generated_at": "2026-04-16T12:00:00Z"
}
}Response fields
| Field | Type | Description |
|---|---|---|
path | string | The vanity path with a leading slash (e.g. /spring-sale). |
behavior | enum | redirect | render_in_place |
content_type | string | Canonical content type (e.g. page, article, landing). |
slug | string | Canonical slug — compose with content_type to form the canonical URL. |
Caching
| Layer | TTL |
|---|---|
Cache-Control: max-age= | 60 seconds (browser / CDN) |
stale-while-revalidate | 300 seconds |
| In-service cache | 15 minutes per (org_id, location_id, platform_key) |
| CloudFront | Invalidated on content publish / unpublish / distribution changes that affect vanity URL visibility |
The manifest is intentionally designed to be fetched once per SSR bootstrap and cached client-side. See the Quick Links pattern in the reference consumer for the recommended architecture.
Error responses
| Status | Description |
|---|---|
400 | Organization context could not be determined. |
401 | Missing or invalid API client credentials. |
Authoring vanity URLs
Vanity URLs are authored as part of the standard content item create/update payload. There are no dedicated vanity URL CRUD endpoints — they live under metadata.vanity_urls and flow through draft/publish with the parent content.
As part of POST /api/v1/content/items / PUT /api/v1/content/items/{id}
Required permission
engagehq:content.write
Pass a vanity_urls array alongside the other content fields:
json
{
"title": "Spring Savings 2026",
"type": "landing",
"organization_id": "org_...",
"vanity_urls": [
{ "path": "/spring-sale", "behavior": "redirect", "is_active": true },
{ "path": "/deals", "behavior": "render_in_place", "is_active": true }
]
}The API normalizes each entry (leading slash, lowercase, trailing-slash trimming) before persisting it under metadata.vanity_urls on the content item. For published content, changes flow through the existing draft system and only go live after POST /api/v1/content/items/{id}/publish-draft.
Entry shape
| Field | Type | Required | Description |
|---|---|---|---|
path | string | yes | Must match /^\/[a-z0-9][a-z0-9\-\/]*$/ and not collide with any reserved path registered for any platform (authoring-time check applies the union of all platforms' reserved lists — most restrictive). |
behavior | enum | yes | redirect | render_in_place. |
is_active | boolean | no | Defaults to true. false keeps the entry in storage but excludes it from the manifest. |
POST /api/v1/content/items/check-vanity-path
Debounced collision check used by the admin UI to validate paths as the user types.
Request body
| Field | Type | Required |
|---|---|---|
path | string | yes |
content_id | string | no — hashkey of the content item being edited, so it can be excluded from the check |
Response
json
{
"success": true,
"data": {
"available": false,
"normalized_path": "/spring-sale",
"conflicting_content_ids": ["con_..."],
"reason": null
}
}If available is false because of a format or reserved-path violation, the response includes "reason": "invalid_format".
GET /api/v1/content/vanity-url-conflicts
Lists the most recent vanity URL conflicts observed across the caller's org reach. Conflicts are logged when the manifest builder dedupes two content items claiming the same path visible to the same location (Layer 3), or when a distribution change was forced through despite conflicts (Layer 2 force override).
Response
json
{
"success": true,
"data": {
"conflicts": [
{
"id": "vuc_...",
"path": "/spring-sale",
"source": "layer3_dedup",
"detected_at": "2026-04-16T12:00:00Z",
"winning_content": { "id": "con_...", "title": "...", "slug": "...", "organization_id": "org_..." },
"losing_content": { "id": "con_...", "title": "...", "slug": "...", "organization_id": "org_..." }
}
]
}
}Uniqueness semantics
Vanity URL paths must be unique within the orgs that see them. Because distribution determines visibility, uniqueness is checked across the union of orgs the content reaches — the home org, its ancestors, and all distribution targets.
Uniqueness is enforced in three layers:
Synchronous check at save time. The Form Request rejects the save with a 422 if the same path is already claimed by another visible content item.
Distribution-change re-validation. When the content's
distribution_modeordistribution_ruleschange, the update endpoint re-validates vanity URLs against the newly-included orgs. Conflicts are returned in a structured 422 body:json{ "success": false, "message": "This distribution change would create vanity URL conflicts. Resolve them or retry with force=true.", "data": { "vanity_url_conflicts": [ { "path": "/spring-sale", "conflicting_content_id": 123, "conflicting_content_title": "Store Sale", "conflicting_org_id": "org_..." } ] } }The admin UI surfaces these as a resolution dialog. Clients can retry with
"force": truein the request body; the forced override is logged in the conflict report.Manifest-time deterministic dedup. Even with Layers 1 and 2, race conditions or cross-org authoring can produce transient duplicates. The manifest endpoint always dedupes by most-specific org wins: the org closest to the requesting location in the hierarchy claims the path. Ties are broken by
published_at DESC, thenid ASC.
Platform-scoped reserved paths
In addition to same-path uniqueness across content items, vanity URL paths are also checked against a table of reserved paths registered per consumer platform. A reserved path is a route that the consuming frontend owns (e.g. /cart, /sign-in) and that content authors must not be able to shadow. See the dedicated Reserved Paths reference for the data model and admin CRUD.
- At authoring time (
POST/PUT /content/items): the union of every platform's active reserved entries is applied — most restrictive. - At manifest-build time (
GET /public/vanity-urls?platform_key=...): only the requesting platform's reserved entries plus universal (*) entries are applied. Grandfathered entries authored before a path was reserved silently drop out of the manifest for that platform.
A rejected save returns a 422 with a platform-specific error message:
The path '/cart' collides with reserved path for platform 'shophero-ecom-v2' '/cart'.or for universal entries:
The path '/api/foo' collides with universal reserved path '/api'.Content-type eligibility
Vanity URLs are only available on content types that opt in via the supports_vanity_urls flag on cms_content_types. The built-in eligible types are page, article, recipe, and landing. Custom content types can opt in from the admin schema editor.
Submitting a vanity_urls payload on an ineligible content type returns a 422 validation error.
Frontend integration pattern
The consuming frontend should:
- Fetch the manifest during SSR bootstrap (once per cache window) and cache it client-side with
stale-while-revalidatesemantics so navigation is synchronous. - Run a global middleware on every route that consults the cached manifest:
behavior === 'redirect'→ 301 redirect to/content/{content_type}/{slug}.behavior === 'render_in_place'→ internally rewrite the Nuxt/Next route to/content/{content_type}/{slug}without changing the browser URL.
- Run the middleware before any short-link or Quick Links middleware so content-authored paths take precedence.
Changelog
Version history
- 2026-04-16 — Initial release.
GET /api/v1/public/vanity-urlsmanifest endpoint, admin authoring viavanity_urlsfield on content create/update, three-layer uniqueness enforcement, CloudFront invalidation integration.