openapi: 3.1.0

info:
  title: NotifyPress REST API
  version: '1.0.0'
  summary: Browser push notifications — campaigns, subscribers, segments, and automations.
  description: |
    The NotifyPress REST API is the sole external interface for subscriber lifecycle
    management, notification creation/dispatch/scheduling, site configuration,
    segmentation, automation triggers, and reporting.

    ## Authentication

    Every route under `/api/v1` except the `/api/v1/public/*` group requires a
    **site API token**, sent as a bearer token:

    ```
    Authorization: Bearer YOUR_API_TOKEN
    ```

    Copy it from **Site settings → API token**.

    If you have a value containing a pipe, send only the part *after* the pipe. The
    part before it is an internal id that the server supplies on its own; including it
    still authenticates, so it does not look like an auth problem, but the site can
    then no longer be resolved and every site-scoped endpoint fails with
    `404 No site is linked to this API token.`

    A token is issued per site and scopes every request to that site. It is a secret
    with full access to the site; use it from servers only.

    The `/api/v1/public/*` group uses the site's **public key** (`npk_…`) as a path
    parameter instead. It carries no bearer token and is safe to embed in a web page,
    because those routes are additionally origin-checked: the request's `Origin` (or
    `Referer`) host must match the site's configured URL, or its `www.` variant.

    ## Errors

    Three distinct error body shapes are returned, and a client must handle all three:

    | Shape | Emitted by | Example |
    |---|---|---|
    | `{message, errors}` | Validation failures (422) | `{"message":"The title field is required.","errors":{"title":["The title field is required."]}}` |
    | `{message, upgrade_url}` | Paid-plan gate (403) | `{"message":"This feature requires a paid subscription.","upgrade_url":"https://app.notifypress.com/user/billing"}` |
    | `{error}` | Token verification (401/403) | `{"error":"Unauthorized"}` |

    Everything else returns `{message}`.

    ## Rate limits

    Limits are per minute and are configurable per deployment; the values below are the
    defaults. Exceeding one returns `429` with a `Retry-After` header.

    | Scope | Default | Keyed by |
    |---|---|---|
    | All authenticated routes | 100/min | user ID |
    | `POST /notifications/send`, `/schedule`, `/{id}/schedule`, `/{id}/recurring-send` | 50/min | user ID |
    | `POST /reports/*` generation | 10/min | user ID |
    | `POST /public/sites/{publicKey}/subscribers` | 10/min | public key + IP |
    | `POST /public/sites/{publicKey}/subscribers/erase` | 5/min | public key + IP |
    | `GET /public/sites/{publicKey}/notifications/recent` | 60/min | public key + IP |

    ## Timezone

    `scheduled_at` is **wall-clock time in the account's display timezone**, formatted
    `Y-m-d H:i:s`. It is not UTC and must not carry an offset — an ISO 8601 string with
    a `Z` or `+00:00` suffix is rejected by validation.
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary
  contact:
    name: NotifyPress Support
    url: https://notifypress.com

servers:
  - url: https://app.notifypress.com
    description: Production

security:
  - apiToken: []

tags:
  - name: Sites
    description: Site configuration, opt-in prompt appearance, and welcome notification.
  - name: Notifications
    description: Campaign drafts, sending, scheduling, recurring sends, and cancellation.
  - name: Subscribers
    description: Subscriber records and their tag assignments.
  - name: Tags
    description: The tag taxonomy used to build custom audience segments.
  - name: Segments
    description: Saved audience filters, and live audience counts for an unsaved rule.
  - name: Automations
    description: Trigger-based notification sequences. Paid plans only, except `GET /automations`.
  - name: Reports
    description: Asynchronous PDF report generation and download. Paid plans only.
  - name: Analytics
    description: Aggregate dashboard and engagement metrics.
  - name: Announcements
    description: Product announcements. Account-wide, not site-scoped.
  - name: Public
    description: |
      Unauthenticated endpoints keyed by the site's public key (`npk_…`) and protected by
      origin validation. Used by the browser widget for opt-in and GDPR erasure.

