openapi: 3.1.0
info:
  title: 'publica.la website API'
  version: 1.0.0
  summary: 'Content, newsletter, scheduling and notification-widget API of the publica.la marketing site.'
  description: |-
    The JSON API behind publica.la, the marketing site: blog posts and product updates, the changelog, newsletter editions and their audience, booking pages, and the notification widget embedded in publica.la storefronts.

    **This is not the publica.la platform API.** Catalogue, titles, orders, users, entitlements and everything else scoped to a single publisher store live in the platform REST API v3, documented at https://docs.publica.la/ (ONIX 3.0 intake, webhooks/IPNs and auth-token integrations included). Nothing on this site answers those questions.

    **Authentication.** Every `/v1` request carries `Authorization: Bearer <token>`. Tokens are minted per consumer by publica.la with only the scopes that consumer needs; there is no sign-up endpoint and no OAuth flow. Write to hello@publica.la to ask for one. A refusal for a missing scope is a `403` naming the scope in `required_scope`, never a `401` — the bearer is fine, the answer is "not for this".

    **Translatable fields.** Fields such as `title`, `summary`, `body`, `name` and `subject` are objects keyed by locale — `{"en": "…", "es": "…"}` over en, es, pt, fr, de, it, pl. Sending one locale sets that locale and leaves the others as they were, so a partial translation is the normal way to work rather than a mistake.

    **Errors.** Failures answer JSON carrying both the original top-level `message` (and `errors` on a 422) and the RFC 9457 problem members `type`, `title`, `status`, `detail`, `code`, `hint` and `docs`. `hint` says what to do; `docs` links the page that explains it.

    **Rate limits.** `300` requests per `60` seconds, reported on every response as `RateLimit-Policy` / `RateLimit` (draft-ietf-httpapi-ratelimit-headers) plus the legacy `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset`. On a `429`, honour `Retry-After` (seconds) rather than retrying immediately. Firing an edition and suppressing addresses are throttled far tighter, and each operation says so.
  termsOfService: 'https://publica.la/en/terms'
  contact:
    name: publica.la
    email: hello@publica.la
    url: 'https://publica.la/en/contact'
  license:
    name: 'Proprietary — access by agreement with publica.la'
    url: 'https://publica.la/en/terms'
  x-sandbox:
    url: 'https://docs.publica.la/rest-api-v3/content/content-v3-sandbox'
    description: 'A hosted try-it-out page for the publica.la **platform** REST API v3 (per-store catalogue, orders, entitlements) — a different API from this one.'
    this_api: 'This API has no sandbox host. The `/site/*` operations are anonymous, so a real call against the production server needs no credentials and changes nothing.'
externalDocs:
  description: 'Developer portal for this API, and the way to ask for a key.'
  url: 'https://publica.la/en/developers'
servers:
  -
    url: 'https://publica.la/api/v1'
    description: 'Production, and the only server: there is no staging host and no sandbox for this API. The `/site/*` operations need no credentials, so a first call against this URL is the way to try it.'
security:
  -
    bearerAuth: []
tags:
  -
    name: 'Site (anonymous)'
    description: 'The published site, readable with **no API key**: blog posts, the changelog, the plans, and what publica.la is for. Read-only, publicly cacheable, and the only operations here an agent can call without a person first asking publica.la for a bearer.'
    externalDocs:
      description: 'The site these describe'
      url: 'https://publica.la/en'
  -
    name: Posts
    description: 'Blog posts and product updates. One table backs the whole blog: `category` says which kind of post it is, `pillar` which audience it is written for.'
    externalDocs:
      description: 'The blog these become'
      url: 'https://publica.la/en/blog'
  -
    name: Changelog
    description: 'Changelog entries — one per month, grouped into sections (Added, Changed, Fixed…), each holding `{heading, items}` blocks.'
    externalDocs:
      description: 'The published changelog'
      url: 'https://publica.la/en/changelog'
  -
    name: Newsletter
    description: 'Newsletter editions: authoring them, uploading their images, testing them, reading the audience and engagement counters, and firing one at the list. Sending is a scope of its own because it cannot be undone.'
  -
    name: Subscribers
    description: 'Newsletter contact ingestion. A subscriber is identified by email; suppression always wins, so ingesting a suppressed address never reactivates it.'
  -
    name: Suppressions
    description: 'List hygiene: put addresses on the suppression list so no future send reaches them. Its own scope, because taking somebody off is deliberately hard to undo.'
  -
    name: Scheduling
    description: "Booking pages — the organisation's catalogue (`/book/{slug}`) and the hosts' own meetings (`/meet/{slug}`), which are one table. Everything the admin form can set is settable here."
  -
    name: Widget
    description: 'The product-update notification widget embedded in publica.la storefronts. Not bearer-authenticated: it carries an HMAC widget token and is answered only for an allow-listed `Origin`.'
  -
    name: Deprecated
    description: 'The `/releases` alias of the post endpoints, kept because a live consumer still publishes through it. New integrations use `/posts`.'
x-mcp:
  url: 'https://publica.la/mcp'
  manifest: 'https://publica.la/.well-known/mcp.json'
  serverCard: 'https://publica.la/.well-known/mcp/server-card.json'
  transport: streamable-http
  authentication: none
  description: 'A read-only, anonymous MCP server over the same public content as the `/site/*` operations below. The publica.la **platform** documentation runs a second, separate MCP server at https://docs.publica.la/mcp.'
x-anonymous-access:
  description: 'The `/site/*` operations take no `Authorization` header at all: published blog posts, the published changelog, the plans, and what publica.la is for. Everything else needs a bearer a person asks for by email, so these are the endpoints an agent can call on its first try.'
  operations:
    - listSitePosts
    - getSitePost
    - listSiteChangelog
    - getSitePricing
    - getSiteOverview
  rate_limit: "The same 300/60s policy, keyed by IP rather than by key. Answers are `public, max-age=300, s-maxage=3600`, so the per-caller counter is deliberately withheld from them — a remaining count stored at the edge would be the first caller's number replayed to everyone behind them."
x-rate-limit:
  policy: api
  quota: 300
  window: 60
  headers:
    - RateLimit-Policy
    - RateLimit
    - RateLimit-Limit
    - RateLimit-Remaining
    - RateLimit-Reset
    - Retry-After
x-agent-hints:
  start_here: 'Call `GET /site/overview` first. It needs no credentials and it says what publica.la is, when to use it, what it is not for, and where every other machine-readable document lives.'
  anonymous_access: 'The five `/site/*` operations take no `Authorization` header. Everything else does, and there is no way to get a key without a person — so if the task can be answered from published content, do not authenticate at all.'
  authenticate: 'Send `Authorization: Bearer <token>`. Tokens are minted by publica.la per consumer (`php artisan rondine:api-client <name> --scopes=…`); there is no signup, token or OAuth endpoint on this API. Ask hello@publica.la.'
  on_401: 'The bearer is missing, unknown or revoked. Do not retry the same token; get a valid one. The response carries `WWW-Authenticate: Bearer realm="publica.la website API", error="invalid_token", error_description="Send Authorization: Bearer <key>"`.'
  on_403: 'The bearer is valid but does not carry the scope this route requires — read `required_scope` in the body. Retrying will not help; ask for a key with that scope.'
  on_404: 'The id does not exist (route-model binding runs before authentication, so a wrong id answers 404 even with a bad token).'
  on_422: 'Validation failed. Read `errors` (field → messages) and fix the payload; the same body will always fail. A few endpoints answer 422 with a single `error` string instead — a campaign already sending, a locale with no body, a fetch that failed.'
  on_429: 'Honour `Retry-After` (seconds) before retrying, and read `RateLimit` for what is left in the window. Never retry a `POST …/send` on a 429 without checking the campaign status first — a send is not reversible.'
  pagination: 'List endpoints answer a Laravel paginator envelope: `data` (the array), `links` (first/last/prev/next) and `meta` (current_page, last_page, per_page, total). Page with `?page=` and size with `?per_page=`.'
  idempotency: 'Send `Idempotency-Key: <uuid>` on any POST/PUT/PATCH/DELETE. The first answer is remembered for 24 hours and a repeat with the same key and body replays it with `Idempotency-Replayed: true` instead of writing twice; the same key with a *different* body is a 422 with `code: idempotency_key_reused`. Without the header nothing changes: `POST /subscribers` and `POST /subscribers/origami` still upsert by email, `POST /suppressions/bulk` is still idempotent per address, and `POST …/send` is still guarded by campaign status rather than by a key.'
  sandbox: 'This API has no sandbox host — the `/site/*` operations are anonymous, so a real call needs no credentials. The platform API v3 has a hosted try-it-out page at https://docs.publica.la/rest-api-v3/content/content-v3-sandbox'
  mcp: 'The same public content is available over MCP at https://publica.la/mcp (streamable HTTP, read-only, no auth; manifest at https://publica.la/.well-known/mcp.json). The platform documentation runs a separate MCP server at https://docs.publica.la/mcp.'
  platform_api: 'For catalogue, orders, users or anything scoped to one publisher store, use the platform API instead: https://docs.publica.la/'
  machine_readable_site:
    - /llms.txt
    - /llms-full.txt
    - /sitemap.xml
    - /blog.xml
    - /robots.txt
    - /openapi.json
    - /openapi.yaml
    - /.well-known/api-catalog
    - /index.md
    - /agents.md
    - /guide.md
    - /auth.md
    - /api.md
    - /pricing.md
    - /skills/publicala/SKILL.md
    - /.well-known/ard.json
    - /.well-known/agent-skills/index.json
    - /.well-known/mcp.json
    - /mcp
  status_page: 'https://status.publica.la/'
