openapi: 3.1.0
info:
  title: Jamb Notification Server
  version: "1.0.0"
  description: |
    One private endpoint for sending push notifications to Jamb's iOS and Android users.

    A caller names a recipient by ACI and describes the notification once; this service
    resolves the account's device tokens from DynamoDB and renders the per-platform
    envelope — APNs headers and `aps` dict for iOS, an FCM HTTP v1 `message` for Android.

    ## What this is not

    This does not carry Signal's "wake and fetch" pushes. Those are sent by
    jamb-signal-server, whose payloads the clients parse as an instruction to fetch
    messages, and they are unaffected by anything here. Nor does it wake agents — a
    device carrying only a `topicId` resolves to no push target.

    ## Dead tokens

    When a provider reports a token as dead, the per-device result says so and the token
    is left alone. jamb-signal-server owns writes to the accounts table; a second writer
    racing it would be worse than a stale token. A caller that cares should act on
    `results[].unregistered`.
servers:
  - url: http://127.0.0.1:8005
    description: Host-local, behind nginx in a deployment
security:
  - apiKey: []
paths:
  /v1/notifications:
    post:
      summary: Send a notification to one account
      description: |
        Fans out to every push-capable device on the account unless `deviceId` names one.

        The status reflects delivery as a whole: `200` when at least one device was
        reached, or when the only failures are permanent (a dead token is not worth
        retrying); `502` when every attempt failed for a reason a retry could fix.
        Per-device detail is always in `results`.
      operationId: sendNotification
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendRequest'
            examples:
              alert:
                summary: A visible alert to every device on the account
                value:
                  recipient:
                    aci: 8f2b7c1e-4d3a-4f5b-9c8d-1a2b3c4d5e6f
                  notification:
                    title: Missed call
                    body: Alice called you
                    data:
                      type: call
                      callId: c-1024
                    urgency: high
                    collapseKey: calls
                    ttlSeconds: 3600
              silent:
                summary: A data-only wake for one device
                value:
                  recipient:
                    aci: 8f2b7c1e-4d3a-4f5b-9c8d-1a2b3c4d5e6f
                    deviceId: 1
                  notification:
                    silent: true
                    data:
                      type: sync
      responses:
        '200':
          description: Attempted; see `results` for what each device reported.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendResponse'
        '400':
          description: The body is not a valid notification.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
        '401':
          description: Missing or unrecognised `x-api-key`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No such account, or no such device on it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: The request body is larger than `MAX_BODY_BYTES`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '502':
          description: |
            Every device failed, and every failure was transient. Nothing was delivered;
            the same request may succeed on a retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendResponse'
        '503':
          description: The recipient could not be resolved — the account store is unreachable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /healthz:
    get:
      summary: Liveness probe
      description: |
        Unauthenticated on purpose: a probe that needs a credential reports "unhealthy"
        whenever one is rotated. Says nothing about any account.
      operationId: health
      security: []
      responses:
        '200':
          description: The process is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    const: ok
