{
  "openapi": "3.1.0",
  "info": {
    "title": "Winnow API",
    "version": "0.3.0",
    "description": "## Overview\nWinnow scores a single survey response for quality and fraud — bots, speeders, straight-liners,\nduplicates — using deterministic rules. No AI. Send one response with its answers and timing\nmetadata; get back a quality score (0–100), a recommendation, and the exact flags that fired.\n\n## Base URL\n`https://api.licrat.com/v1`\n\n## Authentication\nSend your API key as a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer YOUR_API_KEY\n```\n\n## Example\n```bash\ncurl -X POST https://api.licrat.com/v1/score \\\n  -H \"Authorization: Bearer YOUR_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"response_id\":\"resp_demo_001\",\"duration_seconds\":240,\n       \"answers\":[{\"question_id\":\"q1\",\"type\":\"single\",\"value\":\"Yes\",\"seconds_spent\":12}]}'\n```\n\n## Getting a key\nKeys are issued by hand during early access. [Request access](https://winnow.licrat.com/#cta).\n"
  },
  "servers": [
    {
      "url": "https://api.licrat.com/v1",
      "description": "Production"
    }
  ],
  "security": [
    {
      "BearerAuth": []
    }
  ],
  "paths": {
    "/score/batch": {
      "post": {
        "operationId": "scoreBatch",
        "summary": "Score a batch of survey responses",
        "description": "Submit many responses in one call. Each item uses the same shape as the body of POST /score. Duplicate detection is scoped to the batch: responses that share a fingerprint with an earlier one in the same batch are flagged \"duplicate\" (the first occurrence is not). Stateless and deterministic; the content of the responses is not stored. A batch may contain at most 2000 responses.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchScoreRequest"
              },
              "examples": {
                "mixed": {
                  "summary": "A small batch containing a duplicate",
                  "value": {
                    "responses": [
                      {
                        "response_id": "resp_1",
                        "duration_seconds": 95,
                        "fingerprint": "9f86d0818...",
                        "answers": []
                      },
                      {
                        "response_id": "resp_2",
                        "duration_seconds": 95,
                        "fingerprint": "9f86d0818...",
                        "answers": []
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Scores computed for every response in the batch.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BatchScoreResult"
                }
              }
            }
          },
          "400": {
            "description": "The \"responses\" field is missing, not an array, or empty.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Too many responses (more than 2000) or request body too large.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/report": {
      "post": {
        "operationId": "reportBatch",
        "summary": "Aggregate quality report for a batch of responses",
        "description": "Same input as POST /score/batch (an array of responses plus an optional batch-level \"mapping\"), scored with the same deterministic engine. The response is an AGGREGATE only — no per-response scores: totals, mean and median score, an overall grade, per-recommendation counts/percentages, a fixed 10-bin score distribution and the frequency of each canonical flag. Stateless and deterministic; nothing is stored. At most 2000 responses.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchScoreRequest"
              },
              "examples": {
                "mixed": {
                  "summary": "A small batch containing a duplicate",
                  "value": {
                    "responses": [
                      {
                        "response_id": "resp_1",
                        "duration_seconds": 95,
                        "fingerprint": "9f86d0818...",
                        "answers": []
                      },
                      {
                        "response_id": "resp_2",
                        "duration_seconds": 95,
                        "fingerprint": "9f86d0818...",
                        "answers": []
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Aggregate report computed for the batch.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReportResult"
                }
              }
            }
          },
          "400": {
            "description": "The \"responses\" field is missing, not an array, or empty.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Too many responses (more than 2000) or request body too large.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/health": {
      "get": {
        "operationId": "health",
        "summary": "Liveness check",
        "security": [],
        "responses": {
          "200": {
            "description": "The service is alive",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "status",
                    "service",
                    "version"
                  ],
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "ok"
                    },
                    "service": {
                      "type": "string",
                      "example": "winnow"
                    },
                    "version": {
                      "type": "string",
                      "description": "Service/engine version.",
                      "example": "0.3.0"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/score": {
      "post": {
        "operationId": "scoreResponse",
        "summary": "Score a survey response for quality and fraud",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScoreRequest"
              },
              "examples": {
                "speeder": {
                  "summary": "A speeder who fails an attention check",
                  "value": {
                    "response_id": "resp-2024-0001",
                    "duration_seconds": 12,
                    "fingerprint": "9f86d081884c7d659a2feaa0c55ad015",
                    "survey": {
                      "total_questions": 4,
                      "min_expected_seconds": 60,
                      "attention_checks": [
                        {
                          "question_id": "ac1",
                          "expected_value": 3
                        }
                      ],
                      "grids": [
                        [
                          "g1",
                          "g2",
                          "g3",
                          "g4"
                        ]
                      ]
                    },
                    "answers": [
                      {
                        "question_id": "ac1",
                        "type": "scale",
                        "value": 5,
                        "seconds_spent": 3
                      },
                      {
                        "question_id": "g1",
                        "type": "grid",
                        "value": 1,
                        "seconds_spent": 3
                      },
                      {
                        "question_id": "g2",
                        "type": "grid",
                        "value": 1,
                        "seconds_spent": 3
                      },
                      {
                        "question_id": "g3",
                        "type": "grid",
                        "value": 1,
                        "seconds_spent": 3
                      },
                      {
                        "question_id": "g4",
                        "type": "grid",
                        "value": 1,
                        "seconds_spent": 3
                      },
                      {
                        "question_id": "o1",
                        "type": "open_text",
                        "value": "asdfghjkl",
                        "seconds_spent": 3
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Evaluation result",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScoreResult"
                },
                "examples": {
                  "flagged": {
                    "summary": "Response flagged for rejection",
                    "value": {
                      "response_id": "resp-2024-0001",
                      "quality_score": 0,
                      "recommendation": "reject",
                      "flags": [
                        {
                          "code": "speeding",
                          "severity": "high",
                          "detail": "Duration 12 s below the expected minimum of 60 s."
                        },
                        {
                          "code": "straight_lining",
                          "severity": "medium",
                          "detail": "Same option across all rows of 1 battery."
                        },
                        {
                          "code": "attention_check_failed",
                          "severity": "high",
                          "detail": "1 attention check failed: ac1."
                        },
                        {
                          "code": "uniform_timing",
                          "severity": "medium",
                          "detail": "Near-identical time (~3.00 s) on 5 of 5 questions."
                        }
                      ],
                      "checks_run": [
                        "speeding",
                        "straight_lining",
                        "attention_check_failed",
                        "duplicate",
                        "gibberish_open_text",
                        "uniform_timing"
                      ]
                    }
                  },
                  "clean": {
                    "summary": "Clean response",
                    "value": {
                      "response_id": "resp-2024-0002",
                      "quality_score": 100,
                      "recommendation": "accept",
                      "flags": [],
                      "checks_run": [
                        "speeding",
                        "straight_lining",
                        "attention_check_failed",
                        "duplicate",
                        "gibberish_open_text",
                        "uniform_timing"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid request",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "validation_error",
                  "message": "'response_id' is required and must be a non-empty string."
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid authentication token"
          },
          "413": {
            "description": "Body exceeds the maximum allowed size",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "payload_too_large",
                  "message": "The body exceeds the maximum of 262144 bytes."
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer"
      }
    },
    "schemas": {
      "BatchScoreRequest": {
        "type": "object",
        "required": [
          "responses"
        ],
        "properties": {
          "responses": {
            "type": "array",
            "minItems": 1,
            "maxItems": 2000,
            "description": "The responses to score. Each item uses the same shape as the body of POST /score.",
            "items": {
              "$ref": "#/components/schemas/ScoreRequest"
            }
          },
          "mapping": {
            "allOf": [
              {
                "$ref": "#/components/schemas/FieldMapping"
              }
            ],
            "description": "Optional. A single field mapping applied to every response in the batch (same semantics as the per-request \"mapping\")."
          }
        }
      },
      "BatchScoreResult": {
        "type": "object",
        "required": [
          "results",
          "summary"
        ],
        "properties": {
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BatchResult"
            }
          },
          "summary": {
            "$ref": "#/components/schemas/BatchSummary"
          },
          "engine_version": {
            "type": "string",
            "description": "Version of the Winnow engine that produced these scores.",
            "example": "0.3.0"
          }
        }
      },
      "BatchResult": {
        "type": "object",
        "required": [
          "id",
          "quality_score",
          "recommendation",
          "flags"
        ],
        "properties": {
          "id": {
            "description": "The item's own \"id\" if provided, otherwise its response_id, otherwise its zero-based index in the batch.",
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "integer"
              }
            ]
          },
          "quality_score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "recommendation": {
            "type": "string",
            "enum": [
              "accept",
              "review",
              "reject"
            ]
          },
          "flags": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Flag"
            }
          }
        }
      },
      "BatchSummary": {
        "type": "object",
        "required": [
          "total",
          "accepted",
          "review",
          "rejected",
          "duplicates",
          "average_score"
        ],
        "properties": {
          "total": {
            "type": "integer",
            "description": "Number of responses in the batch."
          },
          "accepted": {
            "type": "integer"
          },
          "review": {
            "type": "integer"
          },
          "rejected": {
            "type": "integer"
          },
          "duplicates": {
            "type": "integer",
            "description": "How many responses were flagged as duplicates within the batch."
          },
          "average_score": {
            "type": "number",
            "description": "Mean quality_score across the batch, rounded to two decimals."
          }
        }
      },
      "ReportResult": {
        "type": "object",
        "required": [
          "total_responses",
          "summary",
          "recommendations",
          "estimated_clean_n",
          "score_distribution",
          "flag_frequency"
        ],
        "properties": {
          "total_responses": {
            "type": "integer",
            "description": "Number of responses scored."
          },
          "summary": {
            "type": "object",
            "required": [
              "mean_score",
              "median_score",
              "overall_grade",
              "note"
            ],
            "properties": {
              "mean_score": {
                "type": "number",
                "description": "Mean quality_score across the batch, rounded to one decimal."
              },
              "median_score": {
                "type": "number",
                "description": "Median quality_score across the batch."
              },
              "overall_grade": {
                "type": "string",
                "description": "\"good\" if accept_pct >= 80, \"fair\" if 50–79.9, \"poor\" if < 50.",
                "enum": [
                  "good",
                  "fair",
                  "poor"
                ]
              },
              "note": {
                "type": "string",
                "description": "Deterministic English summary built from the recommendation percentages. No AI."
              }
            }
          },
          "recommendations": {
            "type": "object",
            "required": [
              "accept",
              "review",
              "reject"
            ],
            "properties": {
              "accept": {
                "$ref": "#/components/schemas/RecommendationBucket"
              },
              "review": {
                "$ref": "#/components/schemas/RecommendationBucket"
              },
              "reject": {
                "$ref": "#/components/schemas/RecommendationBucket"
              }
            }
          },
          "estimated_clean_n": {
            "type": "integer",
            "description": "Number of responses recommended for acceptance — equals recommendations.accept.count."
          },
          "score_distribution": {
            "type": "array",
            "minItems": 10,
            "maxItems": 10,
            "description": "Ten fixed buckets (\"0-9\" … \"90-100\"); a score of 100 counts in the last bucket.",
            "items": {
              "$ref": "#/components/schemas/ScoreBin"
            }
          },
          "flag_frequency": {
            "type": "array",
            "description": "One entry per canonical flag, with how many responses raised it and the percentage over total_responses.",
            "items": {
              "$ref": "#/components/schemas/FlagFrequency"
            }
          },
          "engine_version": {
            "type": "string",
            "description": "Version of the Winnow engine that produced this report.",
            "example": "0.3.0"
          }
        }
      },
      "RecommendationBucket": {
        "type": "object",
        "required": [
          "count",
          "pct"
        ],
        "properties": {
          "count": {
            "type": "integer"
          },
          "pct": {
            "type": "number",
            "description": "Percentage of total_responses, rounded to one decimal."
          }
        }
      },
      "ScoreBin": {
        "type": "object",
        "required": [
          "bin",
          "count"
        ],
        "properties": {
          "bin": {
            "type": "string",
            "description": "Bucket label, one of \"0-9\" … \"90-100\".",
            "example": "90-100"
          },
          "count": {
            "type": "integer"
          }
        }
      },
      "FlagFrequency": {
        "type": "object",
        "required": [
          "code",
          "count",
          "pct"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "Canonical flag code (same identifiers as Flag.code).",
            "enum": [
              "speeding",
              "straight_lining",
              "attention_check_failed",
              "duplicate",
              "gibberish_open_text",
              "uniform_timing"
            ]
          },
          "count": {
            "type": "integer",
            "description": "How many responses raised this flag."
          },
          "pct": {
            "type": "number",
            "description": "Percentage of total_responses with this flag, one decimal."
          }
        }
      },
      "ScoreRequest": {
        "type": "object",
        "required": [
          "response_id",
          "answers"
        ],
        "properties": {
          "response_id": {
            "type": "string",
            "description": "Your identifier for this response."
          },
          "duration_seconds": {
            "type": "number",
            "description": "Total time the respondent took, in seconds."
          },
          "fingerprint": {
            "type": "string",
            "description": "Hashed device/IP fingerprint (optional), used only to detect duplicates. Hash it on your side; never send raw PII.\n"
          },
          "survey": {
            "$ref": "#/components/schemas/SurveyMeta"
          },
          "answers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Answer"
            }
          },
          "mapping": {
            "$ref": "#/components/schemas/FieldMapping"
          }
        }
      },
      "FieldMapping": {
        "type": "object",
        "description": "Optional field mapping so you can send your own field names without transforming the data first. Each entry maps a canonical Winnow field to the name (or dot-path, e.g. \"meta.time_taken\") of the field in your payload. Before scoring, the value at that path is copied to the canonical field. Fields already sent under their canonical name are respected; a path that doesn't exist is ignored (no error). Without \"mapping\", behaviour is unchanged. This is generic — nothing provider-specific.\n",
        "additionalProperties": {
          "type": "string"
        },
        "example": {
          "response_id": "resp_id",
          "duration_seconds": "meta.time_taken"
        }
      },
      "SurveyMeta": {
        "type": "object",
        "description": "Optional hints about the questionnaire to fine-tune detection.",
        "properties": {
          "total_questions": {
            "type": "integer"
          },
          "min_expected_seconds": {
            "type": "number",
            "description": "Below this total time, the response is considered \"speeding\"."
          },
          "attention_checks": {
            "type": "array",
            "description": "Trap/control questions and their correct value.",
            "items": {
              "type": "object",
              "required": [
                "question_id",
                "expected_value"
              ],
              "properties": {
                "question_id": {
                  "type": "string"
                },
                "expected_value": {}
              }
            }
          },
          "grids": {
            "type": "array",
            "description": "Groups of question_id that form a battery/matrix (used to detect straight-lining).\n",
            "items": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          }
        }
      },
      "Answer": {
        "type": "object",
        "required": [
          "question_id",
          "type",
          "value"
        ],
        "properties": {
          "question_id": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "single",
              "multi",
              "scale",
              "grid",
              "open_text",
              "numeric"
            ]
          },
          "value": {
            "description": "The answer value (string, number, array, etc.)."
          },
          "seconds_spent": {
            "type": "number",
            "description": "Time spent on this question (optional), in seconds."
          }
        }
      },
      "ScoreResult": {
        "type": "object",
        "required": [
          "response_id",
          "quality_score",
          "recommendation",
          "flags"
        ],
        "properties": {
          "response_id": {
            "type": "string"
          },
          "quality_score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "100 = clean, 0 = almost certainly fraudulent."
          },
          "recommendation": {
            "type": "string",
            "enum": [
              "accept",
              "review",
              "reject"
            ]
          },
          "flags": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Flag"
            }
          },
          "checks_run": {
            "type": "array",
            "description": "Rules that were run on this response.",
            "items": {
              "type": "string"
            }
          },
          "engine_version": {
            "type": "string",
            "description": "Version of the Winnow engine that produced this score.",
            "example": "0.3.0"
          }
        }
      },
      "Flag": {
        "type": "object",
        "required": [
          "code",
          "severity"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "Codes emitted by v1: speeding, straight_lining, attention_check_failed, duplicate, gibberish_open_text, uniform_timing. `inconsistent_answers` is reserved and not emitted yet.\n",
            "enum": [
              "speeding",
              "straight_lining",
              "attention_check_failed",
              "duplicate",
              "gibberish_open_text",
              "inconsistent_answers",
              "uniform_timing"
            ]
          },
          "severity": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high"
            ]
          },
          "detail": {
            "type": "string",
            "description": "Human-readable explanation of why the flag fired."
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "message": {
            "type": "string"
          }
        }
      }
    }
  }
}
