# The Fellowverse API, described as OpenAPI 3.1 for your tools and client
# generators.

openapi: 3.1.0
info:
  title: Fellowverse API
  version: 1.0.0
  summary: Search the World English Bible by text or voice, and align recordings to the text they read.
  description: |
    JSON over HTTPS, one key per request. Every error says what went wrong, why,
    and what would change the outcome.

    Scripture is the World English Bible, which is in the public domain.

    Billing is metered: search per request, voice search and alignment per
    second of audio. `GET /v1/usage` shows the same figures billing uses.

    Make your keys in the developer console at
    https://play.fellowverse.app/developers. Every account starts with free
    test keys, so you can try everything before anything is billed.

    ## Get started

    1. Sign in to the developer console with your email and name your account.
    2. Under **Keys**, make a test key and copy it: it is shown once.
    3. Search:

       ```
       curl https://play.fellowverse.app/v1/search \
         -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
         -d '{"query": "rest for the weary", "limit": 3}'
       ```

    4. See what you used with `GET /v1/usage`, or under **Use** in the console.

    Test keys call the same API as live keys, so the code you write now is the
    code you ship.

    ## Keep your key on your server

    A key in an Android or iOS app, or in a web page, can be read by anyone who
    has the app. Call Fellowverse from your own server and have your app call
    your server: it holds the key, decides who may search, and can cache
    popular results. Use one key per server or environment, named for it, so
    you can replace one without touching the others.

    ## Add voice search to an app

    Record the request in the app, send it to your server, and pass the audio
    to `POST /v1/voice-search` as the body with its type. On Android,
    `MediaRecorder` with MPEG-4 and AAC gives `audio/mp4`; in a browser,
    `MediaRecorder` gives `audio/webm` or `audio/mp4`. Show the `transcript`
    with the results, so people see what was heard; an empty one means nothing
    could be heard.

    ## Play a recording verse by verse

    1. Upload the recording (`POST /v1/uploads`) and start an alignment
       (`POST /v1/alignments`). For a Bible reading, leave out `segments` and
       the passage and its verses are found for you.
    2. Poll `GET /v1/jobs/{id}` every few seconds until it is `done` or `failed`.
    3. Store the result with the recording. Each segment's `startMs` and
       `endMs` let your player seek to a verse; `words` times each word.
    4. If you asked for `clips`, download them with your key and host them
       yourself. They are there for 7 days after the job finishes.

    Align each recording once, when it is published, and serve the timings
    from your own storage, so listeners never wait on an alignment.

    ## Handle errors and limits

    Every error has a stable `code` to branch on, and `problem`, `reason` and
    `fix` written to be read. Fix a `400` before sending it again. Retry a
    `429` after a short wait (for `api_test_quota`, `fix` says when the day's
    allowance resets) and a `5xx` after a wait that doubles each time.
    Searches are safe to retry; before retrying `POST /v1/alignments` after a
    timeout, check whether the first request made a job. A failed job's
    `error.code` says why it failed, such as `api_text_mismatch` or
    `api_passage_unknown`, and it fails at once rather than being retried.

    ## Go live

    1. The account's owner chooses **Request live access** in the console. We
       get in touch to set up billing and turn on live keys.
    2. Make a live key under **Keys**. It takes a code we email you.
    3. Put it on your server in place of the test key. Nothing else changes.
    4. To replace a key, roll it and give the old one up to a week of grace
       while you deploy the new one.

    Every live key made, rolled or revoked is emailed to the account's owners,
    and the console's **Activity** shows who did what and when.
  contact:
    name: Fellowverse
    url: https://fellowverse.app
servers:
  - url: https://play.fellowverse.app
    description: Fellowverse
security:
  - apiKey: []
tags:
  - name: Search
    description: Passages by meaning or by reference, typed or spoken.
  - name: Alignment
    description: When each section and word of a text is spoken in a recording.
  - name: Account
    description: Metered use, as billed.

