openapi: 3.1.0
info:
  title: bulkqr public API
  version: "1.0"
  description: >
    Bulk QR code generation. Authenticate every request with an organisation
    API key: `Authorization: Bearer bqk_…`. Keys are managed on
    https://bulkqr.com/account. Full docs: https://bulkqr.com/docs/api
servers:
  - url: https://bulkqr.com
security:
  - apiKey: []
paths:
  /api/v1/jobs:
    post:
      summary: Create a bulk generation job
      description: >
        JSON body for up to 20,000 items, or NDJSON streaming
        (content-type: application/x-ndjson) for up to 1,000,000 items.
        Rate limit: 10 requests/min per key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 20000
                  items: { $ref: "#/components/schemas/Item" }
                format: { type: string, enum: [png, svg, both], default: png }
                style: { $ref: "#/components/schemas/Style" }
                sendEmail:
                  type: boolean
                  default: false
                  description: Email the download link to the organisation owner
          application/x-ndjson:
            schema:
              type: string
              description: >
                Line 1 is the job meta object ({"format", "style", "sendEmail"});
                every following line is one item object ({"data", "filename",
                "caption"}). Maximum 1,000,000 items / 100 MB.
      responses:
        "202":
          description: Job accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobId: { type: string }
                  total: { type: integer }
                  statusUrl: { type: string, description: Signed, session-free status URL }
                  downloadUrl: { type: string, description: Signed, session-free download URL }
        "400": { $ref: "#/components/responses/InvalidInput" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "402":
          description: Insufficient credits
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "413":
          description: Body too large
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /api/v1/jobs/{jobId}:
    get:
      summary: Job status
      description: "Rate limit: 60 requests/min per key."
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Current progress
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string, enum: [processing, zipping, complete, failed] }
                  total: { type: integer }
                  completed: { type: integer }
                  failed: { type: integer }
                  downloadUrl: { type: string, description: Present once complete }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /api/v1/jobs/{jobId}/download:
    get:
      summary: Download the result ZIP
      description: "Streams the archive; supports Range. Rate limit: 60 requests/min per key."
      parameters:
        - { name: jobId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: ZIP archive
          content:
            application/zip:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Job not complete yet
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: bqk_…
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string, description: snake_case error code }
    Style:
      type: object
      description: Optional QR styling, applied to every item in the job.
      properties:
        ecLevel: { type: string, enum: [L, M, Q, H], default: M }
        scale: { type: integer, minimum: 1, maximum: 40 }
        margin: { type: integer, minimum: 0, maximum: 16 }
        foreground:
          type: string
          description: Hex colour (e.g. "#000000") or "transparent". Foreground and background cannot both be transparent.
        background:
          type: string
          description: Hex colour (e.g. "#ffffff") or "transparent".
        logo: { $ref: "#/components/schemas/Logo" }
        caption: { $ref: "#/components/schemas/Caption" }
    Logo:
      type: object
      description: Centre logo. Raise ecLevel to Q or H so the code stays scannable.
      required: [dataUrl]
      properties:
        dataUrl:
          type: string
          description: PNG data URL (data:image/png;base64,…), at most 512 KB. Remote URLs are rejected.
        sizeRatio:
          type: number
          minimum: 0.05
          maximum: 0.3
          default: 0.2
          description: Logo edge as a fraction of the QR edge.
    Caption:
      type: object
      description: Text rendered beneath the code.
      required: [text]
      properties:
        text: { type: string, maxLength: 120 }
        colour:
          type: string
          description: Hex colour; defaults to the foreground colour.
        fontSize: { type: integer, minimum: 6, maximum: 64, default: 16 }
    Item:
      type: object
      required: [data]
      properties:
        data: { type: string, maxLength: 2953 }
        filename:
          type: string
          maxLength: 120
          description: Defaults to a name derived from the data. JSON jobs suffix duplicates; NDJSON jobs append the item index to every name. ZIP entries are sanitised and capped at 100 characters.
        caption:
          type: string
          maxLength: 120
          description: Overrides the job-level caption text for this item.
  responses:
    InvalidInput:
      description: Validation failed
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InvalidKey:
      description: Missing, malformed, or revoked API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such job in this organisation
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Per-key rate limit exceeded (Retry-After header set)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
