openapi: 3.0.3
info:
  title: PodPDF API
  version: 1.0.0
  description: |
    Generate PDFs from HTML, Markdown, URLs, images and saved templates, and merge, split or
    edit existing documents.

    This spec covers the endpoints an **API key** can call. The dashboard-only routes
    (account, billing, key and webhook management, template CRUD) need a Cognito ID token and
    are left out deliberately: an API key is rejected on them at the gateway.

    Each successful PDF costs one credit ($0.01), taken from the monthly allowance first and
    then from credit packs. An account with no paid plan gets `402 UPGRADE_REQUIRED`.
  contact:
    name: PodPDF support
    email: podpdfapp@gmail.com
    url: https://podpdf.com
  license:
    name: Proprietary
    url: https://podpdf.com/terms/
servers:
  - url: https://api.podpdf.com
    description: Production
security:
  - ApiKeyAuth: []
tags:
  - name: Convert
    description: Turn content into a PDF.
  - name: Jobs
    description: Check jobs and fetch their output.
  - name: Templates
    description: Render a saved template.
  - name: PDF tools
    description: Merge, split and inspect existing PDFs.
  - name: PDF editor
    description: Stamp, number, redact and rewrite an uploaded document.
  - name: Account
    description: Who the key belongs to, and the public plan list.

