# Pages

Read, create, change and delete CMS pages with blocks, images, search-engine fields and a version check.

Section: Commerce · REST API
Canonical URL: https://docs.omniretailconnect.nl/commerce/api/pages

## Overview

Read the CMS pages of the shop (the home page, the legal pages and every other content page) with `commerce.read`; create, change and delete them with `commerce.write.content` (edit permission **Pages**, *Pagina's*, in **Settings** > **API tokens**; a token with it lives at most 30 days). Each endpoint is the MCP tool of the same name, described in [Content tools](https://docs.omniretailconnect.nl/commerce/mcp/content-tools.md), with the same `data`, the same change log (channel `api`) and the same [write limit](https://docs.omniretailconnect.nl/commerce/getting-started/limits.md#write-limit). Every write goes through the page editor's own rules (Website > Pages), is recorded in the shop's content activity log with the token as the causer, and shows in the page's history as "API-token" followed by the token name.

- **Drafts are visible to read tokens.** `GET /pages` and `GET /pages/{id}` include drafts and scheduled or expired pages for every token with `commerce.read`. Do not give a read token to anyone who may not see unpublished content.
- **Request size.** `POST /pages` and `PATCH /pages/{id}` accept a body of up to 262144 bytes (256 KiB). Every other write keeps 65536 bytes.
- **No customer data.** Pages hold shop content only.

## List pages

`GET /api/v1/pages` — Needs commerce.read. Equivalent MCP tool: list_pages.

Query parameters: `status` (`draft`, `published` or `all`, the default), `limit` and `cursor`. Pages by id. The rows have no blocks; use the next endpoint for those.

**List the draft pages**

```bash
curl -s -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  "https://<your-shop-domain>/api/v1/pages?status=draft"
```

## Get a page

`GET /api/v1/pages/{id}` — Needs commerce.read. Equivalent MCP tool: get_page.

*Fields of a page*

| Field | Type | Meaning |
| --- | --- | --- |
| `id, title, slug` |  | Page id, title (at most 255 characters) and the address part. The home page has the slug `home`. |
| `is_home, url` |  | Whether it is the home page, and its public address. |
| `status` |  | `draft` or `published`. |
| `visibility` |  | Whether visitors can open it now: `draft`, `scheduled`, `published` or `expired`. |
| `publish_at, unpublish_at` | string\|null | ISO 8601, UTC. |
| `noindex` | boolean | Keeps the page out of search results. |
| `updated_at` |  | The version. `PATCH` needs it back as `expected_updated_at`. |
| `meta_title, meta_description, canonical_url, og_image, og_image_url, blocks` |  | `GET /pages/{id}` only. `og_image` is the stored share image path, `og_image_url` its public URL. `blocks` is the whole list as stored: each `{type, data, image_urls}`, where `image_urls` holds the public URL per image field (`null` without images). |

The writable fields have the names and forms the write endpoints take, so the answer can be sent back as it is (the `image_urls` of a block are ignored). With the current version that answers `changed: false`.

## Create a page

`POST /api/v1/pages` — Needs commerce.write.content. Equivalent MCP tool: create_page.

*Body fields of POST /pages and PATCH /pages/{id}*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | string | Required | `POST` only (optional on `PATCH`). At most 255 characters. |
| `slug` | string | Required | `POST` only (optional on `PATCH`). Lower-case letters, digits and hyphens, unique, never `home`. The home page keeps `home`. |
| `expected_updated_at` | string | Required | `PATCH` only. The `updated_at` you read. When the page was saved after that moment the call is `409` and nothing changes. |
| `status` | string | Optional | `published` publishes, `draft` makes it a draft. Left out keeps it; a new page is a draft. |
| `publish_at, unpublish_at` | string\|null | Optional | ISO 8601 with an offset (a date that does not exist is `422`). `null` clears. The end must be after the start. |
| `meta_title, meta_description` | string\|null | Optional | At most 255 and 320 characters. `null` or empty: none. |
| `canonical_url` | string\|null | Optional | An `https` URL or a path of this shop (`/pagina/...`), only for a copy of another page. `null`: the page's own address. |
| `noindex` | boolean | Optional | `true` adds `noindex` to the robots meta tag and the `X-Robots-Tag` header and leaves the page out of the sitemap. |
| `og_image` | string\|null | Optional | The share image: an `https` URL (downloaded), its current value, or `null` to remove it. |
| `blocks` | array | Optional | The whole list in page order, at most 60, each `{type, data}`. It replaces every block; `[]` empties the page. |

Not idempotent: a repeat with the same slug is `422` because the slug is taken; a slug that another page takes at the same moment is `409`. The answer is the page as `GET /pages/{id}` returns it, plus `changed` and `changes` (`{field: {old, new}}`; blocks appear as a count and a `sha256` fingerprint so the change log does not keep whole texts).

## Change a page

`PATCH /api/v1/pages/{id}` — Needs commerce.write.content. Equivalent MCP tool: update_page.

Send `expected_updated_at` and the fields you want to change; the others keep their value. The input is merged onto the page as it is read under a row lock. Every committed change moves the version, also a second change within the same second, so two writers with the same `expected_updated_at` never both succeed. When the page was saved after that moment the answer is `409` `conflict` and nothing changes: read the page again and retry.

**Change the title and blocks of a page**

```bash
curl -sS -X PATCH "https://<your-shop-domain>/api/v1/pages/7" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"expected_updated_at": "2026-11-01T11:58:00+00:00", "title": "Over ons", "noindex": false, "blocks": [{"type": "heading", "data": {"text": "Over ons", "level": 2}}, {"type": "image", "data": {"path": "https://images.example.com/winkel.jpg", "alt": "Onze winkel"}}]}'
```

## Delete a page

`DELETE /api/v1/pages/{id}` — Needs commerce.write.content. Equivalent MCP tool: delete_page.

No body. Deletes the page with its saved versions and the menu links that point at it. The answer is `page_id`, `deleted`, `menu_links_removed`, `changed`, `changes`. The home page cannot be deleted (`422`); a repeat is `404`.

## Blocks

`blocks` is the whole list in page order and replaces every block, so read the page first, change what you need and send the list back. There are 20 block types. Ten work on any page; ten work only on the home page, or on a page that has that type already. The exact fields of each type are in the [OpenAPI document](https://docs.omniretailconnect.nl/docs/openapi/commerce.json) (`PageBlock`, one schema per type). A block that breaks a rule of its type is `422` and names the field.

*Block types*

| type | Where | Main fields |
| --- | --- | --- |
| `heading` | any page | `text` (at most 255), `level` (2, 3 or 4). |
| `richtext` | any page | `html` (sanitised), optional `aside` (`reviews`). |
| `faq` | any page | `items`: at most 20 questions and answers. |
| `cta` | any page | `label` (at most 60), `url` (at most 500), `style` (`primary` or `secondary`). |
| `usp` | any page | `items`: at most 6 selling points. |
| `callout` | any page | `heading` or `text` is required; optional `note`, `button_label`, `button_url`. |
| `image` | any page | `path` (image), `alt` (at most 200), optional `caption`. Width and height are set by the server. |
| `media-text` | any page | An image beside a heading and rich text: `position` (`left` or `right`), `heading` or `html` required, optional `path` and `alt`, `button_label`, `button_url`. |
| `feature-grid` | any page | A titled list of at most 8 features with an icon each, and an optional image. |
| `promo-banners` | any page | Two to four image tiles that link somewhere: `ratio` (`landscape`, `wide`, `square`), `tiles`. |
| `product-row` | home page | A row of products from a collection: `collection`, `sort` (`featured`, `newest`, `sale`), `limit`. |
| `category-tiles` | home page | Tiles of categories: `layout` (`tiles` or `list`), `source` (`collections` or `manual`), `limit`, at most 8 entries. |
| `brand-carousel` | home page | Brand logos; no brands means the most popular ones. At most 50 brands. |
| `article-teaser` | home page | The latest blog articles: `limit`. |
| `locations-teaser` | home page | The shop locations: `limit`. |
| `related-products` | home page | Hand-picked products (`source` `manual`, at most 12); the other sources only work on a product page. |
| `product-tabs` | home page | At most 4 tabs of product rows. |
| `hero-slider` | home page | One to five slides with an image each; `autoplay`, `interval`. |
| `newsletter` | home page | The newsletter sign-up: `heading`, `text`, `privacy_note`. |
| `goal-tiles` | home page | The health-goal tiles: `title`, `intro`. |

- **Rich text** (`richtext.html`, `media-text.html`) is sanitised as in the editor: scripts, event handlers, inline styles and unsafe links (`javascript:`) are removed. Other block text is stored as plain text.
- **References.** Collection, brand and product slugs in blocks must exist (`422` otherwise), unless the page has them already.

### Images

An image field and `og_image` take either a path the page already uses, which is kept as it is, or an `https` URL that the shop downloads before the write.

- Public hosts only (no private, loopback or link-local addresses), no redirects, JPEG, PNG or WebP of at most 5 MB each, stored as WebP.
- At most 20 new image URLs per call, 40 MB and 30 seconds of downloading in all. Past the budget the call is `422` and names the field.
- A GIF, an AVIF or an image above 5 MB is `422` and names the field (`blocks.2.data.path`, `og_image`). Any other value (an `http` URL, a path of another page) is `422`.
- `og_image` takes only an `https` URL, `null` (removes the share image) or its current value, never a path a block uses.
- A file stored by a call that then fails or rolls back is deleted again.

### The home page

- It keeps the slug `home`; another slug is `422`, and no other page may take `home`.
- It cannot be deleted.
- The ten home-page block types (`product-row`, `category-tiles`, `brand-carousel`, `article-teaser`, `locations-teaser`, `related-products`, `product-tabs`, `hero-slider`, `newsletter`, `goal-tiles`) are accepted there, and on another page only when that page has the type already.

### Search engines and addresses

- `noindex: true` keeps the page out of search results, the sitemap and `llms.txt`. The shop-wide "hide from search engines" setting still wins when it is on.
- A page whose `canonical_url` points to another address stays in the sitemap for now; set `noindex` as well if it must not be listed.
- A slug change of a page that is public keeps the old address working with a redirect, as in the editor.

## Errors

*Errors of the page endpoints*

| Status | error.code | When |
| --- | --- | --- |
| `403` | `forbidden_scope` | A write without `commerce.write.content`. |
| `404` | `not_found` | Unknown page, or a repeated delete. |
| `409` | `conflict` | `PATCH` with an `expected_updated_at` that is no longer current; a slug that another page takes at the same moment. |
| `422` | `invalid_argument` | A rule that fails, naming the field (`blocks.2.data.alt: ...`): a slug that is taken or `home`, a block of a home-page-only type, an unknown collection, brand or product slug, an image that is refused, more than 60 blocks, deleting the home page, or a date that does not exist. |
| `429` | `too_many_requests` | The write limit. The write did not happen. |
