# Generated by scripts/sync-zona.mjs from Zona repository, docs/openapi.yaml, reduced to POST /functions/v1/notify. Do not edit.
openapi: 3.1.0
info:
  title: Zona notification API
  version: 1.1.0
  summary: Send an alert to a Zona inbox from any script, server, CI job or agent.
  description: |
    Zona's version 1 sender contract. All traffic uses TLS. Senders call
    `POST /functions/v1/notify` with a source token created in the Zona app;
    the token selects the source and the owning account.

    A 202 response from `notify` means the durable, retention-bounded inbox
    record exists. `pushQueued` counts durable delivery jobs and can be zero
    when account or source quiet hours are active. A background worker sends
    queued jobs with bounded retries and checks Expo receipts; neither a queue
    entry nor a provider receipt proves that the phone displayed an alert.

    Every JSON request has a 16 KiB transport limit. `notify.data` has an
    additional 4 KiB UTF-8 serialized limit. These byte limits are normative
    even where JSON Schema cannot express serialized byte size.
  license:
    name: Proprietary; private project
    identifier: LicenseRef-Proprietary
servers:
  - url: https://gerncrjtrdjtjvybvseb.supabase.co
tags:
  - name: Sender
    description: Operations authenticated by an independent source credential.
paths:
  /functions/v1/notify:
    post:
      operationId: notify
      tags: [Sender]
      summary: Accept a notification from the authenticated source.
      description: |
        The source and owner are derived from the Bearer credential. Callers
        cannot provide or override source/owner identity. The inbox record and
        eligible delivery jobs are durable before the response is returned.
        Quiet hours suppress only those jobs; the inbox record is still
        accepted and returned normally.

        `Idempotency-Key` is required. Re-sending the same key with an
        identical payload returns the stored notification with
        `idempotentReplay: true` and does not create a duplicate or retry
        push. Reusing the key with a different payload is rejected with
        `409 IDEMPOTENCY_CONFLICT`. Optional severity is part of that payload,
        so changing only severity is also a conflict.

        Up to five evidence images (PNG/JPEG/WebP) may be attached by
        sending `multipart/form-data` with the `attachment` part repeated once
        per image. The whole set shares the account's server-resolved byte
        budget, and the images participate in idempotency: a single file keeps
        its own SHA-256, and reusing the key with a different set of images
        (added, removed, or reordered) is a conflict.
      security:
        - sourceBearer: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotifyRequest'
            examples:
              buildComplete:
                value:
                  title: Build complete
                  body: The release build finished successfully.
                  category: build
                  severity: high
                  data:
                    eventId: build-2026-07-20-14
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/NotifyMultipartRequest'
            encoding:
              data:
                contentType: application/json
              attachment:
                contentType: image/png, image/jpeg, image/webp
      responses:
        '200':
          description: |
            Idempotent replay: this source already sent the same
            `Idempotency-Key` with an identical payload. The stored
            notification is returned; no duplicate is created and push is not
            re-attempted.
          headers:
            Cache-Control:
              schema:
                type: string
                const: no-store
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotifyAccepted'
        '202':
          description: |
            Inbox record accepted. Eligible push delivery jobs are durably
            queued, or intentionally omitted while quiet hours are active.
          headers:
            Cache-Control:
              schema:
                type: string
                const: no-store
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotifyAccepted'
        '400':
          $ref: '#/components/responses/InvalidPayload'
        '401':
          $ref: '#/components/responses/InvalidSourceToken'
        '403':
          description: A server control currently rejects this optional capability.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                attachmentsDisabled:
                  value:
                    error: ATTACHMENTS_DISABLED
                criticalSeverityDisabled:
                  value:
                    error: CRITICAL_SEVERITY_DISABLED
                todoDisabled:
                  value:
                    error: TODO_DISABLED
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '409':
          description: The `Idempotency-Key` was already used by this source with a different payload.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: IDEMPOTENCY_CONFLICT
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '423':
          $ref: '#/components/responses/AccountInactive'
        '429':
          description: Rate limit exceeded; currently 60 accepted requests per source or 20 per standard account in the rolling minute. Operator-configured plan limits may be lower or higher where documented.
          headers:
            Retry-After:
              description: Minimum retry delay in seconds.
              schema:
                type: integer
                minimum: 1
              example: 60
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                sourceLimited:
                  value:
                    error: RATE_LIMITED
                accountLimited:
                  value:
                    error: ACCOUNT_RATE_LIMITED
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

