openapi: 3.1.0
info:
  title: Nucleo Content Delivery API
  version: v1
  summary: Read published pages, collections, menus and redirects of a Nucleo CMS site.
  description: |
    The headless CMS inside Nucleo Brain publishes websites through this read-only API. Your website's
    server (Next.js, Astro, Nuxt…) fetches the **published** content of one site: pages resolved by path,
    collection entries (blog posts, news…), menus, redirects and a sitemap. Drafts are never returned.

    Authentication is a per-site **delivery token** sent as `Authorization: Bearer cmsdt_…`. A token
    reads exactly one site. Use it only server-side: the API does not allow cross-origin browser calls
    and the token must never reach the visitor's browser.

    After every publish Nucleo can call your site back with a signed request, retried until your site
    answers (see **Webhooks → cmsRevalidate**), so you can refresh your cache.
x-nucleo:
  product: content
  module: brain
  audience: [server]
  stability: beta
  format: json
  order: 20
servers:
  - url: https://api-brain.nucleoplatform.com/api/delivery/v1/{workspace}/sites/{siteId}
    description: Production. The full base URL of a site is shown in Nucleo under CMS → *site* → Settings → For developers.
    variables:
      workspace:
        default: acme
        description: Slug of your Nucleo Brain workspace (lowercase letters, digits and dashes).
      siteId:
        default: "12"
        description: Numeric id of the CMS site.
security:
  - deliveryToken: []
tags:
  - name: Site
    description: Basic data of the site the token belongs to.
  - name: Pages
    description: Pages resolved by URL path and locale.
  - name: Collections
    description: Entries of a collection (blog, news, case studies…), paginated.
  - name: Navigation
    description: Menus, redirects and sitemap — everything a frontend needs to build routes.
  - name: Media
    description: Resolve a media id to its public URL.