paths:
  /site/overview:
    get:
      operationId: getSiteOverview
      summary: 'What publica.la is, and when to use it'
      description: 'The orientation document, and the first call to make: what publica.la is, the cases it is the right answer for, the cases it is **not** for, the solution and feature pages with their URLs, who to write to, and every machine-readable entry point (`llms.txt`, this description, the developer portal, the MCP server). **No API key**: send no `Authorization` header. Answered `public, max-age=300, s-maxage=3600`, so it may be served by a shared cache.'
      tags:
        - 'Site (anonymous)'
      security: []
      x-anonymous-access: 'No credential of any kind. The same 300 requests per 60 seconds apply, keyed by IP.'
      parameters:
        -
          name: locale
          in: query
          required: false
          description: 'Which language to answer in. Defaults to `en`, which is also the fallback for a field a locale has no translation of.'
          schema:
            type: string
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
            default: en
      responses:
        '200':
          description: 'What publica.la is.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    $ref: '#/components/schemas/SiteOverview'
                  meta:
                    $ref: '#/components/schemas/SiteMeta'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /site/pricing:
    get:
      operationId: getSitePricing
      summary: 'The plans and what they cost'
      description: 'The three plans exactly as the pricing page states them — monthly and annual price in USD, the headline features and the full feature list — plus the URL of the page itself. Read from the same language files the page renders, so the two cannot disagree. **No API key**: send no `Authorization` header. Answered `public, max-age=300, s-maxage=3600`, so it may be served by a shared cache.'
      tags:
        - 'Site (anonymous)'
      security: []
      x-anonymous-access: 'No credential of any kind. The same 300 requests per 60 seconds apply, keyed by IP.'
      parameters:
        -
          name: locale
          in: query
          required: false
          description: 'Which language to answer in. Defaults to `en`, which is also the fallback for a field a locale has no translation of.'
          schema:
            type: string
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
            default: en
      responses:
        '200':
          description: 'The plans, in USD.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    $ref: '#/components/schemas/SitePricing'
                  meta:
                    $ref: '#/components/schemas/SiteMeta'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /site/posts:
    get:
      operationId: listSitePosts
      summary: 'Search published blog posts'
      description: 'Published posts only, newest first. `q` matches the title and the summary, case-insensitively; `category` narrows to one kind of post. The body is deliberately not in the list — fetch one post for that. **No API key**: send no `Authorization` header. Answered `public, max-age=300, s-maxage=3600`, so it may be served by a shared cache.'
      tags:
        - 'Site (anonymous)'
      security: []
      x-anonymous-access: 'No credential of any kind. The same 300 requests per 60 seconds apply, keyed by IP.'
      parameters:
        -
          name: q
          in: query
          required: false
          description: 'Case-insensitive substring of the title or summary. Omit to list the most recent posts.'
          schema:
            type: string
            maxLength: 200
        -
          name: category
          in: query
          required: false
          description: 'Narrow to one category.'
          schema:
            type: string
            enum:
              - product-updates
              - guides
              - case-studies
              - publishing-insights
              - industry
        -
          name: limit
          in: query
          required: false
          description: 'How many rows to answer with, 1–50.'
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
        -
          name: locale
          in: query
          required: false
          description: 'Which language to answer in. Defaults to `en`, which is also the fallback for a field a locale has no translation of.'
          schema:
            type: string
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
            default: en
      responses:
        '200':
          description: 'The matching posts, newest first.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/SitePost'
                  meta:
                    $ref: '#/components/schemas/SiteMeta'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  '/site/posts/{slug}':
    get:
      operationId: getSitePost
      summary: 'Get one published blog post'
      description: 'One published post by slug, with `body` as plain text (the HTML is stripped, so the answer is quotable as it stands). An unpublished post and a slug that never existed answer the same `404`: whether a draft exists is not a public fact. **No API key**: send no `Authorization` header. Answered `public, max-age=300, s-maxage=3600`, so it may be served by a shared cache.'
      tags:
        - 'Site (anonymous)'
      security: []
      x-anonymous-access: 'No credential of any kind. The same 300 requests per 60 seconds apply, keyed by IP.'
      parameters:
        -
          name: slug
          in: path
          required: true
          description: "The post's slug, as it appears in the blog URL and in the list above."
          schema:
            type: string
            maxLength: 255
        -
          name: locale
          in: query
          required: false
          description: 'Which language to answer in. Defaults to `en`, which is also the fallback for a field a locale has no translation of.'
          schema:
            type: string
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
            default: en
      responses:
        '200':
          description: 'The post, body included.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    $ref: '#/components/schemas/SitePost'
                  meta:
                    $ref: '#/components/schemas/SiteMeta'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /site/changelog:
    get:
      operationId: listSiteChangelog
      summary: 'List published changelog entries'
      description: 'Published changelog entries, most recent month first. One entry per month, grouped into sections (Added, Changed, Fixed…), each holding `{heading, items}` blocks. **No API key**: send no `Authorization` header. Answered `public, max-age=300, s-maxage=3600`, so it may be served by a shared cache.'
      tags:
        - 'Site (anonymous)'
      security: []
      x-anonymous-access: 'No credential of any kind. The same 300 requests per 60 seconds apply, keyed by IP.'
      parameters:
        -
          name: limit
          in: query
          required: false
          description: 'How many rows to answer with, 1–50.'
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
        -
          name: locale
          in: query
          required: false
          description: 'Which language to answer in. Defaults to `en`, which is also the fallback for a field a locale has no translation of.'
          schema:
            type: string
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
            default: en
      responses:
        '200':
          description: 'The entries, newest month first.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/SiteChangelogEntry'
                  meta:
                    $ref: '#/components/schemas/SiteMeta'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /posts:
    get:
      operationId: listPosts
      summary: 'List blog posts'
      description: 'Paginated posts, newest first. Filter by category and publication window; `published_only=true` narrows to what is live on the site. Requires the `content.read` scope (read posts and changelog entries).'
      tags:
        - Posts
      security:
        -
          bearerAuth:
            - content.read
      x-required-scope: content.read
      parameters:
        -
          name: published_only
          in: query
          required: false
          description: 'Only posts that are published and whose `published_at` has passed.'
          schema:
            type: boolean
            default: false
        -
          name: category
          in: query
          required: false
          description: 'Narrow to one category.'
          schema:
            type: string
            enum:
              - product-updates
              - guides
              - case-studies
              - publishing-insights
              - industry
        -
          name: published_after
          in: query
          required: false
          description: 'Only posts published on or after this date.'
          schema:
            type: string
            format: date
        -
          name: published_before
          in: query
          required: false
          description: 'Only posts published on or before this date.'
          schema:
            type: string
            format: date
        -
          name: sort
          in: query
          required: false
          description: 'Column to sort by, descending. Anything else falls back to `published_at` — the value is whitelisted rather than passed through, so it cannot name another column.'
          schema:
            type: string
            enum:
              - published_at
              - created_at
            default: published_at
        -
          name: per_page
          in: query
          required: false
          description: 'Rows per page.'
          schema:
            type: integer
            minimum: 1
            default: 15
        -
          name: page
          in: query
          required: false
          description: 'Which page to read, 1-based.'
          schema:
            type: integer
            minimum: 1
            default: 1
      responses:
        '200':
          description: 'A page of posts.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - links
                  - meta
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Post'
                  links:
                    $ref: '#/components/schemas/PaginationLinks'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    post:
      operationId: createPost
      summary: 'Create a blog post'
      description: "Creates a post. Leave `is_published` off to keep it a draft — a draft also posts a preview to the team's Slack channel, and a failure there never fails the request. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries)."
      tags:
        - Posts
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      requestBody:
        required: true
        description: 'The post. Translatable fields are locale maps.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostCreate'
      responses:
        '201':
          description: 'The created post.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/Post'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      parameters:
        -
          $ref: '#/components/parameters/IdempotencyKey'
  '/posts/{post}':
    get:
      operationId: getPost
      summary: 'Get a blog post'
      description: 'One post by id, with its author and every locale of every translatable field. Requires the `content.read` scope (read posts and changelog entries).'
      tags:
        - Posts
      security:
        -
          bearerAuth:
            - content.read
      x-required-scope: content.read
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: 'The post.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/Post'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    put:
      operationId: updatePost
      summary: 'Update a blog post'
      description: 'Replaces the fields sent and leaves the rest alone — including, inside a translatable field, the locales not sent. Identical to `PATCH`. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Posts
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'The fields to change.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostUpdate'
      responses:
        '200':
          description: 'The updated post.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/Post'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    patch:
      operationId: patchPost
      summary: 'Update a blog post (PATCH)'
      description: 'The same operation as `PUT /posts/{post}`; both verbs reach the same handler, and both are partial. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Posts
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'The fields to change.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostUpdate'
      responses:
        '200':
          description: 'The updated post.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/Post'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    delete:
      operationId: deletePost
      summary: 'Delete a blog post'
      description: 'Deletes the post outright. There is no trash and no undo. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Posts
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204':
          description: 'Deleted. No body.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  '/posts/{post}/publish':
    post:
      operationId: publishPost
      summary: 'Publish a blog post'
      description: 'Flips `is_published` on and stamps `published_at` with the current time. No body. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Posts
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: 'The published post.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/Post'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  '/posts/{post}/cover-image':
    post:
      operationId: uploadPostCoverImage
      summary: "Set a post's cover image from a URL"
      description: 'Fetches the image at `url`, crops it to the blog cover ratio, stores it and points the post at the result. Answers the post carrying the new `cover_image`. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Posts
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'Where to fetch the image from.'
        content:
          application/json:
            schema:
              type: object
              required:
                - url
              additionalProperties: false
              properties:
                url:
                  type: string
                  format: uri
                  maxLength: 2000
                  description: 'Publicly reachable image URL.'
      responses:
        '200':
          description: 'The post, with `cover_image` set.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/Post'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  '/posts/{post}/slack-preview':
    post:
      operationId: previewPostInSlack
      summary: 'Post a draft preview to Slack'
      description: "Sends a preview of the post to the team's Slack channel for review. Answers Slack's own reply, or `{}` where no Slack channel is configured — neither is an error. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries)."
      tags:
        - Posts
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: "Slack's reply, verbatim."
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SlackResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /event-types:
    get:
      operationId: listEventTypes
      summary: 'List booking pages'
      description: "Paginated booking pages by slug. `kind=personal` is a host's own meeting (`/meet/{slug}`), `kind=catalogue` the organisation's (`/book/{slug}`); they are one table split by `owner_id`. Requires the `scheduling.read` scope (read booking pages)."
      tags:
        - Scheduling
      security:
        -
          bearerAuth:
            - scheduling.read
      x-required-scope: scheduling.read
      parameters:
        -
          name: kind
          in: query
          required: false
          description: 'Narrow to one kind of page. Anything else returns both.'
          schema:
            type: string
            enum:
              - personal
              - catalogue
        -
          name: owner_id
          in: query
          required: false
          description: 'Only pages owned by this host.'
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          name: is_active
          in: query
          required: false
          description: 'Only pages that are switched on (or off).'
          schema:
            type: boolean
        -
          name: per_page
          in: query
          required: false
          description: 'Rows per page.'
          schema:
            type: integer
            minimum: 1
            default: 25
        -
          name: page
          in: query
          required: false
          description: 'Which page to read, 1-based.'
          schema:
            type: integer
            minimum: 1
            default: 1
      responses:
        '200':
          description: 'A page of booking pages.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - links
                  - meta
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/EventType'
                  links:
                    $ref: '#/components/schemas/PaginationLinks'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    post:
      operationId: createEventType
      summary: 'Create a booking page'
      description: 'Creates a page under the same rules as the admin form: English is required (every other locale falls back to it), and an event with its own opening hours must name the timezone they are read in. `host_ids` sets the pool — on a personal meeting it is overruled back to the owner. Requires the `scheduling.write` scope (create and edit booking pages).'
      tags:
        - Scheduling
      security:
        -
          bearerAuth:
            - scheduling.write
      x-required-scope: scheduling.write
      requestBody:
        required: true
        description: 'The booking page.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EventTypeCreate'
      responses:
        '201':
          description: 'The created page, with its public URLs and whether it is really taking bookings.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/EventType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      parameters:
        -
          $ref: '#/components/parameters/IdempotencyKey'
  '/event-types/{eventType}':
    get:
      operationId: getEventType
      summary: 'Get a booking page'
      description: 'One page by id, with its host pool, the URLs it answers on, and `is_live` — whether handing those URLs out would be a promise it can keep. Requires the `scheduling.read` scope (read booking pages).'
      tags:
        - Scheduling
      security:
        -
          bearerAuth:
            - scheduling.read
      x-required-scope: scheduling.read
      parameters:
        -
          name: eventType
          in: path
          required: true
          description: "The booking page's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: 'The booking page.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/EventType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    put:
      operationId: updateEventType
      summary: 'Update a booking page'
      description: 'Every field is optional. A translatable field sent for one locale leaves the others alone; `host_ids` omitted leaves the pool as it is. Identical to `PATCH`. Requires the `scheduling.write` scope (create and edit booking pages).'
      tags:
        - Scheduling
      security:
        -
          bearerAuth:
            - scheduling.write
      x-required-scope: scheduling.write
      parameters:
        -
          name: eventType
          in: path
          required: true
          description: "The booking page's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'The fields to change.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EventTypeUpdate'
      responses:
        '200':
          description: 'The updated page.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/EventType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    patch:
      operationId: patchEventType
      summary: 'Update a booking page (PATCH)'
      description: 'The same operation as `PUT /event-types/{eventType}`. Requires the `scheduling.write` scope (create and edit booking pages).'
      tags:
        - Scheduling
      security:
        -
          bearerAuth:
            - scheduling.write
      x-required-scope: scheduling.write
      parameters:
        -
          name: eventType
          in: path
          required: true
          description: "The booking page's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'The fields to change.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EventTypeUpdate'
      responses:
        '200':
          description: 'The updated page.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/EventType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    delete:
      operationId: deleteEventType
      summary: 'Delete a booking page'
      description: 'A page with no bookings is deleted (`204`). A page **with** bookings is switched off instead and answers `200` saying so, because bookings cascade from it and deleting one would take the attendees` history and their manage links with it. Requires the `scheduling.write` scope (create and edit booking pages).'
      tags:
        - Scheduling
      security:
        -
          bearerAuth:
            - scheduling.write
      x-required-scope: scheduling.write
      parameters:
        -
          name: eventType
          in: path
          required: true
          description: "The booking page's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: 'The page had bookings, so it was switched off rather than deleted.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventTypeDeactivated'
        '204':
          description: 'Deleted. No body.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /newsletter/stats:
    get:
      operationId: newsletterStats
      summary: 'Read audience and engagement counters'
      description: 'The sendable audience — active **and** not suppressed — broken down by locale and by the language each contact would actually receive (Spanish for `es`, English for everyone else), plus per-campaign delivery and engagement counters. Answers "how many contacts, and how many in Spanish?" without querying the database. Requires the `newsletter.read` scope (read newsletter audience size and engagement counters).'
      tags:
        - Newsletter
      security:
        -
          bearerAuth:
            - newsletter.read
      x-required-scope: newsletter.read
      parameters:
        -
          name: campaigns_limit
          in: query
          required: false
          description: 'How many recent campaigns to include. Clamped to 1–200.'
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: 'Audience and per-campaign counters. Not wrapped in `data`.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NewsletterStats'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /newsletter-campaigns:
    get:
      operationId: listNewsletterCampaigns
      summary: 'List newsletter campaigns'
      description: 'Paginated campaigns, newest first. `stats` is only present on a single campaign read, not in this list. Requires the `newsletter.read` scope (read newsletter audience size and engagement counters).'
      tags:
        - Newsletter
      security:
        -
          bearerAuth:
            - newsletter.read
      x-required-scope: newsletter.read
      parameters:
        -
          name: status
          in: query
          required: false
          description: 'Narrow to one status.'
          schema:
            type: string
            enum:
              - draft
              - scheduled
              - sending
              - sent
              - cancelled
        -
          name: per_page
          in: query
          required: false
          description: 'Rows per page.'
          schema:
            type: integer
            minimum: 1
            default: 15
        -
          name: page
          in: query
          required: false
          description: 'Which page to read, 1-based.'
          schema:
            type: integer
            minimum: 1
            default: 1
      responses:
        '200':
          description: 'A page of campaigns.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - links
                  - meta
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/NewsletterCampaign'
                  links:
                    $ref: '#/components/schemas/PaginationLinks'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    post:
      operationId: createNewsletterCampaign
      summary: 'Create a newsletter campaign'
      description: 'Authors an edition as a draft. `subject` and `body_html` are locale maps and both are required; a locale with no `body_html` is a locale that cannot be sent. `slug` is generated from the English subject when omitted. Creating a campaign never sends anything. Requires the `newsletter.write` scope (author newsletter editions, upload assets, send test emails).'
      tags:
        - Newsletter
      security:
        -
          bearerAuth:
            - newsletter.write
      x-required-scope: newsletter.write
      requestBody:
        required: true
        description: 'The edition.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewsletterCampaignCreate'
      responses:
        '201':
          description: 'The created campaign, in `draft`.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/NewsletterCampaign'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      parameters:
        -
          $ref: '#/components/parameters/IdempotencyKey'
  '/newsletter-campaigns/{newsletterCampaign}':
    get:
      operationId: getNewsletterCampaign
      summary: 'Get a newsletter campaign'
      description: 'One campaign by id, with `stats` — the per-send counters aggregated from its delivery records. Requires the `newsletter.read` scope (read newsletter audience size and engagement counters).'
      tags:
        - Newsletter
      security:
        -
          bearerAuth:
            - newsletter.read
      x-required-scope: newsletter.read
      parameters:
        -
          name: newsletterCampaign
          in: path
          required: true
          description: "The campaign's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: 'The campaign, including `stats`.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/NewsletterCampaign'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    put:
      operationId: updateNewsletterCampaign
      summary: 'Update a newsletter campaign'
      description: 'Edits a draft or scheduled edition. A campaign that is `sending` or `sent` is locked and answers `422` — what has gone out cannot be edited. Identical to `PATCH`. Requires the `newsletter.write` scope (author newsletter editions, upload assets, send test emails).'
      tags:
        - Newsletter
      security:
        -
          bearerAuth:
            - newsletter.write
      x-required-scope: newsletter.write
      parameters:
        -
          name: newsletterCampaign
          in: path
          required: true
          description: "The campaign's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'The fields to change.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewsletterCampaignUpdate'
      responses:
        '200':
          description: 'The updated campaign.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/NewsletterCampaign'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    patch:
      operationId: patchNewsletterCampaign
      summary: 'Update a newsletter campaign (PATCH)'
      description: 'The same operation as `PUT /newsletter-campaigns/{newsletterCampaign}`. Requires the `newsletter.write` scope (author newsletter editions, upload assets, send test emails).'
      tags:
        - Newsletter
      security:
        -
          bearerAuth:
            - newsletter.write
      x-required-scope: newsletter.write
      parameters:
        -
          name: newsletterCampaign
          in: path
          required: true
          description: "The campaign's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'The fields to change.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewsletterCampaignUpdate'
      responses:
        '200':
          description: 'The updated campaign.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/NewsletterCampaign'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    delete:
      operationId: deleteNewsletterCampaign
      summary: 'Delete a newsletter campaign'
      description: 'Deletes a draft or scheduled edition. One that is `sending` or `sent` is locked and answers `422`. Requires the `newsletter.write` scope (author newsletter editions, upload assets, send test emails).'
      tags:
        - Newsletter
      security:
        -
          bearerAuth:
            - newsletter.write
      x-required-scope: newsletter.write
      parameters:
        -
          name: newsletterCampaign
          in: path
          required: true
          description: "The campaign's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204':
          description: 'Deleted. No body.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  '/newsletter-campaigns/{newsletterCampaign}/send-test':
    post:
      operationId: sendNewsletterCampaignTest
      summary: 'Send a test of one locale'
      description: 'Renders the edition for one locale and mails it to a single address. Touches no subscriber and writes no send record, so it is safe to repeat. Answers `422` when the campaign has no body for that locale. Requires the `newsletter.write` scope (author newsletter editions, upload assets, send test emails).'
      tags:
        - Newsletter
      security:
        -
          bearerAuth:
            - newsletter.write
      x-required-scope: newsletter.write
      parameters:
        -
          name: newsletterCampaign
          in: path
          required: true
          description: "The campaign's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'Where to send the test, and in which language.'
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              additionalProperties: false
              properties:
                email:
                  type: string
                  format: email
                  description: 'The test recipient.'
                locale:
                  type:
                    - string
                    - 'null'
                  enum:
                    - en
                    - es
                    - pt
                    - fr
                    - de
                    - it
                    - pl
                    - null
                  description: 'Which language to render. Defaults to the newsletter fallback locale (`en`).'
      responses:
        '200':
          description: 'The test was handed to the mailer.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - sent
                properties:
                  sent:
                    type: boolean
                    const: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/SendFailed'
  '/newsletter-campaigns/{newsletterCampaign}/send':
    post:
      operationId: sendNewsletterCampaign
      summary: 'Fire an edition at the list'
      description: 'The wave. `mode=now` fans the edition out to the whole sendable audience; `mode=schedule` sets `scheduled_at` and a scheduled command picks it up. `limit` caps a warm-up wave, leaving the campaign `sending` until a later wave drains the list. **Irreversible** — a send that has gone out cannot be recalled — so it is the one operation on its own scope, and it is throttled to 10 calls a minute. Answers `422` when the campaign is already sent or has no sendable `body_html`. Requires the `newsletter.send` scope (fire an edition at the whole list (irreversible)). Rate limit: 10 requests per minute, on top of the global policy.'
      tags:
        - Newsletter
      security:
        -
          bearerAuth:
            - newsletter.send
      x-required-scope: newsletter.send
      parameters:
        -
          name: newsletterCampaign
          in: path
          required: true
          description: "The campaign's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'How to send.'
        content:
          application/json:
            schema:
              type: object
              required:
                - mode
              additionalProperties: false
              properties:
                mode:
                  type: string
                  enum:
                    - now
                    - schedule
                  description: '`now` dispatches immediately; `schedule` defers to `scheduled_at`.'
                scheduled_at:
                  type:
                    - string
                    - 'null'
                  format: date-time
                  description: 'Required when `mode=schedule`. Must be in the future.'
                limit:
                  type:
                    - integer
                    - 'null'
                  minimum: 1
                  description: 'Cap this wave at N not-yet-sent subscribers. Omit for the whole list.'
      responses:
        '200':
          description: 'The campaign as it now stands. `now` leaves the status alone — the dispatch job flips it to `sending` as its first act, which keeps the trigger idempotent.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/NewsletterCampaign'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /newsletter-assets:
    post:
      operationId: createNewsletterAsset
      summary: 'Upload an image for newsletter HTML'
      description: "Takes either a multipart `file` or a remote `url` to fetch, stores the original (no resizing) on object storage and answers the public URL to embed in an edition's HTML. Requires the `newsletter.write` scope (author newsletter editions, upload assets, send test emails)."
      tags:
        - Newsletter
      security:
        -
          bearerAuth:
            - newsletter.write
      x-required-scope: newsletter.write
      requestBody:
        required: true
        description: 'Exactly one of `file` or `url`.'
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - file
              properties:
                file:
                  type: string
                  format: binary
                  description: 'jpg, jpeg, png, gif, webp or svg. Up to 10 MB.'
          application/json:
            schema:
              type: object
              required:
                - url
              additionalProperties: false
              properties:
                url:
                  type: string
                  format: uri
                  maxLength: 2000
                  description: 'Publicly reachable image URL to fetch and store.'
      responses:
        '201':
          description: 'The stored asset, with the URL to embed.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/NewsletterAsset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      parameters:
        -
          $ref: '#/components/parameters/IdempotencyKey'
  /subscribers:
    post:
      operationId: createSubscriber
      summary: 'Add or update one newsletter contact'
      description: 'Upserts by email, so repeating the call is safe. **Suppression always wins**: an address on the suppression list is never reactivated by ingestion — the answer carries `suppressed: true` and the contact stays opted out. `201` when the row was created, `200` when it already existed. Requires the `subscribers.write` scope (add subscribers, including the Origami push).'
      tags:
        - Subscribers
      security:
        -
          bearerAuth:
            - subscribers.write
      x-required-scope: subscribers.write
      requestBody:
        required: true
        description: 'The contact.'
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              additionalProperties: false
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 255
                  description: 'Lower-cased and trimmed before matching, so casing never creates a duplicate.'
                locale:
                  type:
                    - string
                    - 'null'
                  enum:
                    - en
                    - es
                    - pt
                    - fr
                    - de
                    - it
                    - pl
                    - null
                  description: 'Which language this contact reads. Defaults to the newsletter fallback locale (`en`).'
      responses:
        '200':
          description: 'The contact already existed and was updated.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubscriberEnvelope'
        '201':
          description: 'The contact was created.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubscriberEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      parameters:
        -
          $ref: '#/components/parameters/IdempotencyKey'
  /subscribers/origami:
    post:
      operationId: createOrigamiSubscriber
      summary: 'Register an Origami sign-up'
      description: 'The same upsert as `POST /subscribers`, plus the `is_origami_user` tag. Origami (origami.publica.la) pushes every new user here at first sign-in, so somebody who signed up there and never touched a publica.la funnel still lands on the list. The tag is for segmentation; it does not change who a send reaches. A suppressed address is still tagged and still never reactivated. Requires the `subscribers.write` scope (add subscribers, including the Origami push).'
      tags:
        - Subscribers
      security:
        -
          bearerAuth:
            - subscribers.write
      x-required-scope: subscribers.write
      requestBody:
        required: true
        description: 'The contact.'
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              additionalProperties: false
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 255
                  description: 'Lower-cased and trimmed before matching, so casing never creates a duplicate.'
                locale:
                  type:
                    - string
                    - 'null'
                  enum:
                    - en
                    - es
                    - pt
                    - fr
                    - de
                    - it
                    - pl
                    - null
                  description: 'Which language this contact reads. Defaults to the newsletter fallback locale (`en`).'
      responses:
        '200':
          description: 'The contact already existed and was updated.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubscriberEnvelope'
        '201':
          description: 'The contact was created.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubscriberEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      parameters:
        -
          $ref: '#/components/parameters/IdempotencyKey'
  /subscribers/bulk:
    post:
      operationId: bulkCreateSubscribers
      summary: 'Ingest a batch of newsletter contacts'
      description: 'Up to 5,000 contacts per call, each upserted under the same policy as the single endpoint. Answers a summary rather than the rows. Throttled to 60 calls a minute. Requires the `subscribers.write` scope (add subscribers, including the Origami push). Rate limit: 60 requests per minute, on top of the global policy.'
      tags:
        - Subscribers
      security:
        -
          bearerAuth:
            - subscribers.write
      x-required-scope: subscribers.write
      requestBody:
        required: true
        description: 'The batch.'
        content:
          application/json:
            schema:
              type: object
              required:
                - contacts
              additionalProperties: false
              properties:
                contacts:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items:
                    type: object
                    required:
                      - email
                    additionalProperties: false
                    properties:
                      email:
                        type: string
                        format: email
                        maxLength: 255
                      locale:
                        type:
                          - string
                          - 'null'
                        enum:
                          - en
                          - es
                          - pt
                          - fr
                          - de
                          - it
                          - pl
                          - null
                        description: 'Defaults to the newsletter fallback locale (`en`).'
      responses:
        '200':
          description: 'What the batch did.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - summary
                properties:
                  summary:
                    type: object
                    required:
                      - created
                      - updated
                      - reactivated
                      - skipped_suppressed
                    properties:
                      created:
                        type: integer
                        description: 'Contacts that did not exist before.'
                      updated:
                        type: integer
                        description: 'Contacts that already existed.'
                      reactivated:
                        type: integer
                        description: 'Previously unsubscribed contacts brought back — never a suppressed one.'
                      skipped_suppressed:
                        type: integer
                        description: 'Addresses on the suppression list, left opted out.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      parameters:
        -
          $ref: '#/components/parameters/IdempotencyKey'
  /suppressions/bulk:
    post:
      operationId: bulkSuppress
      summary: 'Suppress a batch of addresses'
      description: 'Puts up to 5,000 addresses on the suppression list and opts out any matching active subscriber, so every future fan-out skips them. **Irreversible in practice** — taking somebody off is deliberately hard to undo — which is why this is its own scope and throttled to 60 calls a minute. Requires the `suppressions.write` scope (suppress email addresses (irreversible)). Rate limit: 60 requests per minute, on top of the global policy.'
      tags:
        - Suppressions
      security:
        -
          bearerAuth:
            - suppressions.write
      x-required-scope: suppressions.write
      requestBody:
        required: true
        description: 'The addresses and why.'
        content:
          application/json:
            schema:
              type: object
              required:
                - emails
              additionalProperties: false
              properties:
                emails:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items:
                    type: string
                    format: email
                    maxLength: 255
                reason:
                  type:
                    - string
                    - 'null'
                  enum:
                    - unsubscribe
                    - bounce
                    - complaint
                    - manual
                    - null
                  description: 'Why these addresses are being suppressed. Defaults to `manual`.'
      responses:
        '200':
          description: 'What the batch did.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - summary
                properties:
                  summary:
                    type: object
                    required:
                      - suppressed
                      - already_suppressed
                      - subscribers_opted_out
                    properties:
                      suppressed:
                        type: integer
                        description: 'Addresses newly added to the list.'
                      already_suppressed:
                        type: integer
                        description: 'Addresses that were on it already.'
                      subscribers_opted_out:
                        type: integer
                        description: 'Active subscribers switched off as a result.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      parameters:
        -
          $ref: '#/components/parameters/IdempotencyKey'
  /releases:
    get:
      operationId: listReleases
      summary: 'List posts (deprecated alias)'
      description: 'Identical to `GET /posts`. **Deprecated**: use the `/posts` equivalent. This alias is kept because a live consumer still publishes through it. Requires the `content.read` scope (read posts and changelog entries).'
      tags:
        - Deprecated
      security:
        -
          bearerAuth:
            - content.read
      x-required-scope: content.read
      deprecated: true
      parameters:
        -
          name: published_only
          in: query
          required: false
          description: 'Only posts that are published and whose `published_at` has passed.'
          schema:
            type: boolean
            default: false
        -
          name: category
          in: query
          required: false
          description: 'Narrow to one category.'
          schema:
            type: string
            enum:
              - product-updates
              - guides
              - case-studies
              - publishing-insights
              - industry
        -
          name: published_after
          in: query
          required: false
          description: 'Only posts published on or after this date.'
          schema:
            type: string
            format: date
        -
          name: published_before
          in: query
          required: false
          description: 'Only posts published on or before this date.'
          schema:
            type: string
            format: date
        -
          name: sort
          in: query
          required: false
          description: 'Column to sort by, descending. Anything else falls back to `published_at` — the value is whitelisted rather than passed through, so it cannot name another column.'
          schema:
            type: string
            enum:
              - published_at
              - created_at
            default: published_at
        -
          name: per_page
          in: query
          required: false
          description: 'Rows per page.'
          schema:
            type: integer
            minimum: 1
            default: 15
        -
          name: page
          in: query
          required: false
          description: 'Which page to read, 1-based.'
          schema:
            type: integer
            minimum: 1
            default: 1
      responses:
        '200':
          description: 'A page of posts.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - links
                  - meta
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Post'
                  links:
                    $ref: '#/components/schemas/PaginationLinks'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    post:
      operationId: createRelease
      summary: 'Create a post (deprecated alias)'
      description: 'Identical to `POST /posts`. **Deprecated**: use the `/posts` equivalent. This alias is kept because a live consumer still publishes through it. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Deprecated
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      deprecated: true
      requestBody:
        required: true
        description: 'The post.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostCreate'
      responses:
        '201':
          description: 'The created post.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/Post'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      parameters:
        -
          $ref: '#/components/parameters/IdempotencyKey'
  '/releases/{post}':
    get:
      operationId: getRelease
      summary: 'Get a post (deprecated alias)'
      description: 'Identical to `GET /posts/{post}`. **Deprecated**: use the `/posts` equivalent. This alias is kept because a live consumer still publishes through it. Requires the `content.read` scope (read posts and changelog entries).'
      tags:
        - Deprecated
      security:
        -
          bearerAuth:
            - content.read
      x-required-scope: content.read
      deprecated: true
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id (the parameter is `post`, not `release`)."
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: 'The post.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/Post'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    put:
      operationId: updateRelease
      summary: 'Update a post (deprecated alias)'
      description: 'Identical to `PUT /posts/{post}`. **Deprecated**: use the `/posts` equivalent. This alias is kept because a live consumer still publishes through it. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Deprecated
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      deprecated: true
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id (the parameter is `post`, not `release`)."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'The fields to change.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostUpdate'
      responses:
        '200':
          description: 'The updated post.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/Post'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    patch:
      operationId: patchRelease
      summary: 'Update a post (deprecated alias, PATCH)'
      description: 'Identical to `PATCH /posts/{post}`. **Deprecated**: use the `/posts` equivalent. This alias is kept because a live consumer still publishes through it. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Deprecated
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      deprecated: true
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id (the parameter is `post`, not `release`)."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'The fields to change.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostUpdate'
      responses:
        '200':
          description: 'The updated post.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/Post'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    delete:
      operationId: deleteRelease
      summary: 'Delete a post (deprecated alias)'
      description: 'Identical to `DELETE /posts/{post}`. **Deprecated**: use the `/posts` equivalent. This alias is kept because a live consumer still publishes through it. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Deprecated
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      deprecated: true
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id (the parameter is `post`, not `release`)."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204':
          description: 'Deleted. No body.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  '/releases/{post}/publish':
    post:
      operationId: publishRelease
      summary: 'Publish a post (deprecated alias)'
      description: 'Identical to `POST /posts/{post}/publish`. **Deprecated**: use the `/posts` equivalent. This alias is kept because a live consumer still publishes through it. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Deprecated
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      deprecated: true
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id (the parameter is `post`, not `release`)."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: 'The published post.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/Post'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  '/releases/{post}/slack-preview':
    post:
      operationId: previewReleaseInSlack
      summary: 'Post a draft preview to Slack (deprecated alias)'
      description: 'Identical to `POST /posts/{post}/slack-preview`. **Deprecated**: use the `/posts` equivalent. This alias is kept because a live consumer still publishes through it. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Deprecated
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      deprecated: true
      parameters:
        -
          name: post
          in: path
          required: true
          description: "The post's numeric id (the parameter is `post`, not `release`)."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: "Slack's reply, verbatim."
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SlackResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /changelog:
    get:
      operationId: listChangelogEntries
      summary: 'List changelog entries'
      description: 'Paginated entries, most recent month first. Requires the `content.read` scope (read posts and changelog entries).'
      tags:
        - Changelog
      security:
        -
          bearerAuth:
            - content.read
      x-required-scope: content.read
      parameters:
        -
          name: published_only
          in: query
          required: false
          description: 'Only published entries.'
          schema:
            type: boolean
            default: false
        -
          name: per_page
          in: query
          required: false
          description: 'Rows per page.'
          schema:
            type: integer
            minimum: 1
            default: 15
        -
          name: page
          in: query
          required: false
          description: 'Which page to read, 1-based.'
          schema:
            type: integer
            minimum: 1
            default: 1
      responses:
        '200':
          description: 'A page of entries.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - links
                  - meta
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ChangelogEntry'
                  links:
                    $ref: '#/components/schemas/PaginationLinks'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    post:
      operationId: createChangelogEntry
      summary: 'Create a changelog entry'
      description: "One entry per month. A draft entry also posts a preview to the team's Slack channel; a Slack failure never fails the request. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries)."
      tags:
        - Changelog
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      requestBody:
        required: true
        description: 'The entry.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangelogEntryCreate'
      responses:
        '201':
          description: 'The created entry.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/ChangelogEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      parameters:
        -
          $ref: '#/components/parameters/IdempotencyKey'
  '/changelog/{changelogEntry}':
    get:
      operationId: getChangelogEntry
      summary: 'Get a changelog entry'
      description: 'One entry by id, with every locale of its sections. Requires the `content.read` scope (read posts and changelog entries).'
      tags:
        - Changelog
      security:
        -
          bearerAuth:
            - content.read
      x-required-scope: content.read
      parameters:
        -
          name: changelogEntry
          in: path
          required: true
          description: "The changelog entry's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: 'The entry.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/ChangelogEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    put:
      operationId: updateChangelogEntry
      summary: 'Update a changelog entry'
      description: "Sending `sections` for one locale replaces that locale's sections and leaves the others alone. Identical to `PATCH`. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries)."
      tags:
        - Changelog
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      parameters:
        -
          name: changelogEntry
          in: path
          required: true
          description: "The changelog entry's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'The fields to change.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangelogEntryUpdate'
      responses:
        '200':
          description: 'The updated entry.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/ChangelogEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    patch:
      operationId: patchChangelogEntry
      summary: 'Update a changelog entry (PATCH)'
      description: 'The same operation as `PUT /changelog/{changelogEntry}`. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Changelog
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      parameters:
        -
          name: changelogEntry
          in: path
          required: true
          description: "The changelog entry's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        description: 'The fields to change.'
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangelogEntryUpdate'
      responses:
        '200':
          description: 'The updated entry.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/ChangelogEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    delete:
      operationId: deleteChangelogEntry
      summary: 'Delete a changelog entry'
      description: 'Deletes the entry outright. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Changelog
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      parameters:
        -
          name: changelogEntry
          in: path
          required: true
          description: "The changelog entry's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204':
          description: 'Deleted. No body.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  '/changelog/{changelogEntry}/publish':
    post:
      operationId: publishChangelogEntry
      summary: 'Publish a changelog entry'
      description: 'Flips `is_published` on and stamps `published_at`. No body. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries).'
      tags:
        - Changelog
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      parameters:
        -
          name: changelogEntry
          in: path
          required: true
          description: "The changelog entry's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: 'The published entry.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: '#/components/schemas/ChangelogEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  '/changelog/{changelogEntry}/slack-preview':
    post:
      operationId: previewChangelogEntryInSlack
      summary: 'Post a changelog preview to Slack'
      description: "Sends a preview of the entry to the team's Slack channel. Answers Slack's own reply, or `{}` where no channel is configured. Requires the `content.write` scope (create, edit, publish and delete posts and changelog entries)."
      tags:
        - Changelog
      security:
        -
          bearerAuth:
            - content.write
      x-required-scope: content.write
      parameters:
        -
          name: changelogEntry
          in: path
          required: true
          description: "The changelog entry's numeric id."
          schema:
            type: integer
            format: int64
            minimum: 1
        -
          $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: "Slack's reply, verbatim."
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
            Idempotency-Replayed:
              $ref: '#/components/headers/Idempotency-Replayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SlackResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /widget/status:
    get:
      operationId: widgetStatus
      summary: 'Unseen product updates for one user'
      description: 'How many published product updates this user has not seen, plus the five most recent — always the five, even once everything is marked seen, so the bell can always open the widget. Only `product-updates` are ever counted or returned; the rest of the blog is not what the widget is for. A read tolerates a missing `Origin` (a server-side fetch of published content), but never a disallowed one. Authenticated by the widget token, not by an API key: sign `{user_id}.{timestamp}` with HMAC-SHA256 under the shared secret and send `{user_id}.{timestamp}.{signature}` as `X-Widget-Token`. A missing or unverifiable token is a 403, and so is an `Origin` that is not on the allow-list — a refusal deliberately carries no CORS headers. Rate limit: 120 requests per minute.'
      tags:
        - Widget
      security:
        -
          widgetToken: []
        -
          widgetTokenQuery: []
      x-origin-allow-list: 'Only the storefront origins configured in `WIDGET_ALLOWED_ORIGINS` are answered. A successful response echoes that one origin in `Access-Control-Allow-Origin` with `Vary: Origin`, never `*`.'
      parameters:
        -
          name: user_id
          in: query
          required: true
          description: 'The platform user the widget is showing. Opaque to this API.'
          schema:
            type: string
            maxLength: 255
        -
          name: locale
          in: query
          required: false
          description: 'Which language to render titles, summaries and URLs in. Defaults to `en`.'
          schema:
            type: string
            maxLength: 10
            default: en
        -
          name: token
          in: query
          required: false
          description: 'The widget token, as an alternative to the `X-Widget-Token` header. The header is preferred.'
          schema:
            type: string
      responses:
        '200':
          description: 'The widget state for this user.'
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WidgetStatus'
        '403':
          $ref: '#/components/responses/WidgetForbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /widget/seen:
    post:
      operationId: widgetMarkSeen
      summary: 'Mark one product update as seen'
      description: 'Records that this user has seen one post. Idempotent per `(user_id, release_id)`. A write **requires** an allow-listed `Origin` header: a browser always sends one, and a caller that can omit it is a caller writing rows under a `user_id` it chose. Authenticated by the widget token, not by an API key: sign `{user_id}.{timestamp}` with HMAC-SHA256 under the shared secret and send `{user_id}.{timestamp}.{signature}` as `X-Widget-Token`. A missing or unverifiable token is a 403, and so is an `Origin` that is not on the allow-list — a refusal deliberately carries no CORS headers. Rate limit: 60 requests per minute.'
      tags:
        - Widget
      security:
        -
          widgetToken: []
        -
          widgetTokenQuery: []
      x-origin-allow-list: 'Only the storefront origins configured in `WIDGET_ALLOWED_ORIGINS` are answered. A successful response echoes that one origin in `Access-Control-Allow-Origin` with `Vary: Origin`, never `*`.'
      parameters:
        -
          name: token
          in: query
          required: false
          description: 'The widget token, as an alternative to the `X-Widget-Token` header. The header is preferred.'
          schema:
            type: string
      requestBody:
        required: true
        description: 'Who saw what.'
        content:
          application/json:
            schema:
              type: object
              required:
                - user_id
                - release_id
              additionalProperties: false
              properties:
                user_id:
                  type: string
                  maxLength: 255
                  description: 'The platform user.'
                release_id:
                  type: integer
                  format: int64
                  minimum: 1
                  description: 'The id of a **published** post. An unpublished or unknown id is a 422.'
      responses:
        '200':
          description: Recorded.
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WidgetOk'
        '403':
          $ref: '#/components/responses/WidgetForbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /widget/seen-all:
    post:
      operationId: widgetMarkAllSeen
      summary: 'Mark every product update as seen'
      description: 'Records every currently unseen published `product-updates` post as seen for this user, in one transaction. Idempotent. Same `Origin` requirement as `POST /widget/seen`. Authenticated by the widget token, not by an API key: sign `{user_id}.{timestamp}` with HMAC-SHA256 under the shared secret and send `{user_id}.{timestamp}.{signature}` as `X-Widget-Token`. A missing or unverifiable token is a 403, and so is an `Origin` that is not on the allow-list — a refusal deliberately carries no CORS headers. Rate limit: 60 requests per minute.'
      tags:
        - Widget
      security:
        -
          widgetToken: []
        -
          widgetTokenQuery: []
      x-origin-allow-list: 'Only the storefront origins configured in `WIDGET_ALLOWED_ORIGINS` are answered. A successful response echoes that one origin in `Access-Control-Allow-Origin` with `Vary: Origin`, never `*`.'
      parameters:
        -
          name: token
          in: query
          required: false
          description: 'The widget token, as an alternative to the `X-Widget-Token` header. The header is preferred.'
          schema:
            type: string
      requestBody:
        required: true
        description: 'Whose widget to clear.'
        content:
          application/json:
            schema:
              type: object
              required:
                - user_id
              additionalProperties: false
              properties:
                user_id:
                  type: string
                  maxLength: 255
                  description: 'The platform user.'
      responses:
        '200':
          description: Recorded.
          headers:
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimit-Policy'
            RateLimit:
              $ref: '#/components/headers/RateLimit'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WidgetOk'
        '403':
          $ref: '#/components/responses/WidgetForbidden'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: rnd_<token>
      description: |-
        A key minted by publica.la for one named consumer: `php artisan rondine:api-client <name> --scopes=content.read,content.write`. The plaintext is shown once and only its hash is stored, so a lost token is replaced rather than recovered.

        The scopes a key carries decide what it may reach, and each operation names the one it needs in `x-required-scope`. A bearer without the scope gets a `403` naming it in `required_scope`, never a `401`.

        Scopes: `content.read` — read posts and changelog entries; `content.write` — create, edit, publish and delete posts and changelog entries; `newsletter.read` — read newsletter audience size and engagement counters; `newsletter.write` — author newsletter editions, upload assets, send test emails; `newsletter.send` — fire an edition at the whole list (irreversible); `subscribers.write` — add subscribers, including the Origami push; `suppressions.write` — suppress email addresses (irreversible); `scheduling.read` — read booking pages; `scheduling.write` — create and edit booking pages.

        There is no token endpoint, no sign-up and no OAuth flow. Ask hello@publica.la.
      x-scopes:
        content.read: 'Read posts and changelog entries'
        content.write: 'Create, edit, publish and delete posts and changelog entries'
        newsletter.read: 'Read newsletter audience size and engagement counters'
        newsletter.write: 'Author newsletter editions, upload assets, send test emails'
        newsletter.send: 'Fire an edition at the whole list (irreversible) Never granted in bulk: asking for it means naming it.'
        subscribers.write: 'Add subscribers, including the Origami push'
        suppressions.write: 'Suppress email addresses (irreversible) Never granted in bulk: asking for it means naming it.'
        scheduling.read: 'Read booking pages'
        scheduling.write: 'Create and edit booking pages'
      x-mint-command: 'php artisan rondine:api-client <name> --scopes=<comma-separated>'
    widgetToken:
      type: apiKey
      in: header
      name: X-Widget-Token
      description: "The notification widget's own door, used instead of a bearer on `/widget/*`. Value is `{user_id}.{timestamp}.{signature}`, where the signature is HMAC-SHA256 of `{user_id}.{timestamp}` under the secret publica.la and the platform share. Verified always — there is no unsigned path — and the timestamp has to be inside the configured TTL."
    widgetTokenQuery:
      type: apiKey
      in: query
      name: token
      description: 'The same widget token in the query string, for a caller that cannot set a header. The header is preferred: a token in a URL ends up in logs.'
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |-
        Optional. A caller-chosen value, 1–255 characters — a uuid is the usual choice — that makes this write safe to retry.

        The first answer is remembered for 24 hours under the caller, the key, the method and the path. A repeat with the same key **and the same body** replays that answer with `Idempotency-Replayed: true` and never reaches the handler. A repeat with the same key and a **different** body is refused with `422` and `code: idempotency_key_reused` — a key is bound to the request it was first sent with.

        What is **not** remembered: a `5xx`, and any refusal about the caller rather than the request — `401`, `403`, `429`. Those are the answers a caller is meant to fix and retry, so a key that met one is still usable. A `422`, `404` or `409` *is* remembered, because the same body will always get the same answer.
      schema:
        type: string
        minLength: 1
        maxLength: 255
      example: 5b2fb1e6-6a04-4c1e-9e9b-6c7c5a3f0f11
  headers:
    RateLimit-Policy:
      description: 'The named policy in force (draft-ietf-httpapi-ratelimit-headers).'
      schema:
        type: string
      example: '"api";q=300;w=60'
    RateLimit:
      description: 'What is left of the quota: `r` remaining requests, `t` seconds until the window resets.'
      schema:
        type: string
      example: '"api";r=299;t=60'
    RateLimit-Limit:
      description: 'Requests allowed in the window. Legacy header, kept beside the two above.'
      schema:
        type: integer
      example: 300
    RateLimit-Remaining:
      description: 'Requests left in the window.'
      schema:
        type: integer
      example: 299
    RateLimit-Reset:
      description: 'Seconds until the window resets.'
      schema:
        type: integer
      example: 60
    Retry-After:
      description: 'Seconds to wait before retrying. Sent on a 429; honour it rather than retrying immediately.'
      schema:
        type: integer
      example: 30
    Idempotency-Replayed:
      description: 'Present and `true` only when this body is a replay of an earlier answer under the same `Idempotency-Key` — the write happened once, on the first call. Absent means this response is the write itself.'
      schema:
        type: string
        enum:
          - 'true'
      example: 'true'
    Cache-Control:
      description: 'On the anonymous `/site/*` operations, the answer is the same for every caller and may be stored by a shared cache. Every other operation is `no-cache, private`.'
      schema:
        type: string
      example: 'public, max-age=300, s-maxage=3600'
  responses:
    Unauthorized:
      description: 'No usable bearer: missing, unknown, or belonging to a revoked key. Note that a bad id answers 404 before this is ever reached, because route-model binding runs first.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            message: Unauthorized
            type: 'https://publica.la/en/developers#error-unauthenticated'
            title: Unauthenticated
            status: 401
            detail: Unauthorized
            code: unauthenticated
            hint: 'Send Authorization: Bearer <key>; keys are issued by publica.la — see https://publica.la/en/developers'
            docs: 'https://publica.la/en/developers#errors'
    Forbidden:
      description: 'The bearer is valid but does not carry the scope this route requires. `required_scope` names it. Retrying will not help.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            message: 'This API key does not carry the required scope.'
            type: 'https://publica.la/en/developers#error-forbidden'
            title: Forbidden
            status: 403
            detail: 'This API key does not carry the required scope.'
            code: forbidden
            hint: 'The key is valid but does not carry the scope this route requires — see https://publica.la/en/developers'
            docs: 'https://publica.la/en/developers#errors'
            required_scope: newsletter.send
    NotFound:
      description: 'No record with that id. Route-model binding runs before authentication, so this answer does not imply the bearer was accepted.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            message: 'Not Found'
            type: 'https://publica.la/en/developers#error-not_found'
            title: 'Not Found'
            status: 404
            detail: 'Not Found'
            code: not_found
            hint: 'Check the path against the OpenAPI description at https://publica.la/openapi.json.'
            docs: 'https://publica.la/en/developers#errors'
    ValidationFailed:
      description: 'The payload was refused. `errors` maps each field to its messages; the same body will always fail. One other 422 travels in this shape and is told apart by `code`: `idempotency_key_reused`, when an `Idempotency-Key` is sent again with a different body.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            message: 'The title field is required.'
            type: 'https://publica.la/en/developers#error-validation_failed'
            title: 'Validation Failed'
            status: 422
            detail: 'The title field is required.'
            code: validation_failed
            hint: 'Read "errors" for the fields that failed and resend.'
            docs: 'https://publica.la/en/developers#errors'
            errors:
              title:
                - 'The title field is required.'
    UnprocessableEntity:
      description: 'Either validation failed (`message` + `errors`) or the operation was refused on its own terms — a campaign already sending or sent, a locale with no body, an image that could not be fetched — which answers a single `error` string. Both shapes are 422.'
      headers:
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimit-Policy'
        RateLimit:
          $ref: '#/components/headers/RateLimit'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
      content:
        application/json:
          schema:
            allOf:
              -
                $ref: '#/components/schemas/Error'
              -
                type: object
                properties:
                  error:
                    type: string
                    description: 'Only on the second shape: one sentence saying why the operation was refused, in place of an `errors` map.'
          examples:
            validation:
              summary: 'Validation failed'
              value:
                message: 'The mode field is required.'
                type: 'https://publica.la/en/developers#error-validation_failed'
                title: 'Validation Failed'
                status: 422
                detail: 'The mode field is required.'
                code: validation_failed
                hint: 'Read "errors" for the fields that failed and resend.'
                docs: 'https://publica.la/en/developers#errors'
                errors:
                  mode:
                    - 'The mode field is required.'
            refused:
              summary: 'Refused on its own terms'
              value:
                message: 'Validation Failed'
                type: 'https://publica.la/en/developers#error-validation_failed'
                title: 'Validation Failed'
                status: 422
                detail: 'Validation Failed'
                code: validation_failed
                hint: 'Read "errors" for the fields that failed and resend.'
                docs: 'https://publica.la/en/developers#errors'
                error: 'Campaign is already sent.'
    SendFailed:
      description: 'The mailer refused the test send. The campaign is untouched. The body carries the shared error envelope **and** the legacy `error` sentence naming what the mailer said.'
      headers:
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimit-Policy'
        RateLimit:
          $ref: '#/components/headers/RateLimit'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
      content:
        application/json:
          schema:
            allOf:
              -
                $ref: '#/components/schemas/Error'
              -
                $ref: '#/components/schemas/LegacyError'
          example:
            message: 'Internal Server Error'
            type: 'https://publica.la/en/developers#error-server_error'
            title: 'Internal Server Error'
            status: 500
            detail: 'Internal Server Error'
            code: server_error
            hint: 'Retry with exponential backoff; if it persists, write to support@publica.la with the time of the request.'
            docs: 'https://publica.la/en/developers#errors'
            error: 'Send failed: Connection could not be established.'
    WidgetForbidden:
      description: 'The widget token was missing or could not be verified, or the `Origin` is not on the allow-list (a write requires one; a read only requires that a present origin be allowed). A refusal deliberately carries **no** CORS headers, so a browser cannot read it — which is itself the signal.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            message: 'Invalid or expired widget token'
            type: 'https://publica.la/en/developers#error-forbidden'
            title: Forbidden
            status: 403
            detail: 'Invalid or expired widget token'
            code: forbidden
            hint: 'This endpoint is not the bearer API: send a valid X-Widget-Token (HMAC-SHA256 of {user_id}.{timestamp} under the shared secret) from an allow-listed Origin — see https://publica.la/en/developers'
            docs: 'https://publica.la/en/developers#errors'
    TooManyRequests:
      description: 'The rate limit was hit. Wait `Retry-After` seconds before retrying — and never blind-retry a send, which is not reversible.'
      headers:
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimit-Policy'
        RateLimit:
          $ref: '#/components/headers/RateLimit'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            message: 'Too Many Attempts.'
            type: 'https://publica.la/en/developers#error-rate_limited'
            title: 'Too Many Requests'
            status: 429
            detail: 'Too Many Attempts.'
            code: rate_limited
            hint: 'Wait the number of seconds in Retry-After and retry; the quota is 300 requests per minute per key.'
            docs: 'https://publica.la/en/developers#errors'
  schemas:
    Error:
      type: object
      description: 'The error envelope. `message` (and `errors` on a 422) is the original contract and is always present; the RFC 9457 problem members sit beside it, so a client can read either without content negotiation.'
      required:
        - message
      properties:
        message:
          type: string
          description: 'Human-readable summary. The original, unchanging field.'
        errors:
          type: object
          description: 'Only on a 422: each rejected field mapped to its messages.'
          additionalProperties:
            type: array
            items:
              type: string
        required_scope:
          type: string
          enum:
            - content.read
            - content.write
            - newsletter.read
            - newsletter.write
            - newsletter.send
            - subscribers.write
            - suppressions.write
            - scheduling.read
            - scheduling.write
          description: 'Only on a 403: the scope this route needs and the key does not carry.'
        type:
          type: string
          format: uri
          description: 'RFC 9457: a URI identifying the problem type.'
        title:
          type: string
          description: 'RFC 9457: a short, stable summary of the problem type.'
        status:
          type: integer
          description: 'RFC 9457: the HTTP status, repeated in the body.'
        detail:
          type: string
          description: 'RFC 9457: what went wrong with this specific request.'
        code:
          type: string
          description: 'A stable machine-readable code for this failure.'
        hint:
          type: string
          description: 'What to do about it — written for an agent deciding whether to retry.'
        docs:
          type: string
          format: uri
          description: 'The page that explains this failure.'
    LegacyError:
      type: object
      description: 'The older refusal shape a few endpoints still answer with: a single `error` sentence, no `errors` map. Kept because live consumers read it.'
      required:
        - error
      properties:
        error:
          type: string
          description: 'Why the operation was refused.'
    PaginationLinks:
      type: object
      description: "The paginator's own links. `prev` and `next` are null at the ends."
      properties:
        first:
          type:
            - string
            - 'null'
          format: uri
        last:
          type:
            - string
            - 'null'
          format: uri
        prev:
          type:
            - string
            - 'null'
          format: uri
        next:
          type:
            - string
            - 'null'
          format: uri
    PaginationMeta:
      type: object
      description: 'Where in the collection this page sits.'
      properties:
        current_page:
          type: integer
        from:
          type:
            - integer
            - 'null'
        last_page:
          type: integer
        path:
          type: string
          format: uri
        per_page:
          type: integer
        to:
          type:
            - integer
            - 'null'
        total:
          type: integer
        links:
          type: array
          description: 'Ready-made pagination links, as the front end renders them.'
          items:
            type: object
            properties:
              url:
                type:
                  - string
                  - 'null'
                format: uri
              label:
                type: string
              active:
                type: boolean
    TranslatedText:
      type: object
      description: 'A translatable field: locale to text, over en, es, pt, fr, de, it, pl. A locale that was never written is absent rather than empty.'
      propertyNames:
        enum:
          - en
          - es
          - pt
          - fr
          - de
          - it
          - pl
      additionalProperties:
        type:
          - string
          - 'null'
      example:
        en: 'Reader accessibility'
        es: 'Accesibilidad del lector'
    SlackResult:
      type: object
      description: 'Slack''s own `chat.postMessage` reply, passed through untouched — `{"ok": true, "channel": …, "ts": …}` on success, `{"ok": false, "error": …}` on a refusal Slack answers 200 to. An empty object means no Slack channel is configured here, which is a normal state and not an error.'
      additionalProperties: true
      properties:
        ok:
          type: boolean
        channel:
          type: string
        ts:
          type: string
        error:
          type: string
    SiteMeta:
      type: object
      description: 'Which language the answer is in and when the origin generated it. These answers are edge-cacheable for an hour, so `generated_at` is how old the copy in hand may be.'
      required:
        - locale
        - generated_at
      properties:
        locale:
          type: string
          enum:
            - en
            - es
            - pt
            - fr
            - de
            - it
            - pl
          description: 'The language this answer is in.'
        generated_at:
          type: string
          format: date-time
          description: 'When the origin built this answer, ISO 8601.'
        count:
          type: integer
          description: 'On a list: how many rows are in `data`.'
        limit:
          type: integer
          description: 'On a list: the cap in force. `count === limit` means there may be more.'
    SitePost:
      type: object
      description: 'One published blog post in one language. `body` is present only when a single post was fetched, and is plain text — the HTML is stripped, so it is quotable as it stands.'
      required:
        - id
        - slug
        - title
        - summary
        - category
        - url
      properties:
        id:
          type: integer
          format: int64
          description: "The post's numeric id."
        slug:
          type: string
          description: 'The slug in the blog URL, and what `GET /site/posts/{slug}` takes.'
        title:
          type: string
          description: 'The headline, in the requested language.'
        summary:
          type: string
          description: 'The standfirst — a paragraph, in the requested language.'
        body:
          type: string
          description: 'The article as plain text. Only on a single-post read.'
        category:
          type: string
          enum:
            - product-updates
            - guides
            - case-studies
            - publishing-insights
            - industry
          description: 'Which kind of post it is.'
        pillar:
          type:
            - string
            - 'null'
          enum:
            - publishers
            - bookshops
            - libraries
            - magazines
            - university-presses
            - authors
            - null
          description: 'Which audience it is written for. Null on a product update.'
        published_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When it went live.'
        url:
          type: string
          format: uri
          description: 'Where a person reads it.'
    SiteChangelogEntry:
      type: object
      description: "One published changelog entry — a month of the product's history, grouped into sections."
      required:
        - id
        - month
        - period
        - sections
        - url
      properties:
        id:
          type: integer
          format: int64
        month:
          type: string
          description: 'The month as a person would read it, in the requested language — "August 2026".'
        period:
          type: string
          description: 'The same month as `YYYY-MM`, for sorting and comparing.'
        date:
          type:
            - string
            - 'null'
          format: date
          description: 'The first day of the month the entry covers.'
        sections:
          type: object
          description: 'Section name (Added, Changed, Deprecated, Removed, Fixed, Security) to the blocks under it, each `{heading, items}`.'
          additionalProperties:
            type: array
            items:
              type: object
              properties:
                heading:
                  type: string
                items:
                  type: array
                  items:
                    type: string
        published_at:
          type:
            - string
            - 'null'
          format: date-time
        url:
          type: string
          format: uri
          description: 'The changelog page this entry is on.'
    SitePricing:
      type: object
      description: 'The plans as the pricing page states them. Read from the same language files the page renders, so the two cannot disagree.'
      required:
        - currency
        - plans
        - url
      properties:
        currency:
          type: string
          const: USD
          description: 'Every price on this API is in US dollars.'
        plans:
          type: array
          description: 'Starter, Growth and Enterprise, in that order.'
          items:
            type: object
            required:
              - key
              - name
              - monthly_price
            properties:
              key:
                type: string
                description: 'A stable identifier — `starter`, `growth`, `enterprise`.'
              name:
                type: string
              description:
                type: string
                description: 'Who the plan is for.'
              monthly_price:
                type: string
                description: 'As written, including the currency symbol and any "From" — `$20`, `From $492`.'
              annual_price:
                type: string
                description: 'The same plan paid yearly.'
              key_features:
                type: array
                items:
                  type: string
                description: 'The two lines the pricing card leads with.'
              features:
                type: array
                items:
                  type: string
                description: 'The full list under them.'
        url:
          type: string
          format: uri
          description: 'The pricing page, in the requested language.'
    SiteOverview:
      type: object
      description: 'What publica.la is, and — as importantly — what it is not for. `not_for` exists because the commonest wrong answer an agent gives about a platform is recommending it for something it does not do.'
      required:
        - name
        - tagline
        - description
        - url
        - when_to_use
        - not_for
      properties:
        name:
          type: string
          const: publica.la
        tagline:
          type: string
        description:
          type: string
          description: 'A paragraph: the whole of what the platform does.'
        locale:
          type: string
          enum:
            - en
            - es
            - pt
            - fr
            - de
            - it
            - pl
          description: 'The language the URLs below are in. The prose itself is English — inventing a translation of "what publica.la is not for" would put claims nobody wrote in front of an agent.'
        url:
          type: string
          format: uri
          description: "The site's home page in that language."
        when_to_use:
          type: array
          items:
            type: string
          description: 'The cases publica.la is the right answer for.'
        not_for:
          type: array
          items:
            type: string
          description: 'The cases it is not, said plainly.'
        solutions:
          type: array
          description: 'The audience pages, each with its URL.'
          items:
            type: object
            required:
              - name
              - url
            properties:
              name:
                type: string
              url:
                type: string
                format: uri
        features:
          type: array
          description: 'The feature pages, each with its URL.'
          items:
            type: object
            required:
              - name
              - url
            properties:
              name:
                type: string
              url:
                type: string
                format: uri
        contacts:
          type: object
          description: 'Who to write to. There is no phone number and no street address on this API.'
          additionalProperties:
            type: string
        machine_entry_points:
          type: object
          description: 'Every other machine-readable door: `llms.txt`, this description, the developer portal, the MCP server.'
          additionalProperties:
            type: string
    Post:
      type: object
      description: 'A blog post. One table backs the whole blog; `category` says which kind of post it is.'
      required:
        - id
        - slug
        - category
        - title
      properties:
        id:
          type: integer
          format: int64
        slug:
          type: string
          description: 'The URL slug. Published at `/{locale}/blog/{slug}`.'
        category:
          type: string
          enum:
            - product-updates
            - guides
            - case-studies
            - publishing-insights
            - industry
        pillar:
          type:
            - string
            - 'null'
          enum:
            - publishers
            - bookshops
            - libraries
            - magazines
            - university-presses
            - authors
            - null
        subcategory:
          type:
            - string
            - 'null'
          enum:
            - feature
            - announcement
            - improvement
            - null
        version:
          type:
            - string
            - 'null'
        author_id:
          type:
            - integer
            - 'null'
          format: int64
        author:
          oneOf:
            -
              $ref: '#/components/schemas/PostAuthor'
            -
              type: 'null'
          description: 'The author, loaded on the list, create, read and update operations. **Absent** — not null — from the answers to `publish` and `cover-image`, which do not load the relation; read the post back if you need it there.'
        published_at:
          type:
            - string
            - 'null'
          format: date
          description: 'Date only, not a timestamp.'
        is_published:
          type: boolean
        cover_image:
          type:
            - string
            - 'null'
        og_image:
          type:
            - string
            - 'null'
        title:
          $ref: '#/components/schemas/TranslatedText'
        summary:
          $ref: '#/components/schemas/TranslatedText'
        body:
          $ref: '#/components/schemas/TranslatedText'
        seo_title:
          $ref: '#/components/schemas/TranslatedText'
        seo_description:
          $ref: '#/components/schemas/TranslatedText'
        created_at:
          type:
            - string
            - 'null'
          format: date-time
        updated_at:
          type:
            - string
            - 'null'
          format: date-time
    PostAuthor:
      type: object
      description: "The post's author, as the blog shows them."
      properties:
        id:
          type: integer
          format: int64
        slug:
          type:
            - string
            - 'null'
        name:
          type:
            - string
            - 'null'
        avatar:
          type:
            - string
            - 'null'
        role:
          $ref: '#/components/schemas/TranslatedText'
        bio:
          $ref: '#/components/schemas/TranslatedText'
        twitter_url:
          type:
            - string
            - 'null'
          format: uri
        linkedin_url:
          type:
            - string
            - 'null'
          format: uri
        website_url:
          type:
            - string
            - 'null'
          format: uri
        email:
          type:
            - string
            - 'null'
          format: email
    ChangelogEntry:
      type: object
      description: 'One month of the changelog.'
      required:
        - id
        - sections
      properties:
        id:
          type: integer
          format: int64
        date:
          type:
            - string
            - 'null'
          format: date
          description: 'The month this entry covers.'
        sections:
          type: object
          description: 'Locale to section name (Added, Changed, Fixed…) to its `{heading, items}` blocks.'
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type: object
            additionalProperties:
              type: array
              items:
                $ref: '#/components/schemas/ChangelogBlock'
          example:
            en:
              Added:
                -
                  heading: Footnotes
                  items:
                    - 'In-place footnotes in the reader'
        is_published:
          type: boolean
        published_at:
          type:
            - string
            - 'null'
          format: date
        created_at:
          type:
            - string
            - 'null'
          format: date-time
        updated_at:
          type:
            - string
            - 'null'
          format: date-time
    ChangelogBlock:
      type: object
      description: 'One block inside a changelog section: a heading and the lines under it.'
      required:
        - heading
        - items
      properties:
        heading:
          type: string
          description: 'Translated per locale, because the block lives inside a locale.'
        items:
          type: array
          minItems: 1
          items:
            type: string
    NewsletterCampaign:
      type: object
      description: "A newsletter edition — sent once to the whole sendable list, segmented by each contact's locale."
      required:
        - id
        - slug
        - status
      properties:
        id:
          type: integer
          format: int64
        slug:
          type: string
        status:
          type: string
          enum:
            - draft
            - scheduled
            - sending
            - sent
            - cancelled
          description: '`sending` and `sent` are locked: the edition can no longer be edited or deleted.'
        from_name:
          type:
            - string
            - 'null'
        from_email:
          type:
            - string
            - 'null'
          format: email
        subject:
          $ref: '#/components/schemas/TranslatedText'
        preheader:
          $ref: '#/components/schemas/TranslatedText'
        body_html:
          $ref: '#/components/schemas/TranslatedText'
        scheduled_at:
          type:
            - string
            - 'null'
          format: date-time
        sent_at:
          type:
            - string
            - 'null'
          format: date-time
        stats:
          oneOf:
            -
              $ref: '#/components/schemas/NewsletterCampaignStats'
            -
              type: 'null'
          description: 'Present when a single campaign is read, absent from the list.'
        created_at:
          type:
            - string
            - 'null'
          format: date-time
        updated_at:
          type:
            - string
            - 'null'
          format: date-time
    NewsletterCampaignStats:
      type: object
      description: 'Per-campaign counters, aggregated from its send records.'
      properties:
        total:
          type: integer
          description: 'Send records written for this campaign.'
        sent:
          type: integer
          description: 'Handed to the mailer.'
        failed:
          type: integer
          description: 'The mailer refused.'
        skipped:
          type: integer
          description: 'Skipped — suppressed or unsubscribed before the send reached them.'
        delivered:
          type: integer
          description: 'The provider confirmed delivery.'
        opened:
          type: integer
          description: 'Opened at least once.'
        clicked:
          type: integer
          description: 'A link was clicked.'
        bounced:
          type: integer
          description: Bounced.
        complained:
          type: integer
          description: 'Marked as spam.'
    NewsletterAsset:
      type: object
      description: "An image stored for use inside an edition's HTML. The original, never resized."
      required:
        - id
        - url
      properties:
        id:
          type: integer
          format: int64
        filename:
          type:
            - string
            - 'null'
          description: 'The name it arrived under, kept for humans. Not the stored path.'
        url:
          type: string
          format: uri
          description: 'The public URL to embed.'
        mime:
          type:
            - string
            - 'null'
        size:
          type:
            - integer
            - 'null'
          description: Bytes.
        created_at:
          type:
            - string
            - 'null'
          format: date-time
    Subscriber:
      type: object
      description: 'A newsletter contact. Deliberately minimal: an address, the language it reads, and when it opted in or out.'
      required:
        - id
        - email
      properties:
        id:
          type: integer
          format: int64
        email:
          type: string
          format: email
        locale:
          type:
            - string
            - 'null'
          enum:
            - en
            - es
            - pt
            - fr
            - de
            - it
            - pl
            - null
        active:
          type: boolean
          description: 'True while `unsubscribed_at` is null. Suppression is a separate list and is not visible here.'
        subscribed_at:
          type:
            - string
            - 'null'
          format: date-time
        unsubscribed_at:
          type:
            - string
            - 'null'
          format: date-time
    SubscriberEnvelope:
      type: object
      description: 'The upsert answer: the contact, plus whether the address is on the suppression list. A suppressed address is never reactivated, so `suppressed: true` with `active: false` is the ingestion working as intended.'
      required:
        - data
        - suppressed
      properties:
        data:
          $ref: '#/components/schemas/Subscriber'
        suppressed:
          type: boolean
          description: 'True when the address is suppressed and was therefore left opted out.'
    NewsletterStats:
      type: object
      description: "The newsletter module's read-only analytics. Not wrapped in `data`."
      required:
        - audience
        - campaigns
      properties:
        audience:
          type: object
          required:
            - subscribers
            - suppressions
            - sendable
            - origami_users
            - by_locale
            - by_email_language
          properties:
            subscribers:
              type: object
              properties:
                total:
                  type: integer
                active:
                  type: integer
                  description: 'Not unsubscribed. Suppression is counted separately.'
                unsubscribed:
                  type: integer
            suppressions:
              type: integer
              description: 'Rows on the suppression list, which is not limited to subscribers.'
            sendable:
              type: integer
              description: 'Active **and** not suppressed — who a send would actually reach.'
            origami_users:
              type: integer
              description: 'Sendable contacts tagged as Origami users.'
            by_locale:
              type: object
              description: 'Sendable contacts per stored locale, largest first.'
              additionalProperties:
                type: integer
            by_email_language:
              type: object
              description: 'What each contact would actually receive once the send falls back: Spanish for `es`, English for everyone else.'
              properties:
                es:
                  type: integer
                en:
                  type: integer
        campaigns:
          type: array
          description: 'The most recent editions, newest first.'
          items:
            type: object
            properties:
              id:
                type: integer
                format: int64
              slug:
                type: string
              status:
                type: string
                enum:
                  - draft
                  - scheduled
                  - sending
                  - sent
                  - cancelled
              subject:
                $ref: '#/components/schemas/TranslatedText'
              scheduled_at:
                type:
                  - string
                  - 'null'
                format: date-time
              sent_at:
                type:
                  - string
                  - 'null'
                format: date-time
              sends:
                $ref: '#/components/schemas/NewsletterCampaignStats'
              rates:
                type: object
                description: 'Percentages, one decimal place. Each is 0 where its denominator is 0 — read a rate of 0 alongside `sends`, never on its own.'
                properties:
                  delivery:
                    type: number
                    description: 'delivered ÷ sent.'
                  open:
                    type: number
                    description: 'opened ÷ delivered.'
                  click:
                    type: number
                    description: 'clicked ÷ delivered.'
                  bounce:
                    type: number
                    description: 'bounced ÷ sent.'
                  complaint:
                    type: number
                    description: 'complained ÷ delivered.'
    EventType:
      type: object
      description: 'A booking page: the row, plus the three things only the application can answer — which URLs it responds on, whether it is really taking bookings, and who is in the pool.'
      required:
        - id
        - slug
        - kind
        - name
      properties:
        id:
          type: integer
          format: int64
        slug:
          type: string
        kind:
          type: string
          enum:
            - personal
            - catalogue
          description: 'Derived from `owner_id`: `personal` is booked at `/meet/{slug}`, `catalogue` at `/book/{slug}`.'
        owner_id:
          type:
            - integer
            - 'null'
          format: int64
        is_active:
          type: boolean
        name:
          $ref: '#/components/schemas/TranslatedText'
        description:
          $ref: '#/components/schemas/TranslatedText'
        scheduling_type:
          type: string
          enum:
            - personal
            - round_robin
            - collective
            - group
        duration_minutes:
          type: integer
        duration_options:
          type:
            - array
            - 'null'
          items:
            type: integer
        slot_interval_minutes:
          type:
            - integer
            - 'null'
        buffer_before_minutes:
          type: integer
        buffer_after_minutes:
          type: integer
        minimum_notice_minutes:
          type: integer
        daily_limit:
          type:
            - integer
            - 'null'
        seats_per_slot:
          type:
            - integer
            - 'null'
        language_source:
          type: string
          enum:
            - listed
            - hosts
        offered_languages:
          type: array
          items:
            type: string
          description: 'The list as stored, so a round-trip is lossless.'
        languages_on_offer:
          type: array
          items:
            type: string
          description: 'What the page actually offers today. Differs from `offered_languages` on a page reading its languages off its hosts.'
        location_type:
          type: string
          enum:
            - google_meet
            - in_person
        location:
          $ref: '#/components/schemas/TranslatedText'
        hours_source:
          type: string
          enum:
            - page
            - hosts
        weekly_hours:
          type: array
          items:
            $ref: '#/components/schemas/WeeklyHourWindow'
          description: 'Normalised and sorted.'
        event_hours:
          type: array
          items:
            $ref: '#/components/schemas/EventHourWindow'
          description: 'Normalised and sorted; rows the slot engine would have dropped are already gone.'
        available_from:
          type:
            - string
            - 'null'
          format: date
        available_until:
          type:
            - string
            - 'null'
          format: date
        timezone:
          type:
            - string
            - 'null'
        collect_details_first:
          type: boolean
          description: 'The stored flag, so a round-trip is lossless.'
        collects_details_first:
          type: boolean
          description: 'What the page actually does. An event calendar always shows its times first, whatever the flag says.'
        questions:
          type: array
          items:
            $ref: '#/components/schemas/QuestionDefinition'
        brand_display:
          type:
            - string
            - 'null'
          enum:
            - photo
            - logo
            - none
            - null
        public_urls:
          type: object
          description: 'Locale to the URL the page answers on. Only a language somebody actually wrote a name in gets a link.'
          additionalProperties:
            type: string
            format: uri
        is_live:
          type: boolean
          description: 'Whether handing those URLs out is a promise the page can keep: switched on, inside its dates, with at least one host whose calendar is connected.'
        hosts:
          type: array
          items:
            $ref: '#/components/schemas/EventTypeHost'
          description: 'The pool. Present on every endpoint here.'
        bookings_count:
          type: integer
          description: 'Present when the count was loaded — on read, and it is what makes `DELETE` deactivate instead of delete.'
        created_at:
          type:
            - string
            - 'null'
          format: date-time
        updated_at:
          type:
            - string
            - 'null'
          format: date-time
    EventTypeHost:
      type: object
      description: 'A host in the pool.'
      properties:
        id:
          type: integer
          format: int64
        name:
          type:
            - string
            - 'null'
        email:
          type:
            - string
            - 'null'
          format: email
        weight:
          type: integer
          minimum: 1
          description: 'Round-robin weight. Anything missing or unreadable reads as 1 — a weight of 0 would drop the host out of the rotation entirely.'
        spoken_languages:
          type: array
          items:
            type: string
          description: 'The languages this host can hold a meeting in.'
        calendar_connected:
          type: boolean
          description: 'Whether their calendar is connected, which is what makes them bookable at all.'
    EventTypeDeactivated:
      type: object
      description: 'The answer when a `DELETE` hit a page that has bookings: it was switched off instead, because bookings cascade from it.'
      required:
        - deleted
        - deactivated
        - message
      properties:
        deleted:
          type: boolean
          const: false
        deactivated:
          type: boolean
          const: true
        message:
          type: string
    WeeklyHourWindow:
      type: object
      description: 'One window of one weekday. A window that ends before it starts, or overlaps the one above it, is refused rather than silently dropped.'
      required:
        - day
        - start
        - end
      properties:
        day:
          type: string
          enum:
            - monday
            - tuesday
            - wednesday
            - thursday
            - friday
            - saturday
            - sunday
        start:
          type: string
          pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'
          description: '`HH:MM`, read on the clock the page resolves to.'
        end:
          type: string
          pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'
          description: '`HH:MM`, after `start`.'
    EventHourWindow:
      type: object
      description: 'Opening hours for an event. A row with a `date` replaces the undated rows for that date; an undated row is the ordinary day.'
      required:
        - start
        - end
      properties:
        date:
          type:
            - string
            - 'null'
          format: date
        start:
          type: string
          pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'
        end:
          type: string
          pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'
    QuestionDefinition:
      type: object
      description: 'One question the booking form asks on top of first name, last name and email — which are always asked.'
      required:
        - key
        - label
      properties:
        key:
          type: string
          maxLength: 50
          pattern: '^[A-Za-z0-9_-]+$'
          description: 'Stored on the booking under this key, and prefillable from the URL as `?{key}=…`.'
        type:
          type: string
          enum:
            - text
            - textarea
            - select
            - phone
          default: text
        required:
          type: boolean
          default: false
        label:
          type: object
          description: 'What the visitor reads. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were. English (`en`) is what every other locale falls back to, so it has to be there.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type: string
            maxLength: 200
          required:
            - en
        options:
          type:
            - array
            - 'null'
          description: 'Only for `select`. The **value** is what is stored and what validation accepts; the label is only what is shown.'
          items:
            type: object
            required:
              - value
              - label
            properties:
              value:
                type: string
                maxLength: 100
              label:
                type: object
                description: 'What the visitor reads for this option. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were. English (`en`) is what every other locale falls back to, so it has to be there.'
                minProperties: 1
                propertyNames:
                  enum:
                    - en
                    - es
                    - pt
                    - fr
                    - de
                    - it
                    - pl
                additionalProperties:
                  type: string
                  maxLength: 200
                required:
                  - en
    WidgetStatus:
      type: object
      description: 'What the notification widget shows one user. Only published `product-updates` posts are ever counted or listed.'
      required:
        - has_unseen
        - unseen_count
        - latest
        - releases
      properties:
        has_unseen:
          type: boolean
        unseen_count:
          type: integer
        site_url:
          type: string
          format: uri
          description: 'The site root the URLs below were built from. Absent when there are no posts at all.'
        latest:
          oneOf:
            -
              $ref: '#/components/schemas/WidgetRelease'
            -
              type: 'null'
          description: 'The most recent post, or null when there are none.'
        releases:
          type: array
          maxItems: 5
          items:
            $ref: '#/components/schemas/WidgetRelease'
          description: 'The five most recent — always returned, even once everything is seen, so the bell can always open the widget.'
    WidgetRelease:
      type: object
      description: 'One product update, rendered for the requested locale with an English fallback.'
      properties:
        id:
          type: integer
          format: int64
          description: 'Pass this back as `release_id` to `POST /widget/seen`.'
        slug:
          type: string
        version:
          type:
            - string
            - 'null'
        published_at:
          type:
            - string
            - 'null'
          format: date
        cover_image:
          type:
            - string
            - 'null'
          format: uri
          description: 'Absolute URL.'
        url:
          type: string
          format: uri
          description: 'The post on the blog, in the requested locale — `/{locale}/blog/{slug}`.'
        title:
          type:
            - string
            - 'null'
        summary:
          type:
            - string
            - 'null'
    WidgetOk:
      type: object
      description: 'The write landed.'
      required:
        - ok
      properties:
        ok:
          type: boolean
          const: true
    PostCreate:
      type: object
      required:
        - title
      additionalProperties: false
      description: 'A new post. Only `title` is required; everything else has a default or is optional.'
      properties:
        slug:
          type: string
          maxLength: 255
          description: 'URL slug, unique across posts. Generated from the English title when omitted.'
        category:
          type: string
          enum:
            - product-updates
            - guides
            - case-studies
            - publishing-insights
            - industry
          default: product-updates
          description: 'Which kind of post this is. Omitting it keeps the database default, `product-updates` — existing consumers depend on that.'
        pillar:
          type:
            - string
            - 'null'
          enum:
            - publishers
            - bookshops
            - libraries
            - magazines
            - university-presses
            - authors
            - null
          description: 'Which audience the post is written for. Product updates carry none.'
        subcategory:
          type:
            - string
            - 'null'
          enum:
            - feature
            - announcement
            - improvement
            - null
          description: 'Only meaningful on `product-updates`.'
        version:
          type:
            - string
            - 'null'
          maxLength: 255
          description: 'Platform version this update belongs to. Only meaningful on `product-updates`.'
        author_id:
          type:
            - integer
            - 'null'
          format: int64
          description: "An existing author's id. Mutually exclusive with `author_email`."
        author_email:
          type:
            - string
            - 'null'
          format: email
          description: "An existing author's email — human-friendly and stable across environments; it is resolved to `author_id`. Sending both is a 422 unless they agree."
        published_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When the post goes (or went) live.'
        is_published:
          type: boolean
          default: false
          description: 'Leave false to keep it a draft; `POST /posts/{post}/publish` is the other way to flip it.'
        cover_image:
          type:
            - string
            - 'null'
          maxLength: 500
          description: 'Cover image URL or path. `POST /posts/{post}/cover-image` sets it from a remote image instead.'
        og_image:
          type:
            - string
            - 'null'
          maxLength: 500
          description: 'Social preview image. Falls back to the cover.'
        title:
          type: object
          description: 'The post title. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type: string
            maxLength: 500
        summary:
          type: object
          description: 'One-paragraph summary, shown in listings and feeds. Links to login-gated publica.la hosts are refused. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
            maxLength: 1000
        body:
          type: object
          description: 'The post body (HTML). Links to login-gated publica.la hosts are refused. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
        seo_title:
          type: object
          description: 'SEO title. Keep it to 60 characters. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
            maxLength: 255
        seo_description:
          type: object
          description: 'SEO meta description. Keep it to 160 characters. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
            maxLength: 500
    PostUpdate:
      type: object
      required: []
      additionalProperties: false
      description: 'The fields to change. Every one is optional, and `slug` cannot be changed here.'
      properties:
        category:
          type: string
          enum:
            - product-updates
            - guides
            - case-studies
            - publishing-insights
            - industry
          default: product-updates
          description: 'Which kind of post this is. Omitting it keeps the database default, `product-updates` — existing consumers depend on that.'
        pillar:
          type:
            - string
            - 'null'
          enum:
            - publishers
            - bookshops
            - libraries
            - magazines
            - university-presses
            - authors
            - null
          description: 'Which audience the post is written for. Product updates carry none.'
        subcategory:
          type:
            - string
            - 'null'
          enum:
            - feature
            - announcement
            - improvement
            - null
          description: 'Only meaningful on `product-updates`.'
        version:
          type:
            - string
            - 'null'
          maxLength: 255
          description: 'Platform version this update belongs to. Only meaningful on `product-updates`.'
        author_id:
          type:
            - integer
            - 'null'
          format: int64
          description: "An existing author's id. Mutually exclusive with `author_email`."
        author_email:
          type:
            - string
            - 'null'
          format: email
          description: "An existing author's email — human-friendly and stable across environments; it is resolved to `author_id`. Sending both is a 422 unless they agree."
        published_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When the post goes (or went) live.'
        is_published:
          type: boolean
          default: false
          description: 'Leave false to keep it a draft; `POST /posts/{post}/publish` is the other way to flip it.'
        cover_image:
          type:
            - string
            - 'null'
          maxLength: 500
          description: 'Cover image URL or path. `POST /posts/{post}/cover-image` sets it from a remote image instead.'
        og_image:
          type:
            - string
            - 'null'
          maxLength: 500
          description: 'Social preview image. Falls back to the cover.'
        title:
          type: object
          description: 'The post title. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type: string
            maxLength: 500
        summary:
          type: object
          description: 'One-paragraph summary, shown in listings and feeds. Links to login-gated publica.la hosts are refused. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
            maxLength: 1000
        body:
          type: object
          description: 'The post body (HTML). Links to login-gated publica.la hosts are refused. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
        seo_title:
          type: object
          description: 'SEO title. Keep it to 60 characters. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
            maxLength: 255
        seo_description:
          type: object
          description: 'SEO meta description. Keep it to 160 characters. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
            maxLength: 500
    ChangelogEntryCreate:
      type: object
      required:
        - date
        - sections
      additionalProperties: false
      description: 'One entry per month. `sections` is a locale map of section name (Added, Changed, Deprecated, Removed, Fixed, Security) to a list of `{heading, items}` blocks.'
      properties:
        date:
          type: string
          format: date
          description: 'The month this entry covers — any date inside it.'
        sections:
          type: object
          description: 'Keyed by locale (en, es, pt, fr, de, it, pl), then by section name. Sending one locale replaces that locale and leaves the others alone.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type: object
            description: 'Section name (Added, Changed, Fixed…) to its blocks.'
            additionalProperties:
              type: array
              items:
                $ref: '#/components/schemas/ChangelogBlock'
        is_published:
          type: boolean
          default: false
        published_at:
          type:
            - string
            - 'null'
          format: date-time
    ChangelogEntryUpdate:
      type: object
      required: []
      additionalProperties: false
      description: 'One entry per month. `sections` is a locale map of section name (Added, Changed, Deprecated, Removed, Fixed, Security) to a list of `{heading, items}` blocks.'
      properties:
        date:
          type: string
          format: date
          description: 'The month this entry covers — any date inside it.'
        sections:
          type: object
          description: 'Keyed by locale (en, es, pt, fr, de, it, pl), then by section name. Sending one locale replaces that locale and leaves the others alone.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type: object
            description: 'Section name (Added, Changed, Fixed…) to its blocks.'
            additionalProperties:
              type: array
              items:
                $ref: '#/components/schemas/ChangelogBlock'
        is_published:
          type: boolean
          default: false
        published_at:
          type:
            - string
            - 'null'
          format: date-time
    EventTypeCreate:
      type: object
      required:
        - slug
        - name
      additionalProperties: false
      description: 'A new booking page. `slug` and an English `name` are required; everything else has a default.'
      properties:
        slug:
          type: string
          maxLength: 255
          pattern: '^[A-Za-z0-9_-]+$'
          description: 'The URL slug, unique across personal and catalogue pages alike — a slug names exactly one meeting.'
        name:
          type: object
          description: 'The meeting name, as the visitor reads it. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were. English (`en`) is what every other locale falls back to, so it has to be there.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type: string
            maxLength: 150
          required:
            - en
        description:
          type: object
          description: 'One line under the name. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
            maxLength: 300
        owner_id:
          type:
            - integer
            - 'null'
          format: int64
          description: "Set it and the page is that host's own personal meeting (`/meet/{slug}`); leave it null and it is the organisation's catalogue (`/book/{slug}`)."
        is_active:
          type: boolean
          description: 'Off takes the page out of every surface without deleting it.'
        scheduling_type:
          type: string
          enum:
            - personal
            - round_robin
            - collective
            - group
          description: '`round_robin` routes to the least-booked host; `personal` is one host; `collective` needs every host free at once; `group` shares a slot between attendees.'
        brand_display:
          type: string
          enum:
            - photo
            - logo
            - none
          description: 'What the standalone page puts above the card. A catalogue page only takes `logo` or `none` — a face belongs to a person.'
        duration_minutes:
          type: integer
          minimum: 5
          maximum: 480
          description: 'The default length, always offered.'
        duration_options:
          type:
            - array
            - 'null'
          items:
            type: integer
            minimum: 5
            maximum: 480
          description: 'Extra lengths offered on top of the default. Null or empty means one length and no choice shown.'
        slot_interval_minutes:
          type:
            - integer
            - 'null'
          minimum: 5
          maximum: 480
          description: 'How often a slot starts, aligned to the clock. Null steps by the duration.'
        buffer_before_minutes:
          type: integer
          minimum: 0
          maximum: 120
          description: 'Time kept clear before a booking. Buffers pad bookings; they do not thin out the grid.'
        buffer_after_minutes:
          type: integer
          minimum: 0
          maximum: 120
          description: 'Time kept clear after a booking.'
        minimum_notice_minutes:
          type: integer
          minimum: 0
          description: 'How far ahead a visitor must book.'
        daily_limit:
          type:
            - integer
            - 'null'
          minimum: 1
          description: 'Most bookings per day. Null is unlimited.'
        seats_per_slot:
          type:
            - integer
            - 'null'
          minimum: 2
          description: 'Only meaningful for `group`. Null is unlimited.'
        offered_languages:
          type:
            - array
            - 'null'
          items:
            type: string
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          description: 'The languages the meeting can be held in. Kept whichever `language_source` is in force, so switching back and forth loses nothing. Declaring none means every language.'
        language_source:
          type: string
          enum:
            - listed
            - hosts
          description: '`listed` offers exactly the list above; `hosts` offers the union of what the bookable pool has declared, so a language is never promised without somebody who can hold the meeting in it.'
        location_type:
          type: string
          enum:
            - google_meet
            - in_person
          description: '`in_person` replaces the video link with the address everywhere — page, calendar event, emails, `.ics`.'
        location:
          type: object
          description: 'The address, when the meeting happens somewhere rather than in a video room. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
            maxLength: 200
        hours_source:
          type: string
          enum:
            - page
            - hosts
          description: "`page` slices the week written below; `hosts` reads each host's own week, on their own clock, falling back to the page's for a host who has not written one."
        weekly_hours:
          type:
            - array
            - 'null'
          items:
            $ref: '#/components/schemas/WeeklyHourWindow'
          description: 'The week the page is open. In `hosts` mode this stays as the per-host fallback.'
        event_hours:
          type:
            - array
            - 'null'
          items:
            $ref: '#/components/schemas/EventHourWindow'
          description: 'Opening hours for a real event, overriding `weekly_hours` on the dates they cover. An undated row is the ordinary day. Setting any of these **requires** `timezone`.'
        available_from:
          type:
            - string
            - 'null'
          format: date
          description: 'First bookable date. Together with `available_until` this pins the page to a real event and overrides the rolling horizon.'
        available_until:
          type:
            - string
            - 'null'
          format: date
          description: 'Last bookable date. Must not precede `available_from`.'
        timezone:
          type:
            - string
            - 'null'
          description: 'An IANA timezone. Required once `event_hours` are set: one clock for everyone, or the same "10:00" lands at a different instant per host.'
        collect_details_first:
          type: boolean
          description: 'Ask the questions before showing the times. Ignored on a page with event dates — those always show the times first.'
        questions:
          type:
            - array
            - 'null'
          items:
            $ref: '#/components/schemas/QuestionDefinition'
          description: 'What the booking form asks on top of first name, last name and email, which are always asked.'
        host_ids:
          type: array
          items:
            type: integer
            format: int64
          description: 'The host pool, as user ids. Not a column — it is synced as a relation. Omit on an update to leave the pool alone; on a personal meeting it is overruled back to the owner.'
    EventTypeUpdate:
      type: object
      required: []
      additionalProperties: false
      description: 'The fields to change. Every one is optional.'
      properties:
        slug:
          type: string
          maxLength: 255
          pattern: '^[A-Za-z0-9_-]+$'
          description: 'The URL slug. Must stay unique against every other page.'
        name:
          type: object
          description: 'The meeting name, as the visitor reads it. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type: string
            maxLength: 150
        description:
          type: object
          description: 'One line under the name. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
            maxLength: 300
        owner_id:
          type:
            - integer
            - 'null'
          format: int64
          description: "Set it and the page is that host's own personal meeting (`/meet/{slug}`); leave it null and it is the organisation's catalogue (`/book/{slug}`)."
        is_active:
          type: boolean
          description: 'Off takes the page out of every surface without deleting it.'
        scheduling_type:
          type: string
          enum:
            - personal
            - round_robin
            - collective
            - group
          description: '`round_robin` routes to the least-booked host; `personal` is one host; `collective` needs every host free at once; `group` shares a slot between attendees.'
        brand_display:
          type: string
          enum:
            - photo
            - logo
            - none
          description: 'What the standalone page puts above the card. A catalogue page only takes `logo` or `none` — a face belongs to a person.'
        duration_minutes:
          type: integer
          minimum: 5
          maximum: 480
          description: 'The default length, always offered.'
        duration_options:
          type:
            - array
            - 'null'
          items:
            type: integer
            minimum: 5
            maximum: 480
          description: 'Extra lengths offered on top of the default. Null or empty means one length and no choice shown.'
        slot_interval_minutes:
          type:
            - integer
            - 'null'
          minimum: 5
          maximum: 480
          description: 'How often a slot starts, aligned to the clock. Null steps by the duration.'
        buffer_before_minutes:
          type: integer
          minimum: 0
          maximum: 120
          description: 'Time kept clear before a booking. Buffers pad bookings; they do not thin out the grid.'
        buffer_after_minutes:
          type: integer
          minimum: 0
          maximum: 120
          description: 'Time kept clear after a booking.'
        minimum_notice_minutes:
          type: integer
          minimum: 0
          description: 'How far ahead a visitor must book.'
        daily_limit:
          type:
            - integer
            - 'null'
          minimum: 1
          description: 'Most bookings per day. Null is unlimited.'
        seats_per_slot:
          type:
            - integer
            - 'null'
          minimum: 2
          description: 'Only meaningful for `group`. Null is unlimited.'
        offered_languages:
          type:
            - array
            - 'null'
          items:
            type: string
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          description: 'The languages the meeting can be held in. Kept whichever `language_source` is in force, so switching back and forth loses nothing. Declaring none means every language.'
        language_source:
          type: string
          enum:
            - listed
            - hosts
          description: '`listed` offers exactly the list above; `hosts` offers the union of what the bookable pool has declared, so a language is never promised without somebody who can hold the meeting in it.'
        location_type:
          type: string
          enum:
            - google_meet
            - in_person
          description: '`in_person` replaces the video link with the address everywhere — page, calendar event, emails, `.ics`.'
        location:
          type: object
          description: 'The address, when the meeting happens somewhere rather than in a video room. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
            maxLength: 200
        hours_source:
          type: string
          enum:
            - page
            - hosts
          description: "`page` slices the week written below; `hosts` reads each host's own week, on their own clock, falling back to the page's for a host who has not written one."
        weekly_hours:
          type:
            - array
            - 'null'
          items:
            $ref: '#/components/schemas/WeeklyHourWindow'
          description: 'The week the page is open. In `hosts` mode this stays as the per-host fallback.'
        event_hours:
          type:
            - array
            - 'null'
          items:
            $ref: '#/components/schemas/EventHourWindow'
          description: 'Opening hours for a real event, overriding `weekly_hours` on the dates they cover. An undated row is the ordinary day. Setting any of these **requires** `timezone`.'
        available_from:
          type:
            - string
            - 'null'
          format: date
          description: 'First bookable date. Together with `available_until` this pins the page to a real event and overrides the rolling horizon.'
        available_until:
          type:
            - string
            - 'null'
          format: date
          description: 'Last bookable date. Must not precede `available_from`.'
        timezone:
          type:
            - string
            - 'null'
          description: 'An IANA timezone. Required once `event_hours` are set: one clock for everyone, or the same "10:00" lands at a different instant per host.'
        collect_details_first:
          type: boolean
          description: 'Ask the questions before showing the times. Ignored on a page with event dates — those always show the times first.'
        questions:
          type:
            - array
            - 'null'
          items:
            $ref: '#/components/schemas/QuestionDefinition'
          description: 'What the booking form asks on top of first name, last name and email, which are always asked.'
        host_ids:
          type: array
          items:
            type: integer
            format: int64
          description: 'The host pool, as user ids. Not a column — it is synced as a relation. Omit on an update to leave the pool alone; on a personal meeting it is overruled back to the owner.'
    NewsletterCampaignCreate:
      type: object
      required:
        - subject
        - body_html
      additionalProperties: false
      description: 'A newsletter edition. `subject` and `body_html` are locale maps; a locale with no `body_html` cannot be sent, and the fallback locale (`en`) must have one before a send is allowed.'
      properties:
        slug:
          type: string
          maxLength: 255
          description: 'Unique. Generated from the English subject when omitted.'
        from_name:
          type:
            - string
            - 'null'
          maxLength: 255
          description: 'Overrides the sender name for this edition only.'
        from_email:
          type:
            - string
            - 'null'
          format: email
          maxLength: 255
          description: 'Overrides the sender address. Keep it on the verified sending domain or DKIM/SPF/DMARC break.'
        scheduled_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'Must be in the future. Setting it here does not schedule the send — `POST …/send` with `mode=schedule` does.'
        subject:
          type: object
          description: 'The subject line. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type: string
            maxLength: 255
        preheader:
          type: object
          description: 'The preview text after the subject in an inbox. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
            maxLength: 255
        body_html:
          type: object
          description: 'The edition body, as HTML. Tokens `{{unsubscribe_url}}`, `{{ view_as_page_url }}`, `{{ site.url }}` and `{{ subscriber.email }}` are resolved per recipient. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type: string
    NewsletterCampaignUpdate:
      type: object
      required: []
      additionalProperties: false
      description: 'A newsletter edition. `subject` and `body_html` are locale maps; a locale with no `body_html` cannot be sent, and the fallback locale (`en`) must have one before a send is allowed.'
      properties:
        slug:
          type: string
          maxLength: 255
          description: 'Unique. Generated from the English subject when omitted.'
        from_name:
          type:
            - string
            - 'null'
          maxLength: 255
          description: 'Overrides the sender name for this edition only.'
        from_email:
          type:
            - string
            - 'null'
          format: email
          maxLength: 255
          description: 'Overrides the sender address. Keep it on the verified sending domain or DKIM/SPF/DMARC break.'
        scheduled_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'Must be in the future. Setting it here does not schedule the send — `POST …/send` with `mode=schedule` does.'
        subject:
          type: object
          description: 'The subject line. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type: string
            maxLength: 255
        preheader:
          type: object
          description: 'The preview text after the subject in an inbox. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type:
              - string
              - 'null'
            maxLength: 255
        body_html:
          type: object
          description: 'The edition body, as HTML. Tokens `{{unsubscribe_url}}`, `{{ view_as_page_url }}`, `{{ site.url }}` and `{{ subscriber.email }}` are resolved per recipient. Keyed by locale (en, es, pt, fr, de, it, pl); sending one locale leaves the others as they were.'
          minProperties: 1
          propertyNames:
            enum:
              - en
              - es
              - pt
              - fr
              - de
              - it
              - pl
          additionalProperties:
            type: string
