---
title: "Edit a hosted page"
method: PATCH
path: "/api/v1/pages/{id}"
tags: ["Public API v1"]
---

# Edit a hosted page

`PATCH /api/v1/pages/{id}`

## Request

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

Refused with `page_frozen` while the page is under moderation review. The standard abuse
pattern for hosted content is to publish something benign, get it indexed and printed, then
swap the content — so while a notice is being assessed the content has to hold still,
including for the API. `moderationState` is reported and never accepted.

There is no DELETE. A published page may already be printed on something, so removing it is a
decision a person makes in the dashboard having read the warning.

## Request body

- UpdatePageRequest: `slug` and `type` are deliberately absent — the slug is printable and therefore permanent, and the type selects the content schema.
  - `name` string
  - `theme` 'paper' | 'ink' | 'vermilion'
  - `locale` 'en' | 'nl' | 'de' | 'fr' | 'es' | 'it' | 'pt' | 'ta'
  - `status` 'draft' | 'published'
  - `venueId` string, nullable: Bind this menu page to ONE venue, so it renders that venue's live catalogue instead of its own `content`, or send `null` to unbind it. Omit the key to leave the binding alone. The venue must be in the same workspace as the page; a venue in another workspace is **404**, never 403, so the endpoint cannot be used to enumerate other tenants' venue ids. One venue, never a workspace. A brand with six locations has six catalogues, six tills and six places labelled "Table 6"; a page bound to anything wider than a location would have to guess which one a guest is sitting in.
  - `content` union: 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 `200`

Updated.

- 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.
- `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.
- `404`: No such code in this workspace. Another tenant's id reads as absent, not as forbidden.
- `409`: The page is frozen pending review, or the chosen slug is taken.
- `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)