components:
  securitySchemes:
    sourceBearer:
      type: http
      scheme: bearer
      bearerFormat: zona_live_SOURCE_TOKEN
      description: Independent one-time-issued source credential.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Sender-chosen unique event ID. Replays with an identical payload
        return the original notification; reuse with a different payload is
        rejected with `409 IDEMPOTENCY_CONFLICT`.
      schema:
        type: string
        minLength: 8
        maxLength: 128
        pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$'
      example: build-2026-07-20-14

  schemas:
    Error:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          type: string

    NotifyRequest:
      type: object
      additionalProperties: false
      required: [title, body]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 120
          description: Whitespace-trimmed title.
        body:
          type: string
          minLength: 1
          maxLength: 2000
          description: Whitespace-trimmed message.
        category:
          type: [string, 'null']
          minLength: 1
          maxLength: 80
        severity:
          type: [string, 'null']
          enum: [low, medium, high, critical, null]
          description: Optional visual urgency. Null or omitted uses the active theme's neutral inbox style.
        data:
          type: object
          description: |
            JSON object no more than 4096 UTF-8 bytes when serialized. Reserved
            routing fields in the produced push are controlled by the server.
          additionalProperties: true
        todo:
          type: [array, 'null']
          maxItems: 20
          description: |
            Optional checklist delivered with the alert. Each entry is either a
            plain string or an object with `text` and an optional `id`. Ids must
            match `^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$` and be unique; omitted
            ids are assigned positionally as `i1`, `i2`, and so on. Completion
            is recipient state, so an entry carrying `done_at` is rejected with
            `INVALID_PAYLOAD`. The recipient may tick items off or remove them
            in the app; the sender cannot. Replaying the same idempotency key
            never rewrites an existing list, so completed items survive a
            retry. Rejected with `403 TODO_DISABLED` when the operator switch
            is off.
          items:
            oneOf:
              - type: string
                minLength: 1
                maxLength: 200
              - type: object
                additionalProperties: false
                required: [text]
                properties:
                  id:
                    type: string
                    minLength: 1
                    maxLength: 64
                  text:
                    type: string
                    minLength: 1
                    maxLength: 200

    NotifyAccepted:
      type: object
      additionalProperties: false
      required:
        - notificationId
        - sourceId
        - sourceName
        - acceptedAt
        - idempotentReplay
        - attachmentAccepted
        - attachmentError
        - pushAttempted
        - pushAccepted
      properties:
        notificationId:
          type: string
          format: uuid
        sourceId:
          type: string
          format: uuid
        sourceName:
          type: string
          minLength: 1
          maxLength: 80
        acceptedAt:
          type: string
          format: date-time
        idempotentReplay:
          type: boolean
          description: True when an existing record was replayed instead of newly accepted.
        attachmentAccepted:
          type: boolean
          description: True when every sent evidence image is stored for this notification.
        attachmentError:
          type: [string, 'null']
          description: Sanitized failure code (UPLOAD_FAILED) when a sent image set could not be stored.
        pushAttempted:
          type: integer
          minimum: 0
          description: |
            Compatibility alias. For a newly accepted notification this equals
            `pushQueued`; for an idempotent replay it is zero. It does not mean
            that an Expo request has already run.
        pushAccepted:
          type: integer
          minimum: 0
          description: |
            Compatibility field retained for older clients. `notify` returns
            zero because Expo ticket and receipt processing is asynchronous.
        pushQueued:
          type: integer
          minimum: 0
          description: |
            Durable delivery jobs created for eligible phones. This is zero
            while account or source quiet hours are active. Present on a newly
            accepted notification and omitted on an idempotent replay.

    NotifyMultipartRequest:
      type: object
      required: [title, body]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 120
          description: Whitespace-trimmed title.
        body:
          type: string
          minLength: 1
          maxLength: 2000
          description: Whitespace-trimmed message.
        category:
          type: string
          minLength: 1
          maxLength: 80
        severity:
          type: string
          enum: [low, medium, high, critical]
        data:
          type: string
          description: JSON-encoded object, at most 4096 UTF-8 bytes when serialized.
        todo:
          type: string
          description: |
            JSON-encoded array of checklist entries, at most 20, matching the
            JSON body's `todo` field.
        attachment:
          type: array
          maxItems: 5
          items:
            type: string
            format: binary
          description: |
            Optional evidence images, one `attachment` part per file, up to
            five. Only magic bytes are trusted: PNG, JPEG, and WebP are
            accepted; anything else (including SVG and renamed executables) is
            rejected with `INVALID_PAYLOAD`. The set shares the
            server-resolved byte budget (5 MiB for the standard plan by
            default): the sum of the images must fit the plan limit, and the
            whole multipart request is limited to that budget plus 64 KiB of
            form overhead.

  responses:
    InvalidPayload:
      description: Invalid JSON, content type, field, or size-constrained value.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidPayload:
              value:
                error: INVALID_PAYLOAD
            invalidIdempotencyKey:
              value:
                error: INVALID_IDEMPOTENCY_KEY
    InvalidSourceToken:
      description: Source token is absent, malformed, unknown, or revoked.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: INVALID_TOKEN
    AccountInactive:
      description: The owning account is deleted, deleting, suspended, or otherwise inactive.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: ACCOUNT_INACTIVE
    MethodNotAllowed:
      description: Endpoint accepts POST only, except for CORS preflight.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: METHOD_NOT_ALLOWED
    PayloadTooLarge:
      description: Request body exceeds 16 KiB (JSON) or the plan-resolved attachment budget plus 64 KiB (multipart).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: PAYLOAD_TOO_LARGE
    InternalError:
      description: The request was not confirmed as accepted.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: INTERNAL_ERROR
    ServiceUnavailable:
      description: A fail-closed operator switch temporarily paused this operation.
      headers:
        Retry-After:
          description: Minimum retry delay in seconds.
          schema:
            type: integer
            minimum: 1
          example: 60
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: SERVICE_UNAVAILABLE
