openapi: 3.1.0

info:
  title: Alert API
  version: "2.0.0"
  description: |
    Operate alert channels from your own code: create a channel, compose an
    alert, send it, and read the honest half of what happened to it.

    > **Scope: the operator half of `/api/v1`, and only that.** Everything here
    > is an account acting on its own rows, authenticated with an `sk_live_…`
    > key. The app-facing half is a separate surface with different rules
    > (unauthenticated, CORS-open, cursor-paginated, identified by an
    > `x-device-id` header rather than a key) and is deliberately not documented
    > here: it is not a public integration point and its shape follows the
    > handset. It shares this error envelope. It lives under
    > `/api/v1/apps/<slug>/**`, keyed by a registry in `src/lib/apps.ts`, and is
    > described in `APPS-API.md`.

    > **The same operations are available to an LLM, over MCP.** `/api/mcp` is a
    > Model Context Protocol server publishing one tool per operation below, so
    > an assistant can be pointed at it and will discover the rest itself
    > (OAuth 2.1 with dynamic client registration — no key is pasted anywhere,
    > and the grant is revocable per client). It is not a second implementation:
    > every tool builds a request and runs the very handler documented here, so
    > the rules, the rate limits and this error envelope are identical. The only
    > thing that differs is what a caller may do — key management is not
    > reachable over MCP. Connecting one asks for consent first. Each operation
    > below names its tool in `x-mcp-tool`; an operation without one (the
    > per-alert analytics, the preview) is reachable by key today and will grow a
    > tool when it earns one.

    > **The product name is a runtime setting, and no wire format carries it.**
    > The signature header is `X-Webhook-Signature` and stays that way whatever
    > the product is called, because renaming a header silently breaks every
    > integration that verifies it. The same rule governs the public channel
    > link: it is built from the deployment's own hostname, never from the
    > display name, so rebranding does not rewrite a URL somebody has printed on
    > a poster.

    ## Scopes
    Every credential — an `sk_live_…` key or an MCP grant — carries a list of
    scopes shaped `family:level`: `channels:write`, `alerts:send`. Each operation
    below names the one it requires, both in its `security` entry and in
    `x-required-scope`. Implication is resolved when the check runs, never
    stored: `x:write` satisfies `x:read`, and `alerts:send` satisfies
    `alerts:write` and `alerts:read`. A grant holds exactly what was approved;
    the check widens it.

    `alerts:send` is separate from `alerts:write` **on purpose**: composing
    drafts is not the authority to ring phones. Scheduling and cancelling both
    ARE that authority — a scheduled alert reaches phones with no further
    approval, and withdrawing a live evacuation notice changes what reaches
    them — so both verbs require it too.

    **Legacy credentials keep working, labeled.** An API key minted before
    scopes existed (its stored list is empty) and an OAuth grant that carries
    only OIDC identity scopes keep doing everything they could — breaking every
    operator's cron job and every connected assistant on deploy day, for a
    permission they de-facto had, is not hygiene. The UIs label both
    "Full access — created before scopes" so owners can rotate deliberately;
    new keys always carry a non-empty list and new grants always carry
    capability scopes, so the legacy encoding stays unambiguous.

    **A key may be bound to one channel.** A channel-bound key sees exactly its
    own channel and may carry only the operating scopes (`account:read`,
    `channels:read`, `alerts:read`/`write`/`send`, `analytics:read`,
    `subscribers:read`). Webhook authority stays off it — it would outlive the
    binding's meaning — and so does `channels:write`: a bound key may operate its
    channel's alerts, not re-describe or delete the channel.

    **A refusal is `403` with `error.type: "insufficient_scope"`**, a message
    naming the missing scope and the place to fix it (mint a key with it, or
    reconnect the assistant approving it), and the RFC 6750 header beside it:
    `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`. Any
    operation in this document can answer this way; it is only spelled out
    below where an operation needs MORE than its declared scope (sending).

    ## Channels and alerts
    A **channel** has a public page at `<your-host>/<handle>` — cities, schools,
    airports, festivals, private ops teams — and an **alert** is one thing it
    says. The model in one sentence: **a channel asks; the phone decides.**
    `priority` is a ceiling, and the tone, the vibration and the spoken text
    are requests — each is ANDed on the handset with what that subscriber
    granted this channel and with their own global switches, so the loudest
    thing an unpermitted channel can achieve is an ordinary notification.
    Nothing in this API can promise that an alert will ring, take over a
    screen, or speak aloud.

    The handle is claimed once and never edited: it is what gets printed on a
    poster and encoded in a QR code. Creating a channel is claiming a name in a
    shared namespace, which is why creation is rate-limited and deletion refuses
    while anybody still subscribes.

    A channel may be **sealed** (`is_public: false`), and sealed means sealed
    rather than unlisted: an invite code is required to subscribe AND to read the
    channel's public record. Both refusals are the same `404` a nonexistent
    handle gets, because a `403` would be an enumeration oracle.

    The numbers are honest by construction. There is no per-device delivery row
    anywhere — a phone's inbox is a query over sent alerts, which is why sending
    costs the same at four subscribers and four million — so nothing here says
    "delivered". `reach` is how many devices subscribe **now** (the console
    prints it as "could reach"); `reads` and `acks` are self-reports from
    devices that chose to send one. A guaranteed per-recipient receipt is a
    different product with a different table.

    **Location narrows, it never widens, and it fails open.** A geofence is
    evaluated on the handset — the server holds no subscriber position, by
    design — so every alert reaches every subscriber and each phone decides
    locally whether to show it. A device that cannot resolve its own position
    shows the alert rather than hiding it.

    **An alert cannot be unsent.** Cancelling stops it reaching phones that
    have not polled yet and does nothing at all to the phones that already have
    it. Compose with `send: false` unless you mean it, and treat `action: send`
    as the irreversible step it is.

    ## Change policy for 2.x
    Additive only. Within 2.x this API gains fields, endpoints, webhook events
    and enum values; it does not rename, remove or retype anything documented
    here, and error `type` strings never change meaning. Two consequences for
    your client: ignore response fields you do not recognise, and parse enums
    tolerantly — a new webhook event or a new `status` value must not crash a
    consumer that has not shipped it yet. Anything that cannot be done
    additively waits for a 3.0 that does not currently exist.

    ## Attaching a file to an alert
    An alert carries a picture, a sound or a document by **asset id**, never by
    upload in this API: the bytes are reserved and PUT over a one-shot URL by
    whoever holds the file, and only the resulting id is passed to
    `POST /v1/pushers/{id}/alerts`. That is what keeps a send cheap and keeps a
    megabyte out of an assistant's context window.
  license:
    name: Proprietary

servers:
  - url: /api
    description: |
      Relative on purpose — the public hostname is a deployment setting, not a
      constant. In this deployment the API is mounted on the app's own origin,
      so `/api/v1/me` is a full path.

security:
  - bearerAuth: []
  - oauth: []

