{
  "openapi": "3.1.0",
  "info": {
    "title": "kimidoc API",
    "description": "Self-hosted HTML-to-PDF document rendering service. Store HTML templates with mustache-style placeholders, POST JSON data, receive rendered PDFs. Chromium-quality output including complex scripts/RTL, custom fonts, charts, and native CSS Paged Media (page numbers, running headers).",
    "version": "0.2.0",
    "license": {
      "name": "Proprietary",
      "identifier": "LicenseRef-Proprietary"
    }
  },
  "servers": [
    {
      "url": "/"
    }
  ],
  "components": {
    "securitySchemes": {
      "ApiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Api-Key"
      },
      "Bearer": {
        "type": "http",
        "scheme": "bearer"
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          }
        },
        "required": [
          "error"
        ]
      },
      "TemplateSummary": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "builtin": {
            "type": "boolean",
            "description": "Built-in templates are read-only (PUT/DELETE return 403)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Template": {
        "allOf": [
          {
            "$ref": "#/components/schemas/TemplateSummary"
          },
          {
            "type": "object",
            "properties": {
              "html": {
                "type": "string",
                "description": "Mustache-style template: {{a.b.c}} escaped, {{{raw}}} unescaped, numeric segments index arrays ({{items.0.name}}), {{#section}}...{{/section}} loops/conditionals, {{^inverted}}, {{!comment}}."
              },
              "sampleData": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "Example data payload stored with the template; documents what `data` the template expects."
              },
              "renderOptions": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "Default RenderOptions stored with the template."
              }
            }
          }
        ]
      },
      "RenderOptions": {
        "type": "object",
        "properties": {
          "pageSize": {
            "type": "string",
            "enum": [
              "A3",
              "A4",
              "A5",
              "A6",
              "Letter",
              "Legal"
            ],
            "default": "A4"
          },
          "pageSizeMm": {
            "type": "object",
            "description": "Custom page size in millimeters; overrides pageSize. E.g. 80mm receipts, 127x178mm cards.",
            "properties": {
              "width": {
                "type": "number"
              },
              "height": {
                "type": "number"
              }
            }
          },
          "landscape": {
            "type": "boolean",
            "default": false
          },
          "marginsMm": {
            "description": "Number (all sides) or per-side object. Default 10; 0 when pagedMedia. Page margins ALWAYS come from this option: a `margin:` declaration inside CSS `@page` is ignored by the print engine (margin boxes are placed inside these margins). Note: a template's own CSS `@page size` overrides pageSize/landscape in native mode; with pagedMedia, explicit options win.",
            "oneOf": [
              {
                "type": "number"
              },
              {
                "type": "object",
                "properties": {
                  "top": {
                    "type": "number"
                  },
                  "right": {
                    "type": "number"
                  },
                  "bottom": {
                    "type": "number"
                  },
                  "left": {
                    "type": "number"
                  }
                }
              }
            ]
          },
          "waitForReady": {
            "type": "boolean",
            "default": false,
            "description": "Print only after the page calls window.pdfReady() (defined on every page). Use for async content: charts, data fetches, webfonts (document.fonts.ready.then(() => pdfReady())). Times out with 504 if never called."
          },
          "pagedMedia": {
            "type": "boolean",
            "default": false,
            "description": "Advanced: inject the Paged.js polyfill. Usually unnecessary - the engine natively supports @page margin boxes, counter(page)/counter(pages) and @page size. Implies waitForReady."
          },
          "waitMs": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000,
            "default": 0,
            "description": "Fixed settle delay before printing. Legacy escape hatch; prefer waitForReady."
          },
          "timeoutMs": {
            "type": "integer",
            "minimum": 1000,
            "maximum": 90000,
            "default": 20000
          }
        }
      },
      "RenderRequest": {
        "type": "object",
        "properties": {
          "data": {
            "type": "object",
            "description": "Data applied to the mustache template."
          },
          "options": {
            "$ref": "#/components/schemas/RenderOptions"
          },
          "output": {
            "$ref": "#/components/schemas/PostProcessOutput"
          }
        }
      },
      "AdhocRenderRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/RenderRequest"
          },
          {
            "type": "object",
            "properties": {
              "html": {
                "type": "string"
              }
            },
            "required": [
              "html"
            ]
          }
        ]
      },
      "Stats": {
        "type": "object",
        "properties": {
          "version": {
            "type": "string"
          },
          "uptimeSeconds": {
            "type": "number"
          },
          "renders": {
            "type": "object",
            "properties": {
              "ok": {
                "type": "number"
              },
              "failed": {
                "type": "number"
              },
              "timedOut": {
                "type": "number"
              },
              "rejectedBusy": {
                "type": "number"
              },
              "averageMs": {
                "type": "number"
              }
            }
          },
          "pool": {
            "type": "object",
            "properties": {
              "size": {
                "type": "integer"
              },
              "busy": {
                "type": "integer"
              },
              "queued": {
                "type": "integer"
              }
            }
          },
          "config": {
            "type": "object",
            "properties": {
              "allowRemote": {
                "type": "boolean"
              },
              "allowedHosts": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "SubmitJobRequest": {
        "type": "object",
        "description": "Exactly one of templateId/html is required. templateId snapshots the template html at submit time; later edits do not affect the job.",
        "properties": {
          "templateId": {
            "type": "string",
            "format": "uuid"
          },
          "html": {
            "type": "string",
            "description": "Inline template HTML, as an alternative to templateId. Uses the same {{placeholder}} syntax as stored templates."
          },
          "documents": {
            "type": "array",
            "minItems": 1,
            "items": {
              "type": "object"
            },
            "description": "One data object per output document. A document's position in this array is its permanent index — you download it by that index later. The per-job maximum is server-configured (see /v1/stats)."
          },
          "options": {
            "type": "object",
            "description": "Job-wide render options; same schema as /v1/render options."
          },
          "output": {
            "type": "object",
            "properties": {
              "filenameTemplate": {
                "type": "string",
                "default": "doc-{{index}}.pdf",
                "description": "Filename pattern resolved with each document's data; {{index}} is also available. Name collisions get a numeric suffix."
              },
              "zipName": {
                "type": "string",
                "description": "Default job-<id>.zip."
              },
              "encrypt": {
                "$ref": "#/components/schemas/PostProcessOutput/properties/encrypt"
              },
              "assemble": {
                "$ref": "#/components/schemas/PostProcessOutput/properties/assemble"
              }
            }
          },
          "retries": {
            "type": "integer",
            "minimum": 0,
            "maximum": 3,
            "default": 0,
            "description": "How many times a failed document is automatically retried before it counts as failed."
          },
          "retentionHours": {
            "type": "integer",
            "description": "How long the job and its PDFs are kept, in hours. Clamped to the server's configured maximum; the default is server-configured too."
          },
          "idempotencyKey": {
            "type": "string",
            "maxLength": 128,
            "description": "Protects against double submission: resubmitting the same key returns the existing job (200) instead of creating a duplicate. Recommended for scheduled runs, e.g. \"payroll-2026-07\"."
          },
          "meta": {
            "type": "object",
            "description": "Your own metadata (order id, run tag, …); stored and echoed back untouched."
          },
          "webhook": {
            "type": "object",
            "description": "Get notified instead of polling: when the job finishes, kimidoc POSTs {event, job} to this URL. Delivery is retried with increasing delays (1m/5m/15m/1h/6h) until it succeeds, so you may receive the same notification more than once — deduplicate by job id. Destinations inside private networks must be allowlisted by the server operator.",
            "properties": {
              "url": {
                "type": "string",
                "format": "uri"
              },
              "secret": {
                "type": "string",
                "description": "Lets your receiver verify the sender: when set, deliveries carry X-Kimidoc-Signature: sha256=<HMAC-SHA256 hex of the body>. The secret is never echoed back."
              }
            },
            "required": [
              "url"
            ]
          }
        },
        "required": [
          "documents"
        ]
      },
      "JobProgress": {
        "type": "object",
        "properties": {
          "total": {
            "type": "integer"
          },
          "ok": {
            "type": "integer"
          },
          "failed": {
            "type": "integer"
          },
          "pending": {
            "type": "integer",
            "description": "Includes documents currently rendering."
          }
        }
      },
      "JobSummary": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "state": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "completed",
              "completedWithErrors",
              "failed",
              "canceled"
            ]
          },
          "progress": {
            "$ref": "#/components/schemas/JobProgress"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "startedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "finishedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time"
          },
          "meta": {
            "type": "object"
          }
        }
      },
      "Job": {
        "allOf": [
          {
            "$ref": "#/components/schemas/JobSummary"
          },
          {
            "type": "object",
            "properties": {
              "templateId": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "options": {
                "type": "object"
              },
              "output": {
                "type": "object"
              },
              "retries": {
                "type": "integer"
              },
              "error": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Set only when the whole job failed; individual document errors are on the documents themselves."
              },
              "failureSample": {
                "type": "array",
                "maxItems": 5,
                "items": {
                  "$ref": "#/components/schemas/JobDocument"
                },
                "description": "The first few failed documents, included so you can diagnose failures without another request."
              },
              "webhook": {
                "type": "object",
                "description": "Delivery status (secret redacted).",
                "properties": {
                  "url": {
                    "type": "string"
                  },
                  "state": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "pending",
                      "delivered",
                      "failed",
                      null
                    ]
                  },
                  "attempts": {
                    "type": "integer"
                  }
                }
              },
              "artifactBytes": {
                "type": "number",
                "description": "Bytes of storage currently held by this job (document PDFs + cached ZIP); freed on DELETE or retention expiry."
              }
            }
          }
        ]
      },
      "JobDocument": {
        "type": "object",
        "properties": {
          "index": {
            "type": "integer"
          },
          "state": {
            "type": "string",
            "enum": [
              "pending",
              "rendering",
              "ok",
              "failed",
              "canceled"
            ]
          },
          "filename": {
            "type": [
              "string",
              "null"
            ],
            "description": "Resolved output filename (ok documents)."
          },
          "error": {
            "type": [
              "string",
              "null"
            ]
          },
          "attempts": {
            "type": "integer"
          }
        }
      },
      "PostProcessOutput": {
        "type": "object",
        "description": "Optional post-processing applied to every rendered document. Available on synchronous renders AND batch jobs whenever the server supports post-processing (check /v1/stats config.postProcessing).",
        "properties": {
          "encrypt": {
            "type": "object",
            "description": "AES-256 encrypt each document. Requires server post-processing support.",
            "properties": {
              "userPassword": {
                "type": "string",
                "description": "Password pattern resolved with each document's data, e.g. \"{{employee.dob}}\" for per-recipient passwords, or a plain string for one shared password. If it resolves to an empty string for a document, that document fails — others are unaffected, and you can retry after fixing the data."
              },
              "ownerPassword": {
                "type": "string",
                "description": "Optional administrative password that bypasses the permission restrictions below; defaults to the document's user password."
              },
              "allowPrint": {
                "type": "boolean",
                "default": true,
                "description": "Set false to prevent recipients from printing the document."
              },
              "allowCopy": {
                "type": "boolean",
                "default": true,
                "description": "Set false to prevent copying text and images out of the document."
              },
              "allowModify": {
                "type": "boolean",
                "default": true,
                "description": "Set false to prevent editing the document."
              }
            },
            "required": [
              "userPassword"
            ]
          },
          "assemble": {
            "type": "object",
            "description": "Merge uploaded PDF assets around each rendered document. Requires server post-processing support; assets must exist at submit (422 otherwise).",
            "properties": {
              "prepend": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Names of uploaded assets to place before the document (e.g. a cover page)."
              },
              "append": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Names of uploaded assets to place after the document (e.g. terms and conditions)."
              }
            }
          },
          "stamp": {
            "type": "object",
            "required": [
              "text"
            ],
            "description": "Diagonal translucent watermark overlaid on every page (DRAFT/COPY/VOID/...). Requires server post-processing support.",
            "properties": {
              "text": {
                "type": "string",
                "description": "The watermark text. May use {{placeholders}} resolved per document, e.g. \"COPY — {{employee.id}}\". If it resolves to an empty string, that document fails."
              }
            }
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "API key required or invalid.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Not found.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Pdf": {
        "description": "The rendered PDF. If the server's fetch policy blocked any remote images, fonts, or scripts the page requested, the response carries X-Kimidoc-Blocked-Remote (how many) and X-Kimidoc-Blocked-Urls (the first few) headers — and those assets are missing from the PDF.",
        "headers": {
          "X-Kimidoc-Blocked-Remote": {
            "schema": {
              "type": "integer"
            },
            "description": "How many remote requests the fetch policy blocked."
          },
          "X-Kimidoc-Blocked-Urls": {
            "schema": {
              "type": "string"
            }
          }
        },
        "content": {
          "application/pdf": {
            "schema": {
              "type": "string",
              "format": "binary"
            }
          }
        }
      },
      "RenderErrors": {
        "description": "Render failure. 500: the page failed to load or PDF generation failed. 503: the server is busy — wait a moment and retry. 504: the render timed out (waitForReady was set but the page never called pdfReady(), or timeoutMs was exceeded).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  },
  "security": [
    {
      "ApiKey": []
    },
    {
      "Bearer": []
    }
  ],
  "paths": {
    "/health": {
      "get": {
        "summary": "Health probe",
        "description": "Never requires authentication.",
        "security": [],
        "responses": {
          "200": {
            "description": "Service healthy.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string"
                    },
                    "version": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/stats": {
      "get": {
        "summary": "Runtime statistics and active configuration",
        "responses": {
          "200": {
            "description": "Stats.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Stats"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/v1/templates": {
      "get": {
        "summary": "List templates (metadata only)",
        "responses": {
          "200": {
            "description": "Templates.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TemplateSummary"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "summary": "Create a template",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "html"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "html": {
                    "type": "string"
                  },
                  "sampleData": {
                    "type": "object"
                  },
                  "renderOptions": {
                    "$ref": "#/components/schemas/RenderOptions"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Template"
                }
              }
            }
          },
          "400": {
            "description": "Missing name/html or invalid JSON.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/v1/templates/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "summary": "Get a template (incl. html, sampleData, renderOptions)",
        "responses": {
          "200": {
            "description": "Template.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Template"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "summary": "Update a template (partial)",
        "description": "Any of name/html/sampleData/renderOptions. Absent fields keep stored values; null clears. Built-in templates return 403.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "html": {
                    "type": "string"
                  },
                  "sampleData": {
                    "type": [
                      "object",
                      "null"
                    ]
                  },
                  "renderOptions": {
                    "oneOf": [
                      {
                        "$ref": "#/components/schemas/RenderOptions"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Template"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Built-in template is read-only.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "summary": "Delete a template",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Built-in template cannot be deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/templates/{id}/render": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Render a stored template to PDF",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenderRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Pdf"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "description": "Request body exceeds the server's size limit.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/RenderErrors"
          },
          "503": {
            "$ref": "#/components/responses/RenderErrors"
          },
          "504": {
            "$ref": "#/components/responses/RenderErrors"
          }
        }
      }
    },
    "/v1/render": {
      "post": {
        "summary": "Render ad-hoc HTML to PDF (template design/preview loop)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AdhocRenderRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Pdf"
          },
          "400": {
            "description": "Missing html or invalid JSON.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "413": {
            "description": "Request body exceeds the server's size limit.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/RenderErrors"
          },
          "503": {
            "$ref": "#/components/responses/RenderErrors"
          },
          "504": {
            "$ref": "#/components/responses/RenderErrors"
          }
        }
      }
    },
    "/v1/render-url": {
      "post": {
        "summary": "Render a webpage (URL) to PDF",
        "description": "Loads the URL in the rendering engine and prints it to PDF. Remote fetching must be enabled on the server (403 otherwise). Addresses inside private or internal networks are always refused, and the server's host allowlist applies; a refused navigation fails with a message naming the URL.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "description": "http(s) URL to render."
                  },
                  "options": {
                    "$ref": "#/components/schemas/RenderOptions"
                  },
                  "output": {
                    "$ref": "#/components/schemas/PostProcessOutput"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Pdf"
          },
          "400": {
            "description": "Invalid or non-http(s) URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Remote fetches are disabled on this server.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/RenderErrors"
          },
          "503": {
            "$ref": "#/components/responses/RenderErrors"
          },
          "504": {
            "$ref": "#/components/responses/RenderErrors"
          }
        }
      }
    },
    "/v1/assets": {
      "get": {
        "summary": "List uploaded assets",
        "responses": {
          "200": {
            "description": "Asset list with quota usage"
          }
        }
      },
      "post": {
        "summary": "Upload (or replace) an asset",
        "description": "Request body is the raw file content; Content-Type is stored and served back. Assets are referenced from templates as asset://<name>, served to renders from local storage (works with remote fetches disabled). Names: 1-128 chars of letters/digits/._- not starting with a dot, case-insensitive (stored lowercase).",
        "parameters": [
          {
            "name": "name",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "*/*": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          },
          "200": {
            "description": "Replaced existing asset"
          },
          "400": {
            "description": "Invalid name or empty body"
          },
          "507": {
            "description": "Storage quota exceeded"
          }
        }
      }
    },
    "/v1/assets/{name}": {
      "parameters": [
        {
          "name": "name",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "summary": "Download an asset",
        "responses": {
          "200": {
            "description": "Asset bytes with stored Content-Type"
          },
          "404": {
            "description": "Not found"
          }
        }
      },
      "delete": {
        "summary": "Delete an asset",
        "responses": {
          "204": {
            "description": "Deleted"
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    },
    "/v1/jobs": {
      "post": {
        "summary": "Submit a batch job",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubmitJobRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Job accepted and queued.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "200": {
            "description": "Idempotency key matched an existing job; that job is returned.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "404": {
            "description": "templateId unknown."
          },
          "413": {
            "description": "Request body exceeds the server's size limit — submit large batches through the /v1/jobs/stream upload session instead."
          },
          "422": {
            "description": "The submission is invalid: no documents, both or neither of templateId and html, or more documents than the server allows per job (see /v1/stats)."
          },
          "503": {
            "description": "The job queue is full — wait a moment and retry."
          }
        },
        "description": "Send JSON (a documents array), or NDJSON with Content-Type application/x-ndjson where the first line holds the job configuration (everything except documents) and each following line is one document's data. Both forms are limited by the server's request-size limit; for larger batches use the /v1/jobs/stream upload session."
      },
      "get": {
        "summary": "List jobs, newest first",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 25,
              "maximum": 100
            }
          },
          {
            "name": "before",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Return jobs created before this instant (cursor paging)."
          }
        ],
        "responses": {
          "200": {
            "description": "Job summaries.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "jobs": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/JobSummary"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/jobs/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "summary": "Job status and progress",
        "responses": {
          "200": {
            "description": "Job with progress counts and failure sample.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "404": {
            "description": "Unknown or expired job."
          }
        }
      },
      "delete": {
        "summary": "Delete the job and its artifacts immediately",
        "responses": {
          "204": {
            "description": "Deleted. Running jobs are canceled first."
          },
          "404": {
            "description": "Unknown job."
          }
        }
      }
    },
    "/v1/jobs/{id}/documents": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "summary": "Per-document states",
        "parameters": [
          {
            "name": "state",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "rendering",
                "ok",
                "failed",
                "canceled"
              ]
            },
            "description": "Filter by state — most commonly state=failed to collect every error after a run."
          },
          {
            "name": "offset",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 0
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 1000,
              "maximum": 10000
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Document states.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "documents": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/JobDocument"
                      }
                    },
                    "total": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Unknown job."
          }
        }
      }
    },
    "/v1/jobs/{id}/documents/{index}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "index",
          "in": "path",
          "required": true,
          "schema": {
            "type": "integer"
          }
        }
      ],
      "get": {
        "summary": "Download one finished document",
        "responses": {
          "200": {
            "description": "The PDF.",
            "content": {
              "application/pdf": {}
            }
          },
          "404": {
            "description": "Unknown job/index."
          },
          "409": {
            "description": "The document is not finished (still pending, failed, or canceled)."
          }
        }
      }
    },
    "/v1/jobs/{id}/download": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "summary": "ZIP of all ok documents plus manifest.json",
        "description": "The ZIP includes manifest.json listing every document (index, state, filename, error), so downstream systems can account for partial failures without extra API calls. You can download while the job is still running — the archive contains what has finished so far; check the job state for completeness.",
        "responses": {
          "200": {
            "description": "ZIP archive.",
            "content": {
              "application/zip": {}
            }
          },
          "404": {
            "description": "Unknown job."
          }
        }
      }
    },
    "/v1/jobs/{id}/retry": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Re-queue failed documents",
        "responses": {
          "202": {
            "description": "Failed documents re-queued; job back to running.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "409": {
            "description": "Job still running or has no failed documents."
          },
          "404": {
            "description": "Unknown job."
          }
        }
      }
    },
    "/v1/jobs/{id}/cancel": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Cancel pending work",
        "description": "Pending documents become canceled; completed artifacts remain downloadable until expiry.",
        "responses": {
          "202": {
            "description": "Cancellation applied.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "409": {
            "description": "Job already terminal."
          },
          "404": {
            "description": "Unknown job."
          }
        }
      }
    },
    "/v1/jobs/stream": {
      "post": {
        "summary": "Create an NDJSON upload session for large batches",
        "description": "For batches too large for a single request. Append the NDJSON in as many chunks as you like with POST /v1/jobs/stream/{uploadId} — chunks can split anywhere, even mid-line — then POST .../finish to create the job. Sessions expire after an hour; a server restart aborts them.",
        "responses": {
          "201": {
            "description": "{uploadId, maxChunkBytes, maxTotalBytes, expiresAt}"
          }
        }
      }
    },
    "/v1/jobs/stream/{uploadId}": {
      "parameters": [
        {
          "name": "uploadId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Append a chunk of NDJSON bytes",
        "requestBody": {
          "required": true,
          "content": {
            "application/octet-stream": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{uploadId, receivedBytes}"
          },
          "404": {
            "description": "Unknown/expired session"
          },
          "413": {
            "description": "A single chunk exceeds the server's request-size limit, or the session total exceeds maxTotalBytes"
          }
        }
      },
      "delete": {
        "summary": "Abort the session",
        "responses": {
          "204": {
            "description": "Aborted"
          }
        }
      }
    },
    "/v1/jobs/stream/{uploadId}/finish": {
      "parameters": [
        {
          "name": "uploadId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Parse the uploaded NDJSON and create the job",
        "description": "The uploaded NDJSON must have the job configuration on line 1 and one document per line after it. A malformed line returns 422 with the offending line number. Responses are the same as POST /v1/jobs.",
        "responses": {
          "202": {
            "description": "Job accepted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          }
        }
      }
    }
  }
}
