openapi: 3.1.0
info:
  title: labelviewer API
  version: 1.0.0
  summary: Render ZPL II labels to PNG and fill ZPL templates with data.
  description: |
    The HTTP API of a self-hosted labelviewer install. See docs/api.md (the API page of the install's developer portal)
    for examples.

    **No authentication, by design.** labelviewer runs inside your own network next to the systems that print labels.
    Every endpoint is open and answers CORS requests from any origin (`Access-Control-Allow-Origin: *`, never
    credentials). If the install needs access control, put it behind your own gateway, VPN or reverse proxy.

    Every request is bounded: 8 MiB bodies, a 30 s request deadline, a per-render time and memory budget, pixel and work
    budgets in the renderer, and merge limits (1000 records, 16 MiB output).

    Every response carries `X-Request-Id` (yours if you send a valid one, `[A-Za-z0-9._-]{1,64}`).

    **Licensing.** The render routes need a license for the Viewer, the merge routes one for the Editor; otherwise they
    answer `402` (`license_required`, `license_expired` or `product_not_licensed`). A trial license watermarks every
    PNG (never merged ZPL). `/v1/info` and `/healthz` always answer.
servers:
  - url: http://localhost:10090
    description: Your install
security: []
tags:
  - name: render
  - name: merge
  - name: health
paths:
  /v1/render:
    post:
      tags: [render]
      operationId: render
      summary: Render ZPL to PNG labels (JSON)
      description: Same as the legacy `POST /api/render`, which stays available with the legacy error shape.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RenderRequest' }
            example: { zpl: '^XA^FO20,20^GB200,100,4^FS^XZ', dpmm: 8, widthIn: 4, heightIn: 6 }
      responses:
        '200':
          description: Rendered labels
          headers:
            X-Total-Count: { $ref: '#/components/headers/X-Total-Count' }
            X-Warnings: { $ref: '#/components/headers/X-Warnings' }
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RenderResult' }
        '400': { $ref: '#/components/responses/Error' }
        '402': { $ref: '#/components/responses/LicenseError' }
        '404': { $ref: '#/components/responses/Error' }
        '413': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }
        '503': { $ref: '#/components/responses/Error' }
  /v1/merge:
    post:
      tags: [merge]
      operationId: merge
      summary: Fill a ZPL template with data
      description: |
        `{{path}}` placeholders are replaced with context-aware escaping. Returns the merged ZPL as text, one copy per
        record joined with a newline. Same as the legacy `POST /api/merge`. Nothing is stored on the server.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MergeRequest' }
            example:
              template: '^XA^FO50,50^A0N,40,40^FD{{name}}^FS^XZ'
              data: [{ name: Alpha }, { name: Beta }]
      responses:
        '200':
          description: Merged ZPL
          headers:
            X-Record-Count:
              description: Number of records merged.
              schema: { type: integer }
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            text/plain:
              schema: { type: string }
              example: "^XA^FO50,50^A0N,40,40^FDAlpha^FS^XZ\n^XA^FO50,50^A0N,40,40^FDBeta^FS^XZ"
        '400': { $ref: '#/components/responses/Error' }
        '402': { $ref: '#/components/responses/LicenseError' }
        '413':
          description: Body too large or a merge limit was exceeded (`code` = `payload_too_large` or `merge_limit`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Merge errors (`code` = `merge_error`); `details` lists every problem.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: merge_error
                  message: '1 merge error'
                  details: [{ record: 0, placeholder: name, reason: missing value }]
                  requestId: 6c0b0f0e-0d6f-4bd8-9a3c-6c8f3f0a1d2e
  /v1/printers/{dpmm}dpmm/labels/{width}x{height}/{index}:
    post:
      tags: [render]
      operationId: labelaryRender
      summary: Labelary-compatible render
      description: |
        Raw ZPL body (or a form field `file`). Returns one PNG by default, or every label as JSON with
        `Accept: application/json`. The `index` segment and the trailing slash are optional. Errors are plain text unless
        the client asks for JSON, except license errors (402), which are always JSON.
      parameters:
        - { name: dpmm, in: path, required: true, schema: { type: integer, enum: [6, 8, 12, 24] } }
        - { name: width, in: path, required: true, description: Inches (0.1 to 15), schema: { type: number } }
        - { name: height, in: path, required: true, description: Inches (0.1 to 15), schema: { type: number } }
        - { name: index, in: path, required: true, description: 0-based label index (may be omitted from the URL), schema: { type: integer, minimum: 0 } }
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { type: string }
          text/plain:
            schema: { type: string }
      responses:
        '200':
          description: The label
          headers:
            X-Total-Count: { $ref: '#/components/headers/X-Total-Count' }
            X-Warnings: { $ref: '#/components/headers/X-Warnings' }
          content:
            image/png:
              schema: { type: string, contentMediaType: image/png }
            application/json:
              schema: { $ref: '#/components/schemas/RenderResult' }
        '400': { $ref: '#/components/responses/Error' }
        '402': { $ref: '#/components/responses/LicenseError' }
        '404': { $ref: '#/components/responses/Error' }
        '406': { $ref: '#/components/responses/Error' }
        '413': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }
  /healthz:
    get:
      tags: [health]
      operationId: health
      summary: Liveness
      responses:
        '200':
          description: The server is up (whatever the license state). For container and load-balancer health checks.
          content:
            application/json:
              schema:
                type: object
                required: [status, auth, license]
                properties:
                  status: { const: ok }
                  auth: { const: false, description: Kept for older clients; the API has no authentication. }
                  license: { $ref: '#/components/schemas/LicenseStatus' }
  /v1/info:
    get:
      tags: [health]
      operationId: info
      summary: Facts about this install
      description: |
        Version, license summary and where the browser SDK is served. The same answer for every caller; it holds no
        configuration values and never the license text. The developer portal's Status page reads it.
      responses:
        '200':
          description: Install information.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Info'
