openapi: 3.1.0
info:
  title: meetbot API
  version: 0.0.1
  summary: HTTP API for the meetbot bot orchestrator.
  description: |
    The meetbot orchestrator's public HTTP API. Drop a meetbot bot into a
    Google Meet, Microsoft Teams, or Zoom meeting, get back a structured
    recording manifest. Migrating from Recall.ai? The webhook events and
    failure-code taxonomy are deliberately compatible — re-route, re-test,
    re-bill at $0.30/hr flat.

    Spec mirror: https://meetbot.dev/openapi.yaml (YAML),
    https://meetbot.dev/openapi.json (JSON).
    Postman collection: https://meetbot.dev/meetbot.postman_collection.json.
  termsOfService: https://meetbot.dev/legal/terms
  contact:
    name: meetbot support
    email: hello@meetbot.dev
    url: https://meetbot.dev
  license:
    name: MIT
    identifier: MIT

servers:
  - url: https://api.meetbot.dev
    description: Production
  - url: http://localhost:3010
    description: Local orchestrator (default `bun src/server.ts` port)

tags:
  - name: bots
    description: Create, fetch, cancel, and control meeting-bot jobs.
  - name: recordings
    description: Manifest + media artifacts produced by completed jobs.
  - name: calendar
    description: Calendar-invite intake (used by the meetbot-email worker).
  - name: ingest
    description: Bot → orchestrator lifecycle ingest. Documented for
      completeness; called by meetbot bot containers, not by customers.
  - name: cli-auth
    description: Device-flow auth endpoints used by the `meetbot` CLI.
  - name: webhooks
    description: Outbound webhook events the orchestrator sends to
      consumer-supplied URLs.
  - name: admin
    description: Operator endpoints, gated by a shared admin secret.

security:
  - bearerAuth: []

