openapi: 3.1.0

# Source of truth for the Dhavana public API. The docs site, Postman collection
# and client SDKs are all generated from this file — so when a route changes,
# change it here in the same commit.

info:
  title: Dhavana API
  version: "1.0.0"
  summary: Dhivehi language AI — generation, translation, speech, OCR and documents.
  description: |
    The Dhavana API lets an organisation call Dhavana's Dhivehi language tools
    directly from its own systems (document management, HR portals, council
    record systems, media CMSs).

    ## Authentication
    Every request carries an API key issued to your organisation:

        Authorization: Bearer dhv_live_<key-id>_<secret>

    Keys are **organisation-scoped**: usage is billed to the organisation's shared
    credit pool, and a key can only ever see its own organisation's data. Use a
    `dhv_test_...` key to integrate against deterministic stub responses that cost
    nothing and never call a model.

    ## Synchronous vs asynchronous
    Fast text tools respond directly. Slower tools (speech, OCR, documents, and
    long generations) return `202` with a **job**: poll `GET /v1/jobs/{id}` or
    register a `webhook_url` on the request. Polling is always available — many
    government networks cannot accept inbound webhooks.

    ## Retries and idempotency
    Send an `Idempotency-Key` header on any POST. A retry with the same key and
    body replays the original response instead of doing (and charging for) the
    work twice.

    ## Billing
    Requests are metered in credits against your organisation's pool. Every
    response includes `credits_charged`, every credit movement is recorded in
    `GET /v1/ledger`, and `GET /v1/usage` shows the period total against your
    spend cap. Failed work is refunded automatically.
  contact:
    name: Dhavana Support
    email: info@dhavana.com
    url: https://dhavana.com
  termsOfService: https://dhavana.com/terms

servers:
  - url: https://api.dhavana.com/v1
    description: Production

security:
  - ApiKeyAuth: []

tags:
  - name: Text
    description: Generation, translation and editing. Synchronous.
  - name: Speech
    description: Speech-to-text and text-to-speech. Asynchronous.
  - name: Documents
    description: OCR, presentations and spreadsheets. Asynchronous.
  - name: Jobs
    description: Poll, list and cancel asynchronous work.
  - name: Account
    description: Usage, credit ledger, audit trail and webhook subscriptions.
  - name: Service
    description: Liveness and per-tool status. No key required.