paths:
  /v1/search:
    post:
      tags: [Search]
      operationId: search
      summary: Search by words or a reference
      description: |
        Takes a few words, a feeling or a reference, and returns passages of two
        to six verses each, with a context line saying where they sit and who is
        speaking.

        A reference such as "Psalm 23" or "John 3:16" returns that passage and the
        rest of its chapter, with `kind` set to `reference`. Anything else is a
        search by meaning and wording, with `kind` set to `search`.

        Billed per request. Limited to 120 a minute per key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              additionalProperties: false
              properties:
                query:
                  type: string
                  minLength: 1
                  maxLength: 300
                  examples: ['something for fear', 'Psalm 23']
                limit:
                  type: integer
                  minimum: 1
                  maximum: 50
                  default: 20
            example:
              query: something for fear
              limit: 5
      responses:
        '200':
          description: The passages found, best first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Suspended'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'

  /v1/voice-search:
    post:
      tags: [Search]
      operationId: voiceSearch
      summary: Search by a spoken request
      description: |
        Takes a spoken request of up to 30 seconds and 10 MB as the request
        body, with its type, and returns what was heard and the same results as a search. The
        audio is not kept.

        When nothing could be heard, the response holds an empty `transcript`
        and no results.

        Billed per second of audio, at least one second. Limited to 60 a minute
        per key.
      parameters:
        - name: limit
          in: query
          description: How many passages to return.
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
      requestBody:
        $ref: '#/components/requestBodies/Audio'
      responses:
        '200':
          description: What was heard, and the passages found for it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoiceSearchResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Suspended'
        '413':
          $ref: '#/components/responses/TooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          $ref: '#/components/responses/Unavailable'

  /v1/uploads:
    post:
      tags: [Alignment]
      operationId: createUpload
      summary: Upload a recording to align
      description: |
        Takes a recording of up to 200 MB as the request body, with its type.
        One alignment takes up to an hour of audio, or 10 minutes with a test key.
        Uploads are kept for 7 days (a day with a test key), long enough to align
        them; this is not a storage service.

        Limited to 30 a minute per key.
      requestBody:
        $ref: '#/components/requestBodies/Audio'
      responses:
        '201':
          description: The upload was kept. Use its id to start an alignment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Upload'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Suspended'
        '413':
          $ref: '#/components/responses/TooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'

  /v1/alignments:
    post:
      tags: [Alignment]
      operationId: createAlignment
      summary: Align an upload to the text it reads
      description: |
        Starts aligning an upload to its text, given as sections with your own
        ids: paragraphs, sentences, verses or lines. The job runs in the
        background; poll `GET /v1/jobs/{id}` for the result.

        Announcements and closing lines set apart by a pause are found and left
        out of the sections. Wording that differs slightly from your text is
        tolerated.

        Leave out `segments` for a Bible reading: the passage is identified from
        what is said, from any verse to any verse and across chapters, and its
        verses become the sections. The result then holds `passage`. A recording
        that is not a reading, or only quotes a verse, fails with the code
        `api_passage_unknown`. Only the World English Bible is held, so a reading
        of another translation is found only when enough of its wording agrees.

        Billed per second of audio when the job is done. Limited to 30 a minute
        per key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AlignmentRequest'
            examples:
              withText:
                summary: A recording and the text it reads
                value:
                  upload: upl_Hk3vQ9xTmP2a
                  clips: true
                  segments:
                    - id: v1
                      text: Yahweh is my shepherd; I shall lack nothing.
              bibleReading:
                summary: A Bible reading, passage to be identified
                value:
                  upload: upl_Hk3vQ9xTmP2a
                  clips: true
      responses:
        '202':
          description: The job was queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobCreated'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Suspended'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'

  /v1/jobs/{id}:
    get:
      tags: [Alignment]
      operationId: getJob
      summary: See a job's state and result
      description: |
        Poll until `status` is `done` or `failed`. A done job holds its
        `result`; a failed one holds its `error`.

        A job that fails for a reason in the request (an unreadable file, text
        that does not match) fails at once with that reason. One that fails on
        our side is tried again, up to three times, and goes back to `queued`
        in between. Jobs of a suspended account wait in `queued` until it is
        restored.
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: The job.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Suspended'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/Internal'

  /v1/jobs/{id}/files/{name}:
    get:
      tags: [Alignment]
      operationId: getJobFile
      summary: Fetch a clip an alignment made
      description: |
        Clips exist only for alignments made with `clips` set to true, once the
        job is done. Their names are given in the result, one per section. Clips
        are kept for 7 days after the job finishes (a day with a test key); then
        this returns 404 with
        the code `api_file_expired`.
      parameters:
        - $ref: '#/components/parameters/JobId'
        - name: name
          in: path
          required: true
          description: The clip's name from the job result, such as `1.mp3`.
          schema:
            type: string
            pattern: '^\d{1,5}\.mp3$'
      responses:
        '200':
          description: The clip, as MP3.
          content:
            audio/mpeg:
              schema:
                type: string
                format: binary
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Suspended'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/Internal'

  /v1/usage:
    get:
      tags: [Account]
      operationId: getUsage
      summary: See metered use over a period
      description: |
        Returns your use by product, as billed. Without dates it covers the
        current month, in UTC, to now. Called with a test key, it returns the
        account's test use instead.
      parameters:
        - name: from
          in: query
          description: The start of the period, included. An ISO date or time.
          schema:
            type: string
            examples: ['2026-10-01']
        - name: to
          in: query
          description: The end of the period, left out. An ISO date or time. Now when not given.
          schema:
            type: string
            examples: ['2026-11-01']
      responses:
        '200':
          description: Use in the period.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Usage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Suspended'
        '500':
          $ref: '#/components/responses/Internal'

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: fv_live_... or fv_test_...
      description: |
        Send your key as `Authorization: Bearer fv_live_...`. A key is shown once,
        when it is made. Keep it on your server, not in a web page or an app.
        Lost keys are revoked and replaced, not recovered.

        Test keys (`fv_test_...`) call the same API for free, with a daily
        allowance per account and alignments of up to 10 minutes. Rolling a key
        replaces it and lets the old one keep working for up to a week while you
        switch over.

  parameters:
    JobId:
      name: id
      in: path
      required: true
      description: The job id returned when the alignment was started.
      schema:
        type: string
        examples: ['job_Hk3vQ9xTmP2a']

  requestBodies:
    Audio:
      required: true
      description: |
        The recording itself as the body, with its type in Content-Type.
        MP3, WAV, M4A, OGG, WebM and FLAC are read.
      content:
        audio/mpeg:
          schema: { type: string, format: binary }
        audio/mp3:
          schema: { type: string, format: binary }
        audio/wav:
          schema: { type: string, format: binary }
        audio/x-wav:
          schema: { type: string, format: binary }
        audio/wave:
          schema: { type: string, format: binary }
        audio/webm:
          schema: { type: string, format: binary }
        audio/ogg:
          schema: { type: string, format: binary }
        audio/mp4:
          schema: { type: string, format: binary }
        audio/x-m4a:
          schema: { type: string, format: binary }
        audio/aac:
          schema: { type: string, format: binary }
        audio/flac:
          schema: { type: string, format: binary }
        application/octet-stream:
          schema: { type: string, format: binary }

  responses:
    BadRequest:
      description: |
        The request could not be used. `invalid_request` when a field is missing
        or has the wrong type; `bad_request` when the audio or a value could not
        be read.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: bad_request
              problem: The recording is 41 seconds long.
              reason: Voice search takes a spoken request of up to 30 seconds.
              fix: Send a shorter recording; for long audio use alignment.
    Unauthorized:
      description: |
        The key is missing, wrong, revoked, or past the end of its grace period
        after a roll. The code is `api_unauthorized`, and the reason says which.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: api_unauthorized
              problem: The API key was not accepted.
              reason: This key was revoked.
              fix: Make a new key and use that instead.
    Suspended:
      description: |
        The account is suspended (`api_suspended`), or a live key was used on an
        account that has test keys only (`api_live_locked`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: |
        The upload, job or clip does not exist, or belongs to another account.
        The codes are `api_upload_missing`, `api_job_missing` and
        `api_file_missing`. `api_off` means the request went to an address that
        does not serve the API; use https://play.fellowverse.app.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    TooLarge:
      description: |
        The body is over the limit: 200 MB for an upload, 10 MB for voice
        search. The code is `request_error`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    UnsupportedType:
      description: The Content-Type is not one of the audio types read. The code is `request_error`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    RateLimited:
      description: |
        A per-key limit for the minute was reached (`api_rate_limited`; the
        reason names the limit), or a test key used up its account's daily
        allowance (`api_test_quota`; the fix says when it resets).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: api_rate_limited
              problem: Too many search requests in a minute for this key.
              reason: Each key may make 120 a minute.
              fix: Wait a moment, or ask Fellowverse for a higher limit.
    Internal:
      description: Something went wrong on our side. The code is `internal`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unavailable:
      description: |
        Voice search cannot be served right now; typed search keeps working.
        The code is `api_stt_off`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

  schemas:
    ErrorParts:
      type: object
      required: [problem, reason, fix]
      properties:
        problem:
          type: string
          description: What went wrong.
        reason:
          type: string
          description: Why it went wrong.
        fix:
          type: string
          description: What would change the outcome.

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          allOf:
            - type: object
              required: [code]
              properties:
                code:
                  type: string
                  description: A stable code to branch on. The other fields are for people.
                  examples: [api_rate_limited]
            - $ref: '#/components/schemas/ErrorParts'

    Verse:
      type: object
      required: [verse, text]
      properties:
        verse:
          type: integer
          minimum: 1
        text:
          type: string

    Passage:
      type: object
      required:
        [
          id,
          reference,
          book,
          chapter,
          firstVerse,
          lastVerse,
          heroVerse,
          contextLine,
          verses,
          reason,
        ]
      properties:
        id:
          type: string
          description: A stable id for the passage.
          examples: ['psg_1JN_4_18']
        reference:
          type: string
          examples: ['1 John 4:18-21']
        book:
          type: string
          description: The book's name in English.
          examples: ['1 John']
        chapter:
          type: integer
          minimum: 1
        firstVerse:
          type: integer
          minimum: 1
        lastVerse:
          type: integer
          minimum: 1
        heroVerse:
          type: integer
          minimum: 1
          description: The verse that carries the passage, for a short quote.
        contextLine:
          type: string
          description: Where the passage sits and who is speaking.
        verses:
          type: array
          items:
            $ref: '#/components/schemas/Verse'
        reason:
          type: string
          description: Why this passage was returned.

    SearchResponse:
      type: object
      required: [kind, results, translation]
      properties:
        kind:
          type: string
          enum: [search, reference]
          description: '`reference` when the query named a passage, `search` otherwise.'
        title:
          type: string
          description: For a reference, the chapter it names, such as "Psalm 23". Absent for a search.
        method:
          type: string
          enum: [rrf, text, vector]
          description: |
            How a search was matched: `rrf` blends meaning and wording, `text` is
            wording only. Absent for a reference.
        degraded:
          type: string
          description: Present when a search fell back to wording only, saying why.
        results:
          type: array
          items:
            $ref: '#/components/schemas/Passage'
        translation:
          type: string
          const: World English Bible (public domain)

    VoiceSearchResponse:
      type: object
      required: [transcript, results]
      description: A search response with what was heard added.
      properties:
        transcript:
          type: string
          description: What was heard. Empty when nothing was, and then there are no results.
        kind:
          $ref: '#/components/schemas/SearchResponse/properties/kind'
        title:
          $ref: '#/components/schemas/SearchResponse/properties/title'
        method:
          $ref: '#/components/schemas/SearchResponse/properties/method'
        degraded:
          $ref: '#/components/schemas/SearchResponse/properties/degraded'
        results:
          type: array
          items:
            $ref: '#/components/schemas/Passage'
        translation:
          $ref: '#/components/schemas/SearchResponse/properties/translation'

    Upload:
      type: object
      required: [id, bytes]
      properties:
        id:
          type: string
          pattern: '^upl_[A-Za-z0-9_-]{12}$'
        bytes:
          type: integer
          minimum: 1

    AlignmentRequest:
      type: object
      required: [upload]
      additionalProperties: false
      properties:
        upload:
          type: string
          pattern: '^upl_[A-Za-z0-9_-]{12}$'
          description: The id from `POST /v1/uploads`.
        clips:
          type: boolean
          default: false
          description: Make an MP3 clip of each section, cut in the quiet around it.
        segments:
          type: array
          minItems: 1
          maxItems: 5000
          description: |
            The text the recording reads, in order, as sections with your own
            ids. Leave it out for a Bible reading whose passage is to be found.
            The text may be up to 200,000 characters in all, about an hour of reading.
          items:
            type: object
            required: [id, text]
            additionalProperties: false
            properties:
              id:
                type: string
                minLength: 1
                maxLength: 100
              text:
                type: string
                minLength: 1
                maxLength: 20000

    JobCreated:
      type: object
      required: [id, status, url]
      properties:
        id:
          type: string
          examples: ['job_Hk3vQ9xTmP2a']
        status:
          type: string
          const: queued
        url:
          type: string
          description: Where to poll for the job.
          examples: ['/v1/jobs/job_Hk3vQ9xTmP2a']

    Job:
      type: object
      required: [id, kind, status, attempts, createdAt, finishedAt]
      properties:
        id:
          type: string
        kind:
          type: string
          enum: [alignment]
        status:
          type: string
          enum: [queued, running, done, failed]
        attempts:
          type: integer
          minimum: 0
          maximum: 3
          description: How many times the job has been started.
        createdAt:
          type: string
          format: date-time
        finishedAt:
          type: [string, 'null']
          format: date-time
        result:
          $ref: '#/components/schemas/AlignmentResult'
        error:
          description: Present when the job failed.
          allOf:
            - type: object
              properties:
                code:
                  type: string
                  description: |
                    A stable code to branch on, such as `api_passage_unknown` when a
                    recording sent without text is not a Bible reading. Jobs that
                    failed before October 2026 have none.
                  examples: [api_passage_unknown]
            - $ref: '#/components/schemas/ErrorParts'

    AlignmentResult:
      type: object
      description: Present when the job is done. Times are milliseconds in the file you uploaded.
      required:
        [
          durationMs,
          matched,
          speechStartMs,
          speechEndMs,
          droppedBefore,
          droppedAfter,
          segments,
          words,
        ]
      properties:
        passage:
          $ref: '#/components/schemas/IdentifiedPassage'
        durationMs:
          type: integer
          description: The length of the recording.
        matched:
          type: number
          minimum: 0.5
          maximum: 1
          description: |
            The share of your text's words heard as written. Below 0.5 the job
            fails, because the audio does not read the text.
        speechStartMs:
          type: integer
          description: Where the text starts being read.
        speechEndMs:
          type: integer
          description: Where the text stops being read.
        droppedBefore:
          type: string
          description: What was heard before the text and left out, such as an announcement.
          examples: ['Psalm 23. A Psalm by David.']
        droppedAfter:
          type: string
          description: What was heard after the text and left out.
        segments:
          type: array
          description: One per section you sent, in the same order.
          items:
            type: object
            required: [id, startMs, endMs]
            properties:
              id:
                type: string
                description: Your id for the section, or the verse id, such as `PSA.23.1`, for a Bible reading.
              reference:
                type: string
                description: For a Bible reading, the verse, such as "Psalm 23:1".
              text:
                type: string
                description: For a Bible reading, the verse's text.
              startMs:
                type: integer
              endMs:
                type: integer
              clip:
                type: string
                description: The clip's name, when clips were asked for. Fetch it from `/v1/jobs/{id}/files/{name}`.
                examples: ['1.mp3']
        words:
          type: array
          description: Every word of your text, in order, with when it is spoken.
          items:
            type: object
            required: [text, startMs, endMs]
            properties:
              text:
                type: string
              startMs:
                type: integer
              endMs:
                type: integer

    IdentifiedPassage:
      type: object
      description: For an alignment sent without text, the passage the recording was found to read.
      required:
        [reference, book, bookName, firstChapter, firstVerse, lastChapter, lastVerse, translation]
      properties:
        reference:
          type: string
          examples: ['Psalm 23:1-6', 'Proverbs 23:27-24:10']
        book:
          type: string
          description: The standard book code.
          examples: [PSA]
        bookName:
          type: string
          examples: [Psalms]
        firstChapter:
          type: integer
          minimum: 1
        firstVerse:
          type: integer
          minimum: 1
        lastChapter:
          type: integer
          minimum: 1
        lastVerse:
          type: integer
          minimum: 1
        translation:
          type: string
          const: World English Bible (public domain)

    Usage:
      type: object
      required: [from, to, livemode, usage]
      properties:
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        livemode:
          type: boolean
          description: True for a live key's billed use; false for a test key's free use.
        usage:
          type: array
          items:
            type: object
            required: [product, unit, units, events]
            properties:
              product:
                type: string
                enum: [search, voice_search, alignment]
              unit:
                type: string
                enum: [request, audio_second]
              units:
                type: number
                description: The billed amount, to two decimal places.
              events:
                type: integer
                description: How many billable calls or jobs.