tags:
  - name: Account
  - name: Webhooks
  - name: Pushers
    description: |
      Alert channels. A channel owns a permanent public handle, a category, an
      optional geofence, and the list of capabilities it asks subscribers for.
      Creating one is claiming a name in a shared namespace, which is why
      creation is rate-limited and deletion refuses while anybody still
      subscribes.
  - name: Alerts
    description: |
      What a channel says. One POST covers draft, schedule and send-now — the
      `send` flag and `send_at` decide which — and everything after SENT is
      deliberately narrow: a sent alert is a record, editable never, cancellable
      honestly ("stops reaching phones that have not polled; the rest keep it").
  - name: Analytics
    description: |
      The numbers, named for what they are. `reach` is subscribers NOW ("could
      reach"), `reads`/`acks` are self-reports from devices that chose to send
      one, and there is no "delivered" anywhere because no table can back one.
      Buckets are UTC calendar days — the rollups are written at event time,
      where no account context exists — and every response says
      `"timezone": "UTC"` so nobody has to guess.

paths:
  /v1/me:
    get:
      tags: [Account]
      summary: Your creator profile
      security:
        - bearerAuth: []
        - oauth:
            - account:read
      x-required-scope: account:read
      x-mcp-tool: get_account
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Creator" }

  /v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List endpoints
      security:
        - bearerAuth: []
        - oauth:
            - webhooks:read
      x-required-scope: webhooks:read
      x-mcp-tool: list_webhooks
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Webhook" }
    post:
      tags: [Webhooks]
      summary: Register an endpoint
      security:
        - bearerAuth: []
        - oauth:
            - webhooks:write
      x-required-scope: webhooks:write
      x-mcp-tool: create_webhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  description: |
                    What each event means — and, as important, what is deliberately
                    absent:

                    * `alert.sent` fires when an alert becomes SENT: an immediate
                      send, a published draft, or a scheduled alert being sent
                      early by hand.
                    * `alert.scheduled` fires **when the alert is scheduled, not
                      at its `send_at` instant.** Nothing runs at that minute by
                      design — the alert becomes visible because the inbox query's
                      `send_at <= now()` starts including it, which is the
                      property that lets a server that was down deliver late
                      rather than never. An event AT the instant is therefore
                      impossible, and this is the honest substitute. If you need
                      something to happen at `send_at`, schedule it yourself from
                      this event's payload.
                    * `alert.cancelled` fires on withdrawal, with the usual
                      caveat: phones that already received the alert keep it.
                    * `subscriber.created` / `subscriber.deleted` carry the
                      pusher and a count, never anything device-shaped.
                    * There is **no `alert.delivered` and no `alert.failed`**,
                      and there will not be: no per-device delivery row exists
                      anywhere (a phone's inbox is a query), so an event claiming
                      a delivery would be fiction.
                  items:
                    type: string
                    enum:
                      - alert.sent
                      - alert.scheduled
                      - alert.cancelled
                      - subscriber.created
                      - subscriber.deleted
      responses:
        "201":
          description: Created. `secret` is shown once — store it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }

  /v1/webhooks/{webhook_id}:
    delete:
      tags: [Webhooks]
      summary: Remove an endpoint
      description: |
        Not in the original draft of this document, and added because its absence
        was a real gap: a creator whose endpoint moved had no way to stop
        deliveries to the old one.
      security:
        - bearerAuth: []
        - oauth:
            - webhooks:write
      x-required-scope: webhooks:write
      x-mcp-tool: delete_webhook
      parameters:
        - { name: webhook_id, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: Removed }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/pushers:
    get:
      tags: [Pushers]
      summary: List your channels
      description: |
        Every pusher you may see — for a workspace member restricted to certain
        channels, that restriction is folded in here (an editor limited to two
        channels lists two channels, and the difference is scope, not an error;
        the same applies to a channel-bound key). Each row carries `drafts` and
        `scheduled`, the unsent backlog the studio prints as "2 drafts" against
        a channel card.
      security:
        - bearerAuth: []
        - oauth:
            - channels:read
      x-required-scope: channels:read
      x-mcp-tool: list_pushers
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  allOf:
                    - $ref: "#/components/schemas/Pusher"
                    - type: object
                      properties:
                        drafts:
                          type: integer
                          description: Saved and invisible — `send` was never true.
                        scheduled:
                          type: integer
                          description: Waiting for their `send_at` to arrive.
    post:
      tags: [Pushers]
      summary: Create a channel
      description: |
        Creating a channel claims a permanent public handle at
        `{{host}}/<handle>` — permanent because it gets printed on posters and
        encoded in QR codes, which is also why it can never be edited afterwards
        (see PATCH). The unique index is the authority on whether a handle is
        free: two requests for the same one a millisecond apart both get an
        answer, and the loser's answer is the 409 below.

        Rate-limited to 10 per minute per creator — a handle is a claim on a
        name in a shared namespace, so creating them in a loop is squatting
        rather than usage.
      security:
        - bearerAuth: []
        - oauth:
            - channels:write
      x-required-scope: channels:write
      x-mcp-tool: create_pusher
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [handle, name, category, description]
                  properties:
                    handle:
                      type: string
                      minLength: 3
                      maxLength: 32
                      pattern: "^[a-z0-9](?:-?[a-z0-9])*$"
                      description: |
                        `{{host}}/<handle>`. Lowercase letters, digits and single
                        dashes; lowercased for you if you forget. No leading or
                        trailing dash and no double dash (`xn--` is the punycode
                        prefix, and a handle that looks like an internationalised
                        domain in a printed QR code is a phishing primitive).
                        Route-shaped and official-looking words (`admin`, `api`,
                        `verified`, …) are reserved and refused.
                - $ref: "#/components/schemas/PusherInput"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Pusher" }
        "409":
          description: The handle is taken.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/pushers/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Pushers]
      summary: Fetch one channel
      description: |
        The pusher plus the two numbers the studio header shows: `subscribers`
        (recomputed from the subscription table on this single-row read, because
        the denormalised counter is the thing most likely to have drifted) and
        `alerts_sent`, the all-time count of SENT alerts.
      security:
        - bearerAuth: []
        - oauth:
            - channels:read
      x-required-scope: channels:read
      x-mcp-tool: get_pusher
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Pusher"
                  - type: object
                    properties:
                      subscribers:
                        type: integer
                        description: Recounted from the subscription table, not the cached counter.
                      alerts_sent:
                        type: integer
                        description: All-time SENT alerts on this channel.
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Pushers]
      summary: Update a channel
      description: |
        A PATCH in the real sense: a field that is absent is left alone; a field
        that is present and null is cleared. Two rules with reasons:

        **The handle is not editable**, and that is a product decision rather
        than a missing feature. It is printed on posters, encoded in QR codes
        and typed by people; a rename breaks every one of those with no redirect
        possible. Sending a different `handle` is a 422; a creator who needs a
        new one creates a new pusher and tells their subscribers, which is the
        honest version of what a rename would be anyway.

        **`status` takes only `active` and `paused`** — the creator's own pause
        switch. `suspended` is staff's: accepting it here would let a creator
        suspend themselves into a state only an admin can undo, and a suspended
        channel answers 403 to any attempt to flip its status.
      security:
        - bearerAuth: []
        - oauth:
            - channels:write
      x-required-scope: channels:write
      x-mcp-tool: update_pusher
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/PusherInput"
                - type: object
                  properties:
                    status:
                      type: string
                      enum: [active, paused]
                      description: |
                        Pausing keeps the handle (nobody can impersonate a paused
                        channel) and refuses sends until resumed.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Pusher" }
        "403":
          description: |
            The channel is suspended — its status is staff's to change, not
            yours. `error.type` is `forbidden`; contact support.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
    delete:
      tags: [Pushers]
      summary: Delete a channel
      description: |
        Deleting takes the subscribers and the alert history with it, and it
        also **frees the handle** — which is the part that cannot be undone.
        Somebody else may claim it the moment this returns, and every printed
        QR code then points at them. So a channel with subscribers refuses
        (the 409 below), and the remedy offered is the one that is actually
        reversible: pause it. A handle held by a paused channel is a handle
        nobody can impersonate.
      security:
        - bearerAuth: []
        - oauth:
            - channels:write
      x-required-scope: channels:write
      x-mcp-tool: delete_pusher
      responses:
        "204": { description: "Deleted, handle freed." }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The channel still has subscribers. Pause it instead.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/pushers/{id}/invites:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Pushers]
      summary: The ways in to a private channel
      description: |
        Every invite on this channel, newest first, **codes included** — this
        surface is already authenticated as the channel's own creator, and a
        link the owner cannot read back is a link they cannot print next month.

        A private channel answers nobody who cannot present one of these. A
        public channel may hold invites and they do nothing; anyone can
        subscribe to it.
      security:
        - bearerAuth: []
        - oauth:
            - channels:read
      x-required-scope: channels:read
      x-mcp-tool: list_invites
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Invite" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Pushers]
      summary: Mint an invite link
      description: |
        **Hand out one per audience** — the poster at reception, the letter to
        one class, the on-call runbook. That is what makes it possible later to
        kill exactly the link that leaked without locking out everybody else;
        one code per channel makes that question unanswerable.

        The code is drawn from the CSPRNG and is never derived from the handle:
        a code anybody can compute from the visible half of a link is
        decoration. `url` is the whole string a QR code should encode.
      security:
        - bearerAuth: []
        - oauth:
            - channels:write
      x-required-scope: channels:write
      x-mcp-tool: create_invite
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: [string, "null"]
                  maxLength: 60
                  description: Where this link went, in your words. Never shown to a subscriber.
                max_uses:
                  type: [integer, "null"]
                  minimum: 1
                  description: How many phones may join with it. Null for unlimited; a one-shot link is 1.
                expires_at:
                  type: [string, "null"]
                  format: date-time
                  description: |
                    When it stops working. Null for never. A time in the past is
                    a 422 rather than a row: it would be a link that has never
                    worked and cannot be told apart, in the list, from one that
                    worked yesterday.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Invite"
                  - type: object
                    properties:
                      notes:
                        type: array
                        items: { type: string }
                        description: |
                          Present and non-empty when the channel is public, to
                          say that the code is not required there yet.
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }

  /v1/pushers/{id}/invites/{inviteId}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: inviteId, in: path, required: true, schema: { type: string } }
    delete:
      tags: [Pushers]
      summary: Revoke an invite link
      description: |
        **Revoking and evicting are two different acts and you must say which.**
        By itself this stops the link admitting anybody else and costs nothing:
        every phone that already joined with it keeps the channel, which is what
        you want when a poster is replaced.

        `?remove_subscribers=true` **also** unsubscribes exactly the devices
        that came in through this link — never the ones that predate invites,
        never another code's. That is destructive and silent: those people
        simply stop receiving the channel, with no message.

        The row is not deleted. `revoked_at` is a timestamp, so the
        subscriptions it admitted still point at something and "when did we cut
        this off" has an answer. Revoking twice keeps the first timestamp.
      parameters:
        - name: remove_subscribers
          in: query
          required: false
          schema: { type: string, enum: ["true"] }
          description: Also unsubscribe the devices that joined with this link.
      security:
        - bearerAuth: []
        - oauth:
            - channels:write
      x-required-scope: channels:write
      x-mcp-tool: revoke_invite
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Invite"
                  - type: object
                    properties:
                      removed_subscribers:
                        type: integer
                        description: How many subscriptions this call deleted. 0 unless you asked.
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/pushers/{id}/alerts:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Alerts]
      summary: List a channel's alerts
      description: |
        The history, newest first, with keyset paging on `created_at` rather
        than an offset — the history of a busy channel is appended to
        constantly, and `OFFSET 50` in that situation skips rows that moved
        rather than showing the next page. Pass `meta.next_before` back as
        `before` for the next page; null means you have it all.
      security:
        - bearerAuth: []
        - oauth:
            - alerts:read
      x-required-scope: alerts:read
      x-mcp-tool: list_alerts
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [draft, scheduled, sent, cancelled] }
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - name: before
          in: query
          description: An ISO 8601 instant — rows created strictly before it.
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/PusherAlert" }
                  meta:
                    type: object
                    properties:
                      count: { type: integer, description: Rows on this page. }
                      subscribers:
                        type: integer
                        description: |
                          The channel's subscriber count now — the same number
                          each row's `reach` carries, stated once here so a list
                          renderer does not have to reach into a row for it.
                      next_before:
                        type: [string, "null"]
                        description: Pass back as `before`. Null when this is the last page.
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
    post:
      tags: [Alerts]
      summary: Compose an alert — draft, schedule or send
      description: |
        One POST, three outcomes, because they are one action with one
        validation:

        | body | result |
        |---|---|
        | `send: false` (or absent) | `draft` — saved, invisible |
        | `send: true`, future `send_at` | `scheduled` — visible from that instant, no scheduler involved |
        | `send: true`, no `send_at` | `sent` — visible now |

        Composing needs `alerts:write` — that is this operation's declared
        scope. **`send: true` additionally requires `alerts:send`**, checked
        against the body: it is the authority to ring phones, which composing
        is not. Without it the refusal is the standard 403
        `insufficient_scope`, WWW-Authenticate header and all.

        Sending is one UPDATE, not a fan-out — there is no queue and no worker,
        and a scheduled alert needs nothing to run at the appointed minute (the
        inbox query starts including it). Publishing costs the same at four
        subscribers and four million.

        The response is the created alert plus two report fields worth reading:
        `blocked`, what the HTML sanitiser removed (not an error — the alert was
        accepted — but markup that silently loses half its content is a support
        ticket), and `notes`, the capabilities this alert reaches for that its
        channel never asked subscribers for. A siren on a channel that never
        requested `sound` is simply silent — nothing fails, so the sentence in
        `notes` is the only way the author finds out. The fix is almost always
        to tick one more box on the channel.

        Sends are rate-limited to 30 per minute **per pusher** — a courtesy to
        the people receiving them, not to our database, and per pusher so one
        busy channel cannot quiet another belonging to the same creator.
        Drafting is never rate-limited: a creator drafting twenty alerts for
        tomorrow's festival is doing their job.
      security:
        - bearerAuth: []
        - oauth:
            - alerts:write
      x-required-scope: alerts:write
      x-mcp-tool: create_alert
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [title]
                  properties:
                    send:
                      type: boolean
                      default: false
                      description: |
                        The irreversible bit. Requires `alerts:send`. With a
                        future `send_at` this schedules; without one it
                        publishes immediately; false saves a draft.
                - $ref: "#/components/schemas/AlertInput"
      responses:
        "201":
          description: Created — as a draft, scheduled, or already sent; read `status`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PusherAlert"
                  - type: object
                    properties:
                      blocked:
                        type: array
                        items: { type: string }
                        description: What the sanitiser removed from an HTML body.
                      notes:
                        type: array
                        items: { type: string }
                        description: |
                          What will not happen, and why — capabilities used here
                          that the channel never requested from subscribers.
        "403":
          description: |
            Either `insufficient_scope` (`send: true` without `alerts:send` —
            the `WWW-Authenticate` header is present and names the scope) or
            `forbidden` (the pusher is suspended and cannot send).
          headers:
            WWW-Authenticate:
              description: Present only on the scope refusal.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            The pusher is paused. A creator who paused a channel and then sent
            an alert has contradicted themselves, and the resolution they want
            is this message — not an alert that goes nowhere. Resume it, or
            save the alert as a draft.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/pushers/{id}/alerts/{alertId}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: alertId, in: path, required: true, schema: { type: string } }
    get:
      tags: [Alerts]
      summary: Fetch one alert
      security:
        - bearerAuth: []
        - oauth:
            - alerts:read
      x-required-scope: alerts:read
      x-mcp-tool: get_alert
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PusherAlert" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Alerts]
      summary: Edit, send or cancel an alert
      description: |
        The body is EITHER `{"action": "send"}` / `{"action": "cancel"}` OR a
        partial edit of the fields — never both. Sending is a state change on a
        row the caller already identified, which is why it is an action on this
        PATCH rather than its own route: two paths that load-and-authorise the
        same object is how one of them ends up wrong. Over MCP the forms are
        separate tools: `update_alert` for the edit, `send_alert` and
        `cancel_alert` for the actions.

        **Both actions require `alerts:send`.** Send is the obvious one; cancel
        is the same authority because withdrawing changes what reaches phones —
        a write-only integration must not be able to retract a live evacuation
        notice it could never have issued.

        **`action: send` on an already-SENT alert is idempotent, not an
        error** — a double-tapped Send button, or a client retrying a request
        that timed out after it succeeded, must not read as a failure, and the
        status guard underneath means it cannot re-notify. A CANCELLED alert
        cannot be re-sent (409 — duplicate it instead), and sending refuses
        while the pusher is not active (409).

        **A sent alert is not editable**, and it is not a limitation to be
        worked around later. The moment it is SENT the text is on lock screens
        and — for an urgent one — has already spoken itself aloud in somebody's
        kitchen. Editing the row would change what the next phone to poll
        receives while every phone that already has it shows the old words: two
        versions of an evacuation instruction in circulation with no way to
        tell which one you got. Cancel it and send a correction. Cancelled
        alerts are equally frozen — duplicate instead.

        Cancelling answers with a `note` stating exactly what happened: the
        alert stops reaching anyone who has not polled yet, and the phones that
        already received it keep it. It is not a recall, and the response says
        so because an integration calling this from a script deserves the same
        honesty a human gets in the studio.

        On a SCHEDULED alert, an edit that moves `send_at` into the future
        keeps it scheduled; an edit that clears `send_at` drops it back to a
        draft. Recomputed rather than preserved, so the status can never
        disagree with the timestamp it describes.
      security:
        - bearerAuth: []
        - oauth:
            - alerts:write
      x-required-scope: alerts:write
      x-mcp-tool: update_alert
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  title: An action
                  required: [action]
                  properties:
                    action:
                      type: string
                      enum: [send, cancel]
                      description: Requires `alerts:send`, both of them.
                - $ref: "#/components/schemas/AlertInput"
      responses:
        "200":
          description: The alert after the edit or action.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PusherAlert"
                  - type: object
                    properties:
                      blocked:
                        type: array
                        items: { type: string }
                        description: Edits only — what the sanitiser removed.
                      notes:
                        type: array
                        items: { type: string }
                        description: Edits only — capabilities the channel never asked for.
                      note:
                        type: string
                        description: |
                          Cancel only — the honest sentence about what cancelling
                          did and did not do.
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            The state refuses the verb: sending a CANCELLED alert, sending while
            the pusher is not active, or editing a SENT or CANCELLED alert. The
            message names the way out (resume, duplicate, or cancel-and-correct).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Alerts]
      summary: Delete a draft or scheduled alert
      description: |
        **A SENT alert is a record and is not deletable** (409). It is what a
        school points at when asked whether it told the parents, and what a
        city points at when asked when it warned people — and subscribers'
        acknowledgements reference it, so deleting it would erase somebody's
        acknowledgement along with it. Cancel takes it out of circulation; the
        row stays. Drafts, scheduled and cancelled alerts delete normally.
      security:
        - bearerAuth: []
        - oauth:
            - alerts:write
      x-required-scope: alerts:write
      x-mcp-tool: delete_alert
      responses:
        "204": { description: Deleted }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The alert was sent — it is the record of what was said and when.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/pushers/{id}/alerts/{alertId}/stats:
    get:
      tags: [Analytics]
      summary: One alert's read timeline
      description: |
        Aggregate buckets of read and ack reports over time — 30-minute buckets
        for the first six hours after sending, hourly after that. Built from
        the per-device state reports that **still exist**: devices prune their
        oldest reports, so an old alert's timeline thins out honestly, and the
        `note` field says so on every response rather than only here — a chart
        that silently decays looks like a bug. The `reads`/`acks` totals come
        from counters and do not decay; only the timeline's shape does. Past
        10,000 surviving reports the timeline is a truthful-but-sampled view;
        the totals are exact either way.
      security:
        - bearerAuth: []
        - oauth:
            - analytics:read
      x-required-scope: analytics:read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: alertId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlertTimeline" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/pushers/{id}/stats:
    get:
      tags: [Analytics]
      summary: One channel's aggregate story
      description: |
        Honest numbers only, named accordingly: `reads` and `acks` are
        self-reports from devices that chose to send one; per-alert `reach` is
        the subscriber count NOW, which the studio prints as "could reach" —
        there is no per-device delivery row anywhere in this product and
        therefore no "delivered" figure, deliberately. There is also no
        computed read-rate across alerts: reach-then ≠ reach-now, and a
        percentage we cannot stand behind is worse than two honest integers
        side by side.

        Ranges are **UTC calendar days** — unlike the audio `/v1/stats`, which
        resolves in your own zone — because these buckets are written at event
        time, where no creator context exists. The response says
        `"timezone": "UTC"` so nobody has to guess at the difference.

        When a plan limits how far back analytics may look, the range is
        clamped and `window_days_max` appears saying so — visible on the wire
        rather than silent, because a shorter chart with no explanation reads
        as data loss. Until billing lands nothing clamps and the field is
        absent.
      security:
        - bearerAuth: []
        - oauth:
            - analytics:read
      x-required-scope: analytics:read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - name: period
          in: query
          schema: { type: string, enum: [day, week, month], default: week }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to,   in: query, schema: { type: string, format: date } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PusherStats" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }

  /v1/pushers/{id}/subscribers:
    get:
      tags: [Analytics]
      summary: Pseudonymous subscriber records
      description: |
        What a channel may see about the people it can reach, and the line it
        may never cross: platform, app version, locale, timezone, when they
        subscribed, whether they are muted. Never a name, an email, a location,
        or per-device engagement — the subscribe screen promises "the channel
        never learns who you are", and this endpoint is that promise kept.

        **`device` is a hash, never the raw id, and that is load-bearing.** The
        raw device id is the claim a phone authenticates with — a
        write-credential-shaped value: whoever holds it can forge that device's
        read and ack reports and rewrite its subscriptions. So the wire carries
        HMAC-SHA256 of it under a server secret, truncated to 10 hex characters:
        stable (you can follow `a7f39c1b2e` across pages), useless to replay.

        Keyset-paged on `subscribed_at` like the alert list; `breakdown` is
        computed over the whole subscription set, not the page.
      security:
        - bearerAuth: []
        - oauth:
            - subscribers:read
      x-required-scope: subscribers:read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 100 }
        - name: before
          in: query
          description: An ISO 8601 instant — subscriptions created strictly before it.
          schema: { type: string, format: date-time }
        - name: status
          in: query
          schema: { type: string, enum: [active, muted] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/SubscriberRecord" }
                  meta:
                    type: object
                    properties:
                      count: { type: integer, description: Rows on this page. }
                      total: { type: integer, description: All subscribers of this channel. }
                      next_before:
                        type: [string, "null"]
                        description: Pass back as `before`. Null when this is the last page.
                  breakdown:
                    type: object
                    description: Over the whole subscription set, not the page.
                    properties:
                      platform:
                        type: object
                        additionalProperties: { type: integer }
                        description: Counts by platform; unknowns bucket under "unknown".
                      locale:
                        type: object
                        additionalProperties: { type: integer }
                      timezone:
                        type: object
                        additionalProperties: { type: integer }
                      muted:
                        type: integer
                        description: Currently muted (their `muted_until` is in the future).
                      new_30d:
                        type: integer
                        description: Subscribed in the last 30 days.
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }

  /v1/drop/stats:
    get:
      tags: [Analytics]
      summary: Account-level statistics
      description: |
        Every channel you may see, summed, plus a per-channel breakdown for a
        "by channel" rail. Same honesty contract as `/v1/pushers/{id}/stats` —
        reads/acks are device reports, reach is now-not-then, UTC days,
        `window_days_max` appears when a plan clamps — read that operation's
        description first. "Every channel you may see" is literal: a member's
        channel restriction and a bound key's channel are folded in, so the
        account view is not a side door around either.

        The path lives under `/v1/drop` rather than `/v1/pushers/stats` because
        the latter would collide with `/v1/pushers/{id}` routing.
      x-mcp-tool: get_stats
      security:
        - bearerAuth: []
        - oauth:
            - analytics:read
      x-required-scope: analytics:read
      parameters:
        - name: period
          in: query
          schema: { type: string, enum: [day, week, month], default: week }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to,   in: query, schema: { type: string, format: date } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DropStats" }
        "422": { $ref: "#/components/responses/Invalid" }

  /v1/drop/preview:
    post:
      tags: [Alerts]
      summary: Preview alert HTML through the real sanitiser
      description: |
        The write path's own sanitiser, as a preview. A preview rendered from
        your raw input would be a lie twice over — it would show markup the
        phone will never render, and it would run markup in your own browser
        that the write path exists to neuter. This round-trips through the
        literal functions the write path calls, so it cannot drift from what a
        phone will actually get. It stores nothing and sends nothing.

        Under `/v1` rather than a studio-internal route deliberately:
        key-authenticated, so a script or an MCP-driven model composing HTML
        can see the honest result too. For `layout: html` you get the cleaned
        fragment plus `blocked`, the removal report. For `layout: document`
        the page is kept whole — it renders inside the phone's sandboxed
        iframe where scripts are inert — so nothing is blocked and
        `script_count` tells you how many scripts will be carried, inert.
      security:
        - bearerAuth: []
        - oauth:
            - alerts:write
      x-required-scope: alerts:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [html]
              properties:
                html:
                  type: string
                  description: |
                    At most 60,000 characters for `html`, four times that for
                    `document` — the same caps the write path enforces.
                layout:
                  type: string
                  enum: [html, document]
                  default: html
      responses:
        "200":
          description: The sanitised result — exactly what an alert body would store.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PreviewResult" }
        "422": { $ref: "#/components/responses/Invalid" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Send your key as: `Authorization: Bearer sk_live_...`. Scopes apply to
        keys exactly as they do to OAuth grants — a key carries the list chosen
        when it was minted, checked with the same implication rules (see the
        Scopes section in the header). Keys minted before scopes existed are
        grandfathered as full access and labeled so in the studio; a key may
        also be bound to a single channel, which narrows both what it sees and
        what scopes it may carry.
    oauth:
      type: oauth2
      description: |
        The MCP grant. `/api/mcp` clients obtain this through OAuth 2.1
        authorization code with PKCE and dynamic client registration; the
        consent screen shows the scopes below as checkbox rows, and the grant
        is revocable per client. Grants that predate scopes (they carry only
        OIDC identity scopes) are grandfathered as full access, labeled so in
        the connections UI. Each operation names its required scope in its
        `security` entry; the implication rules (`write` ⊃ `read`,
        `alerts:send` ⊃ `alerts:write` ⊃ `alerts:read`) mean a grant holding
        the stronger scope satisfies the weaker requirement.
      flows:
        authorizationCode:
          authorizationUrl: /api/auth/mcp/authorize
          tokenUrl: /api/auth/mcp/token
          scopes:
            "account:read": Handle, display name and follower count. Every key and grant includes this.
            "uploads:read": List episodes and their statuses.
            "uploads:write": Start uploads, publish them into listeners' alarms, withdraw episodes.
            "streams:read": Stream URLs, health and backups.
            "streams:write": Register, repoint, probe and remove streams.
            "schedule:read": Your publishing commitment and coverage.
            "schedule:write": Time zone, deadline and fallback behaviour.
            "stats:read": Plays, listeners and follower counts.
            "webhooks:read": Registered endpoints and their health — never their secrets.
            "webhooks:write": Register and remove endpoints that receive your account's events.
            "channels:read": Alert channels, their settings and subscriber counts.
            "channels:write": Create, edit, pause and delete alert channels.
            "alerts:read": Alerts, their reads and acknowledgements.
            "alerts:write": Write and edit drafts. Never publishes anything.
            "alerts:send": Publish now, schedule, and cancel. An alert cannot be unsent.
            "analytics:read": Sends, reads, acknowledgements and subscriber trends.
            "subscribers:read": Pseudonymous device records — platform, locale, timezone. Never identities.

  schemas:
    Creator:
      type: object
      properties:
        id:          { type: string, examples: ["crt_9Qm4x"] }
        handle:      { type: string, examples: ["mayarivers"] }
        display_name: { type: string }
        category:    { type: string }
        language:    { type: string }
        links:
          type: object
          additionalProperties: { type: string, format: uri }

    Webhook:
      type: object
      properties:
        id:     { type: string }
        url:    { type: string, format: uri }
        events: { type: array, items: { type: string } }
        secret:
          type: [string, "null"]
          description: |
            HMAC-SHA256 key for the `X-Webhook-Signature` header. Returned once, at
            creation, and never again.

            The header is `t=<unix seconds>,v1=<hex>`, and the signature is over
            `` `${t}.${rawBody}` `` — NOT over the body alone. Verify the timestamp
            as well as the digest: a signature over the body alone is valid for
            ever, so anyone who captures one delivery can replay it at any time and
            your check will pass.

    Invite:
      type: object
      description: |
        A way in to a private channel — the credential a QR code carries. It
        decides whether a device may SUBSCRIBE and nothing else: how loud the
        resulting alerts are allowed to be is still the subscriber's own grant
        map. An invite buys a channel an audience, never a capability.
      properties:
        id: { type: string, examples: ["clx9f2m4k0001abcd"] }
        code:
          type: string
          description: |
            The secret itself, base62 from the CSPRNG and never derived from the
            handle. Returned in full to the owner on every read — this surface is
            already authenticated as the channel's creator, and a link they
            cannot read back is one they cannot print next month.
        url:
          type: string
          description: The printable link, and the exact string a QR code should encode.
          examples: ["https://{{host}}/ops-nightshift?k=7Fk2mQ3aB9xLp4Rt"]
        label:
          type: [string, "null"]
          description: Where this link went, in the owner's words. Never shown to a subscriber.
        max_uses:
          type: [integer, "null"]
          description: Null means unlimited.
        uses:
          type: integer
          description: |
            Claimed once per NEW subscription, atomically. Reading the subscribe
            screen spends nothing, and neither does re-subscribing to change
            grants.
        expires_at: { type: [string, "null"], format: date-time }
        revoked_at:
          type: [string, "null"]
          format: date-time
          description: |
            Set means it admits nobody else. The row survives revocation so the
            subscriptions it made still point at something.
        state:
          type: string
          enum: [active, revoked, expired, used_up]
          description: |
            The one word for why it will not work, rather than leaving a caller
            to derive it from three columns.
        created_at: { type: string, format: date-time }

    Pusher:
      type: object
      description: |
        A channel as its owner sees it. The shape a phone receives is this
        minus `is_public`, `status`, `tags`, the `*_asset_id` pair and the
        timestamps — the operational fields are the owner's business, not a
        subscriber's. Both shapes are allowlists: a column added to the table
        later is invisible on the wire until somebody publishes it on purpose.
      properties:
        id:      { type: string, examples: ["psh_7Fk2mQ3a"] }
        handle:
          type: string
          description: Permanent. `{{host}}/<handle>` — see POST /v1/pushers for why it never changes.
        name:        { type: string }
        category:
          type: string
          enum: [cities, travel, events, concerts, restaurants, hotels, business, ai, sports, emergency, community]
        description: { type: string, description: The one line shown in search results. }
        long:        { type: [string, "null"] }
        site:        { type: [string, "null"], description: https only. }
        verified:    { type: boolean, description: Staff-set. Not yours to write. }
        image_url:
          type: [string, "null"]
          description: |
            The channel's avatar — square, and drawn next to every alert it
            sends, in search results and on the subscribe screen.

            ONE FIELD, TWO SOURCES, AND THE UPLOAD WINS. Set it yourself by
            PATCHing `image_url` with an absolute https link to a picture you
            host; or upload one in the console, in which case this comes back
            as `https://<this host>/api/media/<asset_id>` and whatever link
            you set is left in place but no longer emitted. Neither way is
            deprecated: a broadcaster with a CDN and a script has no reason to
            upload, and a school with an mp3 on a laptop has no CDN.

            An uploaded avatar is stored as a 512x512 WebP re-encoded from what
            was picked, so this URL never serves the original file.
        background_url:
          type: [string, "null"]
          description: |
            The wide image behind this channel's card — 800x450 WebP, served
            from `/api/media/<asset_id>` on this host, or null for a channel
            that has not uploaded one (its card then draws `gradient` and
            `initials`, which is what those fields are for).

            READ-ONLY ON THIS API and it is uploaded in the console, which is
            the one asymmetry with `image_url` worth knowing about. There is no
            `background_url` to PATCH because this field has no
            somebody-else-hosts-it history to be compatible with, and a second
            way to spell a brand-new field is a second thing that can disagree
            with the first.

            THIS WAS 1600x900 AND IS NOW 800x450. Nothing about the field
            changed — same URL shape, same 16:9 crop — but a client that had
            hard-coded the stored dimensions is now wrong about them, and one
            that draws the image into a box it chose itself is not.
        background_small_url:
          type: [string, "null"]
          description: |
            The same picture at 400x225, for drawing this channel in a LIST
            rather than as the header of its own screen. Two stored objects,
            two ids, nothing resized on read — so a client may pick either one
            and neither expires.

            NULL IS NOT AN ERROR AND NOT AN ABSENCE OF BACKGROUND. It means
            either that this channel has none at all (`background_url` is null
            too), or that it was branded before the small rendition existed, in
            which case `background_url` is the whole answer. A client that
            reads only `background_url` stays correct forever; this field is
            an optimisation, and one worth taking on a handset, where the large
            one is four times the pixels a list row can draw.
        gradient:
          type: array
          description: "[from, to] hex colours for the fallback avatar."
          items: { type: string }
          minItems: 2
          maxItems: 2
        initials:    { type: string, description: "At most two characters, for the fallback avatar." }
        city:        { type: [string, "null"] }
        geo:
          description: |
            The channel's own circle, or null for a channel that is national or
            global. Public information — it describes a circle on a map, not a
            person — and it travels to devices because the device is what
            evaluates it, which is what keeps a subscriber's position on their
            own handset.
          oneOf:
            - type: object
              properties:
                lat:       { type: number }
                lon:       { type: number }
                radius_km: { type: number }
            - { type: "null" }
        loc_rule:    { type: [string, "null"], description: A human-readable sentence about where alerts apply. }
        requested_perms:
          type: array
          description: |
            What this channel asks subscribers for, shown as switches on the
            subscribe screen BEFORE the button. `notify` is always present — a
            subscription that may not deliver anything is an unsubscription
            with extra steps. Emitted in catalogue order so two channels that
            asked for the same things render identically.
          items:
            type: string
            enum: [notify, sound, vibrate, tts, full, loc, quiet]
        alert_types: { type: array, items: { type: string } }
        subscribers: { type: integer }
        image_asset_id:
          type: [string, "null"]
          description: |
            Owner shape only. The uploaded avatar's MediaAsset id, or null when
            there is none. It exists because `image_url` cannot answer "is a
            picture uploaded?" — that field is non-null for an uploaded avatar
            and for a linked one alike, and the console needs to know which it
            is looking at before it offers to remove one.
        background_asset_id:
          type: [string, "null"]
          description: Owner shape only. The card background's MediaAsset id, or null.
        is_public:
          type: boolean
          description: |
            `false` means PRIVATE, and private means sealed rather than merely
            unlisted: the channel is absent from browse and search, and knowing
            its handle admits nobody. Only a caller presenting an invite code
            can read the subscribe screen or subscribe. See
            `/v1/pushers/{id}/invites`.
        status:
          type: string
          enum: [active, paused, suspended]
          description: |
            `active` and `paused` are yours; `suspended` is staff's, and a
            suspended channel can neither send nor un-suspend itself.
        tags:        { type: array, items: { type: string } }
        created_at:  { type: string, format: date-time }
        updated_at:  { type: string, format: date-time }

    PusherInput:
      type: object
      description: |
        The writable fields, shared by create and update. On POST, `name`,
        `category` and `description` are required (stated at the operation);
        on PATCH everything is optional, and the distinction between absent
        and null is load-bearing: `{"city": null}` clears the city, `{}`
        leaves it alone. An edit form that renders a subset of the record must
        not silently wipe the rest of it.
      properties:
        name:        { type: string, maxLength: 60 }
        category:
          type: string
          enum: [cities, travel, events, concerts, restaurants, hotels, business, ai, sports, emergency, community]
        description: { type: string, maxLength: 160, description: Required on create — it is the one line shown in search results. }
        long:        { type: [string, "null"], maxLength: 1200 }
        site:
          type: [string, "null"]
          maxLength: 200
          description: |
            https only, and this is the one place refusing cleartext costs a
            caller something real. It is refused anyway: this URL is opened
            from a notification a reader trusted, on a phone that may be on a
            café network — and the app's release build would refuse to load
            http regardless, so accepting it would store a link that can only
            fail later.
        city:        { type: [string, "null"], maxLength: 80 }
        lat:
          type: [number, "null"]
          minimum: -90
          maximum: 90
          description: |
            The geofence is all-or-nothing: `lat`, `lon` and `radius_km`
            together, or all null. A latitude with no radius describes no
            circle and would silently deliver nothing to a location-filtered
            subscriber — which presents as a channel that does not work rather
            than as a configuration mistake, so the half-filled form is a 422.
        lon:         { type: [number, "null"], minimum: -180, maximum: 180 }
        radius_km:
          type: [number, "null"]
          exclusiveMinimum: 0
          maximum: 500
          description: |
            Kilometres, decimal. Small circles are legitimate — see the same
            field on an alert for what a handset's own accuracy does to one.
        loc_rule:    { type: [string, "null"], maxLength: 240 }
        requested_perms:
          type: array
          description: |
            `notify` is added if you forget it — a channel that never asks for
            `notify` has asked for nothing at all. Unknown keys are dropped
            rather than rejected: an unknown key is a client from a newer
            build, and losing one permission is a better failure than losing
            the write.
          items:
            type: string
            enum: [notify, sound, vibrate, tts, full, loc, quiet]
        alert_types:
          type: array
          maxItems: 8
          items: { type: string, maxLength: 60 }
        tags:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 40 }
          description: Lowercased, deduplicated. Searched alongside the name.
        is_public:
          type: boolean
          default: true
          description: |
            `false` seals the channel: only a phone holding an invite code may
            subscribe or read what it has sent. Creating a channel private, or
            switching an open one to private, mints a first invite link and
            returns it as `invite` — a private channel with no way in would be
            a channel nobody, including its owner, can ever join. Existing
            subscribers are never removed by the switch.

            **The default is `true`, and omitting the field creates a channel
            anyone can find and join.** That default is kept for compatibility
            with scripts written against it; the MCP `create_pusher` tool makes
            the field REQUIRED instead, because a model omitting a parameter is
            not the same act as an operator accepting a documented default.
        image_url:   { type: [string, "null"], description: https only. }
        gradient_from:
          type: string
          pattern: "^#[0-9a-fA-F]{6}$"
          description: Fallback-avatar gradient. An invalid colour keeps the current (or default) one.
        gradient_to:
          type: string
          pattern: "^#[0-9a-fA-F]{6}$"
        initials:
          type: string
          maxLength: 2
          description: Derived from the name when not supplied, so unbranded channels are not a column of identical grey circles.

    AlertAction:
      type: object
      description: |
        A button on the alert card. The verbs are a closed list because each
        one means something to the app: `ack` puts the acknowledgement button
        on the card and is the only one that writes back, `open` leaves for a
        URL, `dismiss` is the quiet exit, `snooze` postpones. A free-form
        action would render as a button that does nothing on every build that
        has not shipped it yet, which is worse than not offering it.
      required: [k, label]
      properties:
        k:
          type: string
          enum: [ack, open, dismiss, snooze]
        label: { type: string, maxLength: 40 }
        url:
          type: string
          description: Required for `open`; https only, for the same reason as `site` on the channel.

    AlertInput:
      type: object
      description: |
        The writable fields of an alert, shared by create and PATCH-edit. On
        create, `title` is required (stated at the operation); on edit
        everything is optional with the same absent-vs-null rule as
        PusherInput. Everything under "presentation" — `sound`, `vibrate`,
        `tts_*` — is a REQUEST the phone ANDs with the subscriber's grant and
        their global switches; null means "the priority default".
      properties:
        title: { type: string, maxLength: 120 }
        body:  { type: [string, "null"], maxLength: 2000 }
        priority:
          type: string
          enum: [normal, important, urgent]
          default: normal
          description: |
            A ceiling, not a command — and it defaults to the quiet one, which
            is the only reading under which a caller that never mentions
            loudness cannot accidentally be loud. An explicitly wrong value is
            still a 422; there is no guessing, only an absence with an obvious
            meaning.
        layout:
          type: string
          enum: [text, image, html, document]
          default: text
        image_url:
          type: [string, "null"]
          description: |
            https only. Satisfies `layout: image` on its own, as does
            `image_asset_id`; one of the two is required for that layout.
        image_asset_id:
          type: [string, "null"]
          description: |
            A picture UPLOADED to this service rather than linked. The alert
            then travels with `image_url` pointing at our own media route, so
            nothing on the phone distinguishes the two — and this field wins
            where both are set.

            THE ID IS MINTED IN THE STUDIO, NOT HERE. Uploading is a browser
            problem — a file on somebody's desktop — and the three calls that
            do it are session-authenticated and deliberately outside this
            contract, for the same reason `cover_url` exists and a cover
            upload endpoint does not. A script already has a URL for its
            picture and should send `image_url`. This field is documented
            because the studio composes through this endpoint and you will see
            it on the wire.

            Sending null detaches an uploaded picture from a draft.
        audio_asset_id:
          type: [string, "null"]
          description: |
            The same arrangement for the alert's sound file: an upload we hold,
            surfaced to the phone as `audio_url`. Wins over `audio_url` where
            both are set, and null detaches it.
        html_asset_id:
          type: [string, "null"]
          description: |
            An uploaded .html file, kept as the author wrote it. IT IS NOT WHAT
            PHONES RENDER: the file's text is put through exactly the sanitiser
            (or, for `layout: document`, the sandbox) that `html` below goes
            through, and the result is what is stored and sent. The file itself
            is a record you can download back, served as `text/plain` and as an
            attachment — never as markup, from any origin.

            Send it INSTEAD of `html` and the file's contents become the body.
            Send BOTH and `html` wins — the file is then only a record of where
            the body came from. That order exists because the studio uploads a
            file, shows its text in an editable box, and sends the box: if the
            file won, every edit made after uploading would vanish at the moment
            of sending. Null detaches.
        html:
          type: [string, "null"]
          description: |
            Required when `layout` is `html` or `document`. For `html` it is
            sanitised (at most 60,000 characters) and the removals come back in
            `blocked`; for `document` the page is kept whole up to four times
            that, rendered in the phone's sandboxed iframe where scripts are
            inert. `POST /v1/drop/preview` runs the identical code without
            storing anything.
        actions:
          type: array
          maxItems: 4
          items: { $ref: "#/components/schemas/AlertAction" }
        requires_ack:
          type: boolean
          default: false
          description: |
            Puts the acknowledgement button on the card. Combined with
            `expires_at`, the window must stay open at least 5 minutes — the
            reader may be asleep, and an acknowledgement window shorter than
            the time it takes to pick up a phone produces a dashboard that says
            nobody responded to an evacuation instruction.
        send_at:
          type: [string, "null"]
          format: date-time
          description: |
            With `send: true`, a future instant schedules; the alert becomes
            visible at that moment with no scheduler involved, so a server that
            was down at the minute delivers late rather than never.
        expires_at:
          type: [string, "null"]
          format: date-time
          description: |
            "Stop treating this as current", not "pretend it never happened" —
            phones move it out of the spotlight and stop ringing, but a reader
            can still scroll back to last night's storm warning. Must be after
            `send_at`.
        sound:
          type: [string, "null"]
          enum: [default, chime, ping, bell, knock, alarm, siren, silent, null]
          description: |
            A tone id, not a file — the phone synthesises it, so it costs no
            asset, no decode latency, and no network at the one moment the
            phone may have none. `default` and anything unrecognised both store
            null, meaning "whatever this priority already sounds like": a
            retired tone id loses the tone, never the alert. `silent` is a
            choice, not an absence. Heard only if the channel asked for `sound`
            AND the subscriber granted it — otherwise the alert arrives
            quietly and the create response's `notes` says so.
        audio_url:
          type: [string, "null"]
          maxLength: 1000
          description: |
            An absolute https URL to an mp3 — or any audio the handset can
            decode — STREAMED at the moment the alert arrives. A jingle, the
            siren a city already uses, a recorded sentence in a voice the
            reader knows.

            IT WINS OVER `sound`, AND `sound` IS ITS FALLBACK. The phone gives
            the fetch a few seconds; if it is slow, refused, unplayable or the
            phone has no network, the tone id plays instead. So attaching a
            file can never be the reason an alert was silent — which is the
            only arrangement under which streaming audio belongs in an
            emergency path at all.

            Not proxied, not cached and not transcoded: the URL travels to the
            phone exactly as written and the phone fetches it from wherever you
            put it, so it must be publicly reachable with no login. A phone
            plays at most 20 seconds. Heard only where the channel asked for
            `sound` AND the subscriber granted it — the create response's
            `notes` says so when it did not.
        vibrate:
          type: [string, "null"]
          enum: [default, none, short, double, long, sos, heartbeat, null]
          description: |
            Same rules as `sound`: `default` and unknowns store null ("what
            this priority already does"); `none` stores, because "do not buzz"
            is a choice and not an absence.
        tts_text:
          type: [string, "null"]
          maxLength: 400
          description: |
            What a phone reads aloud, where the channel asked for `tts` and the
            subscriber granted it. 400 characters because that is what the
            phone's speech call clamps to — storing more would store a sentence
            cut off at the moment it is spoken.
        tts_lang:
          type: [string, "null"]
          description: BCP-47, e.g. "nl-BE". Null falls back to the phone's own language.
        lat:
          type: [number, "null"]
          minimum: -90
          maximum: 90
          description: |
            An alert's own circle — all three of `lat`/`lon`/`radius_km` or
            none, exactly like the channel's. It may NARROW the channel's area
            and never widen it: the subscribe screen already told every
            subscriber where this channel operates, and an alert reaching past
            that would make the one disclosure they read a false one. A channel
            with no circle of its own may target anywhere.
        lon:       { type: [number, "null"], minimum: -180, maximum: 180 }
        radius_km:
          type: [number, "null"]
          exclusiveMinimum: 0
          maximum: 500
          description: |
            Kilometres, decimal — `0.05` is a 50-metre circle around one
            platform or one gate, and that is a supported thing to want.
            Containment against the channel area allows 2% of the CHANNEL
            radius, at least 10 m: enough that a circle poking a few metres
            over a hand-drawn boundary is accepted, not enough to double the
            area. Note what the number cannot buy — a handset locates itself to
            within roughly 5-20 m outdoors and tens of metres indoors, so a
            circle smaller than that alerts some people just outside it and
            misses some just inside it.
        geo_rule:  { type: [string, "null"], maxLength: 240 }
        collapse_key:
          type: [string, "null"]
          maxLength: 60
          pattern: "^[a-zA-Z0-9._:-]+$"
          description: |
            An author label — "storm-14", "gate-change" — that makes a newer
            alert replace an older one in the notification shade instead of
            piling up. A slug on purpose: the value is hashed into a
            notification id on two sides of a language boundary, and
            restricting the alphabet is what stops a key that round-trips
            differently producing two entries where the author expected one.

    PusherAlert:
      type: object
      description: |
        An alert as its owner sees it: what a phone receives, plus the
        operational half. The counters are named for what they are — `reads`
        and `acks` are reports from devices that chose to send one, and `reach`
        is how many subscribers COULD receive it, as of now. A single
        "delivered" number here would be a figure nobody can stand behind.
      properties:
        id:        { type: string, examples: ["alr_9Qm4xT2b"] }
        pusher_id: { type: string }
        title:     { type: string }
        body:      { type: [string, "null"] }
        priority:  { type: string, enum: [normal, important, urgent] }
        layout:    { type: string, enum: [text, image, html, document] }
        image_url:
          type: [string, "null"]
          description: |
            Absolute https. Where the picture was uploaded rather than linked
            this is a URL on this service, resolved when the alert is
            serialised — a phone fetches it exactly as it fetches anybody
            else's link.
        image_asset_id:
          type: [string, "null"]
          description: |
            Present on the OWNER view only, never on what a device receives — a
            handset was already handed a URL and has no use for an id. It is
            here so a studio reopening a draft can tell "a file is attached"
            from "a link was typed".
        audio_asset_id: { type: [string, "null"] }
        html_asset_id:  { type: [string, "null"] }
        html:
          type: [string, "null"]
          description: Already sanitised — the stored value is what phones render.
        actions:
          type: array
          items: { $ref: "#/components/schemas/AlertAction" }
        requires_ack: { type: boolean }
        sound:
          type: [string, "null"]
          description: Null means the priority default. Still only a request — see the header.
        vibrate: { type: [string, "null"] }
        tts:
          description: Null when nothing is to be spoken.
          oneOf:
            - type: object
              properties:
                text: { type: string }
                lang: { type: [string, "null"] }
            - { type: "null" }
        geo:
          description: The alert's own circle (never wider than the channel's), or null.
          oneOf:
            - type: object
              properties:
                lat:       { type: number }
                lon:       { type: number }
                radius_km: { type: number }
            - { type: "null" }
        geo_rule:     { type: [string, "null"] }
        collapse_key: { type: [string, "null"] }
        sent_at:
          type: string
          format: date-time
          description: |
            The instant this alert is FOR — what phones sort and cursor on. For
            a scheduled alert this is `send_at`, not the moment somebody
            pressed the button; sorting on the other one makes a batch
            published at 03:00 for 08:00 arrive in the wrong order. The
            machine truth lives in `send_at` and `sent_at_actual` below.
        expires_at: { type: [string, "null"], format: date-time }
        status:
          type: string
          enum: [draft, scheduled, sent, cancelled]
        send_at:
          type: [string, "null"]
          format: date-time
          description: The scheduled instant, when there is one.
        sent_at_actual:
          type: [string, "null"]
          format: date-time
          description: When the row actually became SENT. Null until then.
        reads:
          type: integer
          description: Devices that reported opening it. A report, not a receipt.
        acks:
          type: integer
          description: Devices that pressed the acknowledgement button and told us.
        reach:
          type: integer
          description: |
            Subscribers NOW — printed as "could reach", because that is what it
            is. Not how many received this alert, which nothing here knows.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    DropDayRow:
      type: object
      description: |
        One UTC calendar day with any signal. Days with none are omitted — a
        chart renders gaps as zero, and a quiet quarter must not become ninety
        rows of zeros on the wire.
      properties:
        date: { type: string, format: date }
        alerts_sent:
          type: integer
          description: |
            Derived from the alert rows themselves — the source of truth, and
            the reason this series covers all history while the report counters
            honestly start the day their rollup table shipped.
        reads:        { type: integer }
        acks:         { type: integer }
        subscribes:   { type: integer }
        unsubscribes: { type: integer }
        subscriber_count:
          type: [integer, "null"]
          description: |
            A snapshot taken when something happened that day, for the growth
            line. Null means "no snapshot that day", which is different from
            zero subscribers.

    PusherStats:
      type: object
      description: |
        One channel's aggregate story over a range of UTC days. The `alerts`
        funnel deliberately has no computed read-rate: `reach` is now and the
        sends were then, and two honest integers beat a percentage nobody can
        stand behind.
      properties:
        subscribers: { type: integer, description: Right now. }
        timezone:
          type: string
          const: UTC
          description: |
            Always UTC — stated in every response so nobody has to remember
            that the audio `/v1/stats` resolves in your own zone and this one
            does not.
        window_days_max:
          type: integer
          description: |
            Present ONLY when your plan clamped the range, and it names the
            ceiling — a shorter chart with no explanation reads as data loss.
            Absent means the range you asked for is the range you got.
        alerts_sent:  { type: integer }
        reads:        { type: integer }
        acks:         { type: integer }
        subscribes:   { type: integer }
        unsubscribes: { type: integer }
        by_day:
          type: array
          items: { $ref: "#/components/schemas/DropDayRow" }
        by_hour:
          type: array
          description: |
            Hour-of-day (UTC) histogram of read reports in the range, over the
            per-device reports that still exist — devices prune their oldest,
            so this is "from reports that are still stored".
          items:
            type: object
            properties:
              hour:  { type: integer, minimum: 0, maximum: 23 }
              reads: { type: integer }
        alerts:
          type: array
          description: The per-alert funnel — SENT alerts in the range, newest first, at most 50.
          items:
            type: object
            properties:
              id:           { type: string }
              title:        { type: string }
              priority:     { type: string, enum: [normal, important, urgent] }
              requires_ack: { type: boolean }
              sent_at:      { type: [string, "null"], format: date-time }
              reach:
                type: integer
                description: Could reach NOW — the channel's current subscriber count, not a delivery figure.
              reads: { type: integer }
              acks:  { type: integer }

    DropStats:
      type: object
      description: |
        The account level: every channel the caller may see, summed, plus the
        per-channel rail. Same UTC-day buckets, same honesty rules, same
        optional `window_days_max` as PusherStats.
      properties:
        channels:    { type: integer }
        subscribers: { type: integer, description: "Summed across your channels, right now." }
        timezone:    { type: string, const: UTC }
        window_days_max:
          type: integer
          description: Present only when a plan clamped the range. See PusherStats.
        alerts_sent:  { type: integer }
        reads:        { type: integer }
        acks:         { type: integer }
        subscribes:   { type: integer }
        unsubscribes: { type: integer }
        by_day:
          type: array
          items: { $ref: "#/components/schemas/DropDayRow" }
        by_hour:
          type: array
          items:
            type: object
            properties:
              hour:  { type: integer, minimum: 0, maximum: 23 }
              reads: { type: integer }
        by_pusher:
          type: array
          items:
            type: object
            properties:
              id:          { type: string }
              handle:      { type: string }
              name:        { type: string }
              subscribers: { type: integer }
              alerts_sent: { type: integer }
              reads:       { type: integer }
              acks:        { type: integer }

    SubscriberRecord:
      type: object
      description: |
        Pseudonymous by construction. `device` is HMAC-SHA256 of the device id
        under a server secret, truncated to 10 hex characters — stable enough
        to follow across pages, useless to present anywhere. The raw id never
        appears in any response (the smoke suite asserts it), because the raw
        id is the claim the phone authenticates with: a write-credential-shaped
        value that could forge the device's reports and rewrite its
        subscriptions has no business in an analytics payload.
      properties:
        device:
          type: string
          pattern: "^[0-9a-f]{10}$"
          examples: ["a7f39c1b2e"]
        platform:    { type: [string, "null"] }
        app_version: { type: [string, "null"] }
        locale:      { type: [string, "null"] }
        timezone:    { type: [string, "null"] }
        subscribed_at: { type: string, format: date-time }
        muted_until:
          type: [string, "null"]
          format: date-time
          description: Null unless currently muted. "Quiet until Monday" — alerts do not arrive while it is set.

    AlertTimeline:
      type: object
      description: |
        One alert's read/ack reports over time. Buckets are 30 minutes for the
        first six hours after sending, hourly after that — the shape of an
        alert's life is in its first evening. Built from the per-device reports
        that still exist; the totals come from counters and do not decay.
      properties:
        id:      { type: string }
        status:  { type: string, enum: [draft, scheduled, sent, cancelled] }
        sent_at: { type: [string, "null"], format: date-time }
        reach:
          type: integer
          description: Could reach NOW — same caveat as everywhere else.
        reads: { type: integer, description: "Total, from the counter. Does not decay." }
        acks:  { type: integer }
        timeline:
          type: array
          description: Only buckets with activity appear.
          items:
            type: object
            properties:
              t:     { type: string, format: date-time, description: Bucket start. }
              reads: { type: integer }
              acks:  { type: integer }
        note:
          type: string
          description: |
            On every response, restating the pruning caveat — a chart that
            silently decays looks like a bug, so the API says out loud that it
            is not one.

    PreviewResult:
      type: object
      properties:
        layout: { type: string, enum: [html, document] }
        html:
          type: string
          description: The sanitised markup — byte-for-byte what an alert body would store and a phone would render.
        blocked:
          type: array
          items: { type: string }
          description: |
            What the sanitiser removed (`html` layout). Always empty for
            `document`, where nothing is removed because the phone's sandboxed
            iframe neuters it instead.
        script_count:
          type: [integer, "null"]
          description: |
            `document` layout only: how many scripts the page carries. They are
            kept and inert in the sandbox — counted so the author knows the
            page will not behave. Null for `html`.

    Error:
      type: object
      description: |
        Every failure, from every route, in this one envelope. `param` names
        the offending field when there is one — the difference between an
        integration taking ten minutes and taking an afternoon.
      properties:
        error:
          type: object
          properties:
            type:
              type: string
              enum:
                - invalid_request
                - unauthorized
                - payment_required
                - insufficient_scope
                - forbidden
                - not_found
                - conflict
                - rate_limited
                - server_error
              description: |
                `insufficient_scope` is 403 like `forbidden`, but a different
                word on purpose: `forbidden` means "not your role or your
                data"; this means "your credential was minted without this
                permission — mint a key with it, or reconnect the assistant
                approving it". The message always names the scope, and the
                response carries the RFC 6750 hint beside it:
                `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`.

                `payment_required` (HTTP 402) is today the podcast
                subscription's alone, on the app surface — but it is about to
                widen: when plans land, the same type will refuse a creator
                operation that is over a plan limit (the analytics
                `window_days_max` clamp is its advance warning). Read 402 as
                "not yet, and here is the thing you can do about it", never as
                a bug. The app surface adds two more types (`login_required`,
                `app_required`) that this creator API never emits.
            message: { type: string }
            param:   { type: [string, "null"] }

  responses:
    Invalid:
      description: Validation failed
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: Conflicts with existing state
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: |
        Over a ceiling. The message names it and when to retry. These limits
        fail open during a cache outage on purpose — the caller is
        authenticated and refusing real work to protect a counter would be
        backwards.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InsufficientScope:
      description: |
        The credential lacks a scope this request needs — `error.type` is
        `insufficient_scope` and the message names the scope and the place to
        fix it (mint a key carrying it, or disconnect and reconnect the
        assistant, approving it this time). Distinct from `forbidden` so a
        client can tell "get a better credential" apart from "this is not
        yours".
      headers:
        WWW-Authenticate:
          description: |
            RFC 6750: `Bearer error="insufficient_scope", scope="<missing>"` —
            the standard place an OAuth client library looks first, carried on
            API and MCP responses alike.
          schema:
            type: string
            examples: ['Bearer error="insufficient_scope", scope="alerts:send"']
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
