openapi: 3.1.0
info:
  title: BizReviewRadar API
  version: "1.0.0-demo"
  summary: Private client-demo REST API for competitor review analysis, reports, AI review replies, and outbound webhooks.
  description: |
    Private demo surface. Access is granted individually per user via the
    `api_demo` entitlement — Free Starter and standard paid plans do NOT
    automatically receive API access.

    **Live keys are not yet issued.** Only test keys with the prefix
    `brr_test_` are accepted.

    Production base URL (future): `https://api.bizreviewradar.com/v1`
    (DNS not configured yet). The same routes are reachable today at
    `https://bizreviewradar.com/api/v1`.
  contact:
    name: BizReviewRadar API Support
    email: sales@bizreviewradar.com
  license:
    name: Private demo — not for redistribution
servers:
  - url: https://api.bizreviewradar.com/v1
    description: Future production base URL for `/v1` (not yet DNS-configured)
  - url: https://bizreviewradar.com/api/v1
    description: Current production base URL for `/v1`
  - url: https://bizreviewradar.com/api
    description: Base for non-versioned endpoints (`/oauth/token`, `/oauth/revoke`)
  - url: https://bizreviewradar.lovable.app/api/v1
    description: Fallback base URL for `/v1`

tags:
  - name: Auth
    description: Health check and credential validation.
  - name: OAuth
    description: OAuth 2.0 token endpoint (client_credentials grant).
  - name: Businesses
    description: The caller's locked business identity.
  - name: Analyses
    description: Start and poll competitor analyses. Consumes monthly quota.
  - name: Reports
    description: Read finished reports and their competitors.
  - name: Recommendations
    description: Action priorities and AI-visibility notes from the latest report.
  - name: Review replies
    description: Draft AI review replies. Consumes monthly quota.
  - name: Webhooks
    description: Register endpoints, rotate signing secrets, replay deliveries.
  - name: Events
    description: Polling fallback that mirrors the outbound webhook payload.

security:
  - ApiKeyBearer: []
  - ApiKeyHeader: []
  - OAuthBearer: []
  - SupabaseBearer: []

