openapi: 3.1.0
info:
  title: Visualize Session API — for the customer's backend
  version: 1.0.0
  description: |
    ONE CALLER: the customer's own server, holding `sk_live_…`.

    Nothing in this file may be called from a device. The customer's iOS app and
    the Visualize iOS SDK both ship inside an app binary, so neither can hold the
    secret key; they use sdk.openapi.yaml. The Visualize portal is a different
    caller again — portal.openapi.yaml, cookie auth.

      | Party                      | What it is                                    | Spec it uses          |
      |----------------------------|-----------------------------------------------|-----------------------|
      | Customer backend           | The customer's server; sole holder of sk_live_ | THIS FILE            |
      | Customer iOS app           | The customer's own app code                   | none — calls its own backend |
      | Visualize iOS SDK          | Visualize code embedded in that app           | sdk.openapi.yaml      |
      | Visualize portal (Next.js) | Our self-serve console for developers         | portal.openapi.yaml   |
      | Visualize API              | This service                                  | sends the webhook below |

    "Customer" is the company licensing the SDK; the PRDs call it the partner,
    and "developer" is the person who signs up in the portal.

    Two things belong to this caller:

      | Operation         | Direction                        | Auth        |
      |-------------------|----------------------------------|-------------|
      | POST /v1/sessions | Customer backend calls Visualize | sk_live_…   |
      | scan.completed    | Visualize calls customer backend | HMAC-signed |

    The published Integration Guide specifies one flow (Q32):

      customer iOS app ──"a member tapped Scan"──▶ customer backend  (their own API, not ours)
      customer backend ──sk_live_──▶ POST /v1/sessions ──▶ vst_ token
      token returned to the app, handed to the SDK
      Visualize iOS SDK ──vst_ + App Attest──▶ POST /v1/model-key   (sdk.openapi.yaml)
      Visualize iOS SDK ──vst_──▶ POST /v1/scan-events              (sdk.openapi.yaml)
      Visualize API ──signed webhook──▶ customer backend

    "Never embed the secret key. When a member is about to scan, your backend
    requests a short-lived session token." A publishable key on POST /v1/sessions
    is rejected.

    This layer gates access. The package is publicly installable (Q30), so a
    developer without credentials can compile against the SDK but cannot get the
    model or complete a scan.
servers:
  - url: https://api.visualizeme.ai
tags:
  - name: sessions
    description: Called by the customer's backend with the secret key. Never reachable from a device.
  - name: webhook
    description: Sent by Visualize to the customer's backend. Implement a receiver.