paths:
  /:
    get:
      operationId: getSite
      summary: Get the site
      tags: [Site]
      x-rate-limit: { limit: 120, window: "1m", scope: "per IP" }
      description: |
        Id, slug, name, status, default locale, enabled locales and the public URL of the site's
        frontend (as configured in Nucleo; `null` if not set).

        Rate limit (all endpoints): 120 requests per minute per IP address.
      responses:
        "200":
          description: The site.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Site" }
              examples:
                site:
                  value:
                    data:
                      id: 12
                      slug: acme-website
                      name: Acme Apparel website
                      status: active
                      default_locale: en
                      locales: [en, it]
                      frontend_url: https://www.acme.example
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /page:
    get:
      operationId: getPage
      summary: Get a published page by path
      tags: [Pages]
      x-rate-limit: { limit: 120, window: "1m", scope: "per IP" }
      description: |
        Resolves `path` segment by segment through the translated slugs of the page tree in the given
        locale. `/` (or no `path`) returns the page marked as home. Returns `404` when no page matches,
        when the page has no translation in that locale, or when that translation is not published.

        `blocks` and `seo` are returned exactly as edited in Nucleo (free-form JSON defined by the
        page template): render them with your own components.
      parameters:
        - name: path
          in: query
          required: false
          description: URL path, with or without leading/trailing slashes. Default `/`.
          schema: { type: string, default: / }
          example: /about/team
        - $ref: "#/components/parameters/Locale"
      responses:
        "200":
          description: The page.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Page" }
              examples:
                page:
                  value:
                    data:
                      id: 87
                      template: default
                      is_home: false
                      title: Our team
                      slug: team
                      blocks:
                        - type: hero
                          props: { heading: Meet the people behind Acme Apparel, media_id: 311 }
                        - type: rich_text
                          props: { html: "<p>Designed in Milano since 2009.</p>" }
                      seo: { title: Our team · Acme Apparel, description: The people behind Acme Apparel. }
                      locale: en
                      published_at: "2026-09-30T10:15:00.000000Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Page not found, not translated or not published (or wrong site/workspace).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
              examples:
                notFound: { value: { message: Page not found. } }
                notTranslated: { value: { message: Page not translated. } }
                notPublished: { value: { message: Page not published. } }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /collections/{collection}/entries:
    get:
      operationId: listEntries
      summary: List published entries of a collection
      tags: [Collections]
      x-rate-limit: { limit: 120, window: "1m", scope: "per IP" }
      description: |
        Entries that have a **published** translation in the locale, newest first (by publish date,
        then id). Standard pagination: `data` plus `current_page`, `last_page`, `per_page`,
        `total`, `links` and the page URLs. Unknown collection → `404`.
      parameters:
        - $ref: "#/components/parameters/Collection"
        - $ref: "#/components/parameters/Locale"
        - name: page
          in: query
          required: false
          schema: { type: integer, minimum: 1, default: 1 }
        - name: per_page
          in: query
          required: false
          description: Clamped to 1–50.
          schema: { type: integer, minimum: 1, maximum: 50, default: 12 }
      responses:
        "200":
          description: A page of entries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EntryPage" }
              examples:
                blog:
                  value:
                    current_page: 1
                    data:
                      - id: 501
                        sort: 0
                        title: Autumn drop is live
                        slug: autumn-drop
                        fields: { excerpt: Ten new tees in organic cotton., cover_media_id: 340 }
                        seo: { title: Autumn drop · Acme Apparel }
                        published_at: "2026-09-28T08:00:00.000000Z"
                    first_page_url: https://api-brain.nucleoplatform.com/api/delivery/v1/acme/sites/12/collections/blog/entries?page=1
                    from: 1
                    last_page: 4
                    last_page_url: https://api-brain.nucleoplatform.com/api/delivery/v1/acme/sites/12/collections/blog/entries?page=4
                    links: []
                    next_page_url: https://api-brain.nucleoplatform.com/api/delivery/v1/acme/sites/12/collections/blog/entries?page=2
                    path: https://api-brain.nucleoplatform.com/api/delivery/v1/acme/sites/12/collections/blog/entries
                    per_page: 12
                    prev_page_url: null
                    to: 12
                    total: 41
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /collections/{collection}/entries/{entry}:
    get:
      operationId: getEntry
      summary: Get a published entry by slug
      tags: [Collections]
      x-rate-limit: { limit: 120, window: "1m", scope: "per IP" }
      description: One entry by its translated slug in the locale; `404` if it does not exist or is not published in that locale.
      parameters:
        - $ref: "#/components/parameters/Collection"
        - name: entry
          in: path
          required: true
          description: Entry slug in the requested locale.
          schema: { type: string }
          example: autumn-drop
        - $ref: "#/components/parameters/Locale"
      responses:
        "200":
          description: The entry.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Entry" }
              examples:
                entry:
                  value:
                    data:
                      id: 501
                      title: Autumn drop is live
                      slug: autumn-drop
                      fields: { excerpt: Ten new tees in organic cotton., body: "<p>Available from today in Milano and online.</p>", cover_media_id: 340 }
                      seo: { title: Autumn drop · Acme Apparel, description: Ten new tees in organic cotton. }
                      published_at: "2026-09-28T08:00:00.000000Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /menus:
    get:
      operationId: listMenus
      summary: List the site's menus
      tags: [Navigation]
      x-rate-limit: { limit: 120, window: "1m", scope: "per IP" }
      description: Every menu of the site with its items, as edited in Nucleo (items are free-form JSON).
      responses:
        "200":
          description: Menus.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Menu" }
              examples:
                menus:
                  value:
                    data:
                      - slug: main
                        items:
                          - { label: Shop, url: /shop }
                          - { label: Journal, url: /blog }
                          - { label: About, url: /about }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /redirects:
    get:
      operationId: listRedirects
      summary: List the site's redirects
      tags: [Navigation]
      x-rate-limit: { limit: 120, window: "1m", scope: "per IP" }
      description: Redirect rules to apply in your frontend or edge (`code` is the HTTP status, e.g. 301 or 302).
      responses:
        "200":
          description: Redirects.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Redirect" }
              examples:
                redirects:
                  value:
                    data:
                      - { from_path: /chi-siamo, to_path: /it/about, code: 301, locale: it }
                      - { from_path: /summer-sale, to_path: /shop, code: 302, locale: null }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /sitemap:
    get:
      operationId: getSitemap
      summary: List every published path
      tags: [Navigation]
      x-rate-limit: { limit: 120, window: "1m", scope: "per IP" }
      description: |
        Every published path for every enabled locale: pages (only when the page and all its ancestors
        are translated in that locale, so the path is resolvable by `/page?path=`) and collection
        entries (`/<collection>/<entry-slug>`). Use it to generate `sitemap.xml` and static routes.
      responses:
        "200":
          description: Published paths.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/SitemapItem" }
              examples:
                sitemap:
                  value:
                    data:
                      - { locale: en, kind: page, path: /, updated_at: "2026-09-30T10:15:00.000000Z" }
                      - { locale: en, kind: page, path: /about/team, updated_at: "2026-09-30T10:15:00.000000Z" }
                      - { locale: en, kind: entry, collection: blog, path: /blog/autumn-drop, updated_at: "2026-09-28T08:00:00.000000Z" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /media/{mediaId}:
    get:
      operationId: resolveMedia
      summary: Resolve a media id to its URL
      tags: [Media]
      x-rate-limit: { limit: 120, window: "1m", scope: "per IP" }
      description: |
        `302` redirect to the stable public URL of a media file of this site. Meant for your server to
        turn a media id found in `blocks`/`fields` into a URL. **Never** use this endpoint as `src` of
        an `<img>`/`<video>`: the browser would need the token. Put the `Location` URL in your HTML instead.
      parameters:
        - name: mediaId
          in: path
          required: true
          schema: { type: integer }
          example: 311
      responses:
        "302":
          description: Redirect to the public file.
          headers:
            Location:
              schema: { type: string, format: uri }
              example: https://media.acme.example/cms/12/hero-team.jpg
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
webhooks:
  cmsRevalidate:
    post:
      operationId: cmsRevalidate
      summary: Content published on the site
      tags: [Site]
      security: []
      description: |
        Sent by Nucleo right after a page or an entry is published, to the **Revalidate URL** configured
        in CMS → *site* → Settings, signed with the site's **Revalidate secret**.

        - Page published: `path` is `/` (refresh the whole site).
        - Entry published: `path` is `/<collection-slug>`.

        **Signature.** `Nucleo-Signature: t=<unix time>,v1=<signature>`, where the signature is the
        lowercase hex HMAC-SHA256 of `<t>.<raw body>` keyed with the Revalidate secret. Verify it on the
        exact bytes received, compare in constant time and reject a `t` older than 5 minutes. Without a
        secret on the site the request is not signed. The `secret` body field is deprecated: it is still
        sent for older receivers and will be removed.

        **Retries.** 5-second timeout, redirects not followed. Any `2xx` is a success (the body is
        ignored); any other answer or no answer is retried after 1 min, 5 min, 30 min, 2 h and 6 h — at
        most 6 attempts, then the delivery is dropped and logged. Every attempt has the same `id` (also in
        `Nucleo-Webhook-Id`) and its number in `Nucleo-Webhook-Attempt`, so you can ignore duplicates.
      parameters:
        - name: Nucleo-Signature
          in: header
          required: false
          description: "`t=<unix time>,v1=<hex HMAC-SHA256 of <t>.<raw body>>` keyed with the Revalidate secret. Absent when the site has no secret."
          schema: { type: string }
          example: t=1791115200,v1=5f2b6c0e9a8d4f7b1c3e2a6d9b0f8e7c6a5d4b3c2e1f0a9b8c7d6e5f4a3b2c1d
        - name: Nucleo-Webhook-Id
          in: header
          required: true
          description: Delivery id, the same on every attempt (equals `id` in the body).
          schema: { type: string, format: uuid }
          example: 3f6c1a52-8e0b-4d7a-9c2e-1b5d7f9a0c34
        - name: Nucleo-Webhook-Event
          in: header
          required: true
          schema: { type: string, enum: [content.published] }
        - name: Nucleo-Webhook-Attempt
          in: header
          required: true
          description: Attempt number, from 1 to 6.
          schema: { type: integer, minimum: 1, maximum: 6 }
          example: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, event, site, locale, path, published_at]
              properties:
                id: { type: string, format: uuid, description: Delivery id, the same on every attempt. }
                event: { type: string, enum: [content.published] }
                site: { type: string, description: Site slug. }
                locale: { type: string }
                path: { type: string }
                published_at: { type: string, format: date-time, description: When the content was published. }
                secret:
                  type: string
                  deprecated: true
                  description: The Revalidate secret of the site (empty string if not set). Deprecated, verify `Nucleo-Signature` instead.
            examples:
              entryPublished:
                value:
                  id: 3f6c1a52-8e0b-4d7a-9c2e-1b5d7f9a0c34
                  event: content.published
                  site: acme-website
                  locale: en
                  path: /blog
                  published_at: "2026-10-04T12:00:00+00:00"
                  secret: whsec_example_123
      responses:
        "200":
          description: Acknowledged. Any `2xx` stops the retries.