paths:
  /oauth/token:
    servers:
      - url: https://bizreviewradar.com/api
        description: OAuth token endpoint lives outside `/v1`
    post:
      tags: [OAuth]
      operationId: oauthToken
      summary: OAuth 2.0 token endpoint (client_credentials grant)
      description: |
        Exchange a client id + secret for a short-lived HS256 bearer JWT.
        Accepts `application/x-www-form-urlencoded` or `application/json`.
        HTTP Basic (`Authorization: Basic base64(client_id:client_secret)`)
        is also accepted in place of body-borne credentials.

        Additional grants (`authorization_code` + PKCE, `refresh_token`)
        are only enabled when the admin flag `oauth_authcode_enabled`
        is on; otherwise they return `unsupported_grant_type`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/OAuthTokenRequest" }
          application/json:
            schema: { $ref: "#/components/schemas/OAuthTokenRequest" }
      responses:
        "200":
          description: Access token issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthTokenResponse" }
        "400":
          description: Malformed request (`invalid_request` / `unsupported_grant_type` / `invalid_scope`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthErrorResponse" }
        "401":
          description: Client authentication failed (`invalid_client`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }


  /integration-test:
    get:
      tags: [Auth]
      operationId: integrationTest
      summary: Health + auth check
      description: |
        Cheap end-to-end check that the caller's key is valid, the demo
        entitlement is enabled, and rate limits have room. Any valid key
        works — no specific scope required.
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IntegrationTestEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /businesses:
    get:
      tags: [Businesses]
      operationId: listBusinesses
      summary: List the caller's business
      description: |
        One-business-per-user (locked identity). Cursor pagination shape is
        preserved for future multi-location plans.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      x-required-scope: businesses:read
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BusinessListEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /businesses/{id}:
    get:
      tags: [Businesses]
      operationId: getBusiness
      summary: Business detail
      x-required-scope: businesses:read
      parameters:
        - $ref: "#/components/parameters/BusinessId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BusinessEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /businesses/{id}/analyses:
    post:
      tags: [Analyses]
      operationId: startAnalysis
      summary: Start (or resolve) a competitor analysis
      description: |
        Idempotent. Requires the `Idempotency-Key` request header (8-128
        chars, `[A-Za-z0-9._:-]`). Repeating the same key never
        double-charges monthly quota. Returns `202` when queued and `200`
        when a completed report is already available.
      x-required-scope: analyses:write
      parameters:
        - $ref: "#/components/parameters/BusinessId"
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, pattern: "^[A-Za-z0-9._:-]{8,128}$" }
      responses:
        "200":
          description: Completed — a report is already available.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalysisEnvelope" }
        "202":
          description: Accepted — analysis queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalysisEnvelope" }
        "400":
          description: Missing / malformed `Idempotency-Key`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: Monthly quota exhausted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /analyses/{id}:
    get:
      tags: [Analyses]
      operationId: getAnalysis
      summary: Poll analysis status
      description: |
        `id` is the value returned by `POST /businesses/{id}/analyses`
        (the operation id, not a UUID).
      x-required-scope: analyses:read
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, maxLength: 256 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalysisStatusEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /reports/{id}:
    get:
      tags: [Reports]
      operationId: getReport
      summary: Get a report by id (owner-scoped)
      x-required-scope: reports:read
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /businesses/{id}/competitors:
    get:
      tags: [Reports]
      operationId: listCompetitors
      summary: Latest report's competitors
      x-required-scope: businesses:read
      parameters:
        - $ref: "#/components/parameters/BusinessId"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CompetitorListEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /businesses/{id}/recommendations:
    get:
      tags: [Recommendations]
      operationId: getRecommendations
      summary: Latest recommendations and summaries
      x-required-scope: recommendations:read
      parameters:
        - $ref: "#/components/parameters/BusinessId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RecommendationsEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /review-replies:
    post:
      tags: [Review replies]
      operationId: draftReviewReply
      summary: Generate an AI reply to a review
      description: Consumes the `ai_review_reply` monthly quota.
      x-required-scope: review_replies:write
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReviewReplyRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReviewReplyEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: Monthly quota exhausted.
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhook endpoints
      x-required-scope: webhooks:manage
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookListEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Register a webhook endpoint (returns signing secret ONCE)
      x-required-scope: webhooks:manage
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateWebhookRequest" }
      responses:
        "201":
          description: Created. Copy `signing_secret` — it is never shown again.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateWebhookEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /webhooks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Webhooks]
      operationId: getWebhook
      x-required-scope: webhooks:manage
      summary: Webhook detail (secret masked)
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEnvelope" } } }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      x-required-scope: webhooks:manage
      summary: Update name / events / enabled
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateWebhookRequest" }
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEnvelope" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      x-required-scope: webhooks:manage
      summary: Delete a webhook endpoint
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, properties: { deleted: { type: boolean } } }
                  meta: { $ref: "#/components/schemas/Meta" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      x-required-scope: webhooks:manage
      summary: Rotate the signing secret. Old signatures become invalid immediately.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateWebhookEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks/{id}/test:
    post:
      tags: [Webhooks]
      operationId: testWebhook
      x-required-scope: webhooks:manage
      summary: Trigger a synthetic `webhook.test` event to this endpoint
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "202":
          description: Test event enqueued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      enqueued: { type: boolean }
                      event_id: { type: string, format: uuid }
                  meta: { $ref: "#/components/schemas/Meta" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      x-required-scope: webhooks:manage
      summary: Delivery attempt history (cursor-paginated)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeliveryListEnvelope" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhook-deliveries/{id}/resend:
    post:
      tags: [Webhooks]
      operationId: resendWebhookDelivery
      x-required-scope: webhooks:manage
      summary: Manually requeue a delivery
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object, properties: { requeued: { type: boolean } } }
                  meta: { $ref: "#/components/schemas/Meta" }
        "404": { $ref: "#/components/responses/NotFound" }

  /events:
    get:
      tags: [Events]
      operationId: listEvents
      x-required-scope: webhooks:manage
      summary: Polling fallback for webhook events
      description: |
        Mirrors the outbound webhook body shape (`{ id, type, version,
        created_at, data }`). Owner-scoped. Cursor pagination.
      parameters:
        - name: type
          in: query
          schema: { $ref: "#/components/schemas/EventType" }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventListEnvelope" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /public-config:
    get:
      tags: [Auth]
      operationId: publicConfig
      summary: Non-secret public configuration flags
      description: |
        Unauthenticated read-only endpoint that surfaces safe feature flags
        used by the Zapier runtime and other clients. Never returns secrets.
        Cached at the edge for 30 seconds.
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [live_keys_enabled, docs_indexable]
                properties:
                  live_keys_enabled:
                    type: boolean
                    description: When `false`, `brr_live_*` keys are rejected at auth time.
                  docs_indexable:
                    type: boolean
                    description: When `false`, developer docs return `noindex,nofollow`.

  /oauth/revoke:
    servers:
      - url: https://bizreviewradar.com/api
        description: OAuth revoke endpoint lives outside `/v1`
    post:
      tags: [OAuth]
      operationId: oauthRevoke
      summary: OAuth 2.0 token revocation (RFC 7009) — flag-gated
      description: |
        Revoke an access token or refresh token issued by the authorization-
        code flow. Only active when the admin flag `oauth_authcode_enabled`
        is on; while disabled the route returns `404 not_found` and is not
        part of the public API surface. `client_credentials` access tokens
        are stateless HS256 JWTs and expire on their own — they do not
        require revocation.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
                token_type_hint:
                  type: string
                  enum: [access_token, refresh_token]
                client_id: { type: string }
                client_secret: { type: string }
      responses:
        "200":
          description: Revoked (or token was already invalid — RFC 7009 requires 200 either way).
        "401":
          description: Client authentication failed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthErrorResponse" }
        "404":
          description: Endpoint disabled — `oauth_authcode_enabled` is off.



components:
  securitySchemes:
    ApiKeyBearer:
      type: http
      scheme: bearer
      bearerFormat: brr_test_<id>_<secret>
      description: |
        Preferred. Send `Authorization: Bearer brr_test_...`.
        Live keys (`brr_live_*`) are not yet accepted.
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: Alternative to bearer. Same key format.
    OAuthBearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Short-lived HS256 access token issued by `POST /api/oauth/token`
        with `grant_type=client_credentials`. Sent as `Authorization:
        Bearer <access_token>` on `/api/v1/*` requests.
    SupabaseBearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Supabase-issued end-user JWT (internal use for the app itself).

  headers:
    RequestId:
      description: Correlation id echoed on every response.
      schema: { type: string }
    RateLimitLimit:
      description: Requests allowed in the current window.
      schema: { type: integer }
    RateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    RateLimitReset:
      description: Seconds until the rate-limit window fully refills.
      schema: { type: integer }
    RetryAfter:
      description: Seconds the client should wait before retrying (429 only).
      schema: { type: integer }

  parameters:
    BusinessId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    Cursor:
      name: cursor
      in: query
      schema: { type: string }

  schemas:
    Meta:
      type: object
      properties:
        request_id: { type: string }
        plan:
          type: string
          enum: [free, core, review-management, bundle, admin, redemption, one_time]
        quota:
          type: object
          additionalProperties: { type: integer }
        page:
          type: object
          properties:
            limit: { type: integer }
            next_cursor: { type: [string, "null"] }
    ErrorEnvelope:
      type: object
      required: [error, meta]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - invalid_api_key
                - api_key_not_activated
                - insufficient_scope
                - api_access_required
                - rate_limited
                - not_found
                - invalid_id
                - invalid_url
                - invalid_type
                - idempotency_key_required
                - over_cap
                - bad_request
                - db_error
            message: { type: string }
        meta: { $ref: "#/components/schemas/Meta" }
    Business:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: [string, "null"] }
        category: { type: [string, "null"] }
        city: { type: [string, "null"] }
        zip: { type: [string, "null"] }
        place_id: { type: [string, "null"] }
        locked_at: { type: [string, "null"], format: date-time }
    BusinessEnvelope:
      type: object
      properties:
        data: { $ref: "#/components/schemas/Business" }
        meta: { $ref: "#/components/schemas/Meta" }
    BusinessListEnvelope:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Business" }
        meta: { $ref: "#/components/schemas/Meta" }
    IntegrationTestEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            ok: { type: boolean }
            api_version: { type: string, example: v1 }
            plan: { type: string }
            authenticated: { type: boolean }
            timestamp: { type: string, format: date-time }
        meta: { $ref: "#/components/schemas/Meta" }
    AnalysisEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            id: { type: string }
            business_id: { type: string, format: uuid }
            status: { type: string, enum: [queued, completed] }
            report_id: { type: [string, "null"], format: uuid }
            idempotency_key: { type: string }
            reused: { type: boolean }
            remaining: { type: integer }
        meta: { $ref: "#/components/schemas/Meta" }
    AnalysisStatusEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            id: { type: string }
            status: { type: string, enum: [reserved, committed, released, expired, completed] }
            reservation_status: { type: string }
            report_id: { type: [string, "null"], format: uuid }
            business_id: { type: [string, "null"], format: uuid }
            created_at: { type: string, format: date-time }
            resolved_at: { type: [string, "null"], format: date-time }
        meta: { $ref: "#/components/schemas/Meta" }
    Competitor:
      type: object
      properties:
        name: { type: string }
        rating: { type: [number, "null"] }
        review_count: { type: [integer, "null"] }
        place_id: { type: [string, "null"] }
        source: { type: [string, "null"], enum: [auto, manual] }
    CompetitorListEnvelope:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Competitor" }
        meta: { $ref: "#/components/schemas/Meta" }
    ReportEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            id: { type: string, format: uuid }
            status: { type: string }
            created_at: { type: string, format: date-time }
            positive_summary: { type: [string, "null"] }
            negative_summary: { type: [string, "null"] }
            recommendation: { type: [string, "null"] }
            competitors:
              type: array
              items: { $ref: "#/components/schemas/Competitor" }
        meta: { $ref: "#/components/schemas/Meta" }
    RecommendationsEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            report_id: { type: [string, "null"], format: uuid }
            positive_summary: { type: [string, "null"] }
            negative_summary: { type: [string, "null"] }
            recommendation: { type: [string, "null"] }
            action_priorities:
              type: array
              items:
                type: object
                properties:
                  priority: { type: integer, enum: [1, 2, 3] }
                  title: { type: string }
                  detail: { type: string }
            ai_visibility:
              type: [object, "null"]
              properties:
                score: { type: integer }
                notes: { type: [string, "null"] }
        meta: { $ref: "#/components/schemas/Meta" }
    ReviewReplyRequest:
      type: object
      required: [review_text]
      additionalProperties: false
      properties:
        review_text: { type: string, minLength: 1, maxLength: 4000 }
        business_name: { type: string, maxLength: 200 }
        competitor_name: { type: string, maxLength: 200 }
        rating: { type: integer, minimum: 1, maximum: 5 }
    ReviewReplyEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            reply: { type: string }
        meta: { $ref: "#/components/schemas/Meta" }
    EventType:
      type: string
      enum:
        - analysis.completed
        - analysis.failed
        - report.generated
        - recommendation.created
        - webhook.test
    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/EventType" }
        secret_prefix: { type: string }
        secret_masked: { type: string }
        enabled: { type: boolean }
        last_success_at: { type: [string, "null"], format: date-time }
        last_failure_at: { type: [string, "null"], format: date-time }
        consecutive_failures: { type: integer }
        disabled_reason: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WebhookEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            webhook: { $ref: "#/components/schemas/WebhookEndpoint" }
        meta: { $ref: "#/components/schemas/Meta" }
    WebhookListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            webhooks:
              type: array
              items: { $ref: "#/components/schemas/WebhookEndpoint" }
        meta: { $ref: "#/components/schemas/Meta" }
    CreateWebhookRequest:
      type: object
      required: [name, url, events]
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        url: { type: string, minLength: 8, maxLength: 2000, description: "HTTPS only. Loopback and RFC-1918 hosts are rejected." }
        events:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/EventType" }
    UpdateWebhookRequest:
      type: object
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        events:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/EventType" }
        enabled: { type: boolean }
    CreateWebhookEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            webhook: { $ref: "#/components/schemas/WebhookEndpoint" }
            signing_secret:
              type: string
              description: "Full raw secret (prefix `whsec_`). Shown ONCE. Cannot be retrieved later — rotate to issue a new one."
            signing_algorithm:
              type: string
              example: "hmac-sha256(signing_secret, `${timestamp}.${body}`)"
        meta: { $ref: "#/components/schemas/Meta" }
    WebhookDelivery:
      type: object
      properties:
        id: { type: string, format: uuid }
        event_id: { type: string, format: uuid }
        event_type: { $ref: "#/components/schemas/EventType" }
        status: { type: string, enum: [pending, success, failed, dead_letter] }
        attempt: { type: integer }
        max_attempts: { type: integer }
        next_attempt_at: { type: [string, "null"], format: date-time }
        last_status_code: { type: [integer, "null"] }
        last_error: { type: [string, "null"] }
        response_snippet: { type: [string, "null"] }
        delivered_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    DeliveryListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            deliveries:
              type: array
              items: { $ref: "#/components/schemas/WebhookDelivery" }
        meta: { $ref: "#/components/schemas/Meta" }
    Event:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { $ref: "#/components/schemas/EventType" }
        version: { type: integer, example: 1 }
        created_at: { type: string, format: date-time }
        data: { type: object, additionalProperties: true }
    EventListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            events:
              type: array
              items: { $ref: "#/components/schemas/Event" }
        meta: { $ref: "#/components/schemas/Meta" }
    OAuthTokenRequest:
      type: object
      required: [grant_type]
      properties:
        grant_type:
          type: string
          enum: [client_credentials, authorization_code, refresh_token]
          description: |
            `client_credentials` is always supported. The other grants are
            only enabled when `oauth_authcode_enabled` is on.
        client_id: { type: string, description: "Required unless provided via HTTP Basic." }
        client_secret: { type: string, description: "Required unless provided via HTTP Basic." }
        scope:
          type: string
          description: Space-separated list. Must be a subset of the client's registered scopes.
          example: "businesses:read analyses:read"
        code: { type: string, description: "authorization_code grant only." }
        redirect_uri: { type: string, format: uri, description: "authorization_code grant only." }
        code_verifier: { type: string, description: "authorization_code + PKCE only." }
        refresh_token: { type: string, description: "refresh_token grant only." }
    OAuthTokenResponse:
      type: object
      required: [access_token, token_type, expires_in]
      properties:
        access_token: { type: string, description: "HS256 JWT. Send in Authorization: Bearer on /api/v1/* calls." }
        token_type: { type: string, enum: [Bearer] }
        expires_in: { type: integer, example: 3600 }
        scope: { type: string, example: "businesses:read analyses:read" }
        refresh_token: { type: string, description: "Returned only for authorization_code + refresh_token grants (flag-gated)." }
    OAuthErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope]
        error_description: { type: string }
        meta:
          type: object
          properties:
            request_id: { type: string }

  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
    Forbidden:
      description: Authenticated but not entitled (missing `api_demo` or scope).
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
    NotFound:
      description: Not found or not owned by the caller.
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
    BadRequest:
      description: Malformed request.
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
    RateLimited:
      description: Too many requests.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
        X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
      content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
