openapi: 3.0.3
info:
  title: Verbi
  version: "1.0.0"
  description: >
    Synchronous speech-to-text API — 25 European languages, auto-detected
    per file. Audio (or video — the audio
    track is extracted) is sent as the raw request body and the transcript
    is returned in the same response (no polling). Word-level timestamps +
    confidence and speaker diarization are included free; SRT/VTT captions
    on request. Agent-oriented onboarding lives at /llms.txt. This document
    is served by the live app and is asserted equal to real behavior by the
    acceptance suite.
servers:
  - url: https://verbi.services

paths:
  /health:
    get:
      summary: Liveness and capacity.
      responses:
        "200":
          description: Service is operational.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                required: [status, service, instance, lanes, num_threads, max_file_min, rec_load_ms, uptime_s]
                properties:
                  status: {type: string}
                  service: {type: string}
                  instance: {type: string}
                  lanes: {type: integer}
                  num_threads: {type: integer}
                  max_file_min: {type: integer}
                  rec_load_ms: {type: number}
                  uptime_s: {type: number}

  /v1/signup:
    post:
      summary: Mint an API key (optionally tied to an email for a bigger free tier).
      description: >
        An email signup starts on the anonymous grants; the email grants (10h/mo + the one-time
        20h welcome bonus) activate when the mailed verification link is clicked (GET /v1/verify).
        The response reports the CURRENT grant plus email_verified and whether the verification
        mail was sent ("unavailable" while outbound mail is not configured on this deployment).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                email: {type: string}
      responses:
        "201":
          description: Key created (shown once).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                required: [api_key, customer_id, tier, free_hours_this_month, monthly_free_hours, welcome_bonus_hours]
                properties:
                  api_key: {type: string}
                  customer_id: {type: string}
                  tier: {type: string, enum: [anonymous, signed-up]}
                  free_hours_this_month: {type: number}
                  monthly_free_hours: {type: number}
                  welcome_bonus_hours: {type: number}
                  email_verified:
                    description: Email signups only — always false at signup; flips via /v1/verify.
                    type: boolean
                  verification:
                    description: Email signups only — whether the verification mail went out.
                    type: string
                    enum: [sent, unavailable]
        "429":
          description: SIGNUP_LIMIT — this network reached the free-key cap.
        "503":
          description: SERVICE_UNAVAILABLE — the customer store is unreachable; retry shortly.

  /v1/verify:
    get:
      summary: Verify an email address (the single-use link from the verification mail).
      description: >
        No auth — the mailed token IS the credential. On success the email grants (10h/mo,
        welcome bonus +20h) are applied exactly once, atomically with consuming the token; the
        response is a small HTML page, or JSON when the request sends Accept: application/json.
        An unknown or already-used token is 404 INVALID_TOKEN — the bonus can never apply twice.
      parameters:
        - name: token
          in: query
          required: true
          schema: {type: string}
      responses:
        "200":
          description: Email verified — the email grants are now active.
          content:
            text/html:
              schema: {type: string}
            application/json:
              schema:
                type: object
                additionalProperties: true
                required: [verified, email_verified, monthly_allowance_hours, welcome_bonus_hours, remaining_hours]
                properties:
                  verified: {type: boolean}
                  email_verified: {type: boolean}
                  monthly_allowance_hours: {type: number}
                  welcome_bonus_hours: {type: number}
                  remaining_hours: {type: number}
        "404":
          description: INVALID_TOKEN — unknown or already-used verification token.
        "503":
          description: SERVICE_UNAVAILABLE — the customer store is unreachable; retry shortly.

  /v1/pricing:
    get:
      summary: Public price list, limits, and free-tier policy (no auth).
      responses:
        "200":
          description: Pricing, limits, and free-tier policy.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                required: [topup, billing_unit, vad_sensitivity, diarization, max_file_minutes, billing_live, free_tier, concurrency, signups_per_ip_per_month]
                properties:
                  topup:
                    description: Flat prepaid pricing — whole speech-hours at one rate, with a minimum purchase.
                    type: object
                    additionalProperties: true
                    required: [usd_per_hour, min_hours]
                    properties:
                      usd_per_hour: {type: number}
                      min_hours: {type: integer}
                  billing_unit: {type: string, enum: [speech-hour]}
                  vad_sensitivity:
                    type: object
                    additionalProperties: true
                    required: [levels, default, config_version]
                    properties:
                      levels:
                        type: array
                        items: {type: string, enum: [complete, economy]}
                      default: {type: string}
                      config_version: {type: string}
                  diarization: {type: string}
                  max_file_minutes: {type: integer}
                  billing_live: {type: boolean}
                  free_tier:
                    type: object
                    additionalProperties: true
                    required: [anonymous, email]
                    properties:
                      anonymous:
                        type: object
                        additionalProperties: true
                        required: [monthly_hours, welcome_bonus_hours]
                        properties:
                          monthly_hours: {type: number}
                          welcome_bonus_hours: {type: number}
                      email:
                        type: object
                        additionalProperties: true
                        required: [monthly_hours, welcome_bonus_hours]
                        properties:
                          monthly_hours: {type: number}
                          welcome_bonus_hours: {type: number}
                  concurrency:
                    type: object
                    additionalProperties: true
                    required: [anonymous, email]
                    properties:
                      anonymous: {type: integer}
                      email: {type: integer}
                  signups_per_ip_per_month: {type: integer}

  /v1/contact:
    post:
      summary: File feedback that reaches the operator (agent-friendly contact channel).
      description: >
        Auth required. Body {"message": "...", "kind": "...", "reply_to": "..."}. message is
        required, at most 5000 characters; kind is an optional short free-form label (suggested:
        feedback, bug, question; default feedback); reply_to becomes the mail Reply-To.
        400 INVALID_MESSAGE for a missing/over-long message; 502 MAIL_UPSTREAM on delivery failure.
      security: [{bearerAuth: []}]
      responses:
        "200":
          description: Message delivered to a human.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: {type: string, enum: [received]}
                  note: {type: string}
        "400": {description: INVALID_MESSAGE — missing or over-long message.}
        "401": {description: UNAUTHORIZED — missing or invalid Bearer key.}
        "502": {description: MAIL_UPSTREAM — delivery failed; retry shortly.}
  /v1/verify/resend:
    post:
      summary: Re-send the email-verification mail with a fresh single-use link.
      description: >
        Auth required (the api key proves account ownership). Mails a NEW single-use link and
        invalidates the previously mailed one; allowed at most once per hour per account.
        400 NO_EMAIL for keys without an email; 400 ALREADY_VERIFIED once verified;
        429 RESEND_RATE_LIMITED (with retry_after_sec) inside the hourly window;
        502 MAIL_UPSTREAM when the mail could not be sent.
      security: [{bearerAuth: []}]
      responses:
        "200":
          description: A fresh verification mail was sent; earlier links no longer work.
          content:
            application/json:
              schema:
                type: object
                required: [verification]
                properties:
                  verification: {type: string, enum: [sent]}
                  note: {type: string}
        "400": {description: NO_EMAIL — no address on this key, or ALREADY_VERIFIED.}
        "401": {description: UNAUTHORIZED — missing or invalid Bearer key.}
        "429": {description: RESEND_RATE_LIMITED — re-sent recently; retry_after_sec included.}
        "502": {description: MAIL_UPSTREAM — the mail could not be sent; try again shortly.}
  /v1/billing:
    get:
      summary: Your account — plan, balances, remaining hours (auth required).
      security: [{bearerAuth: []}]
      responses:
        "200":
          description: Account billing summary (no pricing; see /v1/pricing).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                required:
                  - plan
                  - email_verified
                  - monthly_allowance_hours
                  - used_this_period_hours
                  - welcome_bonus_hours
                  - paid_credit_hours
                  - remaining_hours
                  - period_resets_at
                properties:
                  plan: {type: string}
                  email_verified:
                    description: Whether the account's email is verified (the email grants gate on it).
                    type: boolean
                  monthly_allowance_hours: {type: number}
                  used_this_period_hours: {type: number}
                  welcome_bonus_hours: {type: number}
                  paid_credit_hours: {type: number}
                  remaining_hours: {type: number}
                  period_resets_at: {type: integer}
        "401":
          description: UNAUTHORIZED — missing or invalid key.
        "403":
          description: ACCOUNT_SUSPENDED — the account has been suspended.
        "503":
          description: SERVICE_UNAVAILABLE — the customer store is unreachable; retry shortly.

  /v1/transcribe:
    post:
      summary: Transcribe an audio file synchronously (with free speaker diarization).
      description: >
        The request body is the raw audio bytes (any ffmpeg-decodable format);
        video containers work too — the default audio track is extracted and
        transcribed. Multichannel audio is downmixed to mono before decoding
        (speakers are identified by voice via diarization, not by channel; a
        stereo file never bills double). The response keep-alive-streams
        whitespace during a long decode but is a single JSON object. Speaker
        diarization is included free and on by default; pass ?diarize=false
        to skip it for single-speaker audio.
        Billing is per speech-hour — only audio detected as speech (at the
        request's vad_sensitivity) is decoded and billed, and the response
        reports the exact billed amount. Pass ?partials=true (off by default)
        to receive incremental partial transcripts while the file decodes: the
        200 response becomes NDJSON (application/x-ndjson) — see the partials
        parameter for the frame shapes; the schema below stays the default
        single-JSON contract.
      security: [{bearerAuth: []}]
      parameters:
        - name: diarize
          in: query
          required: false
          description: >
            Speaker diarization (default true). Set false to skip it — ~15% faster end-to-end
            (measured on production; the biggest speed lever).
          schema: {type: boolean, default: true}
        - name: captions
          in: query
          required: false
          description: >
            Comma-separated caption formats (srt, vtt or both) rendered from the word timestamps
            into a `captions` response object keyed by format — request every format you need in
            ONE transcription.
          schema: {type: string, example: "srt,vtt"}
        - name: partials
          in: query
          required: false
          description: >
            Opt in (default false) to incremental partial transcripts. When
            true, the 200 response is NDJSON (Content-Type
            application/x-ndjson), one JSON object per line (blank keep-alive
            lines may appear): zero or more preview frames
            {"type":"partial","n_chunks_done":N,"text":"<cumulative text so
            far>"}, then exactly one terminal frame — on success the standard
            response object below with "type":"final" added, on a processing
            failure {"type":"error","error_code":"PROCESSING_FAILED",...}.
            Partials are text-only and speaker-unstable (speaker labels are
            assigned at the final clustering pass); the final frame always
            carries the complete authoritative speaker-labelled transcript.
            Envelope errors (401/403/429, param 400, exhausted-balance 402)
            keep their normal status codes and plain JSON bodies; content
            verdicts (EMPTY_AUDIO, FILE_TOO_LONG, detected-speech
            QUOTA_EXCEEDED) arrive as the terminal in-body error object.
            Billing follows delivery: disconnecting before the final frame
            bills exactly the speech whose transcript was already delivered
            in partial frames (a processing failure bills nothing).
          schema: {type: boolean, default: false}
        - name: vad_sensitivity
          in: query
          required: false
          description: >
            How aggressively non-speech is trimmed (and therefore how much is
            billed): complete (default — the accurate speech meter) or
            economy (cheaper on media-heavy audio at a completeness cost) —
            each a versioned decode configuration (see
            /v1/pricing's config_version). Default complete (fail-open:
            keeps messy/quiet speech, bills slightly above pure speech). Any
            other value, including a number, returns 400 INVALID_SENSITIVITY.
          schema: {type: string, enum: [complete, economy]}
        - name: X-Glossary
          in: header
          required: false
          description: >
            Custom-vocabulary glossary — a JSON array of up to 100 domain terms (proper names,
            jargon, products, tickers), boosted so they are recognized with better fidelity. Free.
            Terms may be multi-word phrases and non-ASCII; entries may be strings or {"term": "..."}
            objects (any weight is ignored — every term is boosted at a fixed strength). Per-request
            and stateless. Validation is strict and up-front: a malformed array, an empty/over-long
            term, more than 100 terms, or ANY term the recognizer's tokenizer cannot represent
            returns 400 INVALID_GLOSSARY before any processing or billing (unrepresentable terms are
            named in the body's `invalid_terms` — fixing the glossary and retrying costs nothing;
            there is no partial apply). The applied count is echoed as `glossary_applied`.
          schema: {type: string, example: '["Monro","SunTrust","paracetamol"]'}
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: {type: string, format: binary}
      responses:
        "200":
          description: Transcript. With diarization on (default), each word carries a `speaker` and the body adds `speaker_count` + `speakers`.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                required: [text, words, audio_duration_sec, billed_speech_sec, speech_ratio, vad_sensitivity, n_chunks, timing, remaining_hours, request_id]
                properties:
                  text: {type: string}
                  words:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                      required: [word, start, end]
                      properties:
                        word: {type: string}
                        start: {type: number}
                        end: {type: number}
                        confidence: {type: number}
                        speaker: {type: string}
                  language:
                    description: ISO 639-1 code of the returned transcript's dominant language, auto-detected from the OUTPUT TEXT and restricted to the 25 supported languages — lets a caller verify the audio was transcribed in the language they expected. Omitted (together with language_confidence) when the transcript is too short to classify.
                    type: string
                  language_confidence:
                    description: Confidence in [0,1] for `language`. Present if and only if `language` is.
                    type: number
                  language_segments:
                    description: Present ONLY when the transcript mixes two or more languages (code-switching) — sentence-level spans merged into runs of one language, each with its time range in seconds in the original audio. Omitted for the common single-language transcript. Sentence granularity is deliberate; word-by-word language is not offered (single words are language-ambiguous).
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                      required: [start, end, language, confidence]
                      properties:
                        start: {type: number}
                        end: {type: number}
                        language: {type: string}
                        confidence: {type: number}
                  speaker_count: {type: integer}
                  speakers:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                      required: [speaker, word_count, total_speech_sec]
                      properties:
                        speaker: {type: string}
                        word_count: {type: integer}
                        total_speech_sec: {type: number}
                  audio_duration_sec: {type: number}
                  billed_speech_sec:
                    description: Speech seconds actually decoded — exactly this is drawn from the balance.
                    type: number
                  speech_ratio:
                    description: billed_speech_sec / audio_duration_sec, rounded to 3 decimals (0.0 for empty audio).
                    type: number
                  vad_sensitivity:
                    description: The resolved sensitivity label this request ran with.
                    type: string
                    enum: [complete, economy]
                  n_chunks: {type: integer}
                  timing:
                    type: object
                    additionalProperties: true
                    required: [audio_sec, processing_sec, rtfx, cold_start]
                    properties:
                      audio_sec: {type: number}
                      processing_sec: {type: number}
                      rtfx: {type: number}
                      cold_start: {type: boolean}
                      ingest_sec: {type: number}
                  remaining_hours: {type: number}
                  request_id: {type: string}
                  glossary_applied:
                    description: Only when X-Glossary was sent — the count of glossary terms boosted (an accepted request applied every term; unrepresentable terms already rejected the request with 400 INVALID_GLOSSARY).
                    type: integer
        "400": {description: INVALID_SENSITIVITY — unparseable vad_sensitivity — or INVALID_CAPTIONS_FORMAT — a captions value other than srt/vtt — or INVALID_GLOSSARY — X-Glossary not a JSON array of terms, a term empty/over-long, more than 100 terms, or a term the recognizer's tokenizer cannot represent (the body then carries invalid_terms; always rejected before any processing or billing). Envelope errors; content verdicts such as EMPTY_AUDIO, FILE_TOO_LONG and detected-speech QUOTA_EXCEEDED arrive as the terminal in-body JSON error object of the 200 stream.}
        "401": {description: UNAUTHORIZED — missing or invalid key.}
        "402": {description: QUOTA_EXCEEDED — balance already exhausted at request time (a clip whose DETECTED SPEECH exceeds a positive balance is reported in-stream instead, with the would-be billed_speech_sec; nothing is billed either way).}
        "403": {description: ACCOUNT_SUSPENDED — the account has been suspended.}
        "429": {description: TOO_MANY_CONCURRENT — concurrency limit hit; the body carries a retry_after_sec hint. Requests over the limit are rejected, never queued.}
        "503": {description: SERVICE_UNAVAILABLE — the customer store is unreachable at the gate; retry shortly. (Store death AFTER the 200 commits arrives as the terminal in-body PROCESSING_FAILED object instead — the error surface stays categorical.)}

  /v1/billing/topup:
    post:
      summary: Buy prepaid speech-hours via a hosted Stripe Checkout page.
      description: >
        Whole hours only, at the flat rate and minimum advertised by /v1/pricing's topup block.
        Returns a Stripe-hosted checkout URL — card data never touches Verbi. The purchased hours
        are credited to paid_credit when the payment completes (via the signed Stripe webhook),
        never at session creation. While billing is not live on a deployment the endpoint answers
        {"status":"coming_soon"} instead.
      security: [{bearerAuth: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hours]
              properties:
                hours:
                  description: Whole speech-hours to buy (>= the advertised min_hours).
                  type: integer
      responses:
        "200":
          description: A checkout envelope (status "checkout" + url/hours/amount_usd), or the coming_soon stub while billing is not live.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                required: [status]
                properties:
                  status: {type: string, enum: [checkout, coming_soon]}
                  url: {type: string, nullable: true}
                  hours: {type: integer}
                  amount_usd: {type: number}
        "400": {description: INVALID_TOPUP_HOURS — hours not a positive whole number, or below the minimum purchase.}
        "401": {description: UNAUTHORIZED — missing or invalid key.}
        "403": {description: ACCOUNT_SUSPENDED — the account has been suspended.}
        "502": {description: PAYMENT_UPSTREAM — the payment provider request failed; retry shortly.}
        "503": {description: SERVICE_UNAVAILABLE — the customer store is unreachable; retry shortly.}

  /v1/billing/stripe-webhook:
    post:
      summary: Stripe webhook receiver (signature-verified fulfillment; no-op until configured).
      description: >
        Fulfills checkout.session.completed events — the Stripe-Signature header is verified
        (HMAC-SHA256, bounded timestamp) and a completed payment credits its hours exactly once;
        replayed deliveries and unknown event types are acknowledged without effect.
      responses:
        "200": {description: Received (fulfilled, duplicate, or ignored — always acknowledged).}
        "400": {description: BAD_SIGNATURE — Stripe-Signature missing, invalid, or stale.}
        "503": {description: SERVICE_UNAVAILABLE — the customer store is unreachable; Stripe retries the delivery.}

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