components:
  securitySchemes:
    deliveryToken:
      type: http
      scheme: bearer
      description: |
        Per-site delivery token (`cmsdt_` + 40 characters). Create it in Nucleo: **CMS → *site* →
        Settings → For developers → Create token** (requires the `cms.manage` permission). The value is
        shown once; Nucleo stores only its hash. Revoke it from the same card. Only the
        `Authorization` header is accepted (no `?token=` query parameter).
  parameters:
    Locale:
      name: locale
      in: query
      required: false
      description: Locale code enabled on the site. Default the site's default locale.
      schema: { type: string }
      example: en
    Collection:
      name: collection
      in: path
      required: true
      description: Collection slug.
      schema: { type: string }
      example: blog
  responses:
    Unauthorized:
      description: Missing or unknown delivery token.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Message" }
          examples:
            missing: { value: { message: "Delivery token required (Authorization: Bearer)." } }
            invalid: { value: { message: Invalid delivery token. } }
    NotFound:
      description: |
        Not found. Also returned when the workspace slug is unknown or when the token belongs to
        another site (Nucleo never confirms that a site exists to a token that cannot read it).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Message" }
          examples:
            notFound: { value: { message: Not found. } }
    TooManyRequests:
      description: More than 120 requests in a minute from the same IP.
      headers:
        Retry-After:
          schema: { type: integer, example: 30 }
        X-RateLimit-Limit:
          schema: { type: integer, example: 120 }
        X-RateLimit-Remaining:
          schema: { type: integer, example: 0 }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Message" }
          examples:
            throttled: { value: { message: Too Many Attempts. } }
  schemas:
    Message:
      type: object
      properties:
        message: { type: string }
    Site:
      type: object
      properties:
        id: { type: integer }
        slug: { type: string }
        name: { type: string }
        status: { type: string }
        default_locale: { type: string }
        locales: { type: array, items: { type: string } }
        frontend_url: { type: [string, "null"], format: uri }
    Page:
      type: object
      properties:
        id: { type: integer }
        template: { type: string }
        is_home: { type: boolean }
        title: { type: string }
        slug: { type: string }
        blocks:
          description: Page content as edited in Nucleo (template-defined JSON).
          type: [array, object, "null"]
        seo: { type: [object, "null"], additionalProperties: true }
        locale: { type: string }
        published_at: { type: [string, "null"], format: date-time }
    Entry:
      type: object
      properties:
        id: { type: integer }
        title: { type: string }
        slug: { type: string }
        fields: { type: [object, "null"], additionalProperties: true, description: Collection-defined fields. }
        seo: { type: [object, "null"], additionalProperties: true }
        published_at: { type: [string, "null"], format: date-time }
    EntryListItem:
      allOf:
        - $ref: "#/components/schemas/Entry"
        - type: object
          properties:
            sort: { type: integer }
    EntryPage:
      type: object
      properties:
        current_page: { type: integer }
        data: { type: array, items: { $ref: "#/components/schemas/EntryListItem" } }
        first_page_url: { type: string, format: uri }
        from: { type: [integer, "null"] }
        last_page: { type: integer }
        last_page_url: { type: string, format: uri }
        links: { type: array, items: { type: object } }
        next_page_url: { type: [string, "null"], format: uri }
        path: { type: string, format: uri }
        per_page: { type: integer }
        prev_page_url: { type: [string, "null"], format: uri }
        to: { type: [integer, "null"] }
        total: { type: integer }
    Menu:
      type: object
      properties:
        slug: { type: string }
        items: { type: [array, "null"], items: { type: object, additionalProperties: true } }
    Redirect:
      type: object
      properties:
        from_path: { type: string }
        to_path: { type: string }
        code: { type: integer, enum: [301, 302, 307, 308] }
        locale: { type: [string, "null"] }
    SitemapItem:
      type: object
      required: [locale, kind, path]
      properties:
        locale: { type: string }
        kind: { type: string, enum: [page, entry] }
        collection: { type: string, description: Only for `kind = entry`. }
        path: { type: string }
        updated_at: { type: [string, "null"], format: date-time }