paths:
  /v1/sessions:
    post:
      tags: [sessions]
      summary: Mint a short-lived session token
      operationId: createSession
      description: |
        **Called by:** the customer's backend, server-side, with `sk_live_…`.
        Never the app, never the SDK — a publishable key is rejected
        (`publishable_key_used`) because it ships in the app binary.

        Checks the key is active (not revoked, suspended, or past its rotation
        grace period). Revocation takes effect within one token TTL — there is no
        long-lived entitlement to claw back (Q13).

        One token authorizes one scan session. Offline scans queue locally and
        re-authenticate at sync (Q9).

        **No `Idempotency-Key`.** Retry freely — a duplicate mint is a second
        short-lived token that expires unused, not a second charge. The header
        is not accepted here, because honouring it would mean storing the
        minted token so a replay could return it, and the token is otherwise
        only ever stored as a hash (Q53). `POST /v1/scan-events` requires the
        header, where a replay would be a double-billed scan.

        **Test keys work here too.** `sk_test_…` mints sessions exactly like
        `sk_live_…`, so the whole integration — session, model key, scan
        reporting — can be driven end to end before anything bills. The
        difference appears in metering: scans on a test key are recorded with
        `billable: false` and excluded from the invoice, never refused. The
        environment is fixed by the key, so nothing in this request can change
        it.
      security:
        - secretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [host_user_ref]
              properties:
                host_user_ref:
                  type: string
                  maxLength: 255
                  description: |
                    The customer's stable identifier for their end user, treated
                    as opaque. This is the unit of MASU deduplication, so its
                    stability determines the invoice: rotating it per session
                    bills many users instead of one.
                  example: member_8f3a92c1
                scopes:
                  type: array
                  items: { type: string, enum: [scan.create] }
                  default: [scan.create]
                  description: |
                    What the minted token may do. One scope, and it gates
                    `POST /v1/scan-events` — the call that bills, and so the
                    one worth being able to withhold.

                    Mint without it for a flow that must not produce a charge.
                    The SDK then gets a 403 `insufficient_scope` when it
                    reports, and the scan is not recorded at all — this is not
                    a way to run an unbilled scan. That is what a test key is
                    for.

                    The other SDK routes are not scope-gated. App Attest is
                    what stands in front of `/v1/model-key`; the challenge and
                    enrollment calls are the mechanics of reaching it.
      responses:
        '201':
          description: |
            Token minted. Return it to the app, which hands it to the SDK. The
            secret key stays on the server.
          content:
            application/json:
              schema:
                type: object
                properties:
                  session_token: { type: string, example: vst_1Hxxxxxxxxxxxxxxxx }
                  expires_at:    { type: string, format: date-time }
        '401':
          description: |
            Unknown or malformed secret key, or a publishable key
            (`publishable_key_used`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: |
            Key is revoked or suspended. `code` is one of `key_revoked`,
            `key_suspended`, `account_suspended` (e.g. unresolved non-payment,
            suspended manually — PRD 006).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: |
            Too many requests, or the app reached its daily session quota
            (`quota_exceeded`). The quota is an operator-set abuse ceiling;
            contact support if a legitimate integration hits it.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/results:
    delete:
      tags: [backend]
      summary: Delete a member's stored results
      operationId: deleteResults
      description: |
        **Called by:** the customer's backend, for a member exercising
        deletion. Removes every stored result and queued delivery for
        `host_user_ref` under the calling key's app. Zero is a success — the
        intent is already true. Exists only alongside results delivery (Q79);
        an app that never opted in has nothing here to delete.
      security:
        - secretKey: []
      parameters:
        - name: host_user_ref
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: How many results were removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  host_user_ref: { type: string }
                  deleted:       { type: integer }
        '401':
          description: The key is not a usable secret key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

webhooks:
  scanCompleted:
    post:
      tags: [webhook]
      summary: scan.completed / scan.failed
      description: |
        **Sent by:** the Visualize API. **Received by:** the customer's backend,
        as a verified copy of each scan outcome. The device is not the source of
        record for billing.

        Set `webhook_url` with `PATCH /v1/apps/{app_id}` (portal.openapi.yaml).
        That call also issues the signing secret, once. An app with no
        `webhook_url` gets no deliveries; its scans are still recorded and billed.

        **No measurements.** This delivery says that a scan happened, for which
        end user, and how it ended. It carries no `ScanResult` and never will
        (Q51) — this event is the billing record. Measurements reach your app
        from the SDK on the device; if you also want them server-side, enable
        results delivery on the app (Q79) and receive the separate
        `scan.results_available` event below. Join on `scan_id` either way.

        | Header | Value |
        |--------|-------|
        | `Visualize-Signature` | HMAC-SHA256 of the raw request body, keyed with your `whsec_…` secret, hex encoded |
        | `Visualize-Event-Id` | Stable per scan and across retries. Dedupe on it |
        | `Visualize-Event-Type` | `scan.completed` or `scan.failed`, same as `type` in the body |

        Verify the signature over the bytes as received, before parsing.

        **Delivery is at-least-once.** Any 2xx acknowledges. Anything else is
        retried with exponential backoff from 30 seconds, capped at 6 hours, for
        17 attempts — about 50 hours. Redirects are not followed. A delivery is
        never re-sent for a duplicate report of the same `scan_id`.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                id:         { type: string }
                type:       { type: string, enum: [scan.completed, scan.failed] }
                created_at: { type: string, format: date-time }
                data:
                  type: object
                  properties:
                    scan_id:       { type: string }
                    app_id:        { type: string }
                    status:
                      type: string
                      enum: [completed, failed]
                      description: Matches `type`. Present so the body is self-contained.
                    occurred_at:
                      type: string
                      format: date-time
                      description: |
                        When the scan finished on device, not when this was
                        delivered. An offline scan syncing days later is billed
                        in the month it happened, and this is that month.
                    host_user_ref: { type: string }
                    model_version: { type: string }
      responses:
        '2XX':
          description: Any 2xx acknowledges delivery. Non-2xx is retried with backoff.

  scanResultsAvailable:
    post:
      tags: [webhook]
      summary: scan.results_available
      description: |
        **Sent by:** the Visualize API, only for apps with
        `results_delivery_enabled` (set with `PATCH /v1/apps/{app_id}`,
        portal.openapi.yaml). One delivery per stored result, however many
        times the device retried its upload.

        `data.result` is the member's scan result exactly as their app
        received it. Enabling this delivery is your statement that your terms
        with Visualize cover it — you warrant the member's consent, and
        `DELETE /v1/results` is the deletion mechanism behind that warranty.

        Same signing, headers, dedupe id, and at-least-once retry schedule as
        scan.completed above.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                id:         { type: string }
                type:       { type: string, enum: [scan.results_available] }
                created_at: { type: string, format: date-time }
                data:
                  type: object
                  properties:
                    scan_id:       { type: string }
                    app_id:        { type: string }
                    host_user_ref: { type: string }
                    occurred_at:   { type: string, format: date-time }
                    model_version: { type: string }
                    result:
                      type: object
                      description: The scan result as the member's app received it.
      responses:
        '2XX':
          description: Any 2xx acknowledges delivery. Non-2xx is retried with backoff.

components:
  securitySchemes:
    secretKey:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer sk_live_…` — the customer's backend only, never
        in an app binary. `sk_test_…` is accepted here as well and produces
        non-billable scans.

  responses:
    RateLimited:
      description: Too many requests
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          required: [type, code, message]
          properties:
            type:
              type: string
              enum: [invalid_request_error, authentication_error, permission_error, rate_limit_error, api_error]
            code:    { type: string, example: key_revoked }
            message: { type: string }
            param:   { type: string }