paths:
  # ── Service ────────────────────────────────────────────────────────────────
  /health:
    get:
      tags: [Service]
      summary: Liveness check
      security: []
      responses:
        "200":
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, examples: [ok] }
                  time: { type: string, format: date-time }

  /status:
    get:
      tags: [Service]
      summary: Per-tool operational status
      security: []
      responses:
        "200":
          description: Current status of each tool.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [operational, degraded, down]
                  tools:
                    type: object
                    additionalProperties:
                      type: string
                      enum: [operational, degraded, down]
                  time: { type: string, format: date-time }

  /openapi.json:
    get:
      tags: [Service]
      summary: This API description, as JSON
      description: |
        Import this URL directly into Postman, Insomnia or Bruno
        (**Import → Link**), or point an OpenAPI generator at it to produce a
        typed client. No key required.
      security: []
      responses:
        "200":
          description: The OpenAPI 3.1 document.
          content:
            application/json:
              schema: { type: object }

  /openapi.yaml:
    get:
      tags: [Service]
      summary: This API description, as YAML
      security: []
      responses:
        "200":
          description: The OpenAPI 3.1 document.
          content:
            application/yaml:
              schema: { type: string }

  /docs:
    get:
      tags: [Service]
      summary: Interactive API reference
      description: |
        A browsable reference with a try-it console, rendered from this
        specification. No key required to read it. Open in a browser.
      security: []
      responses:
        "200":
          description: The reference page.
          content:
            text/html:
              schema: { type: string }

  /docs/standalone.js:
    get:
      tags: [Service]
      summary: Reference renderer bundle
      description: |
        The self-hosted JavaScript bundle used by `/v1/docs`. Served from this
        origin rather than a CDN so the reference works on networks that block
        third-party hosts. Not intended to be called directly.
      security: []
      responses:
        "200":
          description: The renderer bundle.
          content:
            application/javascript:
              schema: { type: string }

  /guide.md:
    get:
      tags: [Service]
      summary: Integration guide (Markdown)
      description: |
        The prose companion to this spec: quickstart, retry and error handling,
        worked examples in PHP, C#, Python and TypeScript, and webhook signature
        verification. Rendered at https://dhavana.com/docs/api. No key required.
      security: []
      responses:
        "200":
          description: The integration guide.
          content:
            text/markdown:
              schema: { type: string }

  /me:
    get:
      tags: [Account]
      summary: Describe the calling key
      description: Confirms a key works and reports what it is allowed to do.
      responses:
        "200":
          description: The authenticated key's context.
          content:
            application/json:
              schema:
                type: object
                properties:
                  key_id: { type: string }
                  organization: { type: string }
                  environment: { type: string, enum: [live, test] }
                  scopes:
                    type: array
                    items: { type: string }
                    description: As granted on the key — may include a group such as `documents:write`.
                  effective_scopes:
                    type: array
                    items: { type: string }
                    description: |
                      What those scopes actually permit, expanded. Use this when
                      debugging a `403 insufficient_scope` — the granted list may
                      be shorthand.
                  retention: { type: string, enum: [standard, none] }
                  department_id: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /scopes:
    get:
      tags: [Account]
      summary: List available scopes
      description: |
        The full catalogue of scopes a key can be granted, with the endpoints
        each unlocks. Public — readable before you hold a key — because it is
        part of the contract, not customer data.

        Scope names follow `<endpoint>:<action>`, so `POST /v1/transcribe`
        requires `transcribe:write`. Group scopes (`text:write`,
        `documents:write`, `media:write`) are shorthand for several at once, and
        `*` grants everything.

        Any tool scope also permits polling and cancelling the jobs it creates,
        so an async integration never needs `jobs:*` spelled out.
      security: []
      responses:
        "200":
          description: The scope catalogue.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, examples: [list] }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        scope: { type: string, examples: ["transcribe:write"] }
                        description: { type: string }
                        endpoints: { type: array, items: { type: string } }
                        group: { type: boolean }

  # ── Text ───────────────────────────────────────────────────────────────────
  /generate:
    post:
      tags: [Text]
      summary: Generate content
      description: |
        Produces content of a given `content_type`. Responds synchronously by
        default; set `async: true` (or supply `webhook_url`) to receive a job
        instead — useful for long-form types.

        Cost depends on the content type and `length` tier.
      operationId: generate
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content_type, prompt]
              properties:
                content_type:
                  type: string
                  description: What to write.
                  enum:
                    - article
                    - news article
                    - blog post
                    - story
                    - poem
                    - speech
                    - letter
                    - email
                    - social media
                    - marketing copy
                    - resume/cv
                    - summarize
                    - meeting minutes
                    - smart templates
                    - custom
                    - lesson plan
                    - worksheet
                    - quiz
                    - reading passage
                    - difficulty adjuster
                    - writing prompts
                    - assignment feedback
                    - gamified content
                    - exam prep
                prompt:
                  type: string
                  description: What the content should be about.
                language:
                  type: string
                  enum: [en, dv]
                  default: en
                  description: Output language. `dv` is Dhivehi (Thaana).
                length:
                  type: string
                  enum: [short, medium, long]
                  default: medium
                options:
                  type: object
                  description: Per-type options.
                  properties:
                    writing_style:
                      type: string
                      enum: [news, report]
                      default: news
                      description: |
                        `news article` only. `news` is inverted-pyramid reporting;
                        `report` produces a longer sectioned document.
                    tone: { type: string }
                  additionalProperties: true
                async:
                  type: boolean
                  default: false
                  description: Return a job instead of waiting for the content.
                webhook_url:
                  type: string
                  format: uri
                  description: Implies `async`. Receives `job.succeeded` / `job.failed`.
                metadata:
                  type: object
                  description: Your own reference data, echoed back to you.
                  additionalProperties: true
            examples:
              newsArticle:
                summary: A short Dhivehi news article
                value:
                  content_type: news article
                  prompt: Council opens the new harbour at Hulhumale'
                  language: dv
                  length: short
                  options: { writing_style: news }
                  metadata: { office_ref: MOE-2026-0412 }
      responses:
        "200":
          description: The generated content.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  object: { type: string, examples: [generation] }
                  content: { type: string }
                  content_type: { type: string }
                  word_count: { type: integer }
                  credits_charged: { type: number }
                  request_id: { type: string }
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /translate:
    post:
      tags: [Text]
      summary: Translate text
      operationId: translate
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text, source_language, target_language]
              properties:
                text:
                  type: string
                  description: Text to translate. Maximum 1500 words.
                source_language: { type: string, examples: [en] }
                target_language: { type: string, examples: [dv] }
            examples:
              toDhivehi:
                value:
                  text: The meeting has been moved to Thursday morning.
                  source_language: en
                  target_language: dv
      responses:
        "200":
          description: The translation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  object: { type: string, examples: [translation] }
                  translated_text: { type: string }
                  source_language: { type: string }
                  target_language: { type: string }
                  word_count: { type: integer }
                  credits_charged: { type: number }
                  request_id: { type: string }
        "400":
          description: |
            Invalid request. `text_too_long` when the input exceeds 1500 words;
            `same_language` when source and target match.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /text/edit:
    post:
      tags: [Text]
      summary: Proofread and improve Dhivehi text
      operationId: edit
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string, description: Text to edit. }
      responses:
        "200":
          description: The edited text.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  object: { type: string, examples: [edit] }
                  edited_text: { type: string }
                  char_count: { type: integer }
                  credits_charged: { type: number }
                  request_id: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ── Speech ─────────────────────────────────────────────────────────────────
  /transcribe:
    post:
      tags: [Speech]
      summary: Transcribe Dhivehi speech to text
      description: |
        Asynchronous. Billed by audio duration.

        Set `dialect: "addu"` for Addu bas — the transcript then preserves Addu
        forms rather than standard Dhivehi spellings, so choose it deliberately.
        The result reports which `dialect` produced it.
      operationId: transcribe
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [audio_url]
              properties:
                audio_url:
                  type: string
                  format: uri
                  description: Publicly reachable http(s) URL of the audio file.
                dialect:
                  type: string
                  enum: [standard, addu]
                  default: standard
                  description: >-
                    Dhivehi dialect model. Ignored when `language` is `en`.
                    `addu` output is not auto-corrected, so dialect words survive.
                language:
                  type: string
                  enum: [dv, en]
                  default: dv
                  description: >-
                    Language of the audio. `dv` uses the Dhivehi model (and also
                    handles Dhivehi/English code-switched speech). `en` uses the
                    English model — pick it only for audio spoken entirely in
                    English.
                identify_speakers:
                  type: boolean
                  default: false
                  description: Label distinct speakers. Adds 50% to the cost.
                webhook_url: { type: string, format: uri }
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /speak:
    post:
      tags: [Speech]
      summary: Synthesise Dhivehi speech
      description: Asynchronous. Billed per character. Maximum 2000 characters.
      operationId: speak
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text, voice]
              properties:
                text: { type: string, maxLength: 2000 }
                voice:
                  type: string
                  description: A voice id from `GET /v1/voices`.
                  examples: [ethan]
                speed:
                  type: number
                  default: 1.0
                  minimum: 0.5
                  maximum: 1.5
                webhook_url: { type: string, format: uri }
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /voices:
    get:
      tags: [Speech]
      summary: List available voices
      description: Read this rather than hardcoding voice ids — the catalogue grows.
      responses:
        "200":
          description: Available voices.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, examples: [list] }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ── Documents ──────────────────────────────────────────────────────────────
  /ocr:
    post:
      tags: [Documents]
      summary: Extract text from an image or PDF
      description: |
        Asynchronous. Billed per page. Multi-page PDFs are supported; the job
        result reports `page_count`.

        **Choose the mode by what is on the page, not by speed** — the two modes
        are different engines, not quality tiers:

        | Mode | For | Languages |
        |---|---|---|
        | `accurate` (default) | Printed and typed documents | English, Dhivehi (Thaana) and Arabic, including mixed pages |
        | `handwritten` | Handwritten documents, across varied hands and writing styles | **Dhivehi only** |

        `handwritten` has no competence in other scripts — sending English or
        Arabic to it produces nonsense rather than a degraded result. Use
        `accurate` for anything printed, whatever the language.
      operationId: ocr
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file_url]
              properties:
                file_url:
                  type: string
                  format: uri
                  description: |
                    Publicly reachable http(s) URL of an image or PDF.

                    Maximum file size **100 MB**. Larger files are rejected with
                    `400 invalid_request_error` before any credits are charged —
                    split long documents and submit them as separate jobs.

                    The URL must be reachable by our servers without
                    authentication, and is fetched once at job start.
                mode:
                  type: string
                  enum: [accurate, handwritten]
                  default: accurate
                  description: |
                    `accurate` — printed text in English, Dhivehi or Arabic.
                    `handwritten` — handwritten Dhivehi only. Costs more per page.
                webhook_url: { type: string, format: uri }
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /presentations:
    post:
      tags: [Documents]
      summary: Generate a PowerPoint presentation
      description: Asynchronous. The job result contains a time-limited `download_url`.
      operationId: createPresentation
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [topic]
              properties:
                topic: { type: string }
                slide_count: { type: integer, minimum: 1 }
                tier:
                  type: string
                  enum: [smart, premium]
                  default: smart
                language: { type: string, enum: [dv, en, auto], default: auto }
                include_images:
                  type: boolean
                  default: false
                  description: Add photographs and generated backgrounds. Costs extra.
                webhook_url: { type: string, format: uri }
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /spreadsheets:
    post:
      tags: [Documents]
      summary: Generate an Excel workbook
      description: Asynchronous. The job result contains a time-limited `download_url`.
      operationId: createSpreadsheet
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prompt]
              properties:
                prompt: { type: string, description: What the workbook should contain. }
                tier: { type: string, enum: [smart, premium], default: smart }
                language: { type: string, enum: [dv, en, auto], default: auto }
                data:
                  type: object
                  description: |
                    Optional source data as `{ "Sheet name": [[row], [row]] }`.
                    Supply this to build a workbook from figures you already hold.
                  additionalProperties:
                    type: array
                    items: { type: array, items: {} }
                webhook_url: { type: string, format: uri }
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ── Jobs ───────────────────────────────────────────────────────────────────
  /jobs/{id}:
    get:
      tags: [Jobs]
      summary: Retrieve a job
      operationId: getJob
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /jobs:
    get:
      tags: [Jobs]
      summary: List jobs
      operationId: listJobs
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [queued, processing, succeeded, failed, canceled]
        - name: tool
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
      responses:
        "200":
          description: A list of jobs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, examples: [list] }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Job" }
                  has_more: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /jobs/{id}/cancel:
    post:
      tags: [Jobs]
      summary: Cancel a job
      description: Only `queued` or `processing` jobs can be cancelled. Credits are refunded.
      operationId: cancelJob
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The cancelled job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "400":
          description: The job has already finished.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Account ────────────────────────────────────────────────────────────────
  /usage:
    get:
      tags: [Account]
      summary: Usage and spend for a period
      description: |
        What your organisation has spent through the API this period, broken down
        by tool, against your spend cap — plus the current pool balance and a
        month-end projection.
      operationId: getUsage
      parameters:
        - name: period
          in: query
          description: Billing period as `YYYY-MM`. Defaults to the current period.
          schema: { type: string, pattern: '^\d{4}-\d{2}$' }
      responses:
        "200":
          description: Usage for the period.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, examples: [usage] }
                  organization: { type: string }
                  period: { type: string }
                  credits_spent: { type: number }
                  by_tool:
                    type: object
                    additionalProperties: { type: number }
                  spend_cap:
                    type: [number, "null"]
                    description: Null when the organisation has no API spend cap.
                  cap_remaining: { type: [number, "null"] }
                  cap_used_percent: { type: [number, "null"] }
                  soft_limit_percent: { type: number }
                  soft_limit_reached: { type: boolean }
                  balance: { type: number }
                  low_balance_threshold: { type: [number, "null"] }
                  low_balance: { type: boolean }
                  projected_month_end: { type: number }
                  request_id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /ledger:
    get:
      tags: [Account]
      summary: Credit statement
      description: |
        Every credit movement your keys caused, newest first — each traceable to
        the request or job that produced it. `summary.spend_total` reconciles
        exactly with `credits_spent` from `GET /v1/usage` for the same period.

        Pass `format=csv` for a spreadsheet-ready export.
      operationId: getLedger
      parameters:
        - name: period
          in: query
          schema: { type: string, pattern: '^\d{4}-\d{2}$' }
        - name: from
          in: query
          schema: { type: string, format: date-time }
        - name: to
          in: query
          schema: { type: string, format: date-time }
        - name: tool
          in: query
          schema: { type: string }
        - name: type
          in: query
          schema: { type: string, enum: [spend, refund] }
        - name: job_id
          in: query
          schema: { type: string }
        - name: request_id
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 100, minimum: 1, maximum: 1000 }
        - name: format
          in: query
          schema: { type: string, enum: [json, csv], default: json }
      responses:
        "200":
          description: Credit movements.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, examples: [list] }
                  period: { type: [string, "null"] }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        created_at: { type: string, format: date-time }
                        type: { type: string, enum: [spend, refund] }
                        amount:
                          type: number
                          description: Signed — negative is charged, positive is returned.
                        balance_after: { type: [number, "null"] }
                        tool: { type: string }
                        reason: { type: [string, "null"] }
                        request_id: { type: [string, "null"] }
                        job_id: { type: [string, "null"] }
                        key_id: { type: [string, "null"] }
                        environment: { type: string, enum: [live, test] }
                  summary:
                    type: object
                    properties:
                      spend_total: { type: number }
                      refund_total: { type: number }
                      net: { type: number }
                      spend_count: { type: integer }
                      refund_count: { type: integer }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /audit:
    get:
      tags: [Account]
      summary: Audit trail
      description: |
        Every authenticated call your keys made — when, from which source IP, with
        what outcome. Retained for 12 months. Pass `format=csv` to export.
      operationId: getAudit
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date-time }
        - name: to
          in: query
          schema: { type: string, format: date-time }
        - name: tool
          in: query
          schema: { type: string }
        - name: key_id
          in: query
          schema: { type: string }
        - name: outcome
          in: query
          schema: { type: string, enum: [success, error] }
        - name: limit
          in: query
          schema: { type: integer, default: 100, minimum: 1, maximum: 1000 }
        - name: format
          in: query
          schema: { type: string, enum: [json, csv], default: json }
      responses:
        "200":
          description: Audit records.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, examples: [list] }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        request_id: { type: string }
                        created_at: { type: string, format: date-time }
                        method: { type: string }
                        path: { type: string }
                        tool: { type: [string, "null"] }
                        key_id: { type: string }
                        environment: { type: string, enum: [live, test] }
                        source_ip: { type: string }
                        status_code: { type: integer }
                        outcome: { type: string, enum: [success, error] }
                        error_code: { type: [string, "null"] }
                        credits_charged: { type: number }
                        duration_ms: { type: integer }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /webhook-endpoints:
    get:
      tags: [Account]
      summary: List webhook endpoints
      operationId: listWebhookEndpoints
      responses:
        "200":
          description: Registered endpoints. The signing secret is never returned here.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, examples: [list] }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Account]
      summary: Register a webhook endpoint
      description: |
        Receives **account** events — credit and spend-cap notifications. Job
        notifications use the `webhook_url` field on the request that created the
        job instead.

        The signing secret is returned **once**, in this response, and cannot be
        retrieved later. To rotate it, delete the endpoint and create a new one.
      operationId: createWebhookEndpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: An https URL on a public host.
                events:
                  type: array
                  description: Events to receive. Omit or leave empty for all.
                  items:
                    type: string
                    enum:
                      - credits.low
                      - credits.exhausted
                      - api.spend_cap_warning
                      - api.spend_cap_reached
                description: { type: string, maxLength: 200 }
      responses:
        "201":
          description: Created. Store `secret` now — it is not shown again.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/WebhookEndpoint"
                  - type: object
                    properties:
                      secret:
                        type: string
                        description: Signing secret. Shown only in this response.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /webhook-endpoints/{id}:
    delete:
      tags: [Account]
      summary: Delete a webhook endpoint
      operationId: deleteWebhookEndpoint
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  object: { type: string, examples: [webhook_endpoint] }
                  deleted: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      description: |
        Your organisation's API key, e.g. `dhv_live_k_abc123_secret`. Treat it
        like a password: server-side only, never in a browser or mobile app.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        A unique value (a UUID is ideal) identifying this request. Retrying with
        the same key and body replays the original response rather than doing and
        charging for the work twice. Records are kept for 24 hours.
      schema: { type: string, minLength: 8, maxLength: 255 }

  schemas:
    Error:
      type: object
      description: |
        Every error uses this shape. Switch on `code` — it is the stable machine
        contract; `message` is human-readable and its wording may change.
      properties:
        error:
          type: object
          properties:
            type:
              type: string
              enum:
                - invalid_request_error
                - authentication_error
                - permission_error
                - not_found_error
                - rate_limit_error
                - insufficient_credits
                - upstream_error
                - api_error
            code:
              type: string
              description: |
                Specific machine-readable cause, e.g. `invalid_api_key`,
                `insufficient_scope` (see `GET /v1/scopes`),
                `ip_not_allowed`, `insufficient_scope`, `insufficient_credits`,
                `spend_cap_exceeded`, `member_limit_exceeded`,
                `department_budget_exceeded`, `rate_limited`, `content_blocked`,
                `text_too_long`, `idempotency_key_reuse`, `request_in_progress`.
            message: { type: string }
            param:
              type: string
              description: The offending request field, when applicable.
            request_id: { type: string }
            doc_url: { type: string }

    Job:
      type: object
      properties:
        id: { type: string }
        object: { type: string, examples: [job] }
        tool: { type: string }
        status:
          type: string
          enum: [queued, processing, succeeded, failed, canceled]
        progress:
          type: integer
          description: Best-effort completion percentage, 0-100.
        result:
          type: [object, "null"]
          description: |
            Present once `status` is `succeeded`. Shape depends on the tool:
            transcribe → `{ text, duration, dialect }`;
            speak → `{ audio_base64, format, voice }`;
            ocr → `{ text, page_count, mode }`;
            presentations / spreadsheets → `{ download_url }`;
            generate → `{ content, word_count }`.
          additionalProperties: true
        error:
          type: [object, "null"]
          properties:
            code: { type: string }
            message: { type: string }
        credits_charged: { type: number }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string }
        object: { type: string, examples: [webhook_endpoint] }
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        description: { type: [string, "null"] }
        status: { type: string, enum: [active, disabled] }
        last_delivery_at: { type: [string, "null"], format: date-time }
        last_delivery_ok: { type: [boolean, "null"] }
        consecutive_failures: { type: integer }
        created_at: { type: string, format: date-time }

  responses:
    JobAccepted:
      description: |
        Accepted. The work runs in the background — poll `GET /v1/jobs/{id}` or
        wait for your webhook.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Job" }
    BadRequest:
      description: The request was malformed or failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing, malformed, revoked or invalid API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: |
        The key is valid but not permitted — it lacks the required scope
        (`insufficient_scope`) or the request came from an address outside the
        key's IP allowlist (`ip_not_allowed`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such resource for this organisation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PaymentRequired:
      description: |
        The request was not billable. Distinguish the causes by `error.code`:

        - `insufficient_credits` — the shared credit pool cannot cover it. Buying
          or granting credits resolves it.
        - `spend_cap_exceeded` — the organisation's API spend cap for the period
          has been reached. An administrator raises the cap.
        - `department_budget_exceeded` — the organisation HAS the credits, but a
          department budget refuses the spend. Buying credits will NOT help; an
          organisation owner or billing admin must raise the budget. The body
          carries `limit`, `spent` and `raisedBy`.
        - `member_limit_exceeded` — as above, for an individual member's monthly
          limit. Not reachable with an API key today, since a key spends as the
          organisation rather than as a member.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: |
        Too many requests. Retry after the interval in `Retry-After`. Limits apply
        per key and, at a higher ceiling, per organisation.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait before retrying.
        RateLimit-Limit:
          schema: { type: integer }
        RateLimit-Remaining:
          schema: { type: integer }
        RateLimit-Reset:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  # Outbound account events. Delivered to endpoints registered via
  # POST /v1/webhook-endpoints and signed with that endpoint's secret.
  x-webhooks-note: |
    Every delivery carries:
      X-Dhavana-Signature: t=<unix-seconds>,v1=<hex hmac-sha256>
      X-Dhavana-Event-Id:  evt_...        (matches `id` in the body)
    Verify by recomputing HMAC-SHA256 over the exact string "<t>.<raw request
    body>" using your endpoint's signing secret, comparing in constant time, and
    rejecting timestamps older than five minutes.

webhooks:
  accountEvent:
    post:
      summary: Account event
      description: |
        Sent to your registered webhook endpoints when your organisation's credit
        or spend-cap state changes. Each event fires once per occurrence — you are
        not flooded while blocked or out of credit.

        Respond `2xx` to acknowledge. Failed deliveries are retried with backoff;
        delivery never affects your requests or job results.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string, description: Matches the X-Dhavana-Event-Id header. }
                type:
                  type: string
                  enum:
                    - credits.low
                    - credits.exhausted
                    - api.spend_cap_warning
                    - api.spend_cap_reached
                created: { type: integer, description: Unix timestamp. }
                data:
                  type: object
                  properties:
                    organization: { type: string }
                    balance_credits: { type: number }
                    threshold_credits: { type: number }
                    needed_credits: { type: number }
                    period: { type: string }
                    spend_cap: { type: number }
                    spent_credits: { type: number }
                    cap_remaining: { type: number }
                    soft_limit_percent: { type: number }
                  additionalProperties: true
      responses:
        "200":
          description: Acknowledged.
