openapi: 3.1.0
info:
  title: Soapbox API
  version: "1.0.0"
  summary: Live audio rooms you can drop into your own app.
  description: |
    Soapbox rooms are deliberately scarce and ephemeral. Ten live casts per
    account, fifty people in a room, four hours maximum, and nothing is ever
    recorded: no audio, no chat, no transcripts. A cast that ends leaves
    counters and nothing else.

    Two rules matter more than the rest when you integrate.

    The API key is a server credential. Your server calls this API and mints
    join tokens; the token goes to your client. A key in a page or an app binary
    is a key that creates casts on your balance, and a live key arriving from a
    browser is rejected as compromised.

    Test keys create real rooms and are never billed. Build against
    `sk_test_...` and switch to `sk_live_...` when you ship, so iterating costs
    nothing.
  contact:
    name: Soapbox
    url: https://soapbox.3sb.io
servers:
  - url: https://soapbox-server.fly.dev
    description: Production

security:
  - apiKey: []

tags:
  - name: Casts
    description: Create, inspect and end rooms.
  - name: Tokens
    description: Let one of your users into a room.
  - name: Usage
    description: What you have spent.

paths:
  /v1/casts:
    post:
      tags: [Casts]
      operationId: createCast
      summary: Start a cast
      description: |
        Creates a live room on your account. Fails with `CAST_FULL` when you
        already hold ten, and with `INSUFFICIENT_MINUTES` when a live key has no
        balance left. A test key never checks the balance.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                  maxLength: 90
                  description: What the room is about. Shown to listeners.
                tags:
                  type: array
                  maxItems: 8
                  items: { type: string, maxLength: 24 }
            examples:
              basic:
                value: { title: "Week 1 overreactions", tags: ["NFL"] }
      responses:
        "201":
          description: The cast is live.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Cast" }
        "402": { $ref: "#/components/responses/NoMinutes" }
        "409": { $ref: "#/components/responses/TooManyCasts" }
    get:
      tags: [Casts]
      operationId: listCasts
      summary: List your live casts
      responses:
        "200":
          description: Every cast currently live on your account.
          content:
            application/json:
              schema:
                type: object
                properties:
                  casts:
                    type: array
                    items: { $ref: "#/components/schemas/Cast" }

  /v1/casts/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Casts]
      operationId: getCast
      summary: Inspect a cast
      responses:
        "200":
          description: The cast, with its current headcount.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Cast" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Casts]
      operationId: endCast
      summary: End a cast
      description: |
        Ends the room and deletes it. Nothing survives: no audio, no chat, no
        polls. This is not reversible and there is nothing to restore.
      responses:
        "200":
          description: Ended.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ended: { type: boolean, const: true }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/casts/{id}/tokens:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Tokens]
      operationId: createToken
      summary: Mint a join token for one of your users
      description: |
        The endpoint you will call most. Your server asks for a token on behalf
        of one of your users; you hand the token to your client, which connects
        with it.

        `identity` is your identifier for your user. It never becomes a Soapbox
        account, never counts against any Soapbox user limit, and never appears
        in the Soapbox app.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [identity, displayName]
              properties:
                identity:
                  type: string
                  maxLength: 128
                  description: Your stable id for this user.
                displayName:
                  type: string
                  maxLength: 40
                role:
                  type: string
                  default: listener
                  enum: [host, cohost, featured_guest, listener]
                  description: |
                    `listener` can hear, chat and react but never publishes
                    audio. The speaking roles publish a microphone and nothing
                    else: no camera, no screen share, at any role.
            examples:
              listener:
                value: { identity: "user_8891", displayName: "Dana", role: "listener" }
              host:
                value: { identity: "user_0001", displayName: "Marcus", role: "host" }
      responses:
        "200":
          description: A join token.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Token" }
        "404": { $ref: "#/components/responses/NotFound" }
        "410": { $ref: "#/components/responses/CastEnded" }

  /v1/usage:
    get:
      tags: [Usage]
      operationId: getUsage
      summary: Balance and recent usage
      description: |
        Minutes are participant-minutes: one person in a room for one minute is
        one. A fifty person room for an hour is three thousand. Partial minutes
        round up per person.
      responses:
        "200":
          description: What is left and what has been spent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Usage" }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer sk_live_...` or `sk_test_...`.

        Server side only. A live key sent with a browser Origin header is
        rejected and should be rotated.

  schemas:
    Cast:
      type: object
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        shareCode:
          type: string
          description: The code in a public listen link.
        participantCount: { type: integer }
        maxParticipants: { type: integer, examples: [50] }
        startedAt: { type: string, format: date-time }
        endsAt:
          type: string
          format: date-time
          description: |
            When the room ends itself. Absolute, not a duration: count down to
            it rather than from four hours, or a sleeping client will show time
            that no longer exists.
        url: { type: string, format: uri }

    Token:
      type: object
      properties:
        token:
          type: string
          description: Pass to a Soapbox client. Do not decode or modify it.
        url:
          type: string
          description: The websocket URL to connect to.
        role: { type: string }
        canPublish:
          type: boolean
          description: Whether this role may open a microphone.
        expiresAt: { type: string, format: date-time }

    Usage:
      type: object
      properties:
        mode: { type: string, enum: [live, test] }
        billable:
          type: boolean
          description: False for test keys, which are never charged.
        balance:
          type: object
          properties:
            freeMinutesRemaining: { type: integer }
            purchasedMinutesRemaining: { type: integer }
            totalRemaining: { type: integer }
            consumedThisPeriod: { type: integer }
        last30Days:
          type: object
          properties:
            participantMinutes: { type: integer }
            sessions: { type: integer }

    Error:
      type: object
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable across versions. Branch on this, not the message.
            message: { type: string }
            fix:
              type: string
              description: The next action to take, when there is an obvious one.

  responses:
    NotFound:
      description: No such cast on this account.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: CAST_NOT_FOUND
              message: No such cast on this account.
    CastEnded:
      description: The cast has ended. Nothing from it was kept.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: CAST_ENDED
              message: That cast has ended.
    NoMinutes:
      description: No minutes left on a live key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: INSUFFICIENT_MINUTES
              message: This account has no minutes left.
              fix: Buy more in the developer portal, or use an sk_test_ key while you build. Test keys create real rooms and are never billed.
    TooManyCasts:
      description: Ten live casts already.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: CAST_FULL
              message: This account already has 10 live casts.
              fix: End one before starting another. The cap is per account, not global.
