Skip to content

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-bakery surfacing 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

ParameterTypeRequiredDescription
location_idstringnoOrganization hashkey. Defaults to the authenticated client's effective location.
platform_keystringnoConsumer 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

FieldTypeDescription
pathstringThe vanity path with a leading slash (e.g. /spring-sale).
behaviorenumredirect | render_in_place
content_typestringCanonical content type (e.g. page, article, landing).
slugstringCanonical slug — compose with content_type to form the canonical URL.

Caching

LayerTTL
Cache-Control: max-age=60 seconds (browser / CDN)
stale-while-revalidate300 seconds
In-service cache15 minutes per (org_id, location_id, platform_key)
CloudFrontInvalidated 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

StatusDescription
400Organization context could not be determined.
401Missing 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

FieldTypeRequiredDescription
pathstringyesMust 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).
behaviorenumyesredirect | render_in_place.
is_activebooleannoDefaults 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

FieldTypeRequired
pathstringyes
content_idstringno — 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:

  1. 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.

  2. Distribution-change re-validation. When the content's distribution_mode or distribution_rules change, 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": true in the request body; the forced override is logged in the conflict report.

  3. 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, then id 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:

  1. Fetch the manifest during SSR bootstrap (once per cache window) and cache it client-side with stale-while-revalidate semantics so navigation is synchronous.
  2. 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.
  3. 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-urls manifest endpoint, admin authoring via vanity_urls field on content create/update, three-layer uniqueness enforcement, CloudFront invalidation integration.

EngageHQ Public Content Delivery API