{
  "openapi": "3.0.0",
  "info": {
    "title": "Speech is Cheap Upload API",
    "description": "Upload an audio or video file from your machine to start a transcription job. The upload host stores the file and creates the same job as the Jobs API, which takes a URL instead, so webhooks, streaming, polling, and cancellation work as they do for any other job. Authenticate with an API key. An x402 upload needs no API key. Pay for the job first, then send the file with the one-use token from the paid purchase. A new upload answers with a Server-Sent Events stream. The upload host accepts browser requests from any origin. Browser code exposes an API key to anyone who loads the page, so send API-key uploads from a server. A browser can send an x402 upload with its one-use token. Guides: https://docs.speechischeap.com/jobs-v2/upload/ and https://docs.speechischeap.com/x402-v2/upload/.",
    "version": "2.0.0",
    "contact": {
      "name": "Speech is Cheap Support",
      "email": "support@speechischeap.com",
      "url": "https://docs.speechischeap.com"
    }
  },
  "servers": [
    {
      "url": "https://upload.speechischeap.com/v2"
    }
  ],
  "tags": [
    {
      "name": "Jobs",
      "description": "Endpoints for creating transcription jobs from uploaded files with an API key, and for checking the upload host."
    },
    {
      "name": "x402",
      "description": "Upload the file for an asynchronous transcription job paid per job with native USDC on Base."
    }
  ],
  "paths": {
    "/jobs/": {
      "post": {
        "summary": "Upload a File to Create a Transcription Job",
        "description": "Creates a transcription job from an audio or video file that you upload. The job is the same one that `POST /jobs/` on the Jobs API creates from a URL. Send a `multipart/form-data` body with the file in `input_file` and every other parameter as a form field. The upload host checks the API key and the file type, stores the file, and then creates the job. A request that passes validation returns 200 with a Server-Sent Events stream, even when the job doesn't stream. The stream starts with `upload` events, and the last event is the created job. With `can_stream_output`, the job's own events follow the `upload` events instead of the created job. A failure after the stream starts can't change the HTTP status, so it arrives as an event with `status` set to `error`. That includes a refusal from the Jobs API, such as an invalid option value. The request can stay open for up to one hour. Guide: https://docs.speechischeap.com/jobs-v2/upload/.",
        "tags": [ "Jobs" ],
        "security": [
          {
            "BearerAuth": [ ]
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Send each parameter except `input_file` as a text form field. Send `true` to turn a boolean on. `input_url` isn't a parameter here, because the upload host supplies the URL of the stored file.",
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [ "input_file" ],
                "properties": {
                  "input_file": {
                    "type": "string",
                    "format": "binary",
                    "description": "The audio or video file to transcribe, up to 2 GiB. The upload host doesn't reject a larger file. It keeps the first 2 GiB and transcribes only that part, so split or compress a larger file before you upload it. The host reads the file's content, not its extension or its `Content-Type` header, and accepts the types at https://docs.speechischeap.com/types/#upload-mime-types. Name the file with a supported extension, in any letter case: `.mp3`, `.wav`, `.m4a`, `.flac`, `.ogg`, `.opus`, `.webm`, or `.mp4`. A file with another extension, or none, passes the content check and then fails in the event stream with `API call failed: 400 - Input URL is not reachable: <url> (HTTP 400)`. For the other rules, see https://docs.speechischeap.com/input-requirements/#uploads."
                  },
                  "can_label_audio": { "type": "boolean", "default": false, "description": "When enabled, includes an audio classification label in the transcription." },
                  "can_parse_speakers": { "type": "boolean", "default": false, "description": "When enabled, adds `speaker_id` to each segment based on the speaker's voice." },
                  "can_parse_words": { "type": "boolean", "default": false, "description": "When enabled, includes a timecode for every word in the transcription." },
                  "can_stream_output": { "type": "boolean", "default": false, "description": "When enabled, streams progress and chronological transcription segments using SSE after the upload finishes. The stream still starts with `upload` events." },
                  "is_private": { "type": "boolean", "default": false, "description": "When enabled, redacts the original `input_url` and deletes the segments 12 hours after the job completes." },
                  "is_split_channel": { "type": "boolean", "default": false, "description": "When enabled, parses speakers from multi-channel audio by assigning one speaker per channel." },
                  "language": {
                    "type": "string",
                    "description": "A code from the supported languages at https://docs.speechischeap.com/languages/, such as `en`. Send it in lowercase. If not provided, the language is detected for each speech segment. An unsupported code fails the job after the API accepts it.",
                    "enum": [ "af", "sq", "am", "ar", "hy", "as", "az", "ba", "eu", "be", "bn", "bs", "br", "bg", "yue", "ca", "zh", "hr", "cs", "da", "nl", "en", "et", "fo", "fi", "fr", "gl", "ka", "de", "el", "gu", "ht", "ha", "haw", "he", "hi", "hu", "is", "id", "it", "ja", "jw", "kn", "kk", "km", "ko", "lo", "la", "lv", "ln", "lt", "lb", "mk", "mg", "ms", "ml", "mt", "mi", "mr", "mn", "my", "ne", "no", "nn", "oc", "ps", "fa", "pl", "pt", "pa", "ro", "ru", "sa", "sr", "sn", "sd", "si", "sk", "sl", "so", "es", "su", "sw", "sv", "tl", "tg", "ta", "tt", "te", "th", "bo", "tr", "tk", "uk", "ur", "uz", "vi", "cy", "yi", "yo" ]
                  },
                  "minimum_confidence": { "type": "number", "format": "float", "minimum": 0, "maximum": 1, "default": 0.5, "description": "Filter out segments that fall below this confidence threshold, from 0 to 1. A value outside that range fails the job after the API accepts it." },
                  "output_format": { "type": "string", "enum": [ "srt", "vtt", "webvtt" ], "description": "Format of the transcript in a streamed response and in the webhook body. `webvtt` is an alias for `vtt`. The value is case-insensitive. Omit it for JSON. An invalid value arrives as an error event in the stream, not as a 400." },
                  "segment_duration": { "type": "number", "default": 30, "description": "The maximum duration of a transcription segment in seconds. The API adjusts a value outside 6 to 30 to that range instead of rejecting it." },
                  "user_agent": { "type": "string", "description": "Custom user agent header to fetch the audio file." },
                  "webhook_url": { "type": "string", "description": "The URL that receives the job's result in a single POST request." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The upload started, and its progress streams as Server-Sent Events. Each event is a line that starts with `data: ` and holds one JSON object, followed by a blank line. The stream starts with `upload` events, and `bytes_uploaded` is the number of bytes stored so far. When the file is stored, the upload host creates the job. Without `can_stream_output`, the last event is the created job, the same one that `POST /jobs/` on the Jobs API returns, and the stream closes. With `can_stream_output`, the stream continues with the job's `initial`, `progress`, `segment`, and `final` events, or with subtitle cues when `output_format` is set. See https://docs.speechischeap.com/jobs-v2/stream/. A failure after the stream starts arrives as a last event, `{\"status\": \"error\", \"error\": \"…\"}`, and the stream closes. The `error` is the underlying message, such as `Upload connection closed`, `API call failed: <status> - <body>` when the Jobs API refuses the job, or `API call failed: 400 - Input URL is not reachable: <url> (HTTP 400)` when the file name has no supported extension.",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                },
                "examples": {
                  "job": {
                    "summary": "The created job",
                    "description": "The last event is the job, the same JSON that the Jobs API returns with 202. The stream closes after it.",
                    "value": "data: {\"type\":\"upload\",\"message\":\"Uploading...\",\"bytes_uploaded\":0}\n\ndata: {\"type\":\"upload\",\"message\":\"Uploading...\",\"bytes_uploaded\":16384}\n\ndata: {\"status\":\"PENDING\",\"id\":\"00000000-1111-7222-b333-444444444444-sic\",\"output\":{}}\n\n"
                  },
                  "streamedJob": {
                    "summary": "A streamed job",
                    "description": "With `can_stream_output`, the job's own events follow the `upload` events. This job is private, so the service redacts `request.input_url`.",
                    "value": "data: {\"type\":\"upload\",\"message\":\"Uploading...\",\"bytes_uploaded\":0}\n\ndata: {\"type\":\"upload\",\"message\":\"Uploading...\",\"bytes_uploaded\":16384}\n\ndata: {\"duration\":4321.5,\"id\":\"00000000-1111-7222-b333-444444444444-sic\",\"request\":{\"can_label_audio\":false,\"can_parse_speakers\":false,\"can_parse_words\":false,\"can_stream_output\":true,\"input_url\":\"https://speechischeap.com/redacted\",\"is_split_channel\":false,\"language\":null,\"minimum_confidence\":0.5,\"segment_duration\":30},\"type\":\"initial\"}\n\ndata: {\"id\":\"00000000-1111-7222-b333-444444444444-sic\",\"message\":\"Transcribing...\",\"processed_segments\":120,\"total_segments\":360,\"type\":\"progress\"}\n\ndata: {\"confidence\":0.987,\"end\":12.345,\"id\":1,\"language\":\"en (99.95%)\",\"processing_duration_in_s\":0.321,\"start\":1.234,\"text\":\"This is an example of some transcribed text output.\",\"type\":\"segment\",\"words\":[]}\n\ndata: {\"type\":\"final\"}\n\n"
                  },
                  "error": {
                    "summary": "A failure after the stream starts",
                    "description": "The error arrives as an event, and the stream closes.",
                    "value": "data: {\"type\":\"upload\",\"message\":\"Uploading...\",\"bytes_uploaded\":0}\n\ndata: {\"status\":\"error\",\"error\":\"Upload connection closed\"}\n\n"
                  }
                }
              }
            }
          },
          "400": {
            "description": "The `input_file` part is missing or misnamed, the file's type isn't supported, or the upload ended before the whole file arrived. The body is `{\"message\": \"…\"}`. In `Unsupported file type detected: <type>.`, `<type>` is the detected MIME type, or `unknown` when the content isn't recognized or the file is empty.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/UploadMessageError" },
                "examples": {
                  "missingFile": { "summary": "No file part", "value": { "message": "Missing required \"input_file\" parameter" } },
                  "misnamedFile": { "summary": "The file part has another name", "value": { "message": "Invalid field name for file upload. Expected 'input_file'." } },
                  "incompleteFile": { "summary": "The request body stopped inside the file", "value": { "message": "Upload ended before the file was complete." } },
                  "unsupportedFile": { "summary": "The content isn't a supported type", "value": { "message": "Unsupported file type detected: image/png." } }
                }
              }
            }
          },
          "401": {
            "description": "The `Authorization` header is missing or malformed, or the API key is unknown, revoked, or its billing isn't active. The upload host answers the Jobs API's 402 for inactive billing as 401. The body is `{\"error\": \"…\"}`. The host remembers a failed key check for 60 seconds and answers with `Unauthorized`, so fix the key or its billing, wait a minute, and send the request again.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/UploadError" },
                "examples": {
                  "missingHeader": { "summary": "No Authorization header", "value": { "error": "Missing Authorization header" } },
                  "unknownKey": { "summary": "The Jobs API rejected the key", "value": { "error": "Speech is Cheap: Unauthorized API Key. Sign up at https://speechischeap.com/ and use code E401 for a special discount while supplies last." } },
                  "malformedHeader": { "summary": "The header isn't Bearer followed by a key", "value": { "error": "Speech is Cheap: Invalid authorization header. Format: \"Authorization: Bearer <key>\"" } },
                  "inactiveBilling": { "summary": "The key is valid, but its billing isn't active", "value": { "error": "Speech is Cheap: Missing subscription. Manage your subscriptions at https://billing.speechischeap.com/." } },
                  "recentFailure": { "summary": "The same header failed a check in the last 60 seconds", "value": { "error": "Unauthorized" } },
                  "unavailable": { "summary": "The upload host couldn't reach the Jobs API to check the key", "value": { "error": "Authentication service unavailable" } }
                }
              }
            }
          },
          "406": {
            "description": "The request isn't `multipart/form-data`, for example because it has a JSON body or no body. Send the file as a form field named `input_file`. The body has the code `FST_INVALID_MULTIPART_CONTENT_TYPE`.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpError" },
                "example": { "statusCode": 406, "code": "FST_INVALID_MULTIPART_CONTENT_TYPE", "error": "Not Acceptable", "message": "the request is not multipart" }
              }
            }
          },
          "415": {
            "description": "The request has a `Content-Type` that the upload host doesn't read, such as `application/x-www-form-urlencoded` or `application/octet-stream`, or it has a body and no `Content-Type`. The host answers before it checks the API key. Send `multipart/form-data`.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpError" },
                "example": { "statusCode": 415, "code": "FST_ERR_CTP_INVALID_MEDIA_TYPE", "error": "Unsupported Media Type", "message": "Unsupported Media Type" }
              }
            }
          },
          "500": {
            "description": "The upload failed before the stream started. The body is `{\"error\": \"…\"}` with the underlying message, or the HTTP layer's own error body when the multipart body is malformed.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/UploadError" }
              }
            }
          }
        }
      }
    },
    "/x402/jobs/": {
      "post": {
        "summary": "Upload the File for a Paid x402 Job",
        "description": "Sends the file for an upload purchase. Pay for the job first at `POST https://api.speechischeap.com/v2/x402/jobs/` with `input_type` set to `upload`, and replay that request until the answer is `READY`. Then send the file as `multipart/form-data` to the `upload_url` from that answer, with the answer's one-use `upload_token` in the `X-SiC-Upload-Token` header. This request needs no payment and no API key. The service takes the processing options, the duration allowance, and the webhook from the paid request, so form fields other than `input_file` have no effect. A new upload returns 200 with a Server-Sent Events stream of `upload` events that ends with the accepted job. x402 jobs have no transcription stream. Start the upload within 300 seconds of payment confirmation. `admission_expires_at` in the `READY` answer is an absolute deadline, and retrying doesn't extend it. An accepted transfer can continue for up to one hour. If the file fails while the service receives it, the purchase ends with a full refund and the token stops working. If you lose the response after the service received the file, send the same request with the same token. It returns the same job as JSON. Guide: https://docs.speechischeap.com/x402-v2/upload/.",
        "tags": [ "x402" ],
        "security": [
          {
            "UploadTokenAuth": [ ]
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Send the file in `input_file`. Other form fields have no effect, because the service takes the options from the paid request.",
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [ "input_file" ],
                "properties": {
                  "input_file": {
                    "type": "string",
                    "format": "binary",
                    "description": "The audio or video file to transcribe, up to 2 GiB. Name the field `input_file`. The upload host reads the file's content, not its extension, and accepts the types at https://docs.speechischeap.com/types/#upload-mime-types. An empty file, a file over 2 GiB, and a file of an unsupported type fail the purchase. A file longer than the `max_input_duration` in the `READY` answer fails after the service accepts it. The service refunds both in full. For the other rules, see https://docs.speechischeap.com/input-requirements/#uploads."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The upload started, or the service had already accepted the file. A new upload returns a Server-Sent Events stream. Each event is a line that starts with `data: ` and holds one JSON object, followed by a blank line. The stream starts with `upload` events that show the file transfer. They don't describe the transcription, and `bytes_uploaded` is the number of bytes stored so far. The last event is the accepted job, which includes `purchase_id`. A failure after the stream starts arrives as a last event with `status` set to `error` and the `purchase_id`, and the stream closes. `Upload saved. Retry this upload token to recover the job.` means the file arrived but the service couldn't confirm the job, so send the same request with the same token. Any other error event ends the purchase with a full refund, and a retry returns 410. A file over 2 GiB fails this way with `Uploaded file exceeds the size limit.` A replay with the same token after the service accepted the file returns the same job as JSON instead of a stream.",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                },
                "examples": {
                  "job": {
                    "summary": "The accepted job",
                    "description": "The last event is the accepted job. The stream closes after it.",
                    "value": "data: {\"type\":\"upload\",\"message\":\"Uploading...\",\"bytes_uploaded\":0}\n\ndata: {\"type\":\"upload\",\"message\":\"Uploading...\",\"bytes_uploaded\":16384}\n\ndata: {\"status\":\"PENDING\",\"id\":\"00000000-1111-4222-8333-555555555555-sic\",\"output\":{},\"purchase_id\":\"00000000-1111-4222-8333-444444444444\"}\n\n"
                  },
                  "fileTooLarge": {
                    "summary": "A failure that ends the purchase",
                    "description": "The service refunds the purchase in full, and the token stops working.",
                    "value": "data: {\"type\":\"upload\",\"message\":\"Uploading...\",\"bytes_uploaded\":0}\n\ndata: {\"status\":\"error\",\"error\":\"Uploaded file exceeds the size limit.\",\"purchase_id\":\"00000000-1111-4222-8333-444444444444\"}\n\n"
                  },
                  "savedRetry": {
                    "summary": "The file arrived, but the job isn't confirmed",
                    "description": "Send the same request with the same token. The answer is the job.",
                    "value": "data: {\"type\":\"upload\",\"message\":\"Uploading...\",\"bytes_uploaded\":0}\n\ndata: {\"status\":\"error\",\"error\":\"Upload saved. Retry this upload token to recover the job.\",\"purchase_id\":\"00000000-1111-4222-8333-444444444444\"}\n\n"
                  }
                }
              },
              "application/json": {
                "schema": { "$ref": "#/components/schemas/X402UploadJob" },
                "example": { "id": "00000000-1111-4222-8333-555555555555-sic", "output": {}, "status": "PENDING", "purchase_id": "00000000-1111-4222-8333-444444444444" }
              }
            }
          },
          "400": {
            "description": "The file is missing, misnamed, empty, incomplete, or of an unsupported type. The service ends the purchase and refunds it in full, and the token stops working. A retry with the same token returns 410, so request a new quote, pay, and upload again.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/X402UploadError" },
                "examples": {
                  "missingFile": { "summary": "No file part", "value": { "status": "error", "error": "Missing required \"input_file\" parameter.", "purchase_id": "00000000-1111-4222-8333-444444444444" } },
                  "misnamedFile": { "summary": "The file part has another name", "value": { "status": "error", "error": "Invalid field name for file upload. Expected 'input_file'.", "purchase_id": "00000000-1111-4222-8333-444444444444" } },
                  "incompleteFile": { "summary": "The request body stopped inside the file", "value": { "status": "error", "error": "Upload ended before the file was complete.", "purchase_id": "00000000-1111-4222-8333-444444444444" } },
                  "emptyFile": { "summary": "The file is empty", "value": { "status": "error", "error": "Expected at least 6 seconds; got 0 seconds", "purchase_id": "00000000-1111-4222-8333-444444444444" } },
                  "unsupportedFile": { "summary": "The content isn't a supported type", "value": { "status": "error", "error": "Unsupported file type detected.", "purchase_id": "00000000-1111-4222-8333-444444444444" } }
                }
              }
            }
          },
          "401": {
            "description": "The `X-SiC-Upload-Token` header is missing or blank. An API key doesn't work on this endpoint.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/UploadError" },
                "example": { "error": "Missing X-SiC-Upload-Token header." }
              }
            }
          },
          "404": {
            "description": "The token is unknown. Send the `upload_token` from the paid `READY` answer unchanged.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/X402UploadError" },
                "example": { "status": "error", "error": "Purchase service rejected this upload operation." }
              }
            }
          },
          "409": {
            "description": "An upload with this token is already in progress, or the purchase can't take an upload in its current state, for example because it already failed. Wait for the first request to finish, then send the same token again. If the earlier upload finished, the answer is its job. While a transfer is in progress, replaying the payment request reports `CLAIMED`.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/X402UploadError" },
                "examples": {
                  "inProgress": { "summary": "Another request is using the token", "value": { "error": "This upload is already in progress." } },
                  "inProgressPurchase": { "summary": "An earlier transfer hasn't ended", "value": { "error": "This upload is already in progress.", "purchase_id": "00000000-1111-4222-8333-444444444444" } },
                  "rejected": { "summary": "The purchase can't take this upload", "value": { "status": "error", "error": "Purchase service rejected this upload operation." } }
                }
              }
            }
          },
          "410": {
            "description": "The token expired, or the upload failed. This is final, and the service refunds the purchase in full. A purchase has 300 seconds after payment confirmation to start the upload and one hour to finish it. A retry with the same token returns the same 410. Request a new quote, pay, and upload again.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/X402UploadError" },
                "examples": {
                  "expired": { "summary": "The purchase's window ended", "value": { "status": "error", "error": "Purchase service rejected this upload operation." } },
                  "failedBefore": { "summary": "A retry after the upload failed", "value": { "error": "Uploaded file exceeds the size limit.", "purchase_id": "00000000-1111-4222-8333-444444444444" } },
                  "interrupted": { "summary": "A retry after an interrupted transfer", "value": { "error": "The accepted upload did not complete.", "purchase_id": "00000000-1111-4222-8333-444444444444" } },
                  "notSubmitted": { "summary": "The purchase ended before the job could start", "value": { "error": "The accepted upload could not be submitted.", "purchase_id": "00000000-1111-4222-8333-444444444444" } }
                }
              }
            }
          },
          "415": {
            "description": "The request isn't `multipart/form-data`. Send the file as a form field named `input_file`. The body is `{\"error\": \"Expected a multipart input_file upload.\"}`. A `Content-Type` that the upload host doesn't read, such as `application/x-www-form-urlencoded`, gets the HTTP layer's own error body instead, and the host answers before it checks the token.",
            "content": {
              "application/json": {
                "schema": {
                  "anyOf": [
                    { "$ref": "#/components/schemas/UploadError" },
                    { "$ref": "#/components/schemas/HttpError" }
                  ]
                },
                "example": { "error": "Expected a multipart input_file upload." }
              }
            }
          },
          "500": {
            "description": "An unexpected error while the service received or stored the file. Send the same request with the same token. The answer is the accepted job, or a 410 if the purchase failed.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/X402UploadError" },
                "examples": {
                  "failed": { "summary": "The service ended the purchase", "value": { "status": "error", "error": "Upload processing failed.", "purchase_id": "00000000-1111-4222-8333-444444444444" } },
                  "savedRetry": { "summary": "The file arrived, but the job isn't confirmed", "value": { "status": "error", "error": "Upload saved. Retry this upload token to recover the job.", "purchase_id": "00000000-1111-4222-8333-444444444444" } }
                }
              }
            }
          },
          "503": {
            "description": "The upload host couldn't reach the payment service or read its answer, or it can't take x402 uploads right now. Send the same request with the same token. The token stays valid, and the answer is the accepted job, or a 410 if the purchase failed. If a 503 keeps coming back, email support@speechischeap.com.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/X402UploadError" },
                "examples": {
                  "unavailable": { "summary": "The payment service didn't answer", "value": { "status": "error", "error": "Purchase service unavailable. Retry this upload token." } },
                  "responseUnavailable": { "summary": "The payment service's answer couldn't be read", "value": { "status": "error", "error": "Purchase response unavailable. Retry this upload token." } },
                  "invalidClaim": { "summary": "The payment service's answer was incomplete", "value": { "status": "error", "error": "Purchase service returned an invalid upload claim." } },
                  "rejected": { "summary": "The payment service had an internal error", "value": { "status": "error", "error": "Purchase service rejected this upload operation." } },
                  "notConfigured": { "summary": "The upload host can't take x402 uploads right now", "value": { "status": "error", "error": "Prepaid uploads are not configured." } },
                  "unresolved": { "summary": "A failed upload is still being confirmed", "value": { "status": "error", "error": "The Worker has not resolved this upload outcome. Retry this upload token.", "purchase_id": "00000000-1111-4222-8333-444444444444" } },
                  "savedRetry": { "summary": "The file arrived, but the job isn't confirmed", "value": { "status": "error", "error": "Upload saved. Retry this upload token to recover the job.", "purchase_id": "00000000-1111-4222-8333-444444444444" } }
                }
              }
            }
          }
        }
      }
    },
    "/health": {
      "servers": [
        {
          "url": "https://upload.speechischeap.com",
          "description": "The health check is on the host root, not under `/v2`."
        }
      ],
      "get": {
        "summary": "Get Upload Health",
        "description": "Reports whether the upload host is working. This endpoint needs no API key. `status` is `ok` when the upload host answers, so use it as the health signal. The `disk` object reports storage use on the host, as strings.",
        "tags": [ "Jobs" ],
        "security": [ ],
        "responses": {
          "200": {
            "description": "The upload host is working",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/UploadHealth" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "An API key, sent as `Authorization: Bearer YOUR_API_KEY`. It is required to upload a file and create a job."
      },
      "UploadTokenAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-SiC-Upload-Token",
        "description": "The one-use `upload_token` from the `READY` answer of a paid x402 upload purchase. It isn't an API key. Keep it private, and send it only to the `upload_url` from that answer."
      }
    },
    "schemas": {
      "UploadError": {
        "type": "object",
        "required": [ "error" ],
        "properties": {
          "error": { "type": "string", "description": "The error message." }
        }
      },
      "UploadMessageError": {
        "type": "object",
        "required": [ "message" ],
        "properties": {
          "message": { "type": "string", "description": "The error message." }
        }
      },
      "HttpError": {
        "type": "object",
        "required": [ "statusCode", "error", "message" ],
        "description": "The error body that the upload host's HTTP layer sends for a request it can't read.",
        "properties": {
          "statusCode": { "type": "integer", "example": 406 },
          "code": { "type": "string", "example": "FST_INVALID_MULTIPART_CONTENT_TYPE" },
          "error": { "type": "string", "example": "Not Acceptable" },
          "message": { "type": "string", "example": "the request is not multipart" }
        }
      },
      "X402UploadError": {
        "type": "object",
        "required": [ "error" ],
        "description": "An error from the x402 upload endpoint. A failure that the service reports while it handles a new upload has `status` set to `error`. The shorter replies, such as for a missing token or a replay of a failed upload, have only `error` and sometimes `purchase_id`.",
        "properties": {
          "status": { "type": "string", "enum": [ "error" ] },
          "error": { "type": "string", "description": "The error message." },
          "purchase_id": { "type": "string", "format": "uuid", "description": "The paid purchase. Absent when the service rejects the token or doesn't know it." }
        }
      },
      "X402UploadJob": {
        "description": "The accepted job. It is the same job that the Jobs API returns, plus the `purchase_id` of the paid purchase. Its `output` is empty while the job is pending.",
        "allOf": [
          { "$ref": "#/components/schemas/Job" },
          {
            "type": "object",
            "required": [ "id", "status", "output", "purchase_id" ],
            "properties": {
              "purchase_id": { "type": "string", "format": "uuid" },
              "admission_status": { "type": "string", "enum": [ "dispatch_pending" ], "description": "Present when the job exists but the service hasn't confirmed that it started. Keep polling the job, and don't pay or upload again." }
            }
          }
        ]
      },
      "UploadHealth": {
        "type": "object",
        "required": [ "status", "disk" ],
        "properties": {
          "status": { "type": "string", "enum": [ "ok" ], "description": "`ok` when the upload host answers.", "example": "ok" },
          "disk": {
            "type": "object",
            "required": [ "used_gb", "available_gb", "usage_percent" ],
            "description": "Storage use on the host, as strings.",
            "properties": {
              "used_gb": { "type": "string", "description": "The storage in use, in GiB, with two decimals.", "example": "26.83" },
              "available_gb": { "type": "string", "description": "The storage available, in GiB, with two decimals.", "example": "10.40" },
              "usage_percent": { "type": "string", "description": "The share of storage in use, in percent, with one decimal.", "example": "71.0" }
            }
          }
        }
      },
      "DurationError": {
        "type": "object", "required": [ "code", "min_input_duration", "max_input_duration", "actual_input_duration", "actual_duration_is_lower_bound" ],
        "properties": {
          "code": { "type": "string", "enum": [ "INPUT_DURATION_MISMATCH" ] },
          "min_input_duration": { "type": "number", "example": 6 },
          "max_input_duration": { "type": "number", "example": 300 },
          "actual_input_duration": { "type": "number", "example": 301 },
          "actual_duration_is_lower_bound": { "type": "boolean", "description": "True when bounded decoding stopped early. Not a complete successful duration." }
        }
      },
      "Job": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "00000000-1111-7222-b333-444444444444-sic"
          },
          "status": {
            "type": "string",
            "enum": [ "PENDING", "COMPLETED", "FAILED", "CANCELED" ],
            "example": "PENDING"
          },
          "output": {
            "$ref": "#/components/schemas/JobOutput"
          }
        }
      },
      "JobOutput": {
        "type": "object",
        "properties": {
          "duration": {
            "type": "number",
            "example": 24.512,
            "description": "The length of the audio in seconds."
          },
          "request": {
            "type": "object",
            "description": "The options the job ran with. It repeats the request, but not `webhook_url`, `output_format`, `is_private`, or `user_agent`. For a private job, `input_url` is `https://speechischeap.com/redacted`.",
            "properties": {
              "can_label_audio": { "type": "boolean" },
              "can_parse_speakers": { "type": "boolean" },
              "can_parse_words": { "type": "boolean" },
              "can_stream_output": { "type": "boolean" },
              "input_url": { "type": "string", "example": "https://example.com/audio-file.mp3" },
              "is_split_channel": { "type": "boolean" },
              "language": { "type": "string", "nullable": true, "description": "`null` when the request has no `language`.", "example": "en" },
              "minimum_confidence": { "type": "number", "format": "float", "example": 0.5 },
              "segment_duration": { "type": "number", "example": 30 }
            }
          },
          "segments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Segment"
            }
          },
          "error": {
            "type": "string",
            "example": "Some error message"
          },
          "duration_error": {
            "$ref": "#/components/schemas/DurationError"
          }
        }
      },
      "Segment": {
        "type": "object",
        "properties": {
          "id": { "type": "integer", "example": 1 },
          "start": { "type": "number", "format": "float", "example": 1.234 },
          "end": { "type": "number", "format": "float", "example": 12.345 },
          "text": { "type": "string", "example": "This is an example of some transcribed text output." },
          "confidence": { "type": "number", "format": "float", "example": 0.987 },
          "language": { "type": "string", "nullable": true, "example": "en (99.95%)", "description": "The detected language and its probability, such as `en (99.95%)`. When you set the `language` parameter, the probability is always `100.00%`. `null` for non-speech segments." },
          "processing_duration_in_s": { "type": "number", "format": "float", "example": 0.321 },
          "speaker_id": { "type": "string", "example": "A", "description": "The speaker, such as `A` or `B`. Only segments with speech have it, and only with `can_parse_speakers` or `is_split_channel`." },
          "label": { "type": "string", "example": "music", "description": "With `can_label_audio`, `speech`, `silence`, or the name of a sound, such as `music`. Without it, segments have no label." },
          "words": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Word"
            },
            "description": "With `can_parse_words`, the words of a segment with speech. Otherwise an empty array."
          }
        }
      },
      "Word": {
        "type": "object",
        "properties": {
          "start": { "type": "number", "format": "float", "example": 2.345 },
          "end": { "type": "number", "format": "float", "example": 2.567 },
          "text": { "type": "string", "example": "hi" }
        }
      }
    }
  }
}