components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: x-api-key
      description: One of the values in `NOTIFICATION_API_KEYS`.
  schemas:
    SendRequest:
      type: object
      additionalProperties: false
      required: [recipient, notification]
      properties:
        recipient:
          $ref: '#/components/schemas/Recipient'
        notification:
          $ref: '#/components/schemas/Notification'
    Recipient:
      type: object
      additionalProperties: false
      required: [aci]
      properties:
        aci:
          type: string
          format: uuid
          description: The account identifier. Tokens are resolved from it server-side.
        deviceId:
          type: integer
          minimum: 1
          maximum: 255
          description: |
            One device rather than the whole account. `1` is the primary. Naming a device
            the account does not have is a 404, so a caller cannot believe it notified a
            device that has been unlinked.
    Notification:
      type: object
      additionalProperties: false
      description: |
        Unknown fields are rejected rather than ignored. Both providers answer `200` to a
        payload whose extra fields mean nothing to the app, so a misspelled field has to
        fail here or it never fails at all.
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 256
        body:
          type: string
          minLength: 1
          maxLength: 2048
        subtitle:
          type: string
          minLength: 1
          maxLength: 256
          description: iOS renders this between title and body. Android has no equivalent and drops it.
        data:
          type: object
          description: |
            Custom key/value pairs for the client to act on. Values that are not strings
            are JSON-encoded, on both platforms — FCM's data map is `map<string, string>`
            on the wire, and coercing for FCM alone would leave iOS receiving a nested
            object where Android receives a string.

            On iOS these arrive under a top-level `data` key alongside `aps`, never
            spread into the root, so a caller's key can never collide with Apple's.
          additionalProperties: true
        urgency:
          type: string
          enum: [high, normal]
          default: high
          description: |
            `high` wakes the device now (APNs priority 10, FCM `high`); `normal` lets the
            OS batch it. A silent notification is always sent at `normal` — Apple
            throttles, and may drop, a background push sent at 10.
        silent:
          type: boolean
          default: false
          description: |
            No alert is shown; the app is woken to handle `data`. On iOS this is
            `content-available`, delivered at the OS's discretion and not at all if the
            user force-quit the app — a best-effort hint, never a guarantee. Requires
            `data`, and forbids `title`, `body`, `subtitle`, `badge` and `sound`.
        collapseKey:
          type: string
          minLength: 1
          maxLength: 64
          description: A later notification with the same key replaces an undelivered earlier one.
        ttlSeconds:
          type: integer
          minimum: 0
          maximum: 2419200
          default: 86400
          description: |
            How long a provider may keep trying. `0` means deliver now or drop, which is
            right for anything worthless late (a ringing call).
        badge:
          type: integer
          minimum: 0
          description: iOS app icon badge; `0` clears it. Android badges are the launcher's business.
        sound:
          type: string
          minLength: 1
          maxLength: 128
          description: A bundled sound file name, or `default` — which is the default.
        category:
          type: string
          minLength: 1
          maxLength: 128
          description: APNs `category` (which action buttons to show) / FCM `click_action`.
        threadId:
          type: string
          minLength: 1
          maxLength: 128
          description: APNs `thread-id` / Android notification `tag`. Groups related notifications.
        androidChannelId:
          type: string
          minLength: 1
          maxLength: 128
          description: The Android notification channel. Android 8+ needs one to ring or vibrate.
    SendResponse:
      type: object
      required: [requestId, notPushRegistered, summary, results]
      properties:
        requestId:
          type: string
        notPushRegistered:
          type: boolean
          description: |
            The account exists but nothing on it can receive a push — every device is an
            agent or has never registered a token. Distinct from a 404, so a caller can
            tell "no such user" from "this user has no app installed".
        summary:
          type: object
          required: [attempted, accepted, failed, unregistered]
          properties:
            attempted: { type: integer }
            accepted: { type: integer }
            failed: { type: integer }
            unregistered: { type: integer }
        results:
          type: array
          items:
            $ref: '#/components/schemas/SendResult'
    SendResult:
      type: object
      required: [deviceId, platform, accepted, unregistered, retryable]
      properties:
        deviceId:
          type: integer
        platform:
          type: string
          enum: [ios, android]
        accepted:
          type: boolean
        messageId:
          type: string
          description: The provider's id for the message, for correlating with their logs.
        errorCode:
          type: string
          description: |
            Verbatim from the provider — `BadDeviceToken`, `UNREGISTERED`,
            `PayloadTooLarge` — or one of this server's own: `TransportNotConfigured`,
            `Timeout`, `TransportError`, `AuthUnavailable`, `SessionUnavailable`,
            `InternalError`.
        unregistered:
          type: boolean
          description: |
            The token is dead and the app is gone from that device. This server reports it
            and does not clear it.
        unregisteredAt:
          type: string
          format: date-time
          description: When APNs knows the token stopped being valid. Absent for FCM, which does not say.
        retryable:
          type: boolean
          description: |
            The failure was the network or the provider, not this payload or this token.
            The server never retries on its own — a duplicate alert is worse than a late
            one for most of what this endpoint carries.
    Error:
      type: object
      required: [error, message]
      properties:
        error: { type: string }
        message: { type: string }
    ValidationError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          properties:
            issues:
              type: array
              items:
                type: object
                properties:
                  path: { type: string }
                  message: { type: string }
