{
  "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,\nand what would change the outcome.\n\nScripture is the World English Bible, which is in the public domain.\n\nBilling is metered: search per request, voice search and alignment per\nsecond of audio. `GET /v1/usage` shows the same figures billing uses.\n\nMake your keys in the developer console at\nhttps://play.fellowverse.app/developers. Every account starts with free\ntest keys, so you can try everything before anything is billed.\n\n## Get started\n\n1. Sign in to the developer console with your email and name your account.\n2. Under **Keys**, make a test key and copy it: it is shown once.\n3. Search:\n\n   ```\n   curl https://play.fellowverse.app/v1/search \\\n     -H \"Authorization: Bearer $KEY\" -H 'content-type: application/json' \\\n     -d '{\"query\": \"rest for the weary\", \"limit\": 3}'\n   ```\n\n4. See what you used with `GET /v1/usage`, or under **Use** in the console.\n\nTest keys call the same API as live keys, so the code you write now is the\ncode you ship.\n\n## Keep your key on your server\n\nA key in an Android or iOS app, or in a web page, can be read by anyone who\nhas the app. Call Fellowverse from your own server and have your app call\nyour server: it holds the key, decides who may search, and can cache\npopular results. Use one key per server or environment, named for it, so\nyou can replace one without touching the others.\n\n## Add voice search to an app\n\nRecord the request in the app, send it to your server, and pass the audio\nto `POST /v1/voice-search` as the body with its type. On Android,\n`MediaRecorder` with MPEG-4 and AAC gives `audio/mp4`; in a browser,\n`MediaRecorder` gives `audio/webm` or `audio/mp4`. Show the `transcript`\nwith the results, so people see what was heard; an empty one means nothing\ncould be heard.\n\n## Play a recording verse by verse\n\n1. Upload the recording (`POST /v1/uploads`) and start an alignment\n   (`POST /v1/alignments`). For a Bible reading, leave out `segments` and\n   the passage and its verses are found for you.\n2. Poll `GET /v1/jobs/{id}` every few seconds until it is `done` or `failed`.\n3. Store the result with the recording. Each segment's `startMs` and\n   `endMs` let your player seek to a verse; `words` times each word.\n4. If you asked for `clips`, download them with your key and host them\n   yourself. They are there for 7 days after the job finishes.\n\nAlign each recording once, when it is published, and serve the timings\nfrom your own storage, so listeners never wait on an alignment.\n\n## Handle errors and limits\n\nEvery error has a stable `code` to branch on, and `problem`, `reason` and\n`fix` written to be read. Fix a `400` before sending it again. Retry a\n`429` after a short wait (for `api_test_quota`, `fix` says when the day's\nallowance resets) and a `5xx` after a wait that doubles each time.\nSearches are safe to retry; before retrying `POST /v1/alignments` after a\ntimeout, check whether the first request made a job. A failed job's\n`error.code` says why it failed, such as `api_text_mismatch` or\n`api_passage_unknown`, and it fails at once rather than being retried.\n\n## Go live\n\n1. The account's owner chooses **Request live access** in the console. We\n   get in touch to set up billing and turn on live keys.\n2. Make a live key under **Keys**. It takes a code we email you.\n3. Put it on your server in place of the test key. Nothing else changes.\n4. To replace a key, roll it and give the old one up to a week of grace\n   while you deploy the new one.\n\nEvery live key made, rolled or revoked is emailed to the account's owners,\nand the console's **Activity** shows who did what and when.\n",
    "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\nto six verses each, with a context line saying where they sit and who is\nspeaking.\n\nA reference such as \"Psalm 23\" or \"John 3:16\" returns that passage and the\nrest of its chapter, with `kind` set to `reference`. Anything else is a\nsearch by meaning and wording, with `kind` set to `search`.\n\nBilled per request. Limited to 120 a minute per key.\n",
        "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\nbody, with its type, and returns what was heard and the same results as a search. The\naudio is not kept.\n\nWhen nothing could be heard, the response holds an empty `transcript`\nand no results.\n\nBilled per second of audio, at least one second. Limited to 60 a minute\nper key.\n",
        "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.\nOne alignment takes up to an hour of audio, or 10 minutes with a test key.\nUploads are kept for 7 days (a day with a test key), long enough to align\nthem; this is not a storage service.\n\nLimited to 30 a minute per key.\n",
        "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\nids: paragraphs, sentences, verses or lines. The job runs in the\nbackground; poll `GET /v1/jobs/{id}` for the result.\n\nAnnouncements and closing lines set apart by a pause are found and left\nout of the sections. Wording that differs slightly from your text is\ntolerated.\n\nLeave out `segments` for a Bible reading: the passage is identified from\nwhat is said, from any verse to any verse and across chapters, and its\nverses become the sections. The result then holds `passage`. A recording\nthat is not a reading, or only quotes a verse, fails with the code\n`api_passage_unknown`. Only the World English Bible is held, so a reading\nof another translation is found only when enough of its wording agrees.\n\nBilled per second of audio when the job is done. Limited to 30 a minute\nper key.\n",
        "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\n`result`; a failed one holds its `error`.\n\nA job that fails for a reason in the request (an unreadable file, text\nthat does not match) fails at once with that reason. One that fails on\nour side is tried again, up to three times, and goes back to `queued`\nin between. Jobs of a suspended account wait in `queued` until it is\nrestored.\n",
        "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\njob is done. Their names are given in the result, one per section. Clips\nare kept for 7 days after the job finishes (a day with a test key); then\nthis returns 404 with\nthe code `api_file_expired`.\n",
        "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\ncurrent month, in UTC, to now. Called with a test key, it returns the\naccount's test use instead.\n",
        "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,\nwhen it is made. Keep it on your server, not in a web page or an app.\nLost keys are revoked and replaced, not recovered.\n\nTest keys (`fv_test_...`) call the same API for free, with a daily\nallowance per account and alignments of up to 10 minutes. Rolling a key\nreplaces it and lets the old one keep working for up to a week while you\nswitch over.\n"
      }
    },
    "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.\nMP3, WAV, M4A, OGG, WebM and FLAC are read.\n",
        "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\nor has the wrong type; `bad_request` when the audio or a value could not\nbe read.\n",
        "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\nafter a roll. The code is `api_unauthorized`, and the reason says which.\n",
        "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\naccount that has test keys only (`api_live_locked`).\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "NotFound": {
        "description": "The upload, job or clip does not exist, or belongs to another account.\nThe codes are `api_upload_missing`, `api_job_missing` and\n`api_file_missing`. `api_off` means the request went to an address that\ndoes not serve the API; use https://play.fellowverse.app.\n",
        "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\nsearch. The code is `request_error`.\n",
        "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\nreason names the limit), or a test key used up its account's daily\nallowance (`api_test_quota`; the fix says when it resets).\n",
        "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.\nThe code is `api_stt_off`.\n",
        "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\nwording only. Absent for a reference.\n"
          },
          "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\nids. Leave it out for a Bible reading whose passage is to be found.\nThe text may be up to 200,000 characters in all, about an hour of reading.\n",
            "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\nrecording sent without text is not a Bible reading. Jobs that\nfailed before October 2026 have none.\n",
                    "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\nfails, because the audio does not read the text.\n"
          },
          "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."
                }
              }
            }
          }
        }
      }
    }
  }
}