components:
  headers:
    X-Request-Id:
      description: Request id, echoed from the request when valid, otherwise generated. Quote it in support requests.
      schema: { type: string }
    X-Total-Count:
      description: Number of labels the input produced.
      schema: { type: integer }
    X-Warnings:
      description: Render warnings joined with " | " (printable ASCII, at most 2000 characters).
      schema: { type: string }
  responses:
    Error:
      description: Error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    LicenseError:
      description: |
        The product is not licensed on this installation: `license_required` (no license, or the key is not valid),
        `license_expired` (past the end of its expiry date; no grace period) or `product_not_licensed`. The message says
        what to do (install a valid key with LICENSE_KEY or LICENSE_FILE and restart). `/api/*` uses the legacy shape.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: license_expired
              message: 'The license of this labelviewer installation expired on 2027-12-31, so the Viewer (label rendering) is disabled. Install a renewed license key with LICENSE_KEY or LICENSE_FILE and restart labelviewer.'
              requestId: 6c0b0f0e-0d6f-4bd8-9a3c-6c8f3f0a1d2e
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, requestId]
          properties:
            code:
              type: string
              description: Stable machine-readable code (see docs/api.md, Errors).
              examples: [invalid_request, license_required, license_expired, product_not_licensed, merge_error, merge_limit, payload_too_large, not_found, method_not_allowed, not_acceptable, unprocessable, unavailable, internal]
            message: { type: string }
            details: { description: 'Extra structured data, e.g. merge errors: [{record, placeholder, reason}]' }
            requestId: { type: string }
    RenderRequest:
      type: object
      required: [zpl]
      properties:
        zpl: { type: string }
        dpmm: { type: integer, enum: [6, 8, 12, 24], default: 8 }
        widthIn: { type: number, minimum: 0.1, maximum: 15, default: 4 }
        heightIn: { type: number, minimum: 0.1, maximum: 15, default: 6 }
        index: { type: integer, minimum: 0, description: Only this label (0-based) }
        upright: { type: boolean, description: 'Ignore ^PO I so labels come back in reading orientation' }
    RenderResult:
      type: object
      required: [dpmm, total, labels, warnings]
      properties:
        dpmm: { type: integer }
        total: { type: integer }
        labels:
          type: array
          items:
            type: object
            required: [index, width, height, png]
            properties:
              index: { type: integer }
              width: { type: integer, description: dots }
              height: { type: integer, description: dots }
              png: { type: string, contentEncoding: base64 }
        warnings: { type: array, items: { type: string } }
    MergeRequest:
      type: object
      required: [template, data]
      properties:
        template: { type: string }
        data:
          oneOf:
            - { type: object }
            - { type: array, items: { type: object }, maxItems: 1000 }
        missing: { type: string, enum: [error, empty], default: error }
    LicenseStatus:
      type: string
      enum: [valid, expired, missing, invalid]
      description: valid = usable; expired = past the end of its expiry date (UTC, no grace period); missing = no license; invalid = the key was not accepted (reason in the server log).
    Info:
      type: object
      required: [name, version, license, sdk]
      properties:
        name: { const: labelviewer }
        version: { type: string, description: Server version }
        license:
          type: object
          required: [status, trial, allowed, products]
          properties:
            status: { $ref: '#/components/schemas/LicenseStatus' }
            trial: { type: boolean, description: 'Trial license: rendered PNGs carry a TRIAL watermark' }
            source: { type: string, enum: [configured, bundled], description: 'configured = LICENSE_KEY / LICENSE_FILE; bundled = the evaluation license in the download. Absent when missing.' }
            allowed:
              type: object
              description: Whether each product works on this installation now.
              required: [viewer, editor]
              properties:
                viewer: { type: boolean }
                editor: { type: boolean }
            products: { type: array, items: { type: string, enum: [viewer, editor] }, description: The products in the license }
            licensee: { type: string }
            id: { type: string, description: 'License id, e.g. LIC-0007' }
            expires: { type: string, description: 'YYYY-MM-DD, valid through that UTC day. Absent for a trial without expiry.' }
            daysLeft: { type: integer, description: 'valid only: days until it expires (the expiry day counts)' }
            maxInstances: { type: integer, description: Contractual number of installations }
            instanceId: { type: string, description: "This installation's id" }
        sdk:
          type: [object, 'null']
          description: The browser SDK (@labelviewer/elements) served by this install; null when the server has no SDK build.
          required: [version, url, latest]
          properties:
            version: { type: string }
            url: { type: string, description: 'Versioned base path, e.g. /sdk/1.0.1/ (cached for a year, immutable)' }
            latest: { type: string, description: '/sdk/latest/: always the installed version (revalidated on every use)' }