paths:
  /api/v1/jobs:
    post:
      tags: [bots]
      operationId: createJob
      summary: Dispatch a meeting bot
      description: |
        Schedule a meetbot bot to join a meeting URL. If `joinAt` is in
        the past, the bot dispatches immediately; otherwise the
        orchestrator sleeps the dispatch task until that wall-clock time.

        The job's `externalId` is your idempotency key — POSTing twice
        with the same `externalId` for the same consumer returns the
        existing job rather than spinning up a duplicate bot.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DispatchBotInput"
            examples:
              minimal:
                summary: Minimum viable dispatch
                value:
                  externalId: lesson-2026-05-09-pavel
                  meetingUrl: https://meet.google.com/abc-defg-hij
              fullyConfigured:
                summary: Scheduled join with custom auto-leave + webhooks
                value:
                  externalId: q2-board-meeting-acme
                  meetingUrl: https://meet.google.com/xyz-abcd-efg
                  platform: meet
                  joinAt: "2026-05-15T14:00:00Z"
                  displayName: Acme Notetaker
                  webhooks:
                    onStatusChange: https://api.acme.example/meetbot/status
                    onFinalize: https://api.acme.example/meetbot/finalize
                  metadata:
                    customer_id: cust_4711
                    workspace: acme-prod
                  autoLeave:
                    afterEntryDelaySeconds: 300
                    afterMaxSeconds: 7200
                    onBotDetected: true
      responses:
        "201":
          description: Job created and queued for dispatch.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Job"
              example:
                id: 5c3e9d8a-7c4e-4d11-b2cb-9d6e0c5e1234
                externalId: lesson-2026-05-09-pavel
                status: scheduled
                meetingUrl: https://meet.google.com/abc-defg-hij
                platform: meet
                joinAt: "2026-05-09T13:00:00.000Z"
                attempts: 0
                createdAt: "2026-05-09T12:59:59.124Z"
                startedAt: null
                finalizedAt: null
                manifestUri: null
                failureCode: null
                failureDetail: null
                metadata: {}
                autoLeave:
                  afterEntryDelaySeconds: 600
                  afterSilenceSeconds: 1200
                  afterSoloSeconds: 60
                  afterMaxSeconds: 14400
                  onBotDetected: false
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"
      callbacks:
        onStatusChange:
          "{$request.body#/webhooks/onStatusChange}":
            post:
              summary: Lifecycle status transition
              requestBody:
                content:
                  application/json:
                    schema:
                      $ref: "#/components/schemas/JobStatusChangedEvent"
              responses:
                "2XX":
                  description: Consumer ack — orchestrator stops retrying.
        onFinalize:
          "{$request.body#/webhooks/onFinalize}":
            post:
              summary: Job finalized (manifest written)
              requestBody:
                content:
                  application/json:
                    schema:
                      $ref: "#/components/schemas/JobFinalizedEvent"
              responses:
                "2XX":
                  description: Consumer ack — orchestrator stops retrying.

  /api/v1/jobs/{id}:
    parameters:
      - $ref: "#/components/parameters/JobId"
    get:
      tags: [bots]
      operationId: getJob
      summary: Fetch a job
      description: Returns the current state of a job. Scoped to the calling consumer.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Job found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Job"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [bots]
      operationId: cancelJob
      summary: Cancel a job
      description: |
        Marks the job as `cancelled`. If the bot has already joined,
        the orchestrator signals it to leave on its next heartbeat;
        if it hasn't dispatched yet, the queued dispatch is skipped.
      security:
        - bearerAuth: []
      responses:
        "204":
          description: Cancelled. No body.
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/jobs/{id}/request-recording-permission:
    parameters:
      - $ref: "#/components/parameters/JobId"
    post:
      tags: [bots]
      operationId: requestRecordingPermission
      summary: Ask the host for explicit recording consent
      description: |
        Queues a bot-control verb that asks the meeting host to grant
        explicit recording permission. The orchestrator returns
        immediately with the queued command id. The actual host
        decision (granted / denied / timeout) lands on the consumer's
        webhook later as `bot.recording_permission_response`.

        Idempotent: re-issuing the verb while a previous one is still
        undispatched returns the existing command id.
      security:
        - bearerAuth: []
      responses:
        "202":
          description: Verb queued (or matched an undispatched verb).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CommandQueuedResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Job is in a terminal/teardown state — there's no one to ask.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/ingest:
    post:
      tags: [ingest]
      operationId: postIngest
      summary: Bot → orchestrator lifecycle ingest
      description: |
        Internal endpoint used by meetbot bot containers to report
        lifecycle events back to the orchestrator. Authenticated via
        per-job HMAC token (NOT a bearer API key). Documented here for
        completeness — customers don't call this directly.
      security:
        - bearerHmacAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IngestEvent"
      responses:
        "200":
          description: Event accepted; state machine advanced.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                properties:
                  ok:
                    type: boolean
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/intake/calendar-invite:
    post:
      tags: [calendar]
      operationId: intakeCalendarInvite
      summary: Receive a parsed calendar invite
      description: |
        Used by the meetbot-email Cloudflare Worker to forward parsed
        calendar invites (after extracting the meeting URL from the
        ICS attachment) to the orchestrator. Authenticated by a
        shared secret in `x-meetbot-intake-secret`.
      security:
        - intakeSecret: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CalendarInvite"
      responses:
        "200":
          description: Existing job matched (idempotent dedupe).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IntakeResult"
        "201":
          description: New job scheduled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IntakeResult"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /webhooks/stripe:
    post:
      tags: [admin]
      operationId: stripeWebhook
      summary: Stripe webhook receiver (operator-only)
      description: |
        Receives Stripe events for subscription + invoice lifecycle.
        Verified via the `Stripe-Signature` header. Customers don't
        call this directly — Stripe does. Documented for operators
        wiring up the Stripe webhook destination.
      security: []
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        "200":
          description: Event accepted (or deduped).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  deduped: { type: boolean }
        "400":
          description: Missing or invalid signature.
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          description: STRIPE_WEBHOOK_SECRET not configured on this orchestrator.

  /api/v1/admin/host:
    get:
      tags: [admin]
      operationId: getAdminHost
      summary: Host metrics + bot counts (operator-only)
      description: |
        Returns CPU/RAM/load/disk pulled from the local Netdata agent,
        plus active-job and scheduled-job counts. Auth via shared
        secret in `x-meetbot-admin-secret`.
      security:
        - adminSecret: []
      responses:
        "200":
          description: Host metrics snapshot.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HostMetrics"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/cli-auth/init:
    post:
      tags: [cli-auth]
      operationId: cliAuthInit
      summary: Mint a device-flow nonce
      description: |
        First step of the `meetbot login` device flow. The CLI calls
        this to get a nonce + a `verifyUrl` to open in the user's
        browser.
      security: []
      responses:
        "201":
          description: Nonce minted.
          content:
            application/json:
              schema:
                type: object
                required: [nonce, verifyUrl, expiresAt]
                properties:
                  nonce: { type: string }
                  verifyUrl: { type: string, format: uri }
                  expiresAt: { type: string, format: date-time }

  /api/v1/cli-auth/poll/{nonce}:
    parameters:
      - name: nonce
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [cli-auth]
      operationId: cliAuthPoll
      summary: Poll for device-flow completion
      description: |
        The CLI polls until `status` flips from `pending` to `complete`.
        Returns `expired` (HTTP 410) past TTL or for unknown nonces.
      security: []
      responses:
        "200":
          description: Pending or complete.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    required: [status]
                    properties:
                      status: { type: string, enum: [pending] }
                  - type: object
                    required: [status, apiKey]
                    properties:
                      status: { type: string, enum: [complete] }
                      apiKey: { type: string }
                      email: { type: string, format: email }
        "410":
          description: Nonce expired or unknown.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [expired] }

  /api/v1/cli-auth/complete:
    post:
      tags: [cli-auth]
      operationId: cliAuthComplete
      summary: Finalize device-flow auth
      description: |
        Called by the authenticated web page once the user clicks
        "authorize the CLI on this device". Web mints a personal API
        key and posts it here.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nonce, apiKey]
              properties:
                nonce: { type: string }
                apiKey: { type: string }
                email: { type: string, format: email }
      responses:
        "200":
          description: Completed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
        "400":
          $ref: "#/components/responses/InvalidInput"
        "410":
          description: Nonce expired or unknown.

  /healthz:
    get:
      tags: [admin]
      operationId: healthz
      summary: Liveness probe
      description: Returns "ok" when the orchestrator process is up.
      security: []
      responses:
        "200":
          description: Alive.
          content:
            text/plain:
              schema:
                type: string
                example: "ok\n"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: meetbot API key (mb_live_… / mb_test_…)
      description: |
        Send your meetbot API key as a Bearer token. Keys are scoped per
        consumer; revoked keys 401 immediately. Issue + rotate keys in
        the dashboard or via the admin API.
    bearerHmacAuth:
      type: http
      scheme: bearer
      description: |
        Per-job HMAC-SHA256 of `<jobId>:<attempt>` keyed by
        MEETBOT_INGEST_SIGNING_KEY, hex-encoded. Used by bot
        containers; never issued to customers.
    intakeSecret:
      type: apiKey
      in: header
      name: x-meetbot-intake-secret
      description: Shared secret between the orchestrator and the
        meetbot-email Cloudflare Worker.
    adminSecret:
      type: apiKey
      in: header
      name: x-meetbot-admin-secret
      description: Operator shared secret for /api/v1/admin/* endpoints.

  parameters:
    JobId:
      name: id
      in: path
      required: true
      description: UUIDv4 of the job, returned by `POST /api/v1/jobs`.
      schema:
        type: string
        format: uuid
        example: 5c3e9d8a-7c4e-4d11-b2cb-9d6e0c5e1234

  responses:
    Unauthorized:
      description: Missing or invalid auth credentials.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: unauthorized }
    NotFound:
      description: Resource not found (or not visible to this consumer).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: not_found }
    InvalidInput:
      description: Request body failed schema validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: invalid_input
            issues:
              - path: ["meetingUrl"]
                message: Invalid url
                code: invalid_string
    InternalError:
      description: Unhandled server error. The orchestrator captured a
        Sentry event; include the response timestamp when reporting.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: internal_server_error }

  schemas:
    Platform:
      type: string
      enum: [meet, teams, zoom-web, zoom-sdk]
      description: |
        - `meet`     — Google Meet
        - `teams`    — Microsoft Teams
        - `zoom-web` — Zoom Web client (default for Zoom URLs today)
        - `zoom-sdk` — Zoom SDK (M2; not yet in production)

    JobStatus:
      type: string
      enum:
        - pending
        - scheduled
        - dispatching
        - joining
        - entering_lobby
        - in_meeting
        - recording_started
        - recording_paused
        - recording_stopped
        - permission_requested
        - finalizing
        - post_processing
        - media_uploading
        - completed
        - failed
        - cancelled
      description: |
        Granular bot lifecycle. Mirrors Attendee's 16-state taxonomy so a
        consumer's automation logic ("page when admitted") can branch on
        `entering_lobby` → `in_meeting` as separate observable transitions.

    FailureCode:
      type: string
      enum:
        - waiting_for_host
        - meeting_not_found
        - host_denied_admission
        - removed_from_meeting
        - meeting_ended
        - internal_error
        - dispatch_timeout
        - network_loss
        - recording_disabled
        - lobby_timeout
        - auth_failure
        - quota_exceeded
      description: |
        Structured failure taxonomy. Recall.ai-compatible — switching
        from Recall to meetbot only requires routing on the same set of
        codes. `null` on non-failed jobs.

    AutoLeaveConfig:
      type: object
      description: |
        Per-bot auto-leave knobs. Each fires INDEPENDENTLY in the bot's
        adapter loop — the bot leaves the moment ANY of them trips.
        Maps 1:1 onto Recall's `automatic_leave` config so a customer
        migrating off Recall can keep their existing settings.
      properties:
        afterEntryDelaySeconds:
          type: integer
          minimum: 1
          maximum: 86400
          default: 600
          description: Leave if not admitted within N seconds of clicking Join.
        afterSilenceSeconds:
          type: integer
          minimum: 1
          maximum: 86400
          default: 1200
          description: Leave if no audio activity for N seconds.
        afterSoloSeconds:
          type: integer
          minimum: 1
          maximum: 86400
          default: 60
          description: Leave if the bot is the only participant for N seconds.
        afterMaxSeconds:
          type: integer
          minimum: 1
          maximum: 86400
          default: 14400
          description: Hard ceiling on meeting duration.
        onBotDetected:
          type: boolean
          default: false
          description: |
            When TRUE, scan the participant roster for known competing-
            notetaker display names (Otter, Fireflies, Read, …) and leave
            immediately if one is present.

    DispatchBotInput:
      type: object
      required: [externalId, meetingUrl]
      properties:
        externalId:
          type: string
          minLength: 1
          description: |
            Your idempotency key for this job. POSTing twice with the
            same value returns the existing job rather than dispatching
            a second bot.
          example: lesson-2026-05-09-pavel
        meetingUrl:
          type: string
          format: uri
          description: Meet/Teams/Zoom join URL.
          example: https://meet.google.com/abc-defg-hij
        platform:
          $ref: "#/components/schemas/Platform"
        joinAt:
          type: string
          format: date-time
          description: Wall-clock time the bot should attempt to join.
            Omit for "join now".
        displayName:
          type: string
          description: Display name shown to other participants.
          example: Acme Notetaker
        webhooks:
          type: object
          properties:
            onStatusChange:
              type: string
              format: uri
            onFinalize:
              type: string
              format: uri
        metadata:
          type: object
          additionalProperties: true
          description: Free-form metadata echoed back in webhooks.
        autoLeave:
          $ref: "#/components/schemas/AutoLeaveConfig"

    Job:
      type: object
      required:
        - id
        - externalId
        - status
        - meetingUrl
        - platform
        - joinAt
        - attempts
        - createdAt
        - startedAt
        - finalizedAt
        - manifestUri
        - failureCode
        - failureDetail
        - metadata
        - autoLeave
      properties:
        id:
          type: string
          format: uuid
        externalId:
          type: string
        status:
          $ref: "#/components/schemas/JobStatus"
        meetingUrl:
          type: string
          format: uri
        platform:
          $ref: "#/components/schemas/Platform"
        joinAt:
          type: string
          format: date-time
        attempts:
          type: integer
          minimum: 0
        createdAt:
          type: string
          format: date-time
        startedAt:
          type: [string, "null"]
          format: date-time
        finalizedAt:
          type: [string, "null"]
          format: date-time
        manifestUri:
          type: [string, "null"]
          format: uri
          description: URI to the recording manifest (S3/R2/etc.) once
            finalized; `null` until then.
        failureCode:
          oneOf:
            - $ref: "#/components/schemas/FailureCode"
            - type: "null"
          description: Structured failure code; `null` on non-failed jobs.
        failureDetail:
          type: [string, "null"]
          description: Human-readable failure detail; `null` on non-failed jobs.
        metadata:
          type: object
          additionalProperties: true
        autoLeave:
          $ref: "#/components/schemas/AutoLeaveConfig"

    CommandQueuedResponse:
      type: object
      required: [status, commandId]
      properties:
        status:
          type: string
          enum: [queued]
        commandId:
          type: string
          format: uuid

    CalendarInvite:
      type: object
      required: [organizerEmail, meetingUrl, platform, joinAt]
      properties:
        organizerEmail:
          type: string
          format: email
        subject:
          type: string
          default: "(no subject)"
        meetingUrl:
          type: string
          format: uri
        platform:
          type: string
          enum: [meet, teams, zoom-web]
        joinAt:
          type: string
          format: date-time
        icalUid:
          type: [string, "null"]

    IntakeResult:
      type: object
      required: [kind, jobId]
      properties:
        kind:
          type: string
          enum: [scheduled, deduped, free_tier_exhausted]
        jobId:
          type: string
          format: uuid
        consumerId:
          type: string

    IngestEvent:
      type: object
      description: |
        Discriminated union on `kind`. The orchestrator validates with
        Zod; see `apps/orchestrator/src/domain/ingest-event.ts` for the
        complete authoritative schema (kept in sync with this spec).
      required: [kind, jobId, attempt, emittedAt]
      properties:
        kind:
          type: string
          enum:
            - lifecycle.joined
            - lifecycle.entering_lobby
            - lifecycle.in_meeting
            - lifecycle.recording_started
            - lifecycle.recording_paused
            - lifecycle.recording_stopped
            - lifecycle.permission_requested
            - lifecycle.permission_response
            - lifecycle.competing_bot_detected
            - lifecycle.post_processing
            - lifecycle.media_uploading
            - lifecycle.exited
            - manifest.finalized
        jobId:
          type: string
          format: uuid
        attempt:
          type: integer
          minimum: 0
        emittedAt:
          type: string
          format: date-time
        data:
          type: object
          additionalProperties: true

    HostMetrics:
      type: object
      properties:
        cpu:
          type: [object, "null"]
          properties:
            user: { type: number }
            system: { type: number }
            iowait: { type: number }
            idle: { type: number }
        ram:
          type: [object, "null"]
          properties:
            used: { type: number }
            free: { type: number }
            cached: { type: number }
            buffers: { type: number }
            totalMB: { type: number }
        load:
          type: [object, "null"]
          properties:
            one: { type: number }
            five: { type: number }
            fifteen: { type: number }
        disk:
          type: [object, "null"]
          properties:
            usedPct: { type: number }
            freeGB: { type: number }
        bots:
          type: object
          properties:
            running: { type: integer }
            scheduled: { type: integer }
        uptimeSec:
          type: [integer, "null"]
        ts:
          type: integer

    WebhookEnvelope:
      type: object
      required: [deliveryId, event, emittedAt, attempt, data]
      properties:
        deliveryId:
          type: string
          description: Stable id; consumers should dedupe on this.
        event:
          type: string
        emittedAt:
          type: string
          format: date-time
        attempt:
          type: integer
          minimum: 1
          description: Number of delivery attempts including this one.
        version:
          type: integer
          enum: [1, 2]
          description: |
            Payload schema version. v1 omits the field; v2 always sets
            it. The orchestrator emits `version: 2` today; SDKs should
            branch on the `x-meetbot-webhook-version` header or this
            field when decoding.
        data:
          type: object
          additionalProperties: true

    JobStatusChangedEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            data:
              type: object
              required: [event, jobId, from, to, job]
              properties:
                event:
                  type: string
                  enum: [job.status_changed]
                jobId:
                  type: string
                  format: uuid
                from:
                  $ref: "#/components/schemas/JobStatus"
                to:
                  $ref: "#/components/schemas/JobStatus"
                job:
                  $ref: "#/components/schemas/Job"
                failure_code:
                  oneOf:
                    - $ref: "#/components/schemas/FailureCode"
                    - type: "null"
                failure_detail:
                  type: [string, "null"]
                failure_reason:
                  type: [string, "null"]
                  deprecated: true
                  description: v1 mirror of failure_detail.

    JobFinalizedEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            data:
              type: object
              required: [event, jobId, manifestUri, job]
              properties:
                event:
                  type: string
                  enum: [job.finalized]
                jobId:
                  type: string
                  format: uuid
                manifestUri:
                  type: string
                  format: uri
                job:
                  $ref: "#/components/schemas/Job"

    JobFailedEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            data:
              type: object
              required: [event, jobId, status, failure_code, failure_detail, job]
              properties:
                event:
                  type: string
                  enum: [job.failed]
                jobId:
                  type: string
                  format: uuid
                status:
                  type: string
                  enum: [failed]
                failure_code:
                  $ref: "#/components/schemas/FailureCode"
                failure_detail:
                  type: string
                failure_reason:
                  type: string
                  deprecated: true
                error:
                  type: string
                  deprecated: true
                  description: v1 free-text error string; use failure_detail.
                job:
                  $ref: "#/components/schemas/Job"

    BotRecordingPermissionResponseEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            data:
              type: object
              required: [event, jobId, granted, job]
              properties:
                event:
                  type: string
                  enum: [bot.recording_permission_response]
                jobId:
                  type: string
                  format: uuid
                granted:
                  type: boolean
                detail:
                  type: string
                job:
                  $ref: "#/components/schemas/Job"

    BotCompetingBotDetectedEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            data:
              type: object
              required: [event, jobId, detectedBotName, leftMeeting, job]
              properties:
                event:
                  type: string
                  enum: [bot.competing_bot_detected]
                jobId:
                  type: string
                  format: uuid
                detectedBotName:
                  type: string
                leftMeeting:
                  type: boolean
                job:
                  $ref: "#/components/schemas/Job"

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Stable machine-readable error code (snake_case).
        message:
          type: string
          description: Optional human-readable detail.
        issues:
          type: array
          description: Zod validation issues; only present on 400s from
            schema-validated endpoints.
          items:
            type: object
            additionalProperties: true

webhooks:
  jobStatusChanged:
    post:
      tags: [webhooks]
      summary: job.status_changed
      description: Fired on every status transition. v2 payloads carry
        failure_code + failure_detail when transitioning into "failed".
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/JobStatusChangedEvent"
      responses:
        "2XX":
          description: Consumer ack.

  jobFinalized:
    post:
      tags: [webhooks]
      summary: job.finalized
      description: Fired once per successful job, when the manifest is
        written. `manifestUri` points to the structured recording bundle.
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/JobFinalizedEvent"
      responses:
        "2XX":
          description: Consumer ack.

  jobFailed:
    post:
      tags: [webhooks]
      summary: job.failed
      description: Fired once when the job moves into terminal "failed".
        Always carries a structured `failure_code` in v2.
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/JobFailedEvent"
      responses:
        "2XX":
          description: Consumer ack.

  botRecordingPermissionResponse:
    post:
      tags: [webhooks]
      summary: bot.recording_permission_response
      description: Fired when the host responds (or times out) to a
        request_recording_permission verb.
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BotRecordingPermissionResponseEvent"
      responses:
        "2XX":
          description: Consumer ack.

  botCompetingBotDetected:
    post:
      tags: [webhooks]
      summary: bot.competing_bot_detected
      description: Fired when the bot spots a competing notetaker
        (Otter, Fireflies, Read, …) in the participant roster. `leftMeeting`
        is `true` when the job had `autoLeave.onBotDetected = true`.
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BotCompetingBotDetectedEvent"
      responses:
        "2XX":
          description: Consumer ack.
