openapi: 3.1.0
info:
  title: myocr.app API
  version: "1.0.0"
  description: |
    Convert PDF and image documents to structured Excel/text. Tables and full-text run on our OCR engine; specialized models return domain-specific fields.

    **Base URL:** `https://api.myocr.app`

    **Authentication:** `X-API-Key` header. Get a key from your [dashboard](/account/api).
    Test keys (`sk_test_...`) and live keys (`sk_live_...`) are supported.

    **Rate limits** (per API key — clients behind shared NAT/proxy do NOT share the limit):
    - `/v1/convert` (sync): 60 requests/min
    - `/v1/jobs` (async): 120 requests/min
    - `/v1/batch` (multi-file): 30 requests/min

    Requests without an API key (`/v1/status`) are rate-limited per IP.

    **Response envelope:**
    ```json
    {"success": true, "data": {...}, "request_id": "abcd1234"}
    ```
    Or on error:
    ```json
    {"success": false, "error": {"code": "INVALID_API_KEY", "message": "..."}, "request_id": "..."}
    ```

    All responses include `X-MyOCR-Request-Id` header. File responses also include `X-MyOCR-Pages-Used` and `X-MyOCR-Model`.

  contact:
    name: myocr.app support
    email: info@myocr.app
    url: https://www.myocr.app
  license:
    name: Proprietary
servers:
  - url: https://api.myocr.app
    description: Production
security:
  - ApiKeyAuth: []

tags:
  - name: Status
    description: Public health check
  - name: Convert
    description: Synchronous conversion (≤5MB, ≤10 pages)
  - name: Jobs
    description: Asynchronous conversion (up to 50MB, up to 500 pages per PDF, with webhook)
  - name: Keys
    description: API key management (requires session login, not API key)

