---
title: "Create a hosted page"
method: POST
path: "/api/v1/pages"
tags: ["Public API v1"]
---

# Create a hosted page

`POST /api/v1/pages`

## Request

- Base URL: `https://scanduro.com`
- URL: `https://scanduro.com/api/v1/pages`
- Auth: HTTP bearer

`Idempotency-Key` is REQUIRED, for the same reason as code creation: a page consumes plan
quota and mints a slug that may end up printed.

Sandbox keys are refused with `test_mode_unsupported`. A hosted page is public content served
under our name and our DSA obligations, so there is no such thing as a test page that is safe
to leave lying around.

## Headers

- `Idempotency-Key` string, required

## Request body

- CreatePageRequest
  - `name` string, required: Owner-facing label. Never rendered on the public page.
  - `type` 'vcard' | 'links' | 'business' | 'app' | 'menu' | 'event' | 'wifi' | 'review', required
  - `slug` string
  - `theme` 'paper' | 'ink' | 'vermilion'
  - `locale` 'en' | 'nl' | 'de' | 'fr' | 'es' | 'it' | 'pt' | 'ta'
  - `status` 'draft' | 'published'
  - `content` union, required: Discriminated on `kind`, which must match the page `type`. Typed fields only — never markup, CSS or script.
    - object
      - `kind` 'vcard', required
      - `firstName` string, required
      - `lastName` string
      - `organisation` string
      - `jobTitle` string
      - `phones` object[]
        - `label` string, required
        - `number` string, required
      - `emails` object[]
        - `label` string, required
        - `address` string, email, required
      - `website` string
      - `address` PageAddress
        - `street` string
        - `postcode` string
        - `city` string
        - `region` string
        - `country` string
      - `note` string
    - object
      - `kind` 'links', required
      - `heading` string, required
      - `subheading` string
      - `links` object[], required
        - `label` string, required
        - `url` string, required
    - object
      - `kind` 'business', required
      - `businessName` string, required
      - `tagline` string
      - `description` string
      - `phone` string
      - `email` string, email
      - `website` string
      - `address` PageAddress
        - `street` string
        - `postcode` string
        - `city` string
        - `region` string
        - `country` string
      - `hours` object[]
        - `day` integer, required: 0 = Monday. Numeric so the server localises the label, not the customer.
        - `closed` boolean
        - `opens` string
        - `closes` string
    - object
      - `kind` 'app', required
      - `appName` string, required
      - `publisher` string
      - `blurb` string
      - `stores` object[], required
        - `platform` 'ios' | 'android' | 'web', required
        - `url` string, required
    - object
      - `kind` 'menu', required
      - `heading` string, required
      - `subheading` string
      - `notice` string: Rendered above the first section, which is where an allergen or additive declaration belongs.
      - `sections` object[], required
        - `title` string, required
        - `note` string
        - `items` object[], required
          - `name` string, required
          - `description` string
          - `price` string: Bounded free text, not a number. Rendered exactly as given, so "9,50", "EUR 9.50" and "market price" are all valid.
          - `tags` string
    - object
      - `kind` 'event', required
      - `title` string, required
      - `summary` string
      - `startDate` string, required
      - `startTime` string: Omit for an all-day event.
      - `endDate` string
      - `endTime` string
      - `timezone` string: An IANA zone name such as Europe/Amsterdam, validated against the server runtime. Times are stored and rendered as local wall-clock in this zone and are never converted to a UTC instant or to the visitor clock.
      - `venue` string
      - `address` PageAddress
        - `street` string
        - `postcode` string
        - `city` string
        - `region` string
        - `country` string
      - `ticketUrl` string
      - `ticketLabel` string
      - `organiser` string
    - object
      - `kind` 'wifi', required
      - `heading` string, required
      - `ssid` string, required
      - `password` string: Shown as readable text on the page and encoded into a generated join code. Because it lives behind the code rather than inside it, rotating it does not invalidate anything printed.
      - `encryption` 'WPA' | 'WEP' | 'nopass'
      - `hidden` boolean
      - `note` string
    - object
      - `kind` 'review', required
      - `heading` string, required
      - `message` string
      - `targets` object[], required: Every visitor is shown every link. There is no rating gate: filtering who is invited to review is prohibited by Google and is a misleading commercial practice under Directive (EU) 2019/2161.
        - `platform` 'google' | 'trustpilot' | 'tripadvisor' | 'yelp' | 'facebook', required
        - `url` string, required: Checked against the hosts that platform owns before the platform label is used; a mismatch renders a generic label instead.

## Response `201`

Created, or replayed from a previous identical request.

- object
  - `data` PublicPage, required: An explicit allowlist, like PublicCode. `url` is null when the pages domain is not configured on this deployment, rather than a dead link.
    - `id` integer, required
    - `slug` string, required
    - `name` string, required
    - `type` string, required: The page type
    - `status` 'draft' | 'published', required
    - `moderationState` string, required: Reported, never accepted. A caller must not be able to lift a freeze that a notice put in place.
    - `theme` string
    - `locale` string
    - `url` string, nullable
    - `publishedAt` string, date-time, nullable
    - `createdAt` string, date-time, required
    - `updatedAt` string, date-time
    - `content` object: The page content

## Other responses

- `400`: Validation failure, a missing Idempotency-Key, or an unusable cursor.
- `401`: No key, a malformed key, an unknown key, a revoked key or an expired key. Deliberately one response for all five: telling the caller which applies would make the endpoint an oracle for enumerating valid keys.
- `402`: The plan's hosted-page allowance is exhausted.
- `403`: Either the plan does not include API access (`plan_required`) or the key does not carry the required scope (`missing_scope`). The credential itself is valid in both cases.
- `409`: The slug is taken or reserved, or the Idempotency-Key was reused with a different body.
- `429`: Per-key limit reached. Retry-After gives the seconds remaining in the window.
- `503`: Hosted pages are not enabled on this deployment. Codes, analytics and resolution are unaffected — pages are a separate domain and a separate feature on purpose.

---

[API](https://skmtc.dev/scanduro/apis/scanduro-public-api.md) · [All operations](https://skmtc.dev/scanduro/apis/scanduro-public-api/llms.txt) · [OpenAPI document](https://skmtc.dev/scanduro/apis/scanduro-public-api/revisions/cdb36d96cad7?raw)