paths:
  /me:
    get:
      operationId: getMe
      tags: [Account]
      summary: Get the authenticated account
      description: Returns the account behind the key. Useful as a connection test.
      responses:
        '200':
          description: The account
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, example: 01KHD59K7TY1EN0JP9BMY7XGHG }
                  email: { type: string, format: email, example: someone@example.com }
                  name: { type: string, nullable: true, example: Jane Doe }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /accounts/me/subscription/plans:
    get:
      operationId: listPublicPlans
      tags: [Account]
      summary: List the active plans
      description: 'Public: no credentials needed.'
      security: []
      responses:
        '200':
          description: The active plans
          content:
            application/json:
              schema:
                type: object
                properties:
                  plans:
                    type: array
                    items:
                      type: object
                      properties:
                        price_id: { type: string }
                        name: { type: string }
                        price: { type: number }
                        monthly_pdfs: { type: integer }

  /quickjob:
    post:
      operationId: createQuickJob
      tags: [Convert]
      summary: Convert to PDF, synchronously
      description: |
        Up to 25 pages, returned within 30 seconds. Send `store: true` for JSON with a signed
        download link (valid 1 hour); omit it to get the PDF bytes in the response body.

        Larger or slower documents time out with `408 QUICKJOB_TIMEOUT` — use `/longjob`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/QuickJobRequest'
            examples:
              html:
                summary: HTML
                value:
                  input_type: html
                  html: '<h1>Invoice</h1><p>Thank you.</p>'
                  options: { format: A4, printBackground: true }
                  store: true
              markdown:
                summary: Markdown
                value:
                  input_type: markdown
                  markdown: "# Report\n\n**Customer:** Acme Ltd"
                  store: true
              url:
                summary: Public HTTPS URL
                value:
                  input_type: url
                  url: https://example.com/
                  store: true
          multipart/form-data:
            schema:
              type: object
              required: [input_type, images]
              properties:
                input_type:
                  type: string
                  enum: [image]
                images:
                  type: array
                  description: PNG or JPEG files; each becomes one page. Up to 25, 5 MB each.
                  items: { type: string, format: binary }
                options:
                  type: string
                  description: JSON string of options, e.g. `{"format":"A4","fit":"contain"}`.
                store:
                  type: string
                  enum: ['true', 'false']
      responses:
        '200':
          description: The PDF, or JSON when `store` is true
          headers:
            X-Job-Id: { $ref: '#/components/headers/XJobId' }
            X-PDF-Pages: { $ref: '#/components/headers/XPdfPages' }
          content:
            application/pdf:
              schema: { type: string, format: binary }
            application/json:
              schema: { $ref: '#/components/schemas/StoredPdf' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/UpgradeRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '408':
          description: Rendering took longer than 30 seconds; use `/longjob`
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }

  /longjob:
    post:
      operationId: createLongJob
      tags: [Convert]
      summary: Queue a large document
      description: |
        Asynchronous rendering for HTML or Markdown up to 100 pages. The document is rendered
        once at submission to count its pages, so this call can take nearly 30 seconds and
        returns `400 PAGE_LIMIT_EXCEEDED` immediately when the document is too long.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [input_type]
              properties:
                input_type: { type: string, enum: [html, markdown] }
                html: { type: string }
                markdown: { type: string }
                options: { $ref: '#/components/schemas/PdfOptions' }
                webhook_url:
                  type: string
                  format: uri
                  description: Validated but ignored; register webhooks through the dashboard.
            example:
              input_type: markdown
              markdown: "# Large report\n\nOne section per page…"
              options: { format: A4 }
      responses:
        '202':
          description: Queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { $ref: '#/components/schemas/JobId' }
                  status: { type: string, example: queued }
                  message: { type: string }
                  estimated_completion:
                    type: string
                    format: date-time
                    description: Always three minutes after submission; a placeholder, not an estimate.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/UpgradeRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }

  /bulkjob:
    post:
      operationId: createBulkJob
      tags: [Convert]
      summary: Convert a bundle of files
      description: |
        Converts every file in a ZIP or file list that matches `bundle_type`; the rest are
        treated as assets for the pages. Send exactly one of `s3_key`, `zip_base64` or
        `files`, or use multipart. Requires `bulk_enabled` on the plan.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                bundle_type: { type: string, enum: [html, markdown], default: html }
                s3_key:
                  type: string
                  description: Key from `/bulkjob/upload-url`.
                zip_base64:
                  type: string
                  description: Base64 ZIP, up to 4 MB decoded.
                files:
                  type: array
                  items:
                    type: object
                    properties:
                      path: { type: string, example: invoice-1.html }
                      content_base64: { type: string }
                options: { $ref: '#/components/schemas/PdfOptions' }
            example:
              bundle_type: html
              files:
                - { path: invoice-1.html, content_base64: PGgxPkludm9pY2UgMTwvaDE+ }
          multipart/form-data:
            schema:
              type: object
              properties:
                bundle_type: { type: string, enum: [html, markdown] }
                files:
                  type: array
                  items: { type: string, format: binary }
                paths:
                  type: string
                  description: JSON array of relative paths, in the same order as the files.
                options: { type: string }
      responses:
        '202':
          description: Queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { $ref: '#/components/schemas/JobId' }
                  status: { type: string, example: queued }
                  job_type: { type: string, example: bulk }
                  bundle_type: { type: string, example: html }
                  message: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/UpgradeRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /bulkjob/upload-url:
    post:
      operationId: createBulkUploadUrl
      tags: [Convert]
      summary: Get a signed URL for a large ZIP
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content_length_bytes]
              properties:
                content_length_bytes: { type: integer, example: 15728640 }
      responses:
        '200':
          description: Where to PUT the ZIP
          content:
            application/json:
              schema:
                type: object
                properties:
                  upload_url: { type: string, format: uri }
                  s3_key: { type: string }
                  expires_at: { type: string, format: date-time }
                  max_bytes: { type: integer }
                  required_headers:
                    type: object
                    additionalProperties: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /jobs/{job_id}:
    parameters:
      - $ref: '#/components/parameters/JobIdPath'
    get:
      operationId: getJob
      tags: [Jobs]
      summary: Get a job
      responses:
        '200':
          description: The job
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Job' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /jobs/{job_id}/download:
    parameters:
      - $ref: '#/components/parameters/JobIdPath'
    get:
      operationId: getJobDownloadLink
      tags: [Jobs]
      summary: Get a fresh download link
      description: |
        Works for any job with stored output: long, bulk, merge and split jobs, and quick or
        template jobs sent with `store: true`. Links last one hour; files are kept 30 days.
      responses:
        '200':
          description: A signed link
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { $ref: '#/components/schemas/JobId' }
                  download_url: { type: string, format: uri }
                  expires_at: { type: string, format: date-time }
                  expires_in_seconds: { type: integer, example: 3600 }
                  content_type: { type: string, example: application/pdf }
                  size_bytes: { type: integer, example: 1843200 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The job has not finished yet
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '410': { $ref: '#/components/responses/Gone' }

  /jobs/{job_id}/files:
    parameters:
      - $ref: '#/components/parameters/JobIdPath'
    get:
      operationId: listJobFiles
      tags: [Jobs]
      summary: List the files a bulk job produced
      responses:
        '200':
          description: One entry per file in the bundle
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { $ref: '#/components/schemas/JobId' }
                  files:
                    type: array
                    items:
                      type: object
                      properties:
                        index: { type: integer }
                        path: { type: string }
                        status: { type: string, enum: [success, failed, skipped] }
                        pages: { type: integer, nullable: true }
                        error_message: { type: string, nullable: true }
                        blocked_requests: { type: integer }
                        missing_assets: { type: integer }
                        render_timeout: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /jobs/{job_id}/files/{file_index}/download:
    parameters:
      - $ref: '#/components/parameters/JobIdPath'
      - name: file_index
        in: path
        required: true
        schema: { type: integer }
        description: Index from `GET /jobs/{job_id}/files`.
    get:
      operationId: getJobFileDownloadLink
      tags: [Jobs]
      summary: Get a download link for one file of a bulk job
      responses:
        '200':
          description: A signed link
          content:
            application/json:
              schema:
                type: object
                properties:
                  download_url: { type: string, format: uri }
                  expires_at: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }

  /templates/{template_id}/render:
    parameters:
      - name: template_id
        in: path
        required: true
        schema: { type: string }
        description: From the dashboard, under Templates → Use via API.
    post:
      operationId: renderTemplate
      tags: [Templates]
      summary: Render a saved template
      description: |
        Send the field values only; layout, totals, currency formatting and page numbering come
        from the template. Nested fields are nested objects, and list fields are arrays of
        objects. Up to 1 MB of data.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data:
                  type: object
                  additionalProperties: true
                filename: { type: string }
                store: { type: boolean, default: false }
            example:
              data:
                invoice_number: INV-1042
                customer: { name: Acme Ltd, address: "1 Main St" }
                items:
                  - { description: Design work, quantity: 10, unit_price: 85 }
                tax_rate: 20
              store: true
      responses:
        '200':
          description: The PDF, or JSON when `store` is true
          headers:
            X-Job-Id: { $ref: '#/components/headers/XJobId' }
            X-PDF-Pages: { $ref: '#/components/headers/XPdfPages' }
          content:
            application/pdf:
              schema: { type: string, format: binary }
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/StoredPdf'
                  - type: object
                    properties:
                      template_id: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/UpgradeRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /pdf/merge:
    post:
      operationId: mergePdfs
      tags: [PDF tools]
      summary: Merge PDFs
      description: One `files` part per PDF, in merge order. Up to 4 MB in total; larger sets go through `/pdf/upload-url` and `/pdf/jobs`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [files]
              properties:
                files:
                  type: array
                  items: { type: string, format: binary }
                page_ranges:
                  type: string
                  description: JSON array with one entry per file — a page range, or null for every page.
                options:
                  type: string
                  description: JSON object; `output_filename`, `title`.
                store: { type: string, enum: ['true', 'false'] }
      responses:
        '200':
          description: The merged PDF, or JSON when `store` is true
          content:
            application/pdf:
              schema: { type: string, format: binary }
            application/json:
              schema: { $ref: '#/components/schemas/StoredPdf' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }

  /pdf/split:
    post:
      operationId: splitPdf
      tags: [PDF tools]
      summary: Split a PDF
      description: Modes are `extract`, `ranges`, `every_n` and `each_page`. A multi-file result comes back as a ZIP.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, split]
              properties:
                file: { type: string, format: binary }
                split:
                  type: string
                  description: 'JSON object, e.g. `{"mode":"extract","pages":"1-3"}`.'
                options:
                  type: string
                  description: JSON object; `filename_pattern`.
                store: { type: string, enum: ['true', 'false'] }
      responses:
        '200':
          description: The PDF or ZIP, or JSON when `store` is true
          content:
            application/pdf:
              schema: { type: string, format: binary }
            application/zip:
              schema: { type: string, format: binary }
            application/json:
              schema: { $ref: '#/components/schemas/StoredPdf' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }

  /pdf/inspect:
    post:
      operationId: inspectPdf
      tags: [PDF tools]
      summary: Inspect a PDF
      description: Page count, page sizes, and the page ids an editor recipe refers to. Free — no credit is spent.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                s3_key: { type: string }
                filename: { type: string }
                for:
                  type: string
                  enum: [edit]
                  description: Include the page ids used by the editor.
            example: { s3_key: pdf-uploads/01HZ…/01J8…pdf, for: edit }
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
      responses:
        '200':
          description: What the file contains
          content:
            application/json:
              schema:
                type: object
                properties:
                  filename: { type: string }
                  size_bytes: { type: integer }
                  page_count: { type: integer }
                  encrypted: { type: boolean }
                  has_forms: { type: boolean }
                  has_signatures: { type: boolean }
                  pages:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, example: p0 }
                        width: { type: number }
                        height: { type: number }
                        rotation: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /pdf/upload-url:
    post:
      operationId: createPdfUploadUrl
      tags: [PDF tools]
      summary: Get a signed URL for a large PDF
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content_length_bytes]
              properties:
                content_length_bytes: { type: integer, example: 482913 }
      responses:
        '200':
          description: Where to PUT the file
          content:
            application/json:
              schema:
                type: object
                properties:
                  upload_url: { type: string, format: uri }
                  s3_key: { type: string }
                  expires_at: { type: string, format: date-time }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /pdf/jobs:
    post:
      operationId: createPdfToolJob
      tags: [PDF tools]
      summary: Merge or split uploaded PDFs as a job
      description: For sets over 4 MB, uploaded with `/pdf/upload-url` first.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operation, inputs]
              properties:
                operation: { type: string, enum: [merge, split] }
                inputs:
                  type: array
                  description: 2 to 100 entries for merge, exactly 1 for split.
                  items:
                    type: object
                    properties:
                      s3_key: { type: string }
                      filename: { type: string }
                      pages: { type: string, description: Page range; merge only. }
                split:
                  type: object
                  additionalProperties: true
                options:
                  type: object
                  additionalProperties: true
            example:
              operation: split
              inputs: [{ s3_key: pdf-uploads/01HZ…/01J8…pdf, filename: payslips.pdf }]
              split: { mode: each_page }
      responses:
        '202':
          description: Queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { $ref: '#/components/schemas/JobId' }
                  status: { type: string, example: queued }
                  operation: { type: string, example: split }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /pdf/edit/preflight:
    post:
      operationId: preflightPdfEdit
      tags: [PDF editor]
      summary: See what a recipe would do
      description: Reports the effect of the recipe without applying it. Free.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EditRequest' }
      responses:
        '200':
          description: What would happen
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /pdf/edit:
    post:
      operationId: editPdf
      tags: [PDF editor]
      summary: Apply a recipe to a document
      description: |
        Applies the operations in order — stamps, page numbers, watermarks, rotation, metadata
        and active content — and returns a link to the new document. Saving rewrites the whole
        file, so an existing digital signature stops validating.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EditRequest' }
      responses:
        '200':
          description: The edited document
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: completed }
                  download_url: { type: string, format: uri }
                  output_pages: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/UpgradeRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /pdf/extract:
    post:
      operationId: extractPdfText
      tags: [PDF editor]
      summary: Read the text inside a box
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source_key]
              properties:
                source_key: { type: string }
                page: { type: string, example: p0 }
                box:
                  type: object
                  properties:
                    x: { type: number }
                    y: { type: number }
                    width: { type: number }
                    height: { type: number }
      responses:
        '200':
          description: The text found
          content:
            application/json:
              schema:
                type: object
                properties:
                  text: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Create one at https://app.podpdf.com under Settings → API Keys.

  headers:
    XJobId:
      description: The job this PDF came from.
      schema: { type: string }
    XPdfPages:
      description: How many pages were generated.
      schema: { type: integer }

  parameters:
    JobIdPath:
      name: job_id
      in: path
      required: true
      schema: { type: string }

  schemas:
    JobId:
      type: string
      example: 9f0a4b78-2c0c-4d14-9b8b-123456789abc

    PdfOptions:
      type: object
      description: "Rendering options. `width`/`height` need `format: null`, which otherwise wins."
      properties:
        format:
          type: string
          nullable: true
          enum: [A3, A4, A5, Legal, Letter, Tabloid, null]
          default: A4
        margin:
          type: object
          properties:
            top: { type: string, example: 20mm }
            right: { type: string, example: 20mm }
            bottom: { type: string, example: 20mm }
            left: { type: string, example: 20mm }
        printBackground: { type: boolean, default: true }
        scale: { type: number, default: 1, minimum: 0.1, maximum: 2 }
        landscape: { type: boolean, default: false }
        preferCSSPageSize: { type: boolean, default: false }
        width: { type: string, example: 100mm }
        height: { type: string, example: 150mm }
        pageRanges: { type: string, example: '1-3, 5' }
        displayHeaderFooter: { type: boolean, default: false }
        headerTemplate: { type: string }
        footerTemplate: { type: string }

    QuickJobRequest:
      type: object
      required: [input_type]
      properties:
        input_type: { type: string, enum: [html, markdown, url] }
        html: { type: string, description: Required when `input_type` is `html`. }
        markdown: { type: string, description: Required when `input_type` is `markdown`. }
        url:
          type: string
          format: uri
          description: |
            Required when `input_type` is `url`. HTTPS only, public hosts only, 10 second
            timeout, 5 MB limit. Relative assets on the page are not resolved.
        options: { $ref: '#/components/schemas/PdfOptions' }
        store:
          type: boolean
          default: false
          description: Return JSON with a signed link instead of the PDF bytes.

    StoredPdf:
      type: object
      properties:
        job_id: { $ref: '#/components/schemas/JobId' }
        pages: { type: integer, example: 3 }
        truncated: { type: boolean, example: false }
        download_url:
          type: string
          format: uri
          nullable: true
          description: Signed link, valid one hour. Null when the upload failed.
        download_url_expires_at: { type: string, format: date-time, nullable: true }

    EditRequest:
      type: object
      required: [source_key, recipe]
      properties:
        source_key:
          type: string
          description: Key from `/pdf/upload-url`.
        recipe:
          type: array
          description: Operations, applied in order.
          items:
            type: object
            required: [op]
            properties:
              op:
                type: string
                example: text.add
                description: e.g. `text.add`, `bates.stamp`, `watermark.text`, `metadata.strip`.
              pages:
                description: Page ids from `/pdf/inspect`, or a page-number range.
                oneOf:
                  - type: array
                    items: { type: string }
                  - type: string
            additionalProperties: true
        confirm_signature_break:
          type: boolean
          description: Required when the document carries a digital signature.

    Job:
      type: object
      properties:
        job_id: { $ref: '#/components/schemas/JobId' }
        status:
          type: string
          enum: [queued, processing, completed, partial_failed, failed, timeout]
        job_type: { type: string, enum: [quick, long, bulk] }
        mode: { type: string, example: html }
        pages: { type: integer, nullable: true }
        truncated: { type: boolean }
        timeout_occurred: { type: boolean }
        bundle_type: { type: string, nullable: true }
        file_count: { type: integer, nullable: true }
        processed_count: { type: integer, nullable: true }
        success_count: { type: integer, nullable: true }
        failure_count: { type: integer, nullable: true }
        pages_total: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time, nullable: true }
        s3_url:
          type: string
          format: uri
          nullable: true
          description: Present only for jobs with stored output.
        s3_url_expires_at: { type: string, format: date-time, nullable: true }
        webhook_delivered: { type: boolean }
        webhook_delivered_at: { type: string, format: date-time, nullable: true }
        webhook_retry_count: { type: integer }
        error_code: { type: string, nullable: true }
        error_message: { type: string, nullable: true }

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: INSUFFICIENT_CREDITS }
            message: { type: string }
            details:
              type: object
              additionalProperties: true

  responses:
    BadRequest:
      description: The request was invalid
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UpgradeRequired:
      description: '`UPGRADE_REQUIRED` — the account has no paid plan yet'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: |
        `INSUFFICIENT_CREDITS`, `CONVERSION_TYPE_NOT_ENABLED`, `PDF_TOOLS_NOT_ENABLED`,
        `RATE_LIMIT_EXCEEDED` or `ACCOUNT_NOT_FOUND`. Note that a plan's rate limit is a 403,
        not a 429.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: No such resource for this account
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Gone:
      description: '`OUTPUT_EXPIRED` — the file has been deleted'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PayloadTooLarge:
      description: The request body is over the limit
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnprocessableEntity:
      description: The values did not validate; `error.details` says which
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooManyRequests:
      description: Platform throttling, shared across accounts. Back off and retry with jitter.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ServerError:
      description: Rendering or the service failed
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
