openapi: 3.1.0
info:
  title: Blasts Client API
  version: 1.0.0
  description: |
    Client / recipient API used by the Chrome & Edge extensions and native
    macOS / iOS apps. **Not** the Partner org-key API.

    Auth: `machine_id` (exactly 64 lowercase hex) + `session_token` (HMAC JWT
    from `/register`) in the JSON body — not Bearer/HMAC Partner headers.

    Base: `https://b2b-leads-licenses.netlify.app` · prefix `/api/ping`.

    Narrative reference: `CLIENT_API_FOR_NATIVE_APPS.md`.
    Partner OpenAPI: `partner-v1.yaml` (separate).
    Rate limits overview: https://blasts.dev/rate-limits.html
    Human docs: https://blasts.dev/docs.html#client

    Prefer branching on JSON `ok` — some legacy failures return HTTP 200 with
    `{ ok: false, error: "snake_case" }`.
servers:
  - url: https://b2b-leads-licenses.netlify.app/api/ping
    description: Production Netlify origin (always works)
  - url: https://api.blasts.app/api/ping
    description: >
      Vanity host (same functions). Enable via DNS CNAME + Netlify domain
      alias on site b2b-leads-licenses — see API_TO_DO_Plan.md Phase I.

paths:
  /email-otp-send:
    post:
      summary: Email a 6-digit OTP (no machine auth)
      operationId: clientEmailOtpSend
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '200':
          description: Neutral success (no email enumeration)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkMessage'
  /register:
    post:
      summary: Sign-up / sign-in + account snapshot (issues session_token)
      operationId: clientRegister
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, machine_id]
              properties:
                email: { type: string, format: email }
                machine_id: { $ref: '#/components/schemas/MachineId' }
                email_otp_code:
                  type: string
                  description: 6 digits — required on first signup
                e2ee_public_key: { type: string, description: SPKI base64 P-256 }
                crypto_suite: { type: string, example: v1_p256_aesgcm }
                rack_tier: { type: string }
                referred_by: { type: string }
                client_sku: { type: string }
                client_version: { type: string }
                previous_machine_id: { $ref: '#/components/schemas/MachineId' }
                candidate_previous_machine_ids:
                  type: array
                  items: { $ref: '#/components/schemas/MachineId' }
                rebind_signature: { type: string }
                rebind_timestamp: { type: integer }
      responses:
        '200':
          description: Account snapshot + session
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegisterResponse'
  /inbox:
    post:
      summary: Pull pending inbox messages
      operationId: clientInbox
      tags: [Inbox]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [recipient_email]
                  properties:
                    recipient_email: { type: string, format: email }
      responses:
        '200':
          description: Pending messages (expired filtered server-side)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InboxResponse'
  /inbox-ack:
    post:
      summary: Mark read / dismiss / reactions / explicit read-ack
      operationId: clientInboxAck
      tags: [Inbox]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [recipient_email]
                  properties:
                    recipient_email: { type: string, format: email }
                    dismiss_ids:
                      type: array
                      items: { type: string }
                    mark_read_ids:
                      type: array
                      items: { type: string }
                    read_ack_ids:
                      type: array
                      items: { type: string }
                      description: When sender requested read-ack; may bill sender 1 credit
                    reactions:
                      type: array
                      items:
                        type: object
                        required: [id, emoji]
                        properties:
                          id: { type: string }
                          emoji: { type: string }
      responses:
        '200':
          description: Ack applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  recipient: { type: string }
                  pending_count: { type: integer }
                  read_ack_errors: { type: array, items: { type: object } }
  /send:
    post:
      summary: Client-lane send (Invite Code / pairing code required)
      operationId: clientSend
      tags: [Send]
      description: >
        Distinct from the Partner org-key send lane (see partner-v1.yaml).
        Requires an active Invite Code (`code`) for the recipient.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [from_email, to_email, code, body]
                  properties:
                    from_email: { type: string, format: email }
                    to_email: { type: string, format: email }
                    code: { type: string, description: Pair / Invite Code }
                    body: { type: string }
                    body_html: { type: string }
                    thread_id: { type: string }
                    shape: { type: string }
                    priority: { type: string, enum: [normal, urgent] }
                    ttl_hours: { type: integer, minimum: 1, maximum: 720 }
                    scheduled_at: { type: string }
                    recipient_tz: { type: string }
                    request_read_ack: { type: boolean }
                    send_batch_id: { type: string }
                    idempotency_key: { type: string }
                    attachments:
                      type: array
                      maxItems: 5
                      items: { $ref: '#/components/schemas/AttachmentRef' }
                    ciphertext_body: { type: string }
                    ciphertext_nonce: { type: string }
                    sender_public_key: { type: string }
                    crypto_suite: { type: string }
                    voice_ciphertext_body: { type: string }
                    voice_ciphertext_nonce: { type: string }
                    voice_duration_ms: { type: integer }
                    voice_mime_type: { type: string }
      responses:
        '200':
          description: Send result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendResponse'
  /outbox:
    post:
      summary: Server-side sent-message list (newest first)
      operationId: clientOutbox
      tags: [Send]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [from_email]
                  properties:
                    from_email: { type: string, format: email }
                    limit: { type: integer, minimum: 1, maximum: 100, default: 25 }
                    cursor: { type: string, description: Offset string from prior next_cursor }
      responses:
        '200':
          description: Outbox page
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OutboxResponse'
  /events/wait:
    post:
      summary: Revision long-poll (near-realtime; no WebSockets on Netlify)
      operationId: clientEventsWait
      tags: [Realtime]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [recipient_email]
                  properties:
                    recipient_email: { type: string, format: email }
                    since_rev: { type: integer, default: 0 }
                    wait_ms:
                      type: integer
                      description: Default ~20000; server caps ~22000
      responses:
        '200':
          description: Poll result — on changed:true call /inbox then reconnect with new rev
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventsWaitResponse'
  /attach/upload:
    post:
      summary: Upload attachment bytes (base64)
      operationId: clientAttachUpload
      tags: [Attachments]
      description: >
        Allowed MIME: image/jpeg|png|gif|webp, application/pdf, text/plain.
        Max 5 MB · 40 uploads/day/sender · stamp into /send as attachments[].
        Voice stays on E2EE fields — not this endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [from_email, mime_type, media_b64]
                  properties:
                    from_email: { type: string, format: email }
                    mime_type: { type: string }
                    filename: { type: string }
                    media_b64: { type: string, description: Base64 file bytes }
      responses:
        '200':
          description: Upload result with serve URL
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AttachUploadResponse'
  /attach:
    get:
      summary: Serve attachment bytes by key
      operationId: clientAttachServe
      tags: [Attachments]
      parameters:
        - name: k
          in: query
          required: true
          schema: { type: string, pattern: '^[0-9a-f]{64}$' }
      responses:
        '200':
          description: Raw bytes (Content-Type = stored mime)
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '400':
          description: invalid_key / not_found / expired (JSON error body)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorBody' }
  /push/register:
    post:
      summary: Register or remove Web Push (VAPID) subscription
      operationId: clientPushRegister
      tags: [Push]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [from_email]
                  properties:
                    from_email: { type: string, format: email }
                    subscription:
                      type: object
                      properties:
                        endpoint: { type: string, format: uri }
                    endpoint: { type: string, format: uri }
                    remove: { type: boolean }
      responses:
        '200':
          description: Registration result
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  removed: { type: boolean }
                  vapid_public_key: { type: string }
  /push/register-fcm:
    post:
      summary: Register or remove FCM token (native doorbell)
      operationId: clientPushRegisterFcm
      tags: [Push]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [from_email, fcm_token]
                  properties:
                    from_email: { type: string, format: email }
                    fcm_token: { type: string }
                    platform: { type: string, description: ios | android | … }
                    remove: { type: boolean }
      responses:
        '200':
          description: FCM registration result
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  removed: { type: boolean }
  /code-create:
    post:
      summary: Create Invite Code (recipient consents to a sender)
      operationId: clientCodeCreate
      tags: [Pairing]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [recipient_email, sender_email]
                  properties:
                    recipient_email: { type: string, format: email }
                    sender_email: { type: string, format: email }
      responses:
        '200':
          description: Code minted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  code: { type: string }
                  recipient: { type: string }
                  sender: { type: string }
                  created_at: { type: integer }
  /code-list:
    post:
      summary: List active Invite Codes for a recipient
      operationId: clientCodeList
      tags: [Pairing]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [recipient_email]
                  properties:
                    recipient_email: { type: string, format: email }
      responses:
        '200':
          description: Code list
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  recipient: { type: string }
                  codes:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string }
                        sender: { type: string }
                        status: { type: string }
                        created_at: { type: integer }
                  truncated: { type: boolean }
  /code-revoke:
    post:
      summary: Revoke Invite Code / consent
      operationId: clientCodeRevoke
      tags: [Pairing]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [recipient_email, sender_email]
                  properties:
                    recipient_email: { type: string, format: email }
                    sender_email: { type: string, format: email }
                    code: { type: string }
      responses:
        '200':
          description: Revoke result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OkOrError' }
  /code-resolve:
    post:
      summary: Resolve peer profile from an Invite Code
      operationId: clientCodeResolve
      tags: [Pairing]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  properties:
                    code: { type: string }
      responses:
        '200':
          description: Peer profile (never exposes unrelated emails)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OkOrError' }
  /resolve-id:
    get:
      summary: Resolve public Blast ID to display label (no email leak)
      operationId: clientResolveId
      tags: [Pairing]
      parameters:
        - name: id
          in: query
          required: true
          schema: { type: string }
          description: blast_public_id
      responses:
        '200':
          description: Public identity
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  blast_public_id: { type: string }
                  display_label: { type: string, nullable: true }
  /connection-request:
    post:
      summary: One-Click Connect request (delivers inbox connection_request)
      operationId: clientConnectionRequest
      tags: [Pairing]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [from_email]
                  properties:
                    from_email: { type: string, format: email }
                    to_blast_public_id: { type: string }
                    to_email: { type: string, format: email }
      responses:
        '200':
          description: Request queued/delivered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OkOrError' }
  /connection-respond:
    post:
      summary: Accept or decline a connection_request
      operationId: clientConnectionRespond
      tags: [Pairing]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [recipient_email, message_id, action]
                  properties:
                    recipient_email: { type: string, format: email }
                    message_id: { type: string }
                    action: { type: string, enum: [accept, decline] }
      responses:
        '200':
          description: On accept may return code_for_peer + peer keys
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  accepted: { type: boolean }
                  peer_email: { type: string }
                  code_for_peer: { type: string }
                  peer_public_key: { type: string }
  /pairing-sync:
    post:
      summary: Sender-side sync of revocations (+ optional roster)
      operationId: clientPairingSync
      tags: [Pairing]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SessionAuth'
                - type: object
                  required: [sender_email]
                  properties:
                    sender_email: { type: string, format: email }
                    since_revoked_at: { type: integer }
                    include_roster: { type: boolean }
                    recipient_email: { type: string, format: email }
      responses:
        '200':
          description: Sync snapshot
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OkOrError' }
components:
  schemas:
    MachineId:
      type: string
      pattern: '^[0-9a-f]{64}$'
      description: Exactly 64 lowercase hex characters
    SessionAuth:
      type: object
      required: [machine_id, session_token]
      properties:
        machine_id: { $ref: '#/components/schemas/MachineId' }
        session_token: { type: string }
    ErrorBody:
      type: object
      properties:
        ok: { type: boolean, enum: [false] }
        error: { type: string }
    OkMessage:
      type: object
      properties:
        ok: { type: boolean }
        message: { type: string }
    OkOrError:
      type: object
      properties:
        ok: { type: boolean }
        error: { type: string }
    RegisterResponse:
      type: object
      properties:
        ok: { type: boolean }
        email: { type: string }
        created: { type: boolean }
        balance_credits: { type: integer }
        text_free_remaining: { type: integer }
        voice_free_remaining: { type: integer }
        referral_welcome_remaining: { type: integer }
        rack_tier: { type: string }
        profile:
          type: object
          properties:
            blast_public_id: { type: string }
        session_token: { type: string }
    InboxMessage:
      type: object
      properties:
        id: { type: string }
        thread_id: { type: string }
        from_email: { type: string }
        body: { type: string }
        body_html: { type: string }
        ts: { type: integer }
        expires_at: { type: integer }
        ttl_hours: { type: integer }
        priority: { type: string, enum: [urgent, normal] }
        shape: { type: string }
        use_avatar: { type: boolean }
        avatar_url: { type: string }
        booking_url: { type: string }
        ciphertext_body: { type: string }
        ciphertext_nonce: { type: string }
        sender_public_key: { type: string }
        crypto_suite: { type: string }
        voice_ciphertext_body: { type: string }
        voice_ciphertext_nonce: { type: string }
        voice_duration_ms: { type: integer }
        voice_mime_type: { type: string }
        message_kind: { type: string }
        reactions: { type: array, items: { type: object } }
        sender_identity: { type: object }
        attachments:
          type: array
          items: { $ref: '#/components/schemas/AttachmentRef' }
    InboxResponse:
      type: object
      properties:
        ok: { type: boolean }
        recipient: { type: string }
        pending_count: { type: integer }
        messages:
          type: array
          items: { $ref: '#/components/schemas/InboxMessage' }
    AttachmentRef:
      type: object
      properties:
        url: { type: string, format: uri }
        mime: { type: string }
        filename: { type: string }
        size: { type: integer }
    SendResponse:
      type: object
      properties:
        ok: { type: boolean }
        message_id: { type: string }
        thread_id: { type: string }
        debit_kind: { type: string }
        credits_charged: { type: number }
        balance_credits: { type: integer }
        expires_at: { type: integer }
        error: { type: string }
    OutboxMessage:
      type: object
      properties:
        message_id: { type: string }
        thread_id: { type: string }
        to_email: { type: string }
        body_preview: { type: string }
        priority: { type: string }
        send_batch_id: { type: string, nullable: true }
        has_attachments: { type: boolean }
        e2ee: { type: boolean }
        status: { type: string }
        ts: { type: integer }
        created_at: { type: integer }
    OutboxResponse:
      type: object
      properties:
        ok: { type: boolean }
        from_email: { type: string }
        messages:
          type: array
          items: { $ref: '#/components/schemas/OutboxMessage' }
        next_cursor: { type: string, nullable: true }
        total: { type: integer }
    EventsWaitResponse:
      type: object
      properties:
        ok: { type: boolean }
        recipient: { type: string }
        rev: { type: integer }
        changed: { type: boolean }
        updated_at: { type: integer }
        transport: { type: string, example: long_poll }
        hint: { type: string }
    AttachUploadResponse:
      type: object
      properties:
        ok: { type: boolean }
        attachment_id: { type: string }
        attachment_url: { type: string, format: uri }
        mime: { type: string }
        size: { type: integer }
        filename: { type: string }
        expires_at_ms: { type: integer }
tags:
  - name: Auth
  - name: Inbox
  - name: Send
  - name: Realtime
  - name: Attachments
  - name: Push
  - name: Pairing
