openapi: 3.0.3
info:
  title: orc — Async OCR API
  version: "1.0"
  description: |
    orc turns scanned and CID-broken PDFs (and images) into clean text.

    **Flow:** `POST /v1/jobs` with a file → receive a `job_id` immediately →
    poll `GET /v1/jobs/{id}` (or receive a webhook) → fetch the text from
    `GET /v1/jobs/{id}/text`.

    **Job lifecycle:** `queued → processing → succeeded | failed | canceled`.
    Finished jobs are deleted automatically after 24 hours by default;
    override per job with `retention_hours` (`0` = burn-after-read).
  contact:
    email: hello@orc.guru
    url: https://orc.guru
servers:
  - url: https://api.orc.guru
    description: Production
security:
  - ApiKeyAuth: []
tags:
  - name: Jobs
    description: OCR job lifecycle
  - name: Health
    description: Service health

paths:
  /v1/jobs:
    post:
      tags: [Jobs]
      operationId: createJob
      summary: Submit a document for OCR
      description: |
        Uploads a PDF, PNG, JPEG or TIFF file and enqueues an OCR job.
        The file type is detected from magic bytes, not the file extension.
        Returns `202 Accepted` with a `job_id` right away; OCR runs in the
        background.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: PDF, PNG, JPEG or TIFF (validated by magic bytes)
                lang:
                  type: string
                  default: tur
                  example: tur+eng
                  description: >
                    Tesseract language code. Combine with `+`
                    (e.g. `tur+eng`). Pattern: `^[a-z]{3}(_[a-z]{4})?(\+[a-z]{3}(_[a-z]{4})?)*$`
                dpi:
                  type: integer
                  default: 300
                  minimum: 72
                  maximum: 600
                  description: Rasterization resolution for PDF pages
                first_page:
                  type: integer
                  minimum: 1
                  description: First PDF page to process (default 1)
                last_page:
                  type: integer
                  minimum: 1
                  description: Last PDF page to process (default last page)
                webhook_url:
                  type: string
                  format: uri
                  description: >
                    If set, orc POSTs a JSON completion payload to this URL
                    when the job reaches a terminal state (3 attempts with
                    backoff)
                retention_hours:
                  type: integer
                  minimum: 0
                  maximum: 720
                  description: >
                    Per-job retention override. Absent → service default
                    (24 h). `0` → burn-after-read: the job is deleted
                    immediately after its text is fetched once (or, for
                    failed/canceled jobs, after the terminal status is
                    observed once).
                region:
                  type: string
                  enum: [TR, EU]
                  default: TR
                  description: Processing region.
      responses:
        "202":
          description: Job accepted and queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { type: string, example: 9cc2059d764a5ef32fd298f0bc87a799 }
                  status: { type: string, example: queued }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          description: File exceeds the upload size limit (default 50 MB)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "415":
          description: Unsupported file type (only PDF, PNG, JPEG, TIFF)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Queue is full — retry after the number of seconds in the `Retry-After` header
          headers:
            Retry-After:
              schema: { type: integer, example: 30 }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/jobs/{id}:
    parameters:
      - $ref: "#/components/parameters/JobId"
    get:
      tags: [Jobs]
      operationId: getJob
      summary: Get job status and progress
      responses:
        "200":
          description: Job state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
              examples:
                processing:
                  summary: Job in progress
                  value:
                    job_id: 9cc2059d764a5ef32fd298f0bc87a799
                    status: processing
                    created_at: "2026-07-16T14:03:00Z"
                    started_at: "2026-07-16T14:03:02Z"
                    lang: tur
                    dpi: 300
                    input_name: court-ruling.pdf
                    progress: { pages_done: 3, pages_total: 12 }
                succeeded:
                  summary: Finished job
                  value:
                    job_id: 9cc2059d764a5ef32fd298f0bc87a799
                    status: succeeded
                    created_at: "2026-07-16T14:03:00Z"
                    started_at: "2026-07-16T14:03:02Z"
                    finished_at: "2026-07-16T14:04:31Z"
                    lang: tur
                    dpi: 300
                    input_name: court-ruling.pdf
                    result: { page_count: 12, confidence: 0.94, duration_ms: 89000 }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Jobs]
      operationId: cancelJob
      summary: Cancel or delete a job
      description: |
        - `queued` job → marked `canceled` before it starts
        - `processing` job → the OCR subprocess group is killed immediately;
          the job ends as `canceled`
        - terminal job (`succeeded`/`failed`/`canceled`) → the record and its
          files are removed
      responses:
        "204":
          description: Canceled or deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/jobs/{id}/text:
    parameters:
      - $ref: "#/components/parameters/JobId"
    get:
      tags: [Jobs]
      operationId: getJobText
      summary: Fetch the extracted text
      responses:
        "200":
          description: Extracted text
          content:
            text/plain:
              schema:
                type: string
              example: |
                CONSTITUTIONAL COURT RULING
                Case No: 2025/…
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Result not ready — the job has not succeeded (check its status first)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /healthz:
    get:
      tags: [Health]
      operationId: healthz
      summary: Liveness + queue depth
      security: []
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  queue_depth: { type: integer, example: 2 }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Request a key at hello@orc.guru
  parameters:
    JobId:
      name: id
      in: path
      required: true
      schema: { type: string }
      example: 9cc2059d764a5ef32fd298f0bc87a799
  responses:
    BadRequest:
      description: Invalid parameter (lang, dpi, page range) or malformed multipart body
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid X-API-Key header
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No job with this id (it may have expired — TTL is 24 h)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      properties:
        error: { type: string, example: job not found }
    Progress:
      type: object
      properties:
        pages_done: { type: integer, example: 3 }
        pages_total: { type: integer, example: 12 }
    Result:
      type: object
      properties:
        page_count: { type: integer, example: 12 }
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
          example: 0.94
          description: Mean of Tesseract per-word confidences
        duration_ms: { type: integer, example: 89000 }
    Job:
      type: object
      properties:
        job_id: { type: string, example: 9cc2059d764a5ef32fd298f0bc87a799 }
        status:
          type: string
          enum: [queued, processing, succeeded, failed, canceled]
        created_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time, nullable: true }
        finished_at: { type: string, format: date-time, nullable: true }
        lang: { type: string, example: tur }
        dpi: { type: integer, example: 300 }
        region: { type: string, enum: [TR, EU], example: TR }
        input_name: { type: string, example: court-ruling.pdf }
        progress: { $ref: "#/components/schemas/Progress" }
        result: { $ref: "#/components/schemas/Result" }
        error: { type: string, example: "page 4: pdftoppm: exit status 1" }
    WebhookPayload:
      type: object
      description: JSON body POSTed to `webhook_url` when a job finishes
      properties:
        job_id: { type: string }
        status:
          type: string
          enum: [succeeded, failed, canceled]
        page_count: { type: integer }
        confidence: { type: number }
        error: { type: string }