paths:

  /v1/status:
    get:
      tags: [Status]
      summary: Public health check
      description: No authentication required. Returns service flags and limits.
      security: []
      responses:
        '200':
          description: Service status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatusResponse'

  /v1/convert:
    post:
      tags: [Convert]
      summary: Sync document conversion
      description: |
        Multipart upload. Max 5MB, 10 pages. Synchronous — response contains the result file (or JSON if `output=json`).

        For larger or batched jobs use `/v1/jobs`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, model]
              properties:
                file:
                  type: string
                  format: binary
                  description: PDF or image (pdf/png/jpg/jpeg/tiff/tif/bmp/heif)
                model:
                  type: string
                  enum: [tables, text, invoice, receipt, bank_statement, business_card]
                  default: tables
                output:
                  type: string
                  enum: [xlsx, txt, json, csv]
                  default: xlsx
                  description: |
                    Override default output format. `text` model always returns txt.
                    `csv` exports only the first sheet of the structured workbook (use xlsx for multi-sheet).
      responses:
        '200':
          description: Conversion successful
          headers:
            X-MyOCR-Request-Id:
              schema: { type: string }
            X-MyOCR-Pages-Used:
              schema: { type: integer }
            X-MyOCR-Model:
              schema: { type: string }
          content:
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema:
                type: string
                format: binary
                description: XLSX file with structured fields based on model
            text/plain:
              schema:
                type: string
                description: Plain text (model=text or output=txt)
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/QuotaExceeded'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimit'
        '502':
          $ref: '#/components/responses/OcrError'

  /v1/jobs:
    post:
      tags: [Jobs]
      summary: Create async conversion job
      description: |
        Upload a file (≤50MB, ≤500 pages for PDFs) for background processing. Returns immediately with `request_id`.
        Poll `GET /v1/jobs/{request_id}` or set `webhook_url` to be notified. PDFs above the page cap are rejected upfront with `TOO_MANY_PAGES`; split the document.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, model]
              properties:
                file:
                  type: string
                  format: binary
                model:
                  type: string
                  enum: [tables, text, invoice, receipt, bank_statement, business_card]
                webhook_url:
                  type: string
                  format: uri
                  description: |
                    HTTPS URL to receive `job.completed` / `job.failed` events.
                    Payload is signed with HMAC SHA-256 (header `X-MyOCR-Signature: sha256=...`).
                    Retries: 1m, 5m, 30m, 2h.
      responses:
        '200':
          description: Job created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobCreatedResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/QuotaExceeded' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimit' }
        '503': { $ref: '#/components/responses/StorageError' }

  /v1/batch:
    post:
      tags: [Jobs]
      summary: Create async batch (multiple files)
      description: |
        Submit up to 20 files (≤50MB each, ≤500 pages per PDF) in a single multipart request.
        Each file becomes a separate ApiJob and consumes one quota call. Returns array of
        `request_id` (one per file) plus an `errors` array for files that failed validation
        (e.g. `FILE_TOO_LARGE`, `TOO_MANY_PAGES`, `UNSUPPORTED_FILE_TYPE`).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [files, model]
              properties:
                files:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: 1–20 files (pdf/png/jpg/...)
                model:
                  type: string
                  enum: [tables, text, invoice, receipt, bank_statement, business_card]
                  description: Single model applied to all files in batch
                webhook_url:
                  type: string
                  format: uri
                  description: Notified once per job (job.completed/failed for each file)
      responses:
        '200':
          description: Batch accepted (some jobs may have errors — check `errors` array)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      batch_id: { type: string }
                      model: { type: string }
                      jobs_created: { type: integer }
                      jobs:
                        type: array
                        items:
                          type: object
                          properties:
                            filename: { type: string }
                            request_id: { type: string }
                            status: { type: string }
                      errors:
                        type: array
                        items:
                          type: object
                          properties:
                            filename: { type: string }
                            code: { type: string }
                            message: { type: string }
                  request_id: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/QuotaExceeded' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimit' }

  /v1/jobs:
    get:
      tags: [Jobs]
      summary: List async jobs for the calling API key (paginated)
      parameters:
        - in: query
          name: status
          schema:
            type: string
            enum: [pending, processing, done, failed]
        - in: query
          name: model
          schema:
            type: string
            enum: [tables, text, invoice, receipt, bank_statement, business_card]
        - in: query
          name: limit
          schema: { type: integer, default: 20, maximum: 100 }
        - in: query
          name: offset
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: Paginated job list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      total: { type: integer }
                      limit: { type: integer }
                      offset: { type: integer }
                      jobs:
                        type: array
                        items: { $ref: '#/components/schemas/JobStateResponse/properties/data' }
                  request_id: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/jobs/{request_id}:
    get:
      tags: [Jobs]
      summary: Poll job status
      parameters:
        - in: path
          name: request_id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Job state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobStateResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Jobs]
      summary: Delete job + cleanup files
      parameters:
        - in: path
          name: request_id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/jobs/{request_id}/result:
    get:
      tags: [Jobs]
      summary: Download job result
      description: |
        Returns a signed URL (24h, R2-backed) or streams the result file directly.
        Only available when `status == "done"`.
      parameters:
        - in: path
          name: request_id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Signed URL or binary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResultUrlResponse'
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema:
                type: string
                format: binary
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Job not done yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/usage:
    get:
      tags: [Status]
      summary: Current month quota usage for the calling API key
      description: |
        Returns pages used vs limit for the current period for the API key passed in X-API-Key.
        Billing is per page: `calls_used`/`calls_limit` are counted in PAGES (1 unit = 1 page).
        The Free plan includes 10 pages one-time (not per month); paid plans reset at renewal.
        Useful for scripts that monitor quota programmatically (e.g. to upgrade before exhaustion).
      responses:
        '200':
          description: Usage data
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      plan: { type: string, nullable: true, example: "free" }
                      calls_used: { type: integer, description: "Pages used this period (1 unit = 1 page).", example: 4 }
                      calls_limit: { type: integer, nullable: true, description: "Included pages (null = unlimited/pay-as-you-go). Free = 10 (one-time).", example: 10 }
                      percentage: { type: number, nullable: true, example: 40.0 }
                      reset_date: { type: string, format: date-time }
                      year_month: { type: string, example: "2026-05" }
                      is_test_key: { type: boolean }
                  request_id: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503':
          description: Billing module not registered (deployment misconfigured)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /v1/keys:
    post:
      tags: [Keys]
      summary: Create API key
      description: |
        Requires logged-in session (cookie auth, not X-API-Key).
        The raw key is returned **once** — store it now.
      security: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, maxLength: 120 }
                is_test: { type: boolean, default: false }
      responses:
        '200':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KeyCreatedResponse'
    get:
      tags: [Keys]
      summary: List own API keys
      security: []
      responses:
        '200':
          description: List
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ApiKeyInfo' }

  /v1/keys/{id}/rotate:
    post:
      tags: [Keys]
      summary: Rotate an API key (atomic revoke + create)
      description: |
        Revokes the existing key and creates a new one with the same `label`, `is_test` and
        `monthly_limit`. The new raw key is returned **once** — store it now.
        Use this when you suspect a key was leaked or as part of periodic rotation policy.
      security: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: Rotated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      old_id: { type: integer }
                      old_revoked: { type: boolean }
                      id: { type: integer }
                      key: { type: string, description: 'New raw key — shown once' }
                      prefix: { type: string }
                      label: { type: string }
                      is_test: { type: boolean }
                      created_at: { type: string, format: date-time }
                      note: { type: string }

  /v1/keys/{id}:
    delete:
      tags: [Keys]
      summary: Revoke an API key
      security: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'

