{
  "openapi": "3.1.0",
  "info": {
    "title": "Woodstitch API — human ratings for TTS",
    "version": "1",
    "summary": "Paid human listeners judge your text-to-speech audio in blinded studies and you get a number back.",
    "description": "Two tasks only: pairwise preference (tts_preference.v1: a listener hears two clips and picks one, forced choice) and human-vs-AI detection (tts_detect.v1: a listener hears one clip, answers human or AI, rates confidence 1–4). Not offered: MOS, CMOS, MUSHRA or any 1–5 rating scale.\n\nFlow: POST /files (upload) → POST /evals (free quote; dry_run prices without storing) → POST /evals/{id}/start (SPENDS MONEY; show the quote to a human first and pass max_total_usd) → GET /evals/{id} (poll; provisional numbers) → GET /evals/{id}/results and /export.\n\nAgent rules: never call /start without the user confirming the quoted amount; send Idempotency-Key on every POST and retry a timeout with the same key; pass max_total_usd on every start. Human-readable reference: https://woodstitch.com/docs (markdown at https://woodstitch.com/docs.md). Discovery file: https://woodstitch.com/llms.txt.",
    "termsOfService": "https://woodstitch.com/legal/privacy",
    "contact": {"name": "Woodstitch support", "email": "support@woodstitch.com", "url": "https://woodstitch.com/contact"},
    "x-provider": {"name": "Woodstitch LLC", "jurisdiction": "Colorado, United States", "methodology": "https://woodstitch.com/research/methodology"}
  },
  "servers": [{"url": "https://api.woodstitch.com/v1"}],
  "security": [{"bearerAuth": []}],
  "tags": [
    {"name": "files", "description": "Upload audio from disk; ids are usable anywhere create takes a URL."},
    {"name": "evals", "description": "Create (free), start (paid), poll, results, export, cancel."},
    {"name": "account", "description": "Balance, spend and top-up."},
    {"name": "public", "description": "No authentication required."}
  ],
  "paths": {
    "/files": {
      "post": {
        "tags": ["files"], "summary": "Upload audio", "operationId": "uploadFiles",
        "description": "Multipart. One file as `file=@x.wav`, or several as repeated `file[]` parts: up to 100 files or 1 GB per call, 50 MB per file. Each file is decoded, measured and stored content-addressed (re-sending the same bytes is harmless). If one file in a call is bad, the call fails and nothing is recorded.",
        "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"type": "object", "properties": {"file": {"type": "string", "format": "binary"}, "file[]": {"type": "array", "items": {"type": "string", "format": "binary"}}}}}}},
        "responses": {
          "200": {"description": "Files in the order sent. A single-file call returns that file object with the same `files` list attached.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FileList"}}}},
          "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "413": {"description": "`upload_too_large`: a call over 1 GB or a file over 50 MB.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
        }
      },
      "get": {"tags": ["files"], "summary": "Your uploads, newest first", "operationId": "listFiles", "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FileList"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}
    },
    "/files/{id}": {
      "parameters": [{"$ref": "#/components/parameters/fileId"}],
      "get": {"tags": ["files"], "summary": "One upload", "operationId": "getFile", "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/File"}}}}, "400": {"description": "`bad_file`: not on this account or audio gone.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}},
      "delete": {"tags": ["files"], "summary": "Remove an upload from your account", "operationId": "deleteFile", "description": "Studies that already used the file keep their copy.", "responses": {"200": {"description": "Deleted"}, "401": {"$ref": "#/components/responses/Unauthorized"}}}
    },
    "/evals": {
      "post": {
        "tags": ["evals"], "summary": "Create and price an evaluation (charges nothing)", "operationId": "createEval",
        "description": "Resolves every clip (a file id or a public http(s) URL it downloads once; redirects not followed), checks it decodes, measures duration, stores an immutable copy and returns a quote. Send `dry_run: true` to price without storing anything. Job identity is a hash of the audio content plus question and rating count, so re-creating the same study returns the existing one with `duplicate: true`. Quotes last 24 hours.",
        "parameters": [{"$ref": "#/components/parameters/idempotencyKey"}],
        "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateEval"}}}},
        "responses": {
          "200": {"description": "The evaluation with its quote. Nothing has been charged.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EvalCreated"}}}},
          "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "409": {"description": "`idempotency_conflict`: key reused with a different body.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
        }
      },
      "get": {"tags": ["evals"], "summary": "Your last 50 evaluations", "operationId": "listEvals", "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"evals": {"type": "array", "items": {"$ref": "#/components/schemas/EvalProgress"}}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}
    },
    "/evals/{id}/start": {
      "parameters": [{"$ref": "#/components/parameters/evalId"}],
      "post": {
        "tags": ["evals"], "summary": "Start the evaluation (SPENDS MONEY)", "operationId": "startEval",
        "description": "Cannot be undone once raters begin answering. Needs a positive balance; billing is metered from the first judgement, so the whole quote is not reserved. ALWAYS confirm the quoted amount with a human before calling this, and pass `max_total_usd`.",
        "parameters": [{"$ref": "#/components/parameters/idempotencyKey"}],
        "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"quote_id": {"type": "integer", "description": "If given, must match the current quote, else `quote_mismatch`."}, "max_total_usd": {"type": "number", "description": "Hard ceiling. Fails with `over_max` rather than overspending."}}}}}},
        "responses": {
          "200": {"description": "Started.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EvalProgress"}}}},
          "400": {"description": "`over_max`, `quote_expired`, `quote_mismatch`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "402": {"description": "`insufficient_funds` (balance empty) or `spend_cap` (key cap would be exceeded).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
        }
      }
    },
    "/evals/{id}": {
      "parameters": [{"$ref": "#/components/parameters/evalId"}],
      "get": {
        "tags": ["evals"], "summary": "Progress and live score", "operationId": "getEval",
        "description": "Cheap; poll as often as you like. Returns the submitted design, `judgements.done/target/percent`, `pairs_with_data`, and the same `metrics`/`counts` shape as /results computed from whatever has been rated so far, flagged `provisional: true` until completed. Everything needed to resume is in this response. Treat an interim figure as a preview: stopping because a number crossed a threshold is optional stopping and inflates false positives.",
        "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EvalProgress"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}
      }
    },
    "/evals/{id}/results": {
      "parameters": [{"$ref": "#/components/parameters/evalId"}],
      "get": {
        "tags": ["evals"], "summary": "Final numbers", "operationId": "getResults",
        "description": "Available once `completed`, or `canceled` (then `incomplete: true` with `judgements_target`). 409 `not_ready` while running. `metrics` is a flat numeric dict (drops straight into a tracker).",
        "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": {"oneOf": [{"$ref": "#/components/schemas/PreferenceResults"}, {"$ref": "#/components/schemas/DetectionResults"}]}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"description": "`not_ready`: still running.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}
      }
    },
    "/evals/{id}/export": {
      "parameters": [{"$ref": "#/components/parameters/evalId"}, {"name": "format", "in": "query", "schema": {"type": "string", "enum": ["json", "csv"], "default": "json"}}],
      "get": {"tags": ["evals"], "summary": "Per-judgement rows", "operationId": "exportEval", "description": "One row per judgement with every clustering dimension: rater (pseudonymous, stable within the eval), source, speaker, passage, duration bucket, answer, confidence, time spent. Fit your own mixed-effects model on this.", "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"rows": {"type": "array", "items": {"$ref": "#/components/schemas/Judgement"}}}}}, "text/csv": {"schema": {"type": "string"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}
    },
    "/evals/{id}/cancel": {
      "parameters": [{"$ref": "#/components/parameters/evalId"}],
      "post": {"tags": ["evals"], "summary": "Stop early", "operationId": "cancelEval", "description": "Charges only for work already handed to raters; anyone mid-task finishes and is paid. Check `max_additional_charge_if_canceled_now_usd` on the progress response first if you want that number before deciding.", "parameters": [{"$ref": "#/components/parameters/idempotencyKey"}], "responses": {"200": {"description": "Canceled.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EvalProgress"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}}}
    },
    "/account": {"get": {"tags": ["account"], "summary": "Balance and spend", "operationId": "getAccount", "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"balance_usd": {"type": "number"}, "reserved_usd": {"type": "number"}, "spent_usd": {"type": "number"}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}},
    "/account/topup": {"post": {"tags": ["account"], "summary": "Add funds", "operationId": "topup", "description": "A paused evaluation resumes automatically when the money lands.", "responses": {"200": {"description": "Checkout or confirmation details."}, "401": {"$ref": "#/components/responses/Unauthorized"}}}},
    "/templates": {"get": {"tags": ["public"], "summary": "Both templates: what the listener is asked, inputs, metric", "operationId": "getTemplates", "security": [], "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"templates": {"type": "array", "items": {"type": "object"}}}}}}}}}},
    "/pricing": {"get": {"tags": ["public"], "summary": "Rate card", "operationId": "getPricing", "security": [], "description": "USD per hour of audio a listener hears (18 at the time of writing), per judgement, rounded up to the cent; priced from measured duration, never from a number you send.", "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object"}}}}}}},
    "/": {"get": {"tags": ["public"], "summary": "Service root: self-describing summary of the flow", "operationId": "root", "security": [], "responses": {"200": {"description": "OK"}}}}
  },
  "components": {
    "securitySchemes": {"bearerAuth": {"type": "http", "scheme": "bearer", "description": "`Authorization: Bearer ws_live_...`. Keys are created at https://woodstitch.com/app/api-keys."}},
    "parameters": {
      "evalId": {"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}},
      "fileId": {"name": "id", "in": "path", "required": true, "schema": {"type": "string", "pattern": "^file_"}},
      "idempotencyKey": {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"type": "string", "maxLength": 128}, "description": "Send on every POST. A retry with the same key returns the original response instead of acting twice; the same key with a different body is `idempotency_conflict`."}
    },
    "responses": {
      "BadRequest": {"description": "`bad_audio`, `fetch_failed`, `bad_file`, `bad_allocation`, `no_files`, `upload_failed`, `too_many_files`, `over_max`, `quote_expired`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
      "Unauthorized": {"description": "`no_key` / `bad_key`.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
      "NotFound": {"description": "No such evaluation on this account.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
    },
    "schemas": {
      "Error": {"type": "object", "required": ["error"], "properties": {"error": {"type": "object", "required": ["code", "message"], "properties": {"code": {"type": "string"}, "message": {"type": "string"}, "hint": {"type": "string"}}}}},
      "File": {"type": "object", "properties": {"id": {"type": "string"}, "object": {"type": "string", "const": "file"}, "name": {"type": "string"}, "duration_s": {"type": "number"}, "format": {"type": "string"}, "bytes": {"type": "integer"}, "created_at": {"type": "string", "format": "date-time"}}},
      "FileList": {"type": "object", "properties": {"object": {"type": "string", "const": "list"}, "count": {"type": "integer"}, "files": {"type": "array", "items": {"$ref": "#/components/schemas/File"}}}},
      "AudioRef": {"type": "string", "description": "A file id from POST /files (`file_…`) or a public http(s) URL. wav, mp3, flac, ogg, m4a; 0.5–120 s; max 50 MB. Use the same format and bitrate on both sides of a pair."},
      "Pair": {"type": "object", "required": ["id", "a", "b"], "properties": {"id": {"type": "string", "maxLength": 64, "description": "Unique within the evaluation; echoed in per_pair."}, "a": {"$ref": "#/components/schemas/AudioRef"}, "b": {"$ref": "#/components/schemas/AudioRef"}, "system_a": {"type": "string", "description": "Label for side a (e.g. model id). Never shown to raters."}, "system_b": {"type": "string"}, "passage": {"type": "string", "description": "Text id shared by every rendering of the same text."}, "speaker": {"type": "string", "description": "Voice id."}}},
      "Clip": {"type": "object", "required": ["id", "audio", "truth", "speaker", "passage"], "properties": {"id": {"type": "string", "maxLength": 64}, "audio": {"$ref": "#/components/schemas/AudioRef"}, "truth": {"type": "string", "enum": ["human", "ai"], "description": "Never sent to a listener."}, "source": {"type": "string", "description": "Model id; real narration uses the reserved value `human` (may be omitted)."}, "speaker": {"type": "string", "description": "Voice id (AI) or narrator id (human). Required: the model-level interval resamples over it."}, "passage": {"type": "string", "description": "Text id; the same id for every rendering of the same text across sources."}, "duration_bucket": {"type": "string", "maxLength": 16, "description": "Your design condition, e.g. 15s / 30s / 60s."}}},
      "PerParticipant": {"type": "object", "description": "Rules for one listener. Works on both templates.", "properties": {"max_judgements": {"type": "integer", "description": "Cap on how many items one listener ever hears in this study."}, "unique_by": {"type": "array", "items": {"type": "string", "enum": ["speaker", "passage", "source", "truth", "duration_bucket", "system"]}, "description": "Fields a listener never meets twice; `unique_by: [\"speaker\",\"passage\"]` stops a rater recognising a voice they already judged."}, "max_per": {"type": "object", "additionalProperties": {"type": "integer"}, "description": "Cap on how often a listener meets one value of a field, e.g. {\"system\": 7}."}, "counts": {"type": "object", "additionalProperties": {"type": "integer"}, "description": "Fixed mix per sitting (detection)."}}},
      "Allocation": {"type": "object", "description": "Study-wide balancing (detection only; refused on preference).", "properties": {"total_judgements": {"type": "integer"}, "balance_by": {"type": "array", "items": {"type": "string"}}}},
      "CreateEval": {"type": "object", "properties": {"template": {"type": "string", "enum": ["tts_preference.v1", "tts_detect.v1"], "default": "tts_preference.v1"}, "name": {"type": "string", "default": "eval"}, "question": {"type": "string", "maxLength": 200, "description": "Shown verbatim to raters; per-template default."}, "pairs": {"type": "array", "maxItems": 5000, "items": {"$ref": "#/components/schemas/Pair"}, "description": "Preference studies."}, "clips": {"type": "array", "maxItems": 5000, "items": {"$ref": "#/components/schemas/Clip"}, "description": "Detection studies."}, "ratings_per_pair": {"type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Independent judgements per pair (`judgments_per_item` accepted as alias)."}, "ratings_per_clip": {"type": "integer", "minimum": 1, "maximum": 50, "default": 5}, "per_participant": {"$ref": "#/components/schemas/PerParticipant"}, "allocation": {"$ref": "#/components/schemas/Allocation"}, "dry_run": {"type": "boolean", "default": false, "description": "Price it and throw it away; nothing stored, no eval_id."}}},
      "Quote": {"type": "object", "properties": {"id": {"type": "integer"}, "total_usd": {"type": "number"}, "expires_at": {"type": "string", "format": "date-time"}}},
      "EvalCreated": {"type": "object", "properties": {"eval_id": {"type": "integer"}, "status": {"type": "string"}, "quote": {"$ref": "#/components/schemas/Quote"}, "nothing_charged_yet": {"type": "boolean", "const": true}, "duplicate": {"type": "boolean"}, "pair_seconds": {"type": "array", "items": {"type": "object"}, "description": "Every pair's measured listening time and price per judgement."}, "warnings": {"type": "array", "items": {"type": "string"}}, "effective_design": {"type": "object", "description": "The compiled plan: judgements_total and how they are allocated."}, "dashboard_url": {"type": "string", "format": "uri"}, "poll_after_s": {"type": "integer"}}},
      "EvalProgress": {"type": "object", "properties": {"eval_id": {"type": "integer"}, "status": {"type": "string", "enum": ["ready", "running", "completed", "canceled", "failed"]}, "provisional": {"type": "boolean"}, "question": {"type": "string"}, "ratings_per_pair": {"type": "integer"}, "cost_usd": {"type": "number"}, "started_at": {"type": "string", "format": "date-time"}, "judgements": {"type": "object", "properties": {"done": {"type": "integer"}, "target": {"type": "integer"}, "percent": {"type": "number"}}}, "pairs_with_data": {"type": "integer"}, "max_additional_charge_if_canceled_now_usd": {"type": "number"}, "metrics": {"type": "object", "additionalProperties": {"type": "number"}}, "counts": {"type": "object"}, "dashboard_url": {"type": "string", "format": "uri"}, "poll_after_s": {"type": "integer"}}},
      "PreferenceResults": {"type": "object", "properties": {"metrics": {"type": "object", "description": "Flat numeric dict: human_pref/b_win_rate (share of preference for your b side; 0.5 = indistinguishable), human_pref/b_win_rate_ci95_low, human_pref/b_win_rate_ci95_high, human_pref/tie_rate (0 on API studies: forced choice), human_pref/pairs, human_pref/judgements.", "additionalProperties": {"type": "number"}}, "counts": {"type": "object", "properties": {"a_wins": {"type": "integer"}, "b_wins": {"type": "integer"}, "ties": {"type": "integer"}}}, "per_pair": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "a_wins": {"type": "integer"}, "b_wins": {"type": "integer"}, "ties": {"type": "integer"}}}}, "incomplete": {"type": "boolean"}, "judgements_target": {"type": "integer"}}},
      "DetectionResults": {"type": "object", "properties": {"metric": {"type": "string", "const": "human_label_rate"}, "results": {"type": "array", "items": {"type": "object", "properties": {"source": {"type": "string"}, "duration": {"type": "string"}, "human_label_rate": {"type": "number", "description": "Share of judgements that called the clip human: fooling rate for AI, recognition rate for `human`."}, "n": {"type": "integer"}, "ci95": {"$ref": "#/components/schemas/TwoScopeCI"}, "baseline_deficit": {"type": "number", "description": "Human rate minus this source's rate; 0 = indistinguishable from real narration on these stimuli."}, "baseline_deficit_ci95": {"$ref": "#/components/schemas/TwoScopeCI"}}}}, "generalisation": {"type": "object", "description": "Machine-readable statement of what the data supports (e.g. to_the_model: not_established with fewer than 8 voices)."}, "coverage": {"type": "object"}, "warnings": {"type": "array", "items": {"type": "string"}}}},
      "TwoScopeCI": {"type": "object", "description": "tested_stimuli resamples raters only (a claim about these files); model resamples raters, voices and passages (a claim about the model) and is typically 3–4× wider. Quote the model interval for any sentence of the form 'model X fools listeners Y% of the time'.", "properties": {"tested_stimuli": {"$ref": "#/components/schemas/Interval"}, "model": {"$ref": "#/components/schemas/Interval"}}},
      "Interval": {"type": "object", "properties": {"low": {"type": "number"}, "high": {"type": "number"}}},
      "Judgement": {"type": "object", "properties": {"rater": {"type": "string", "description": "Pseudonymous, stable within the eval."}, "pair_id": {"type": "string"}, "clip_id": {"type": "string"}, "source": {"type": "string"}, "speaker": {"type": "string"}, "passage": {"type": "string"}, "duration_bucket": {"type": "string"}, "answer": {"type": "string"}, "confidence": {"type": "integer"}, "time_spent_ms": {"type": "integer"}, "created_at": {"type": "string", "format": "date-time"}}}
    }
  }
}