paths:

  # ---------------------------------------------------------------- Sites

  /api/v1/sites:
    get:
      tags: [Sites]
      operationId: listSites
      summary: List the authenticated user's sites
      description: Cursor-paginated, 10 per page. Not limited to the token's site.
      parameters:
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of sites.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Site' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/sites/current:
    get:
      tags: [Sites]
      operationId: getCurrentSite
      summary: Get the site the API token is bound to
      responses:
        '200':
          description: The site linked to this API token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Site' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: |
            No site is linked to this API token. Most often the bearer token was sent
            with an `{id}|` prefix still on the front of it — send only the part after
            the pipe.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    put:
      tags: [Sites]
      operationId: updateCurrentSite
      summary: Update the site the API token is bound to
      description: |
        `site_name` and `site_url` are required on every call. The prompt and welcome
        fields follow present-means-set / absent-means-leave-alone semantics, so a
        partial form does not wipe keys it does not send. `enable_geolocation` and
        `remove_powered_by` are the exception: they are always written, and an omitted
        value is written as `false`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SiteUpdate' }
      responses:
        '200':
          description: The updated site.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Site' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No site is linked to this API token.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/sites/{site}:
    parameters:
      - $ref: '#/components/parameters/SiteId'
    get:
      tags: [Sites]
      operationId: getSite
      summary: Get one of the user's sites by ID
      responses:
        '200':
          description: The site.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Site' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    put:
      tags: [Sites]
      operationId: updateSite
      summary: Update one of the user's sites by ID
      description: Identical merge semantics to `PUT /api/v1/sites/current`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SiteUpdate' }
      responses:
        '200':
          description: The updated site.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Site' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # -------------------------------------------------------- Notifications

  /api/v1/notifications:
    get:
      tags: [Notifications]
      operationId: listNotifications
      summary: List notifications
      description: |
        Cursor-paginated. Rows are a compact projection: `title` is truncated to 20
        characters and `message` to 50. Fetch a single notification for the full record.
      parameters:
        - name: limit
          in: query
          description: Rows per page. Clamped to 1–100.
          schema: { type: integer, minimum: 1, maximum: 100, default: 10 }
        - name: status
          in: query
          description: Filter by status. An unrecognised value is ignored rather than rejected.
          schema: { $ref: '#/components/schemas/NotificationStatus' }
        - name: order
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of notification rows.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CursorPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/NotificationRow' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Notifications]
      operationId: createNotificationDraft
      summary: Create a notification draft
      description: |
        Creates a draft without sending it. Note that `created_by` is always recorded as
        `wordpress` on this route regardless of caller; only `/send` and `/schedule`
        record `rest-api`.

        Set `purpose` to `automation_message` to create a reusable message for an
        automation's `send_notification` step rather than a sendable campaign. That
        choice is immutable, and messages cannot later be sent or scheduled.

        Send as `multipart/form-data` to upload `icon`/`image` files instead of passing
        `icon_url`/`image_url`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NotificationDraft' }
          multipart/form-data:
            schema:
              allOf:
                - $ref: '#/components/schemas/NotificationDraft'
                - type: object
                  properties:
                    icon:
                      type: string
                      format: binary
                      description: Image file, max 2 MB. jpeg, png, jpg, gif, or webp.
                    image:
                      type: string
                      format: binary
                      description: Image file, max 4 MB. jpeg, png, jpg, gif, or webp.
      responses:
        '201':
          description: The draft was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, examples: ['Notification draft created successfully.'] }
                  data: { $ref: '#/components/schemas/Notification' }
                required: [message, data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/notifications/live-activity:
    get:
      tags: [Notifications]
      operationId: getLiveActivity
      summary: List in-flight sends for the current site
      description: Used to restore send-progress UI after a page reload.
      responses:
        '200':
          description: Sends currently in progress.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sends:
                    type: array
                    items: { $ref: '#/components/schemas/LiveActivitySend' }
                required: [sends]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/notifications/{notification}:
    parameters:
      - $ref: '#/components/parameters/NotificationId'
    get:
      tags: [Notifications]
      operationId: getNotification
      summary: Get a notification
      responses:
        '200':
          description: The notification.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Notification' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    put:
      tags: [Notifications]
      operationId: updateNotification
      summary: Update a notification draft
      description: |
        Two distinct behaviours share this route. If the body contains an `analytics`
        key, that value is written directly to the notification's analytics column and
        the request returns immediately — no other field is applied. Any body without an
        `analytics` key goes through normal draft validation and update.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NotificationDraft' }
      responses:
        '200':
          description: The updated notification.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Notification' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    delete:
      tags: [Notifications]
      operationId: deleteNotification
      summary: Delete a notification
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/notifications/send:
    post:
      tags: [Notifications]
      operationId: sendNotification
      summary: Create and immediately send a notification
      description: |
        Creates a draft and dispatches it in one call. Delivery is asynchronous: a `200`
        means the send was accepted and queued, not that any device has received it.

        Pass the `notifypress-subscription-token` header to deliver to exactly one
        subscriber, bypassing audience resolution entirely. That subscriber must be
        active.

        This request is **not idempotent** and must never be retried automatically — a
        call that times out may already have queued the send.
      parameters:
        - name: notifypress-subscription-token
          in: header
          required: false
          description: An FCM token. When present, the send targets only that subscriber.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendNotification' }
      responses:
        '200':
          description: The send was accepted and queued.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SendResult' }
        '400':
          description: |
            The site has no subscribers to send to. The notification is left as a draft
            in `pending` status rather than being deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SendResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The plan's monthly campaign limit has been reached.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SendResult' }
        '404':
          description: |
            A `notifypress-subscription-token` was supplied but no active subscriber
            holds that token.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SendResult' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/notifications/schedule:
    post:
      tags: [Notifications]
      operationId: scheduleNotification
      summary: Create and schedule a notification
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SendNotification'
                - type: object
                  properties:
                    scheduled_at: { $ref: '#/components/schemas/ScheduledAt' }
                  required: [scheduled_at]
      responses:
        '200':
          description: The notification was scheduled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/notifications/{id}/schedule:
    parameters:
      - $ref: '#/components/parameters/NotificationIdPath'
    post:
      tags: [Notifications]
      operationId: scheduleExistingNotification
      summary: Schedule an existing draft
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                scheduled_at: { $ref: '#/components/schemas/ScheduledAt' }
              required: [scheduled_at]
      responses:
        '200':
          description: The draft was scheduled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/notifications/{id}/recurring-send:
    parameters:
      - $ref: '#/components/parameters/NotificationIdPath'
    post:
      tags: [Notifications]
      operationId: startRecurringSend
      summary: Put an existing notification on a recurring schedule
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RecurringSchedule' }
      responses:
        '200':
          description: The recurring schedule was started.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: |
            The notification is an automation message or a welcome message. Neither can
            be delivered as a campaign.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/notifications/{id}/cancel:
    parameters:
      - $ref: '#/components/parameters/NotificationIdPath'
    post:
      tags: [Notifications]
      operationId: cancelNotification
      summary: Cancel a scheduled, sending, or recurring notification
      description: |
        The notification returns to `pending` status and `next_run_at` is cleared. The
        recurrence settings themselves are kept on the record, so a cancelled recurring
        campaign can be restarted without re-entering them.
      responses:
        '200':
          description: Cancelled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, examples: ['Notification cancelled successfully.'] }
                  notification: { $ref: '#/components/schemas/Notification' }
                required: [message, notification]
        '400':
          description: |
            The notification is not in a cancellable status. Only `scheduled`, `sending`,
            and `recurring` can be cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ---------------------------------------------------------- Subscribers

  /api/v1/subscribers:
    get:
      tags: [Subscribers]
      operationId: listSubscribers
      summary: List subscribers for the current site
      description: |
        Cursor-paginated. `total` is a cached count and may lag recent writes by up to
        ten minutes; the paginated rows themselves are always current.
      parameters:
        - name: limit
          in: query
          description: Rows per page. Clamped to 1–100.
          schema: { type: integer, minimum: 1, maximum: 100, default: 10 }
        - name: order
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
        - name: site_id
          in: query
          description: |
            Read subscribers of a different site owned by the same user. Ignored if the
            user does not own that site, in which case the token's site is used.
          schema: { type: integer }
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of subscribers.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CursorPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Subscriber' }
                      total:
                        type: integer
                        description: Cached total for the site. Refreshed every 10 minutes.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Subscribers]
      operationId: createSubscriber
      summary: Create or update a subscriber
      description: |
        Upsert by FCM `token`. If a subscriber with that token already exists for the
        user it is updated in place and the plan's subscriber cap is not consulted;
        otherwise a new record is created and the cap applies.

        The `ip` field is ignored — the caller's own IP is recorded.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SubscriberCreate' }
      responses:
        '200':
          description: An existing subscriber with this token was updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Subscriber' }
                required: [data]
        '201':
          description: A new subscriber was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Subscriber' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The plan's subscriber limit has been reached.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/subscribers/{subscriber}:
    parameters:
      - name: subscriber
        in: path
        required: true
        description: |
          The subscriber's **numeric ID**, not its FCM token. The tag routes below take
          the token instead.
        schema: { type: integer }
    delete:
      tags: [Subscribers]
      operationId: deleteSubscriber
      summary: Delete a subscriber
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The subscriber belongs to another user.
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/subscribers/{token}/tags:
    parameters:
      - $ref: '#/components/parameters/SubscriberToken'
    post:
      tags: [Subscribers]
      operationId: assignSubscriberTag
      summary: Assign a tag to a subscriber
      description: Idempotent — assigning an already-assigned tag is a no-op.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tag_id:
                  type: integer
                  description: Must be a tag belonging to the token's site.
              required: [tag_id]
      responses:
        '200':
          description: The subscriber's full tag list after the assignment.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriberTagsResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No subscriber in this site holds that token.
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/subscribers/{token}/tags/{tag}:
    parameters:
      - $ref: '#/components/parameters/SubscriberToken'
      - name: tag
        in: path
        required: true
        schema: { type: integer }
    delete:
      tags: [Subscribers]
      operationId: removeSubscriberTag
      summary: Remove a tag from a subscriber
      description: Idempotent — removing an unassigned tag is a no-op.
      responses:
        '200':
          description: The subscriber's full tag list after the removal.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriberTagsResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No subscriber in this site holds that token.
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ----------------------------------------------------------------- Tags

  /api/v1/tags:
    get:
      tags: [Tags]
      operationId: listTags
      summary: List tags for the current site
      description: Not paginated. Each tag carries a count of subscribers holding it.
      responses:
        '200':
          description: All tags for the site, ordered by name.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Tag' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Tags]
      operationId: createTag
      summary: Create a tag
      description: The slug is derived from the name and cannot be set directly.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 255 }
                type: { $ref: '#/components/schemas/TagType' }
              required: [name]
      responses:
        '201':
          description: The tag was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Tag' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: |
            Validation failed, or the site's maximum tag count has been reached.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/tags/{tag}:
    parameters:
      - name: tag
        in: path
        required: true
        schema: { type: integer }
    delete:
      tags: [Tags]
      operationId: deleteTag
      summary: Delete a tag
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------------------------------- Segments

  /api/v1/segments:
    get:
      tags: [Segments]
      operationId: listSegments
      summary: List segments
      description: Cursor-paginated, 10 per page.
      parameters:
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of segments.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Segment' }
                  links: { $ref: '#/components/schemas/CursorLinks' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
                  isPaidUser: { type: boolean }
                required: [data, isPaidUser]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Segments]
      operationId: createSegment
      summary: Create a segment
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SegmentInput' }
      responses:
        '200':
          description: The segment was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Segment' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: |
            Validation failed, the site's maximum segment count has been reached, or the
            plan does not include segments.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/segments/meta:
    get:
      tags: [Segments]
      operationId: getSegmentMeta
      summary: List the country values accepted by a location rule
      responses:
        '200':
          description: Valid countries for the `location` rule key.
          content:
            application/json:
              schema:
                type: object
                properties:
                  countries:
                    type: array
                    items: { type: string }
                required: [countries]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/segments/preview-count:
    post:
      tags: [Segments]
      operationId: previewSegmentCount
      summary: Count the audience for an unsaved rule
      description: |
        Returns how many subscribers of the current site a rule would match, without
        saving a segment. An omitted or empty `rule` counts the whole site.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                rule: { $ref: '#/components/schemas/SegmentRule' }
      responses:
        '200':
          description: The matching subscriber count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
                required: [count]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/segments/{segment}:
    parameters:
      - name: segment
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Segments]
      operationId: getSegment
      summary: Get a segment
      responses:
        '200':
          description: The segment.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Segment' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    put:
      tags: [Segments]
      operationId: updateSegment
      summary: Update a segment
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SegmentInput' }
      responses:
        '200':
          description: The updated segment.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Segment' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    delete:
      tags: [Segments]
      operationId: deleteSegment
      summary: Delete a segment
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ----------------------------------------------------------- Automations

  /api/v1/automations:
    get:
      tags: [Automations]
      operationId: listAutomations
      summary: List automations
      description: |
        The only automation route available on a free plan. It is browsable but locked:
        the response carries `isPaidUser` so a client can render its own upgrade prompt.
        Every other automation route returns `403` without a paid subscription.

        Offset-paginated, unlike most list endpoints here.
      parameters:
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: per_page
          in: query
          description: Clamped to 1–100.
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        '200':
          description: A page of automations with per-status enrollment counts.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OffsetPage'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/AutomationListItem' }
                      isPaidUser: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Automations]
      operationId: createAutomation
      summary: Create an automation
      description: Created in `draft` status. Activate it separately.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AutomationInput' }
      responses:
        '201':
          description: The automation was created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutomationResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/automations/templates:
    get:
      tags: [Automations]
      operationId: listAutomationTemplates
      summary: List the pre-built automation templates
      description: |
        Templates are code-owned rather than stored per account, so this list is the same
        for every user; only the readiness flags differ.
      responses:
        '200':
          description: The template gallery, plus the step types and triggers to render.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AutomationTemplate' }
                  stepTypes:
                    type: array
                    description: Only step types that are actually supported today.
                    items: { $ref: '#/components/schemas/LabelledValue' }
                  triggers:
                    type: array
                    items: { $ref: '#/components/schemas/LabelledValue' }
                  hasSite: { type: boolean }
                required: [data, stepTypes, triggers, hasSite]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/automations/messages:
    get:
      tags: [Automations]
      operationId: listAutomationMessages
      summary: List reusable messages available to automation steps
      description: |
        Messages are notifications created with `purpose: automation_message`. They can
        only be delivered by an automation's `send_notification` step, never sent as a
        campaign.
      parameters:
        - name: q
          in: query
          description: Free-text search over the message title and body.
          schema: { type: string }
        - name: automation_id
          in: query
          description: Only messages used by this automation.
          schema: { type: integer }
        - name: automation_status
          in: query
          schema: { $ref: '#/components/schemas/AutomationStatus' }
        - name: sort
          in: query
          schema: { type: string, default: newest }
      responses:
        '200':
          description: Matching messages, plus every automation available to filter by.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { type: object, additionalProperties: true }
                  automations:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        name: { type: string }
                        status: { $ref: '#/components/schemas/AutomationStatus' }
                required: [data, automations]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/automations/trigger:
    post:
      tags: [Automations]
      operationId: triggerAutomation
      summary: Fire a custom event
      description: |
        Enrolls the named subscriber into every active automation on this site whose
        trigger is `custom_event` and whose configured event name matches. A subscriber
        already actively enrolled in a matching automation is skipped rather than
        enrolled twice.

        Enrollment is queued, so `enrolled_in` counts the automations that matched, not
        steps that have run.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  type: string
                  maxLength: 255
                  description: Must match the automation's configured event name exactly.
                subscriber_token:
                  type: string
                  maxLength: 255
                  description: The subscriber's FCM token.
              required: [event, subscriber_token]
      responses:
        '200':
          description: The event was accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, examples: ['Event received.'] }
                  enrolled_in:
                    type: integer
                    description: How many active automations matched the event.
                required: [message, enrolled_in]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '404':
          description: No active subscriber on this site holds that token.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/automations/{automation}:
    parameters:
      - $ref: '#/components/parameters/AutomationId'
    get:
      tags: [Automations]
      operationId: getAutomation
      summary: Get an automation with its steps
      responses:
        '200':
          description: The automation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Automation' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    put:
      tags: [Automations]
      operationId: updateAutomation
      summary: Update an automation
      description: The `steps` array replaces the existing step tree wholesale.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AutomationInput' }
      responses:
        '200':
          description: The updated automation.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutomationResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    delete:
      tags: [Automations]
      operationId: deleteAutomation
      summary: Delete an automation
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/automations/{automation}/activate:
    parameters:
      - $ref: '#/components/parameters/AutomationId'
    post:
      tags: [Automations]
      operationId: activateAutomation
      summary: Activate an automation
      description: Requires at least one step.
      responses:
        '200':
          description: Activated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutomationResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: The automation cannot be activated — most often it has no steps.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/automations/{automation}/pause:
    parameters:
      - $ref: '#/components/parameters/AutomationId'
    post:
      tags: [Automations]
      operationId: pauseAutomation
      summary: Pause an automation
      description: Stops new enrollments. Existing enrollments are left in place.
      responses:
        '200':
          description: Paused.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutomationResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/automations/{automation}/archive:
    parameters:
      - $ref: '#/components/parameters/AutomationId'
    post:
      tags: [Automations]
      operationId: archiveAutomation
      summary: Archive an automation
      description: |
        **One-way.** There is no un-archive endpoint. Enrollment stops immediately.
        Duplicate the automation to start it again.
      responses:
        '200':
          description: Archived.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutomationResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/automations/{automation}/duplicate:
    parameters:
      - $ref: '#/components/parameters/AutomationId'
    post:
      tags: [Automations]
      operationId: duplicateAutomation
      summary: Duplicate an automation
      description: |
        Copies the automation and its full step tree as a fresh `draft`. Enrollments are
        not copied.
      responses:
        '201':
          description: The duplicate.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutomationResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # -------------------------------------------------------------- Reports

  /api/v1/reports/meta:
    get:
      tags: [Reports]
      operationId: getReportMeta
      summary: Check whether reports can be generated
      description: |
        The only report route available on a free plan, so a client can decide what to
        render before hitting a gated endpoint.
      responses:
        '200':
          description: Report availability for this account.
          content:
            application/json:
              schema:
                type: object
                properties:
                  hasSites: { type: boolean }
                  isPaidUser: { type: boolean }
                required: [hasSites, isPaidUser]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/reports/campaign-performance:
    post:
      tags: [Reports]
      operationId: generateCampaignPerformanceReport
      summary: Queue a campaign performance report
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReportDateRange' }
      responses:
        '202': { $ref: '#/components/responses/ReportQueued' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '422': { $ref: '#/components/responses/ReportValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/reports/subscriber-growth:
    post:
      tags: [Reports]
      operationId: generateSubscriberGrowthReport
      summary: Queue a subscriber growth report
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReportDateRange' }
      responses:
        '202': { $ref: '#/components/responses/ReportQueued' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '422': { $ref: '#/components/responses/ReportValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/reports/monthly-summary:
    post:
      tags: [Reports]
      operationId: generateMonthlySummaryReport
      summary: Queue a monthly summary report
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReportDateRange' }
      responses:
        '202': { $ref: '#/components/responses/ReportQueued' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PaidPlanRequired' }
        '422': { $ref: '#/components/responses/ReportValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/reports/download:
    get:
      tags: [Reports]
      operationId: downloadReport
      summary: Download a generated report PDF
      description: |
        Generation is asynchronous, so this returns `404` until the job has written the
        file. Poll it, or use the pre-signed `pdf_url` returned by the generation call.

        The date range must match the generation request exactly — it is part of the
        stored filename. PDFs are retained for 24 hours.

        `site_id` must be the site this API token is bound to.
      parameters:
        - name: report_type
          in: query
          required: true
          schema: { $ref: '#/components/schemas/ReportType' }
        - name: site_id
          in: query
          required: true
          schema: { type: integer, minimum: 1 }
        - name: start_date
          in: query
          required: true
          schema: { type: string, format: date }
        - name: end_date
          in: query
          required: true
          schema: { type: string, format: date }
      responses:
        '200':
          description: The PDF.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: |
            A paid subscription is required, or `site_id` is not the site this token is
            bound to.
        '404':
          description: The report has not finished generating, or has expired.
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------------------------------ Analytics

  /api/v1/dashboard:
    get:
      tags: [Analytics]
      operationId: getDashboard
      summary: Get dashboard summary metrics
      description: |
        Counts here are **pre-formatted display strings** with thousands separators
        (`"1,234"`), not numbers. Use `/api/v1/analytics` for numeric values.
      responses:
        '200':
          description: Dashboard metrics.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Dashboard' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/analytics:
    get:
      tags: [Analytics]
      operationId: getAnalytics
      summary: Get engagement analytics
      responses:
        '200':
          description: Analytics metrics.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Analytics' }
                required: [data]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # -------------------------------------------------------- Announcements

  /api/v1/announcements:
    get:
      tags: [Announcements]
      operationId: listAnnouncements
      summary: List product announcements
      description: |
        Announcements are account-wide, not site-scoped — every user sees the same list,
        and read state is tracked per user. The 20 most recent are returned.
      responses:
        '200':
          description: Recent announcements and the unread count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  announcements:
                    type: array
                    items: { $ref: '#/components/schemas/Announcement' }
                  unread_count: { type: integer }
                required: [announcements, unread_count]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/announcements/read-all:
    post:
      tags: [Announcements]
      operationId: markAllAnnouncementsRead
      summary: Mark every announcement as read
      responses:
        '200': { $ref: '#/components/responses/Ok' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/announcements/{announcement}/read:
    parameters:
      - name: announcement
        in: path
        required: true
        schema: { type: integer }
    post:
      tags: [Announcements]
      operationId: markAnnouncementRead
      summary: Mark one announcement as read
      description: Idempotent.
      responses:
        '200': { $ref: '#/components/responses/Ok' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ----------------------------------------------------------- Public API

  /api/v1/public/sites/{publicKey}/subscribers:
    parameters:
      - $ref: '#/components/parameters/PublicKey'
    post:
      tags: [Public]
      operationId: createPublicSubscriber
      security: []
      summary: Opt a visitor in to push notifications
      description: |
        Called from the browser after Firebase Messaging returns a registration token.
        Upsert by `token`: an existing subscriber is updated and returns `200`, a new one
        returns `201`.

        Requires an `Origin` or `Referer` header whose host matches the site's configured
        URL (or its `www.` variant). A request with neither is rejected — this endpoint
        cannot be called from a server or from `curl` without one.

        To deactivate a subscriber without deleting it, send `active: false`. The visitor
        can re-opt-in later. For permanent deletion use the erase endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PublicSubscriberCreate' }
      responses:
        '200':
          description: An existing subscriber was updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PublicSubscriber' }
                required: [data]
        '201':
          description: A new subscriber was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PublicSubscriber' }
                required: [data]
        '403':
          description: |
            The `Origin`/`Referer` host does not match the site's configured URL, or the
            header is absent.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '404':
          description: No active site holds that public key.
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/public/sites/{publicKey}/subscribers/erase:
    parameters:
      - $ref: '#/components/parameters/PublicKey'
    post:
      tags: [Public]
      operationId: erasePublicSubscriber
      security: []
      summary: Erase a subscriber (GDPR right to erasure)
      description: |
        Hard-deletes the subscriber row. Unlike `active: false` on the opt-in endpoint,
        this is not reversible.

        `POST` rather than `DELETE` so the FCM token travels in a validated JSON body
        instead of a URL path segment, where it would land in access logs.

        Returns `200` with `deleted: false` when no subscriber held that token, rather
        than `404` — a caller must not be able to probe for token existence.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token: { type: string, maxLength: 4096 }
              required: [token]
      responses:
        '200':
          description: Whether a row was deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
                required: [deleted]
        '403':
          description: The `Origin`/`Referer` host does not match the site's configured URL.
        '404':
          description: No active site holds that public key.
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/public/sites/{publicKey}/notifications/recent:
    parameters:
      - $ref: '#/components/parameters/PublicKey'
    get:
      tags: [Public]
      operationId: listRecentPublicNotifications
      security: []
      summary: List the site's three most recent sent notifications
      description: |
        A minimal projection intended for an on-page notification feed. Only `sent`
        notifications are returned, and only their title and target URL — never the body,
        analytics, or audience.
      responses:
        '200':
          description: Up to three recent notifications, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    maxItems: 3
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        title: { type: string }
                        target_url: { type: [string, 'null'], format: uri }
                      required: [id, title, target_url]
                required: [data]
        '403':
          description: The `Origin`/`Referer` host does not match the site's configured URL.
        '404':
          description: No active site holds that public key.
        '429': { $ref: '#/components/responses/TooManyRequests' }

components:

  securitySchemes:
    apiToken:
      type: http
      scheme: bearer
      description: |
        The site API token, exactly as the dashboard shows it — one opaque string, no
        `|` in it. See the Authentication section.

  parameters:

    Cursor:
      name: cursor
      in: query
      required: false
      description: |
        Opaque cursor from a previous response's `next_cursor` or `prev_cursor`. Omit for
        the first page.
      schema: { type: string }

    SiteId:
      name: site
      in: path
      required: true
      schema: { type: integer }

    NotificationId:
      name: notification
      in: path
      required: true
      schema: { type: integer }

    NotificationIdPath:
      name: id
      in: path
      required: true
      schema: { type: integer }

    AutomationId:
      name: automation
      in: path
      required: true
      schema: { type: integer }

    SubscriberToken:
      name: token
      in: path
      required: true
      description: |
        The subscriber's **FCM token**, not its numeric ID. Note that the delete-subscriber
        route takes the ID instead.
      schema: { type: string }

    PublicKey:
      name: publicKey
      in: path
      required: true
      description: |
        The site's public key, `npk_…`. Safe to embed in a web page; it authorizes only
        browser subscription actions, never campaign management or sends.
      schema:
        type: string
        pattern: '^npk_'

  responses:

    Ok:
      description: Success.
      content:
        application/json:
          schema:
            type: object
            properties:
              ok: { type: boolean, const: true }
            required: [ok]

    Unauthorized:
      description: |
        The bearer token is missing, invalid, or the account is suspended. Note the body
        shape here uses `error`, not `message`.
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                enum: [Unauthorized, Account suspended]
            required: [error]

    NotFound:
      description: The resource does not exist, or is not owned by this account.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Message' }

    PaidPlanRequired:
      description: |
        This feature requires a paid subscription. The body carries `upgrade_url` so a
        client can link straight to billing.
      content:
        application/json:
          schema:
            type: object
            properties:
              message: { type: string, examples: ['This feature requires a paid subscription.'] }
              upgrade_url: { type: string, format: uri }
            required: [message, upgrade_url]

    ValidationError:
      description: One or more fields failed validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationError' }

    ReportValidationError:
      description: |
        The date range failed validation, or no site is currently selected (`No active
        site is selected.`).
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/ValidationError'
              - $ref: '#/components/schemas/Message'

    ReportQueued:
      description: |
        The report was queued for generation. `pdf_url` is a pre-signed download link
        valid for 10 minutes; the file itself will not exist until the job completes.
      content:
        application/json:
          schema:
            type: object
            properties:
              message: { type: string, examples: ['The PDF report has been queued for generation.'] }
              report_type: { $ref: '#/components/schemas/ReportType' }
              retention_message: { type: string, examples: ['Generated PDFs are retained for 24 hours.'] }
              pdf_url: { type: string, format: uri }
            required: [message, report_type, retention_message, pdf_url]

    TooManyRequests:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Message' }

  schemas:

    # ------------------------------------------------------------ Envelopes

    Message:
      type: object
      properties:
        message: { type: string }
      required: [message]

    ValidationError:
      type: object
      properties:
        message:
          type: string
          description: The first validation message, repeated for convenience.
        errors:
          type: object
          description: Field name to list of messages.
          additionalProperties:
            type: array
            items: { type: string }
      required: [message, errors]

    CursorLinks:
      type: object
      properties:
        first: { type: [string, 'null'] }
        last: { type: [string, 'null'] }
        prev: { type: [string, 'null'] }
        next: { type: [string, 'null'] }

    CursorMeta:
      type: object
      properties:
        path: { type: string }
        per_page: { type: integer }
        next_cursor: { type: [string, 'null'] }
        prev_cursor: { type: [string, 'null'] }

    CursorPage:
      type: object
      description: |
        Laravel cursor pagination. A `null` `next_cursor` means this is the last page.
        Cursor pages have no total count and no page numbers.
      properties:
        path: { type: string }
        per_page: { type: integer }
        next_cursor: { type: [string, 'null'] }
        prev_cursor: { type: [string, 'null'] }
        next_page_url: { type: [string, 'null'] }
        prev_page_url: { type: [string, 'null'] }

    OffsetPage:
      type: object
      description: Laravel length-aware (offset) pagination.
      properties:
        current_page: { type: integer }
        first_page_url: { type: [string, 'null'] }
        from: { type: [integer, 'null'] }
        last_page: { type: integer }
        last_page_url: { type: [string, 'null'] }
        next_page_url: { type: [string, 'null'] }
        path: { type: string }
        per_page: { type: integer }
        prev_page_url: { type: [string, 'null'] }
        to: { type: [integer, 'null'] }
        total: { type: integer }

    LabelledValue:
      type: object
      properties:
        value: { type: string }
        label: { type: string }
      required: [value, label]

    # ---------------------------------------------------------------- Enums

    NotificationStatus:
      type: string
      description: |
        There is no `cancelled` status — cancelling returns a notification to `pending`.
      enum: [pending, sending, sent, failed, scheduled, recurring]

    NotificationPurpose:
      type: string
      description: |
        Immutable after creation. `campaign` is a sendable notification;
        `automation_message` can only be delivered by an automation step; and
        `welcome_message` is sent automatically to new subscribers. The latter two abort
        if sent or scheduled directly.
      enum: [campaign, automation_message, welcome_message]

    SendVia:
      type: string
      description: How the notification was created.
      enum: [mobile, web, rest-api, wordpress]

    AutomationTrigger:
      type: string
      description: |
        `segment_entered` is defined in the data model but is not yet supported.
      enum: [subscriber_created, custom_event]

    AutomationStepType:
      type: string
      description: |
        `condition` and `ab_split` are defined but **not yet supported** — they are
        rejected at execution time. Use `/automations/templates` to read the list of
        step types that are actually available.
      enum: [wait, send_notification, add_tag, webhook, condition, ab_split]

    AutomationStatus:
      type: string
      enum: [draft, active, paused, archived]

    SitePromptStyle:
      type: string
      enum: [minimal, card, banner]

    TagType:
      type: string
      enum: [category, interest, custom]
      default: category

    ReportType:
      type: string
      enum: [campaign-performance, subscriber-growth, monthly-summary]

    ScheduledAt:
      type: string
      pattern: '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'
      description: |
        Wall-clock time in the account's display timezone, `Y-m-d H:i:s`. **Not UTC**, and
        an ISO 8601 offset is rejected.
      examples: ['2026-08-14 09:30:00']

    # ---------------------------------------------------------------- Sites

    Site:
      type: object
      properties:
        id: { type: integer }
        is_active: { type: boolean }
        site_url: { type: [string, 'null'], format: uri }
        site_name: { type: [string, 'null'] }
        public_key:
          type: string
          description: Safe to embed in a page. Used only by the public endpoints.
        enable_geolocation: { type: boolean, default: false }
        remove_powered_by: { type: boolean, default: false }
        prompt_title: { type: [string, 'null'] }
        prompt_message: { type: [string, 'null'] }
        prompt_icon: { type: [string, 'null'], format: uri }
        prompt_approve_text: { type: [string, 'null'] }
        prompt_cancel_text: { type: [string, 'null'] }
        prompt_style: { $ref: '#/components/schemas/SitePromptStyle' }
        prompt_accent_color: { type: string, default: '#3858e9' }
        prompt_delay_seconds: { type: integer, default: 0 }
        prompt_hide_days: { type: integer, default: 7 }
      required: [id, is_active, public_key]

    SiteUpdate:
      type: object
      properties:
        site_name: { type: string }
        site_url: { type: string, format: uri }
        enable_geolocation:
          type: boolean
          description: Always written. Omitting it writes `false`.
        remove_powered_by:
          type: boolean
          description: Always written. Omitting it writes `false`.
        prompt_title: { type: string, maxLength: 120 }
        prompt_message: { type: string, maxLength: 300 }
        prompt_icon: { type: string, format: uri, maxLength: 500 }
        prompt_approve_text: { type: string, maxLength: 60 }
        prompt_cancel_text: { type: string, maxLength: 60 }
        prompt_style: { $ref: '#/components/schemas/SitePromptStyle' }
        prompt_accent_color: { type: string, maxLength: 20 }
        prompt_delay_seconds: { type: integer, minimum: 0, maximum: 60 }
        prompt_hide_days: { type: integer, minimum: 0, maximum: 365 }
        welcome_notification_enabled: { type: boolean }
        welcome_notification_title: { type: string, maxLength: 120 }
        welcome_notification_message: { type: string, maxLength: 300 }
        welcome_notification_icon: { type: string, format: uri, maxLength: 500 }
        welcome_notification_url: { type: string, format: uri, maxLength: 500 }
      required: [site_name, site_url]

    # -------------------------------------------------------- Notifications

    NotificationAnalytics:
      type: object
      properties:
        views: { type: integer }
        clicks: { type: integer }
        click_through_rate:
          type: integer
          description: Whole-number percentage, rounded. `0` when there are no views.
        browsers:
          type: object
          properties:
            chrome: { type: integer }
            firefox: { type: integer }
            safari: { type: integer }
            edge: { type: integer }
            opera: { type: integer }
            other: { type: integer }
        devices:
          type: object
          properties:
            desktop: { type: integer }
            mobile: { type: integer }
            tablet: { type: integer }

    NotificationRow:
      type: object
      description: |
        The compact projection returned by the list endpoint. `title` is truncated to 20
        characters and `message` to 50, both with a trailing ellipsis.
      properties:
        id: { type: integer }
        title: { type: string }
        message: { type: string }
        status: { $ref: '#/components/schemas/NotificationStatus' }
        purpose: { $ref: '#/components/schemas/NotificationPurpose' }
        created_by: { $ref: '#/components/schemas/SendVia' }
        scheduled_at: { type: [string, 'null'], format: date-time }
        next_run_at: { type: [string, 'null'], format: date-time }
        sent_at: { type: [string, 'null'], format: date-time }
        created_at: { type: [string, 'null'], format: date-time }
        analytics: { $ref: '#/components/schemas/NotificationAnalytics' }
      required: [id, title, message, status, analytics]

    Notification:
      type: object
      description: The full notification record.
      properties:
        id: { type: integer }
        title: { type: string }
        message: { type: string }
        status: { $ref: '#/components/schemas/NotificationStatus' }
        purpose: { $ref: '#/components/schemas/NotificationPurpose' }
        created_by: { $ref: '#/components/schemas/SendVia' }
        site_id: { type: integer }
        segment_id: { type: [integer, 'null'] }
        target_url: { type: [string, 'null'], format: uri }
        icon_url: { type: [string, 'null'], format: uri }
        image_url: { type: [string, 'null'], format: uri }
        icon_preview_url:
          type: [string, 'null']
          format: uri
          description: Resolved URL for an uploaded icon, or the `icon_url` if none was uploaded.
        image_preview_url:
          type: [string, 'null']
          format: uri
          description: Resolved URL for an uploaded image, or the `image_url` if none was uploaded.
        scheduled_at: { type: [string, 'null'], format: date-time }
        next_run_at: { type: [string, 'null'], format: date-time }
        sent_at: { type: [string, 'null'], format: date-time }
        recurring_frequency: { type: [string, 'null'], enum: [daily, weekly, monthly, null] }
        recurring_days:
          type: [array, 'null']
          items: { type: integer, minimum: 0, maximum: 6 }
        recurring_day_of_month: { type: [integer, 'null'], minimum: 1, maximum: 31 }
        created_at: { type: [string, 'null'], format: date-time }
        updated_at: { type: [string, 'null'], format: date-time }
        analytics: { $ref: '#/components/schemas/NotificationAnalytics' }
      required: [id, title, message, status]

    NotificationDraft:
      type: object
      properties:
        title: { type: string, maxLength: 50 }
        message:
          type: string
          maxLength: 150
          description: |
            Note the 150-character ceiling here, versus 100 on the send and schedule
            endpoints.
        status: { $ref: '#/components/schemas/NotificationStatus' }
        purpose: { $ref: '#/components/schemas/NotificationPurpose' }
        created_by: { $ref: '#/components/schemas/SendVia' }
        site_id:
          type: integer
          description: Must be a site owned by this user. Defaults to the token's site.
        segment_id:
          oneOf:
            - type: integer
            - type: string
              const: all
          description: A segment ID belonging to the site, or the literal `"all"`.
        target_url: { type: string, format: uri, maxLength: 255 }
        icon_url: { type: string, format: uri, maxLength: 255 }
        image_url: { type: string, format: uri, maxLength: 255 }
        last_used_icon:
          type: boolean
          description: Reuse the icon from the account's previous notification.
        last_used_image:
          type: boolean
          description: Reuse the image from the account's previous notification.
      required: [title, message]

    SendNotification:
      type: object
      properties:
        title: { type: string, maxLength: 50 }
        message: { type: string, maxLength: 100 }
        target_url: { type: string, format: uri, maxLength: 255 }
        image_url: { type: string, format: uri, maxLength: 255 }
        icon_url: { type: string, format: uri, maxLength: 255 }
        segment_id:
          type: integer
          description: |
            Must be a segment belonging to the token's site. Omit to target the whole
            audience.
      required: [title, message]

    SendResult:
      type: object
      properties:
        message: { type: string }
        notification: { $ref: '#/components/schemas/Notification' }
        subscriber_total:
          type: integer
          description: |
            Subscribers the send was planned for. `1` for a single-subscriber send, `0`
            on the failure responses.
      required: [message, notification, subscriber_total]

    ScheduleResult:
      type: object
      properties:
        message: { type: string }
        notification: { $ref: '#/components/schemas/Notification' }
      required: [message, notification]

    RecurringSchedule:
      type: object
      properties:
        frequency: { type: string, enum: [daily, weekly, monthly] }
        time:
          type: string
          pattern: '^\d{2}:\d{2}$'
          description: 24-hour `H:i` in the account's display timezone.
          examples: ['09:30']
        days:
          type: array
          description: Required when `frequency` is `weekly`. 0 is Sunday.
          items: { type: integer, minimum: 0, maximum: 6 }
        day_of_month:
          type: integer
          minimum: 1
          maximum: 31
          description: Required when `frequency` is `monthly`.
      required: [frequency, time]

    LiveActivitySend:
      type: object
      description: Progress of one in-flight send.
      additionalProperties: true
      properties:
        notification_id: { type: integer }

    # ---------------------------------------------------------- Subscribers

    Subscriber:
      type: object
      properties:
        id: { type: integer }
        ip: { type: [string, 'null'] }
        user_id: { type: integer }
        site_id: { type: [integer, 'null'] }
        token: { type: [string, 'null'], description: The FCM registration token. }
        city: { type: [string, 'null'] }
        country: { type: [string, 'null'] }
        browser: { type: [string, 'null'] }
        timezone: { type: [string, 'null'] }
        device: { type: [string, 'null'] }
        active: { type: boolean }
        created_at: { type: [string, 'null'], format: date-time }
        tags:
          type: array
          description: Present only on endpoints that eager-load tags.
          items: { $ref: '#/components/schemas/TagRef' }
        premium_user:
          type: boolean
          description: Whether the owning account is on a premium or business plan.
        plan: { type: string, description: The owning account's plan name. }
        message:
          type: string
          description: Present only on create responses.
      required: [id, user_id, active]

    SubscriberCreate:
      type: object
      properties:
        token:
          type: string
          description: The FCM registration token. Upsert key.
        city: { type: string, maxLength: 25 }
        country: { type: string, maxLength: 25 }
        browser: { type: string, maxLength: 10 }
        timezone: { type: string, maxLength: 25 }
        device: { type: string, maxLength: 25 }
        active: { type: boolean }
        ip:
          type: string
          format: ipv4
          description: Accepted but ignored — the caller's own IP is recorded instead.
          deprecated: true
      required: [token]

    TagRef:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        slug: { type: string }
        type: { $ref: '#/components/schemas/TagType' }
      required: [id, name, slug]

    SubscriberTagsResult:
      type: object
      properties:
        message: { type: string }
        data:
          type: array
          description: The subscriber's complete tag list after the change.
          items: { $ref: '#/components/schemas/TagRef' }
      required: [message, data]

    # ----------------------------------------------------------------- Tags

    Tag:
      type: object
      properties:
        id: { type: integer }
        site_id: { type: integer }
        name: { type: string }
        slug: { type: string, description: Derived from the name; not settable. }
        type: { $ref: '#/components/schemas/TagType' }
        subscribers_count:
          type: integer
          description: Present on the list endpoint only.
        created_at: { type: [string, 'null'], format: date-time }
        updated_at: { type: [string, 'null'], format: date-time }
      required: [id, name, slug]

    # ------------------------------------------------------------- Segments

    SegmentRule:
      type: object
      description: |
        A map of attribute name to condition. Every clause must hold for a subscriber to
        match. An empty or omitted rule matches the whole site.

        Valid values depend on the key: `browser` accepts chrome/firefox/safari/opera/edge,
        `device` accepts desktop/mobile, `location` accepts a country from
        `/api/v1/segments/meta`, and `tags` accepts numeric tag IDs.
      additionalProperties:
        type: object
        properties:
          condition:
            type: string
            enum: [is_equal_to, is_not_equal_to, is_one_of]
          values:
            type: array
            items:
              oneOf:
                - type: string
                - type: integer
        required: [values]
      examples:
        - location:
            condition: is_one_of
            values: [canada, united states]
          browser:
            condition: is_equal_to
            values: [chrome]

    Segment:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        description: { type: [string, 'null'] }
        is_active: { type: boolean }
        rule: { $ref: '#/components/schemas/SegmentRule' }
        created_at:
          type: [string, 'null']
          description: Formatted for display as `d M Y`, not ISO 8601.
          examples: ['14 Aug 2026']
      required: [id, name, is_active]

    SegmentInput:
      type: object
      properties:
        name: { type: string, maxLength: 255 }
        description: { type: string, maxLength: 255 }
        is_active: { type: boolean }
        rule: { $ref: '#/components/schemas/SegmentRule' }
      required: [name, is_active]

    # ----------------------------------------------------------- Automations

    AutomationStep:
      type: object
      properties:
        id: { type: integer }
        automation_id: { type: integer }
        type: { $ref: '#/components/schemas/AutomationStepType' }
        config: { $ref: '#/components/schemas/AutomationStepConfig' }
        parent_step_id: { type: [integer, 'null'] }
        branch: { type: [string, 'null'] }
        position_x: { type: [number, 'null'] }
        position_y: { type: [number, 'null'] }
      required: [id, type]

    AutomationStepConfig:
      type: object
      description: |
        Which keys apply depends on the step `type`. Unrelated keys are ignored rather
        than rejected.
      properties:
        delay_minutes:
          type: integer
          minimum: 1
          maximum: 43200
          description: '`wait` steps. Maximum 43200 minutes — 30 days.'
        notification_id:
          type: integer
          description: |
            `send_notification` steps. Must reference a notification with
            `purpose: automation_message` owned by this user — a campaign is rejected.
        tag_id:
          type: integer
          description: '`add_tag` steps. Must be a tag belonging to the token''s site.'
        url:
          type: string
          format: uri
          maxLength: 2048
          description: |
            `webhook` steps. HTTPS only, and SSRF-guarded: loopback, link-local, and
            RFC1918 addresses are rejected.
        headers:
          type: object
          description: '`webhook` steps. Header values are capped at 2000 characters.'
          additionalProperties: { type: string, maxLength: 2000 }

    AutomationStepInput:
      type: object
      properties:
        type: { $ref: '#/components/schemas/AutomationStepType' }
        config: { $ref: '#/components/schemas/AutomationStepConfig' }
        parent_step_id: { type: integer }
        branch: { type: string }
        position_x: { type: number, description: Canvas coordinate for the visual builder. }
        position_y: { type: number, description: Canvas coordinate for the visual builder. }
      required: [type]

    Automation:
      type: object
      properties:
        id: { type: integer }
        user_id: { type: integer }
        site_id: { type: integer }
        name: { type: string }
        description: { type: [string, 'null'] }
        status: { $ref: '#/components/schemas/AutomationStatus' }
        trigger_type: { $ref: '#/components/schemas/AutomationTrigger' }
        trigger_config:
          type: [object, 'null']
          additionalProperties: true
          description: |
            For a `custom_event` trigger this holds the `event` name matched by
            `POST /automations/trigger`.
        steps:
          type: array
          items: { $ref: '#/components/schemas/AutomationStep' }
        created_at: { type: [string, 'null'], format: date-time }
        updated_at: { type: [string, 'null'], format: date-time }
      required: [id, name, status, trigger_type]

    AutomationListItem:
      allOf:
        - $ref: '#/components/schemas/Automation'
        - type: object
          properties:
            enrollments_count: { type: integer }
            started_count:
              type: integer
              description: Total enrollments ever. Same value as `enrollments_count`.
            active_count: { type: integer }
            finished_count: { type: integer }
            suspended_count:
              type: integer
              description: Enrollments that failed.

    AutomationInput:
      type: object
      properties:
        name: { type: string, maxLength: 255 }
        description: { type: string, maxLength: 1000 }
        trigger_type: { $ref: '#/components/schemas/AutomationTrigger' }
        trigger_config:
          type: object
          additionalProperties: true
        steps:
          type: array
          description: On update, this replaces the existing step tree wholesale.
          items: { $ref: '#/components/schemas/AutomationStepInput' }
      required: [name, trigger_type]

    AutomationResult:
      type: object
      properties:
        message: { type: string }
        data: { $ref: '#/components/schemas/Automation' }
      required: [message, data]

    AutomationTemplate:
      type: object
      additionalProperties: true
      properties:
        name: { type: string }
        description: { type: string }
        trigger_type: { $ref: '#/components/schemas/AutomationTrigger' }
        steps:
          type: array
          items: { $ref: '#/components/schemas/AutomationStepInput' }
        needs_notification:
          type: boolean
          description: Whether the template contains a `send_notification` step.
        has_notifications:
          type: boolean
          description: |
            Whether the account has at least one `automation_message` to fill that step.
            `true` when the template needs no notification.
        is_custom_event:
          type: boolean
          description: Whether applying this template requires configuring an event name.

    # -------------------------------------------------------------- Reports

    ReportDateRange:
      type: object
      properties:
        start_date: { type: string, format: date }
        end_date:
          type: string
          format: date
          description: Must be on or after `start_date`.
      required: [start_date, end_date]

    # ------------------------------------------------------------ Analytics

    Dashboard:
      type: object
      description: |
        Every count here is a **formatted string** with thousands separators, not a
        number.
      properties:
        totalSubscribers: { type: string, examples: ['12,480'] }
        totalNotifications: { type: string }
        totalScheduledNotifications: { type: string }
        numberOfSubscribersUserCanHave:
          type: string
          description: The plan's subscriber cap.
        totalCountries: { type: string }
        topCountries:
          type: array
          items:
            type: object
            additionalProperties: true
        recentCampaigns:
          type: array
          items:
            type: object
            additionalProperties: true

    Analytics:
      type: object
      description: Numeric counterpart to the dashboard summary.
      properties:
        totalNotifications: { type: integer }
        totalSubscribers: { type: integer }
        totalScheduledNotifications: { type: integer }
        numberOfSubscribersUserCanHave: { type: integer }
        totalClicks: { type: integer }
        totalViews: { type: integer }
        averageCtr: { type: number }
        activeSubscribers: { type: integer }
        inactiveSubscribers: { type: integer }
        notificationsPerMonth:
          type: array
          items:
            type: object
            additionalProperties: true
        subscribersPerMonth:
          type: array
          items:
            type: object
            additionalProperties: true
        browserBreakdown:
          type: object
          additionalProperties: { type: integer }
        notificationsByStatus:
          type: object
          additionalProperties: { type: integer }
        isPaidUser: { type: boolean }

    # -------------------------------------------------------- Announcements

    Announcement:
      type: object
      properties:
        id: { type: integer }
        title: { type: string }
        body: { type: string }
        link_url: { type: [string, 'null'], format: uri }
        link_label: { type: [string, 'null'] }
        created_at: { type: [string, 'null'], format: date-time }
        read: { type: boolean, description: Whether this user has read it. }
      required: [id, title, body, read]

    # ----------------------------------------------------------- Public API

    PublicSubscriberCreate:
      type: object
      properties:
        token:
          type: string
          maxLength: 4096
          description: The FCM registration token. Upsert key.
        city: { type: string, maxLength: 25 }
        country: { type: string, maxLength: 25 }
        browser: { type: string, maxLength: 25 }
        timezone:
          type: string
          maxLength: 50
          description: IANA timezone name.
          examples: ['America/Toronto']
        device: { type: string, maxLength: 25 }
        active:
          type: boolean
          description: |
            Send `false` to deactivate without deleting. The visitor can re-opt-in later.
      required: [token]

    PublicSubscriber:
      type: object
      description: |
        A deliberately minimal projection. The public endpoints never return a
        subscriber's IP, location, or tags.
      properties:
        id: { type: integer }
        site_id: { type: [integer, 'null'] }
        token: { type: [string, 'null'] }
        active: { type: boolean }
        premium_user:
          type: boolean
          description: |
            Whether the site owner is on a paid plan. Read by the widget to decide whether
            to render the "Powered by NotifyPress" attribution.
      required: [id, active, premium_user]