components:

  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Send your API key in the `X-API-Key` header. Get one at /account/api.

  schemas:

    StatusResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            service: { type: string }
            version: { type: string }
            environment: { type: string }
            models_supported:
              type: array
              items: { type: string }
            features:
              type: object
              properties:
                auth: { type: boolean }
                sync_convert: { type: boolean }
                async_jobs: { type: boolean }
                webhook: { type: boolean }
                prebuilt_models_enabled: { type: boolean }
                redis_configured: { type: boolean }
                r2_configured: { type: boolean }
            limits:
              type: object
              properties:
                sync_max_bytes: { type: integer, example: 5242880 }
                sync_max_pages: { type: integer, example: 10 }
                jobs_max_bytes: { type: integer, example: 52428800 }
                sync_rate_per_minute: { type: integer, example: 60 }
                jobs_rate_per_minute: { type: integer, example: 120 }

    SuccessResponse:
      type: object
      properties:
        success: { type: boolean, example: true }
        data: { type: object }
        request_id: { type: string, example: "abcd1234efgh5678" }

    ErrorResponse:
      type: object
      properties:
        success: { type: boolean, example: false }
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - MISSING_API_KEY
                - INVALID_API_KEY
                - UNSUPPORTED_MODEL
                - UNSUPPORTED_OUTPUT
                - UNSUPPORTED_FILE_TYPE
                - MISSING_FILE
                - INVALID_FILE
                - INVALID_PARAMETER
                - FILE_TOO_LARGE
                - TOO_MANY_PAGES
                - INVALID_WEBHOOK_URL
                - CARD_REQUIRED
                - NO_ACTIVE_PLAN
                - INSUFFICIENT_PAGES
                - QUOTA_EXCEEDED
                - SPEND_CAP_REACHED
                - SMART_EXTRACT_LIMIT
                - NOT_READY
                - NOT_FOUND
                - SERVICE_BUSY
                - OCR_ERROR
                - STORAGE_ERROR
                - SERVICE_NOT_READY
                - INTERNAL_ERROR
            message: { type: string }
        request_id: { type: string }

    JobCreatedResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            request_id: { type: string }
            status: { type: string, enum: [pending] }
            model: { type: string }
        request_id: { type: string }

    JobStateResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            request_id: { type: string }
            status:
              type: string
              enum: [pending, processing, done, failed]
            model: { type: string }
            pages_used: { type: integer, nullable: true }
            created_at: { type: string, format: date-time }
            completed_at: { type: string, format: date-time, nullable: true }
            error_detail: { type: string, nullable: true }

    ResultUrlResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            result_url:
              type: string
              format: uri
              description: Signed URL, 24h expiry
            expires_in:
              type: integer
              example: 86400

    KeyCreatedResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            id: { type: integer }
            key:
              type: string
              description: Raw key — shown once, store securely
              example: "sk_live_..."
            prefix: { type: string }
            label: { type: string }
            is_test: { type: boolean }
            created_at: { type: string, format: date-time }
            note: { type: string }

    ApiKeyInfo:
      type: object
      properties:
        id: { type: integer }
        prefix: { type: string }
        label: { type: string, nullable: true }
        is_active: { type: boolean }
        is_test: { type: boolean }
        created_at: { type: string, format: date-time, nullable: true }
        last_used_at: { type: string, format: date-time, nullable: true }

  responses:

    BadRequest:
      description: Validation error (missing/invalid field)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }

    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }

    QuotaExceeded:
      description: Monthly quota exhausted
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }

    PayloadTooLarge:
      description: File too large or too many pages
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }

    RateLimit:
      description: Rate limit exceeded
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }

    OcrError:
      description: Upstream OCR engine error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }

    StorageError:
      description: Object storage (R2) error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }

    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
