openapi: 3.0.3
info:
  title: PodPDF API
  version: 1.4.1
  description: |
    Generate PDFs from HTML, Markdown, URLs, images and saved templates, and merge, split,
    password-protect, unlock 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, then
    from free PDFs (every account starts with 10 on the Free plan), then from credit packs. An
    account with no plan yet 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, password-protect, unlock and inspect existing PDFs.
  - name: PDF editor
    description: Stamp, number, annotate and rewrite an uploaded document.
  - name: Mail merge
    description: |
      Email a personalised PDF to every row of a spreadsheet, as an attachment, through your
      own mail server. Sender connections are added in the dashboard (they hold a password);
      everything else works with an API key.
  - 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.
                email_notification:
                  type: boolean
                  description: false skips the "PDF ready" / "conversion failed" email for this job. Omit to follow the account's email settings; true cannot turn it on when job emails are off.
            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' }
                email_notification:
                  type: boolean
                  description: false skips the "bulk job finished" email for this job. Omit to follow the account's email settings; true cannot turn it on when job emails are off.
            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 }
                email_notification:
                  type: string
                  enum: ['true', 'false']
                  description: '"false" skips the "bulk job finished" email for this job.'
      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, quick or
        template jobs sent with `store: true`, and finished mail merge campaigns (the report CSV).
        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}/cancel:
    parameters:
      - $ref: '#/components/parameters/JobIdPath'
    post:
      operationId: cancelBulkJob
      tags: [Jobs]
      summary: Cancel a bulk job or mail merge campaign
      description: |
        Bulk jobs and mail merge campaigns. A campaign that is sending stops within a few emails
        (202): emails already sent are charged and the rest are marked `skipped` with
        `error_code: CANCELLED`; a `mailmerge.campaign.cancelled` webhook follows.

        A queued job is cancelled at once and nothing is charged (200). A bulk job that
        is converting stops before its next file (202): the PDFs it already made stay in its
        output, downloadable from `/jobs/{job_id}/download`, and are charged; the rest are marked
        `skipped` with `error_code: CANCELLED`. Keep polling `GET /jobs/{job_id}` until `status` is
        `cancelled`. A `bulk.job.cancelled` webhook is sent either way.
      responses:
        '200':
          description: The queued job was cancelled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CancelJobResult' }
        '202':
          description: The job stops before its next file
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CancelJobResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The job has already finished, is a draft, or cannot be cancelled (`JOB_NOT_CANCELLABLE`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /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.

        Templates written in your own HTML (beta) render through this same operation. They print
        the values they are given and do not calculate totals.
      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 or the result is over 4.4 MB
          content:
            application/pdf:
              schema: { type: string, format: binary }
            application/json:
              schema: { $ref: '#/components/schemas/PdfOpResult' }
        '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 split that makes one file
        returns the PDF. A split that makes several stores them and returns JSON with a link for
        each file and `zip_url`, a link to a ZIP of them all.
      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 when there is one output, or JSON when there are several, `store` is true or the PDF is over 4.4 MB
          content:
            application/pdf:
              schema: { type: string, format: binary }
            application/json:
              schema: { $ref: '#/components/schemas/PdfOpResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }

  /pdf/protect:
    post:
      operationId: protectPdf
      tags: [PDF tools]
      summary: Password-protect a PDF
      description: |
        Returns a copy of one PDF encrypted with AES-256. Set `user_password` (needed to open
        the PDF), `owner_password` with `permissions` (restrict printing, copying or editing), or
        both.

        Send the PDF in the request (up to 4 MB), or upload it with `/pdf/upload-url` and pass
        its key as `source_key` (up to 150 MB and 5000 pages). Either way it is one request:
        there is no background job. Passwords are used to write the file and are never stored,
        logged or returned.

        A PDF that is already protected is refused with `422 PDF_ENCRYPTED`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
                user_password:
                  type: string
                  format: password
                  description: Needed to open the PDF. Give this, `owner_password`, or both.
                owner_password:
                  type: string
                  format: password
                  description: Needed to change or remove the protection. Required when `permissions` restricts anything; must differ from `user_password`.
                permissions:
                  type: string
                  description: 'JSON object, e.g. `{"print":"none","copy":false}`. See `ProtectPermissions`.'
                options:
                  type: string
                  description: JSON object; `output_filename`.
                store: { type: string, enum: ['true', 'false'] }
          application/json:
            schema: { $ref: '#/components/schemas/ProtectRequest' }
            examples:
              openPassword:
                summary: A password to open
                value:
                  input: { pdf_base64: JVBERi0xLjcK..., filename: payslip.pdf }
                  user_password: correct-horse-battery
              restricted:
                summary: An uploaded file, no printing or copying
                value:
                  source_key: pdf-uploads/01HZ…/01J8…pdf
                  filename: board-pack.pdf
                  user_password: correct-horse-battery
                  owner_password: a-different-one
                  permissions: { print: none, copy: false }
      responses:
        '200':
          description: The protected PDF, or JSON when `store` is true or the PDF was given by `source_key`
          content:
            application/pdf:
              schema: { type: string, format: binary }
            application/json:
              schema: { $ref: '#/components/schemas/PdfSecurityResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '408': { $ref: '#/components/responses/PdfOpTimeout' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/PdfUnprocessable' }

  /pdf/unlock:
    post:
      operationId: unlockPdf
      tags: [PDF tools]
      summary: Unlock a password-protected PDF
      description: |
        Returns a copy of a protected PDF with no password and no restrictions, which the merge,
        split and edit endpoints accept.

        A correct password is always required. A PDF with a password to open is unlocked by
        that password or by its owner password. A PDF that opens without a password but
        restricts printing, copying or editing needs its owner password.

        Send the PDF in the request (up to 4 MB) or by `source_key` (up to 150 MB and 5000
        pages). A wrong password is `422 PDF_PASSWORD_INCORRECT` and is not charged.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, password]
              properties:
                file: { type: string, format: binary }
                password: { type: string, format: password }
                options:
                  type: string
                  description: JSON object; `output_filename`.
                store: { type: string, enum: ['true', 'false'] }
          application/json:
            schema: { $ref: '#/components/schemas/UnlockRequest' }
            example:
              input: { pdf_base64: JVBERi0xLjcK..., filename: statement.pdf }
              password: correct-horse-battery
      responses:
        '200':
          description: The unlocked PDF, or JSON when `store` is true or the PDF was given by `source_key`
          content:
            application/pdf:
              schema: { type: string, format: binary }
            application/json:
              schema: { $ref: '#/components/schemas/PdfSecurityResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '408': { $ref: '#/components/responses/PdfOpTimeout' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/PdfUnprocessable' }

  /pdf/inspect:
    post:
      operationId: inspectPdf
      tags: [PDF tools]
      summary: Inspect a PDF
      description: |
        Page count, page sizes and whether the file is encrypted or carries forms. Free — no credit is spent.

        With `for: edit` it also returns what an editor recipe needs: a stable id for every page
        (`p0`, `p1`, …) with its size and rotation, and the document's form fields. That mode
        refuses documents over 200 pages and needs a plan that includes the editor.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                s3_key:
                  type: string
                  description: Key from `/pdf/upload-url` (up to 150 MB).
                filename: { type: string }
                input:
                  $ref: '#/components/schemas/InlinePdf'
                for:
                  type: string
                  enum: [edit]
                  description: Add page ids, geometry and form fields for the editor.
            example: { s3_key: pdf-uploads/01HZ…/01J8…pdf, for: edit }
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary, description: 'Up to 4 MB.' }
                for: { type: string, enum: [edit] }
      responses:
        '200':
          description: What the file contains
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InspectResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /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, description: 'PUT the PDF here, with the headers in `required_headers` and no API key.' }
                  s3_key: { type: string }
                  expires_at: { type: string, format: date-time }
                  max_bytes: { type: integer, description: 'Largest PDF this upload accepts, in bytes.', example: 157286400 }
                  required_headers:
                    type: object
                    description: Headers the PUT must send exactly, or the signature is rejected.
                    additionalProperties: { type: string }
                    example: { Content-Type: application/pdf }
        '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: |
        Runs the recipe against the document without writing anything and reports, for each
        operation, the pages it resolves to and what it would do — or why it cannot. Free, and
        never recorded as a job. A bad recipe still returns `200` with `ok: false`; the failing
        operation is marked `blocking` and everything after it `not_evaluated`.
      requestBody: { $ref: '#/components/requestBodies/EditRequest' }
      responses:
        '200':
          description: What the recipe would do
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EditPreflightResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '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 and stores the new document, returning a download link
        valid for one hour (the file is kept for 7 days). All or nothing: if any operation fails,
        nothing is written and nothing is charged. One charge per save, however many operations
        it carries. Saving rewrites the whole file, so an existing digital signature stops
        validating — preflight reports that as `signatures_invalidated`.

        Send a `commit_token` to make retries safe: repeating a save with the same token returns
        the first result (`repeated: true`) instead of charging again.
      requestBody: { $ref: '#/components/requestBodies/EditRequest' }
      responses:
        '200':
          description: The edited document
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EditResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/UpgradeRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '408':
          description: '`PDFOP_TIMEOUT` — the save took longer than 25 seconds. Nothing is written or charged.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: '`COMMIT_IN_PROGRESS` — a save with this `commit_token` is still running.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /mailmerge/connections:
    get:
      operationId: listMailConnections
      tags: [Mail merge]
      summary: List sender connections
      description: |
        The mail servers campaigns can send through. Connections are created, changed and tested
        in the dashboard; the password is never returned.
      responses:
        '200':
          description: The account's connections
          content:
            application/json:
              schema:
                type: object
                properties:
                  connections:
                    type: array
                    items: { $ref: '#/components/schemas/MailConnection' }
                  count: { type: integer, example: 1 }
                  max_connections: { type: integer, example: 3 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /mailmerge/connections/{connection_id}:
    parameters:
      - $ref: '#/components/parameters/ConnectionIdPath'
    get:
      operationId: getMailConnection
      tags: [Mail merge]
      summary: Get a sender connection
      responses:
        '200':
          description: The connection
          content:
            application/json:
              schema:
                type: object
                properties:
                  connection: { $ref: '#/components/schemas/MailConnection' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /mailmerge/uploads:
    post:
      operationId: createMailMergeUploadUrl
      tags: [Mail merge]
      summary: Get a signed URL for a recipient list
      description: PUT a CSV or JSON list (up to 10 MB) to the URL, then pass `s3_key` as `input.s3_key`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content_length_bytes]
              properties:
                content_length_bytes: { type: integer, example: 48213 }
                content_type: { type: string, enum: [text/csv, application/json], default: text/csv }
      responses:
        '200':
          description: Where to PUT the list
          content:
            application/json:
              schema:
                type: object
                properties:
                  upload_url: { type: string, format: uri }
                  s3_key: { type: string, example: mailmerge-uploads/01HZXUSERID/01J9UPLOAD.csv }
                  expires_at: { type: string, format: date-time }
                  max_bytes: { type: integer, example: 10485760 }
                  required_headers:
                    type: object
                    additionalProperties: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }

  /mailmerge/limit-request:
    post:
      operationId: requestMailMergeLimit
      tags: [Mail merge]
      summary: Ask for more recipients per campaign
      description: |
        Campaigns send up to 100 recipients each. Ask for up to 10,000 with the number you need
        and what you will send; requests are reviewed within one business day. One request a day.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [requested_rows, use_case]
              properties:
                requested_rows: { type: integer, minimum: 2, maximum: 10000, example: 2500 }
                use_case:
                  type: string
                  minLength: 30
                  maxLength: 2000
                  example: Monthly statements to our 2,400 business customers, who asked to receive them by email.
      responses:
        '201':
          description: Request received
          content:
            application/json:
              schema:
                type: object
                properties:
                  requested_rows: { type: integer }
                  status: { type: string, example: pending }
                  requested_at: { type: string, format: date-time }
                  message: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429':
          description: '`LIMIT_REQUEST_PENDING` — you asked in the last 24 hours'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /mailmerge/campaigns:
    get:
      operationId: listMailMergeCampaigns
      tags: [Mail merge]
      summary: List campaigns
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [draft, queued, processing, completed, partial_failed, failed, cancelled] } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: next_token, in: query, schema: { type: string } }
      responses:
        '200':
          description: Newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaigns:
                    type: array
                    items: { $ref: '#/components/schemas/MailMergeCampaign' }
                  next_token: { type: string, nullable: true }
                  count: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      operationId: createMailMergeCampaign
      tags: [Mail merge]
      summary: Create a campaign
      description: |
        Parses the recipient list, maps its columns to the template's fields and validates every
        row. Returns a draft with a validation report (201), or with `start: true` and consent,
        queues it straight away (202). Each email the mail server accepts costs one PDF credit.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateMailMergeCampaign' }
            example:
              name: October statements
              template_id: 01J8TEMPLATE0000000000000
              connection_id: 01J9A1B2C3D4E5F6G7H8J9K0LM
              input:
                rows:
                  - { Email: ann@example.com, Name: Ann Lee, Amount: '1204.50' }
                  - { Email: bo@example.com, Name: Bo Chan, Amount: '88.00' }
              mapping: { customer.name: Name, amount: Amount }
              email:
                subject: 'Your statement, {{customer.name}}'
                body_text: "Hello {{customer.name}},\nYour statement for {{amount}} is attached."
                attachment_filename: 'Statement {{customer.name}}'
      responses:
        '201':
          description: Draft created
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaign: { $ref: '#/components/schemas/MailMergeCampaign' }
                  validation: { $ref: '#/components/schemas/MailMergeValidation' }
        '202':
          description: 'Created and queued (`start: true`)'
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaign: { $ref: '#/components/schemas/MailMergeCampaign' }
                  validation: { $ref: '#/components/schemas/MailMergeValidation' }
                  message: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/UpgradeRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: '`CONNECTION_NOT_VERIFIED` or `MAILMERGE_CAMPAIGN_ALREADY_ACTIVE` when starting'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }

  /mailmerge/campaigns/{campaign_id}:
    parameters:
      - $ref: '#/components/parameters/CampaignIdPath'
    get:
      operationId: getMailMergeCampaign
      tags: [Mail merge]
      summary: Get a campaign
      responses:
        '200':
          description: The campaign
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaign: { $ref: '#/components/schemas/MailMergeCampaign' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: updateMailMergeCampaign
      tags: [Mail merge]
      summary: Change a draft
      description: Changing the mapping, email column or options re-validates every row.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                connection_id: { type: string }
                email_column: { type: string }
                mapping:
                  type: object
                  additionalProperties: { type: string }
                email: { $ref: '#/components/schemas/MailMergeEmail' }
                options: { $ref: '#/components/schemas/MailMergeOptions' }
      responses:
        '200':
          description: The updated draft
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaign: { $ref: '#/components/schemas/MailMergeCampaign' }
                  validation: { $ref: '#/components/schemas/MailMergeValidation' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: '`MAILMERGE_NOT_DRAFT` — the campaign has started'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '410': { $ref: '#/components/responses/Gone' }
    delete:
      operationId: deleteMailMergeCampaign
      tags: [Mail merge]
      summary: Delete a draft
      responses:
        '204': { description: Deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: '`MAILMERGE_NOT_DRAFT` — the campaign has started'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /mailmerge/campaigns/{campaign_id}/preview:
    parameters:
      - $ref: '#/components/parameters/CampaignIdPath'
    post:
      operationId: previewMailMergeRow
      tags: [Mail merge]
      summary: Preview one row
      description: The email and the template HTML for one row, exactly as they will be sent. Free; nothing is sent.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                row_index:
                  type: integer
                  description: 0 is the first data row. Defaults to the first valid row.
      responses:
        '200':
          description: The preview
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MailMergePreview' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: '`MAILMERGE_NOT_DRAFT`'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '410': { $ref: '#/components/responses/Gone' }

  /mailmerge/campaigns/{campaign_id}/test:
    parameters:
      - $ref: '#/components/parameters/CampaignIdPath'
    post:
      operationId: sendMailMergeTest
      tags: [Mail merge]
      summary: Send one row to yourself
      description: |
        Renders one row's real PDF and sends it to `to` (default: the account email) with
        `[Test]` before the subject. Charged as one PDF; five per campaign.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                row_index: { type: integer }
                to: { type: string, format: email }
      responses:
        '200':
          description: Sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  sent: { type: boolean, example: true }
                  to: { type: string, format: email }
                  row_index: { type: integer }
                  subject: { type: string }
                  attachment_filename: { type: string }
                  pages: { type: integer }
                  message_id: { type: string }
                  charged: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: '`MAILMERGE_NOT_DRAFT`'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429':
          description: '`TEST_SEND_LIMIT`'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: '`CONNECTION_TEST_FAILED` — the mail server refused the message; `details.kind` says how'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /mailmerge/campaigns/{campaign_id}/start:
    parameters:
      - $ref: '#/components/parameters/CampaignIdPath'
    post:
      operationId: startMailMergeCampaign
      tags: [Mail merge]
      summary: Start sending
      description: |
        Needs a verified sender connection, credits for every valid row, no other campaign
        running, and your confirmation that every recipient agreed to receive these emails.
        Follow progress with `GET /jobs/{job_id}`; stop it with `POST /jobs/{job_id}/cancel`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [consent]
              properties:
                consent:
                  type: object
                  required: [confirmed]
                  properties:
                    confirmed: { type: boolean, enum: [true] }
      responses:
        '202':
          description: Queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaign: { $ref: '#/components/schemas/MailMergeCampaign' }
                  message: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: '`MAILMERGE_NOT_DRAFT`, `CONNECTION_NOT_VERIFIED` or `MAILMERGE_CAMPAIGN_ALREADY_ACTIVE`'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '410': { $ref: '#/components/responses/Gone' }

  /mailmerge/campaigns/{campaign_id}/recipients:
    parameters:
      - $ref: '#/components/parameters/CampaignIdPath'
    get:
      operationId: listMailMergeRecipients
      tags: [Mail merge]
      summary: List what happened to each row
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [rendering, sending, sent, failed, rejected, unconfirmed, skipped] } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        '200':
          description: One page of rows, in row order
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaign_id: { type: string }
                  recipients:
                    type: array
                    items: { $ref: '#/components/schemas/MailMergeRecipient' }
                  cursor: { type: string, nullable: true }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /mailmerge/campaigns/{campaign_id}/report:
    parameters:
      - $ref: '#/components/parameters/CampaignIdPath'
    get:
      operationId: getMailMergeReport
      tags: [Mail merge]
      summary: Get the report CSV
      description: 'A one-hour link to a CSV with one line per row: row,email,status,error_code,error_message,message_id,pages,sent_at. Kept 30 days.'
      responses:
        '200':
          description: A signed link
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaign_id: { type: string }
                  download_url: { type: string, format: uri }
                  expires_at: { type: string, format: date-time }
                  content_type: { type: string, example: text/csv }
                  size_bytes: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: '`REPORT_NOT_READY` — the campaign has not finished'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '410': { $ref: '#/components/responses/Gone' }

  /mailmerge/campaigns/{campaign_id}/unsent:
    parameters:
      - $ref: '#/components/parameters/CampaignIdPath'
    get:
      operationId: getMailMergeUnsentRows
      tags: [Mail merge]
      summary: Get the rows that were not sent
      description: >-
        A one-hour link to a CSV of the rows a finished campaign did not send: the list's own columns, then
        unsent_status, unsent_error_code, unsent_error and original_row. Kept 30 days. Fix the file and use it as a
        new campaign's input, or create a campaign with `input.retry_of` to send the rows again as they are.
      responses:
        '200':
          description: A signed link
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaign_id: { type: string }
                  unsent_count: { type: integer }
                  unsent_summary: { $ref: '#/components/schemas/MailMergeUnsentSummary' }
                  download_url: { type: string, format: uri }
                  expires_at: { type: string, format: date-time }
                  content_type: { type: string, example: text/csv }
                  size_bytes: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: '`CAMPAIGN_NOT_FOUND`, or `MAILMERGE_UNSENT_NOT_AVAILABLE` — every row was sent, or the rows have expired'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: '`REPORT_NOT_READY` — the campaign has not finished'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

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:
    CampaignIdPath:
      name: campaign_id
      in: path
      required: true
      description: The campaign's `campaign_id` (the same as its `job_id`).
      schema: { type: string }
    ConnectionIdPath:
      name: connection_id
      in: path
      required: true
      schema: { type: string }
    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 }

    InlinePdf:
      type: object
      description: A PDF sent with the request instead of uploaded first. Up to 4 MB.
      required: [pdf_base64]
      properties:
        pdf_base64: { type: string, format: byte }
        filename: { type: string, example: contract.pdf }

    ProtectPermissions:
      type: object
      description: |
        What a reader may do with the protected PDF. Everything is allowed unless restricted
        here; restricting anything needs `owner_password`. Screen readers can always read the
        text.
      additionalProperties: false
      properties:
        print:
          type: string
          enum: [full, low, none]
          default: full
          description: '`low` allows low-resolution printing only.'
        copy: { type: boolean, default: true, description: Copying text and images. }
        modify: { type: boolean, default: true, description: Editing the document. }
        annotate: { type: boolean, default: true, description: Comments and annotations. }
        fill_forms: { type: boolean, default: true, description: Filling in form fields. }
        assemble: { type: boolean, default: true, description: 'Inserting, deleting and rotating pages.' }

    ProtectRequest:
      type: object
      description: |
        The PDF — `input` inline (up to 4 MB) or `source_key` from `/pdf/upload-url` (up to
        150 MB) — and at least one of `user_password` and `owner_password`.
      properties:
        input: { $ref: '#/components/schemas/InlinePdf' }
        source_key:
          type: string
          description: Key from `/pdf/upload-url`. Give this or `input`.
          example: pdf-uploads/01HZ…/01J8…pdf
        filename:
          type: string
          description: The name of an uploaded PDF, used for the output name.
          example: board-pack.pdf
        user_password:
          type: string
          format: password
          maxLength: 127
          description: Needed to open the PDF. Up to 127 bytes, no control characters.
        owner_password:
          type: string
          format: password
          maxLength: 127
          description: |
            Needed to change or remove the protection. Required when `permissions` restricts
            anything, and must differ from `user_password`. On its own it needs at least one
            restriction.
        permissions: { $ref: '#/components/schemas/ProtectPermissions' }
        options:
          type: object
          properties:
            output_filename:
              type: string
              description: Without `.pdf`. Defaults to the input name with `-protected`.
        store:
          type: boolean
          default: false
          description: Store the result and return a download link instead of the file.

    UnlockRequest:
      type: object
      description: The protected PDF — `input` inline or `source_key` — and its password.
      required: [password]
      properties:
        input: { $ref: '#/components/schemas/InlinePdf' }
        source_key:
          type: string
          description: Key from `/pdf/upload-url`. Give this or `input`.
          example: pdf-uploads/01HZ…/01J8…pdf
        filename: { type: string, example: statement.pdf }
        password:
          type: string
          format: password
          maxLength: 127
          description: The password to open the PDF, or its owner password.
        options:
          type: object
          properties:
            output_filename:
              type: string
              description: Without `.pdf`. Defaults to the input name with `-unlocked`.
        store: { type: boolean, default: false }

    PdfOutputFile:
      type: object
      description: One file a PDF tool produced.
      properties:
        index: { type: integer, example: 0 }
        name: { type: string, example: scan-001.pdf }
        pages: { type: integer, example: 3 }
        page_range: { type: string, description: 'The pages of the input this file holds.', example: 1-3 }
        size_bytes: { type: integer, example: 91822 }
        warnings: { type: array, items: { type: string } }
        download_url: { type: string, format: uri, description: 'Signed link, valid one hour.' }

    PdfOpResult:
      type: object
      description: A stored merge or split result, with a link for each output.
      properties:
        job_id: { $ref: '#/components/schemas/JobId' }
        operation: { type: string, enum: [pdf_merge, pdf_split] }
        status: { type: string, enum: [completed] }
        input_pages: { type: integer, example: 10 }
        output_count: { type: integer, example: 3 }
        outputs:
          type: array
          items: { $ref: '#/components/schemas/PdfOutputFile' }
        zip_url:
          type: string
          format: uri
          nullable: true
          description: Signed link to a ZIP of every output, valid one hour. Null when there is one output.
        download_url_expires_at: { type: string, format: date-time }
        warnings:
          type: array
          description: '`FORM_FIELDS_NOT_PRESERVED`, `SIGNATURE_INVALIDATED`, `BOOKMARKS_NOT_PRESERVED` or `XFA_FORM`.'
          items: { type: string }

    PdfSecurityResult:
      type: object
      description: A stored protect or unlock result. The one output's link is also given as `download_url`.
      properties:
        job_id: { $ref: '#/components/schemas/JobId' }
        operation: { type: string, enum: [pdf_protect, pdf_unlock] }
        status: { type: string, enum: [completed] }
        input_pages: { type: integer, example: 12 }
        output_count: { type: integer, enum: [1] }
        download_url: { type: string, format: uri, description: 'Signed link, valid one hour.' }
        outputs:
          type: array
          items: { $ref: '#/components/schemas/PdfOutputFile' }
        zip_url: { type: string, nullable: true, description: 'Always null, as there is one output.' }
        download_url_expires_at: { type: string, format: date-time }
        warnings:
          type: array
          description: '`SIGNATURE_INVALIDATED`, `SOURCE_REPAIRED` or `NON_ASCII_PASSWORD`.'
          items: { type: string }

    EditRequest:
      type: object
      description: |
        The document — `source_key` from `/pdf/upload-url` (up to 150 MB) or `input` inline (up
        to 4 MB) — and the recipe to apply to it.
      required: [recipe]
      properties:
        source_key:
          type: string
          description: Key from `/pdf/upload-url`. Give this or `input`.
          example: pdf-uploads/01HZ…/01J8…pdf
        filename:
          type: string
          description: Name of the document, with `source_key`.
        input: { $ref: '#/components/schemas/InlinePdf' }
        recipe:
          type: array
          description: Operations, applied in order. At most 200.
          minItems: 1
          maxItems: 200
          items: { $ref: '#/components/schemas/EditOperation' }
        commit_token:
          type: string
          maxLength: 64
          description: Makes a save safe to retry — the same token returns the first result instead of charging again. Ignored by preflight.
        options:
          type: object
          properties:
            output_filename:
              type: string
              description: Name for the saved document, without `.pdf`.
      example:
        source_key: pdf-uploads/01HZ…/01J8…pdf
        recipe:
          - { op: pages.rotate, pages: '1', degrees: 90 }
          - { op: bates.stamp, prefix: 'ACME-', start: 1, digits: 6 }
          - { op: metadata.strip }
        commit_token: 01JB7QRSTUV
        options: { output_filename: contract-stamped }

    EditMultipartRequest:
      type: object
      description: The same request as multipart form data, each part its own field.
      required: [file, recipe]
      properties:
        file: { type: string, format: binary, description: 'The PDF, up to 4 MB.' }
        recipe: { type: string, description: 'The recipe, as a JSON array.' }
        options: { type: string, description: 'JSON object, e.g. `{"output_filename":"trimmed"}`.' }
        commit_token: { type: string, maxLength: 64 }

    PageSelector:
      description: |
        Which pages an operation applies to. Either a range string such as `"2,5-7"`, read
        against the document as it stands at that step of the recipe, or a list of page ids from
        `/pdf/inspect` (`p0` is the first original page; `n0`, `n1`… are pages a `pages.insert`
        created), which keep pointing at the same page whatever earlier operations did. Leave it
        out to select every page.
      oneOf:
        - type: string
          example: 2,5-7
        - type: array
          minItems: 1
          items:
            type: string
            pattern: '^[pn]\d+$'
          example: [p0, p3]

    HexColor:
      type: string
      pattern: '^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$'
      example: '#1f4e79'

    MarkPosition:
      type: string
      enum: [top-left, top-center, top-right, center-left, center, center-right, bottom-left, bottom-center, bottom-right]

    EditFont:
      type: string
      description: |
        The 8 standard PDF fonts, or `unicode` / `unicode-bold` (Noto Sans) for accented, Greek,
        Cyrillic and Vietnamese text — swapped in automatically when needed and reported as
        `font_substituted`. CJK and right-to-left scripts are refused.
      enum: [helvetica, helvetica-bold, helvetica-oblique, times, times-bold, times-italic, courier, courier-bold, unicode, unicode-bold]
      default: helvetica

    ImageData:
      type: string
      description: A PNG or JPEG, base64-encoded (a `data:image/...;base64,` prefix is accepted). Up to 4 MB.

    PdfRect:
      type: object
      description: A rectangle in PDF user space — points, bottom-left origin, page rotation not applied.
      required: [x, y, width, height]
      properties:
        x: { type: number, minimum: -20000, maximum: 20000 }
        y: { type: number, minimum: -20000, maximum: 20000 }
        width: { type: number, minimum: 0.5, maximum: 20000 }
        height: { type: number, minimum: 0.5, maximum: 20000 }

    EditOperation:
      description: One step of a recipe. `op` says which.
      oneOf:
        - $ref: '#/components/schemas/PagesRotateOp'
        - $ref: '#/components/schemas/PagesDeleteOp'
        - $ref: '#/components/schemas/PagesReorderOp'
        - $ref: '#/components/schemas/PagesExtractOp'
        - $ref: '#/components/schemas/PagesInsertOp'
        - $ref: '#/components/schemas/TextAddOp'
        - $ref: '#/components/schemas/WatermarkTextOp'
        - $ref: '#/components/schemas/WatermarkImageOp'
        - $ref: '#/components/schemas/HeaderFooterOp'
        - $ref: '#/components/schemas/BatesStampOp'
        - $ref: '#/components/schemas/MetadataSetOp'
        - $ref: '#/components/schemas/MetadataStripOp'
        - $ref: '#/components/schemas/SanitizeOp'
        - $ref: '#/components/schemas/FormFillOp'
        - $ref: '#/components/schemas/FormFlattenOp'
        - $ref: '#/components/schemas/AnnotateHighlightOp'
        - $ref: '#/components/schemas/AnnotateNoteOp'
        - $ref: '#/components/schemas/AnnotateStampOp'
        - $ref: '#/components/schemas/AnnotationsFlattenOp'
      discriminator:
        propertyName: op
        mapping:
          pages.rotate: '#/components/schemas/PagesRotateOp'
          pages.delete: '#/components/schemas/PagesDeleteOp'
          pages.reorder: '#/components/schemas/PagesReorderOp'
          pages.extract: '#/components/schemas/PagesExtractOp'
          pages.insert: '#/components/schemas/PagesInsertOp'
          text.add: '#/components/schemas/TextAddOp'
          watermark.text: '#/components/schemas/WatermarkTextOp'
          watermark.image: '#/components/schemas/WatermarkImageOp'
          header_footer: '#/components/schemas/HeaderFooterOp'
          bates.stamp: '#/components/schemas/BatesStampOp'
          metadata.set: '#/components/schemas/MetadataSetOp'
          metadata.strip: '#/components/schemas/MetadataStripOp'
          sanitize: '#/components/schemas/SanitizeOp'
          form.fill: '#/components/schemas/FormFillOp'
          form.flatten: '#/components/schemas/FormFlattenOp'
          annotate.highlight: '#/components/schemas/AnnotateHighlightOp'
          annotate.note: '#/components/schemas/AnnotateNoteOp'
          annotate.stamp: '#/components/schemas/AnnotateStampOp'
          annotations.flatten: '#/components/schemas/AnnotationsFlattenOp'

    PagesRotateOp:
      type: object
      title: pages.rotate
      description: Rotate pages. Absolute by default; `relative` adds to the current rotation.
      required: [op, degrees]
      additionalProperties: false
      properties:
        op: { type: string, enum: [pages.rotate] }
        pages: { $ref: '#/components/schemas/PageSelector' }
        degrees: { type: integer, minimum: -270, maximum: 360, multipleOf: 90 }
        relative: { type: boolean, default: false }

    PagesDeleteOp:
      type: object
      title: pages.delete
      description: Delete pages. `pages` is required, and deleting every page is refused. Form fields left with no page are removed too.
      required: [op, pages]
      additionalProperties: false
      properties:
        op: { type: string, enum: [pages.delete] }
        pages: { $ref: '#/components/schemas/PageSelector' }

    PagesReorderOp:
      type: object
      title: pages.reorder
      description: Put the pages in a new order. `order` must list every page exactly once.
      required: [op, order]
      additionalProperties: false
      properties:
        op: { type: string, enum: [pages.reorder] }
        order: { $ref: '#/components/schemas/PageSelector' }

    PagesExtractOp:
      type: object
      title: pages.extract
      description: Keep only these pages, in the order given, and drop the rest.
      required: [op, pages]
      additionalProperties: false
      properties:
        op: { type: string, enum: [pages.extract] }
        pages: { $ref: '#/components/schemas/PageSelector' }

    PagesInsertOp:
      type: object
      title: pages.insert
      description: Insert blank pages. They get ids `n0`, `n1`… that later operations can write on.
      required: [op]
      additionalProperties: false
      properties:
        op: { type: string, enum: [pages.insert] }
        at:
          description: A page id such as `p3`, a 1-based page number, or `end` (the default).
          oneOf:
            - type: string
              example: p3
            - type: integer
              minimum: 1
        position: { type: string, enum: [before, after], default: before }
        count: { type: integer, minimum: 1, maximum: 200, default: 1 }
        size:
          description: A paper size, or `{ width, height }` in points. Defaults to the size of the neighbouring page.
          oneOf:
            - type: string
              enum: [A3, A4, A5, A6, Letter, Legal, Tabloid]
            - type: object
              required: [width, height]
              properties:
                width: { type: number, minimum: 1, maximum: 14400 }
                height: { type: number, minimum: 1, maximum: 14400 }

    TextAddOp:
      type: object
      title: text.add
      description: Put text on pages — a name, a date, a reference. Multi-line blocks work.
      required: [op, text]
      additionalProperties: false
      properties:
        op: { type: string, enum: [text.add] }
        pages: { $ref: '#/components/schemas/PageSelector' }
        text: { type: string, maxLength: 2000 }
        size: { type: number, minimum: 4, maximum: 300, default: 12 }
        font: { $ref: '#/components/schemas/EditFont' }
        color: { $ref: '#/components/schemas/HexColor' }
        opacity: { type: number, minimum: 0.05, maximum: 1, default: 1 }
        rotation: { type: number, minimum: -360, maximum: 360, default: 0 }
        position:
          allOf: [{ $ref: '#/components/schemas/MarkPosition' }]
          default: top-left
        x: { type: number, minimum: -20000, maximum: 20000, description: 'With `y`, places the text at exact coordinates instead of `position`.' }
        y: { type: number, minimum: -20000, maximum: 20000 }
        margin_pt: { type: number, minimum: 0, maximum: 300, default: 36 }
        line_height: { type: number, minimum: 1, maximum: 400 }

    WatermarkTextOp:
      type: object
      title: watermark.text
      description: A text watermark, centred by default. Same options as `text.add`.
      required: [op, text]
      additionalProperties: false
      properties:
        op: { type: string, enum: [watermark.text] }
        pages: { $ref: '#/components/schemas/PageSelector' }
        text: { type: string, maxLength: 200 }
        size: { type: number, minimum: 4, maximum: 300, default: 12 }
        font: { $ref: '#/components/schemas/EditFont' }
        color: { $ref: '#/components/schemas/HexColor' }
        opacity: { type: number, minimum: 0.05, maximum: 1, default: 1 }
        rotation: { type: number, minimum: -360, maximum: 360, default: 0 }
        position:
          allOf: [{ $ref: '#/components/schemas/MarkPosition' }]
          default: center
        x: { type: number, minimum: -20000, maximum: 20000 }
        y: { type: number, minimum: -20000, maximum: 20000 }
        margin_pt: { type: number, minimum: 0, maximum: 300, default: 36 }
        line_height: { type: number, minimum: 1, maximum: 400 }

    WatermarkImageOp:
      type: object
      title: watermark.image
      description: Draw an image into the page — a logo, a stamp, a signature image. Permanent; use `annotate.stamp` for one that can be removed. Size it with `width`/`height` or `scale`, not both.
      required: [op, data]
      additionalProperties: false
      properties:
        op: { type: string, enum: [watermark.image] }
        pages: { $ref: '#/components/schemas/PageSelector' }
        data: { $ref: '#/components/schemas/ImageData' }
        width: { type: number, minimum: 1, maximum: 14400 }
        height: { type: number, minimum: 1, maximum: 14400 }
        scale: { type: number, minimum: 0.01, maximum: 20 }
        opacity: { type: number, minimum: 0.05, maximum: 1, default: 1 }
        rotation: { type: number, minimum: -360, maximum: 360, default: 0 }
        position:
          allOf: [{ $ref: '#/components/schemas/MarkPosition' }]
          default: center
        x: { type: number, minimum: -20000, maximum: 20000 }
        y: { type: number, minimum: -20000, maximum: 20000 }
        margin_pt: { type: number, minimum: 0, maximum: 300, default: 36 }

    HeaderFooterOp:
      type: object
      title: header_footer
      description: A header or footer. `{page}`, `{pages}` and `{date}` in the text are filled in per page, which makes it page numbering too.
      required: [op, text]
      additionalProperties: false
      properties:
        op: { type: string, enum: [header_footer] }
        pages: { $ref: '#/components/schemas/PageSelector' }
        text: { type: string, maxLength: 200, example: 'Page {page} of {pages}' }
        size: { type: number, minimum: 4, maximum: 300, default: 12 }
        font: { $ref: '#/components/schemas/EditFont' }
        color: { $ref: '#/components/schemas/HexColor' }
        opacity: { type: number, minimum: 0.05, maximum: 1, default: 1 }
        position:
          allOf: [{ $ref: '#/components/schemas/MarkPosition' }]
          default: bottom-center
        margin_pt: { type: number, minimum: 0, maximum: 300, default: 36 }

    BatesStampOp:
      type: object
      title: bates.stamp
      description: Sequential Bates numbers, drawn the right way up even on pages scanned sideways. The result reports `next_start` for continuing in another document.
      required: [op]
      additionalProperties: false
      properties:
        op: { type: string, enum: [bates.stamp] }
        pages: { $ref: '#/components/schemas/PageSelector' }
        prefix: { type: string, maxLength: 40, example: 'ACME-' }
        suffix: { type: string, maxLength: 40 }
        start: { type: integer, minimum: 0, maximum: 1000000000, default: 1 }
        digits: { type: integer, minimum: 1, maximum: 12, default: 6 }
        size: { type: number, minimum: 4, maximum: 72, default: 9 }
        font: { $ref: '#/components/schemas/EditFont' }
        color: { $ref: '#/components/schemas/HexColor' }
        opacity: { type: number, minimum: 0.05, maximum: 1, default: 1 }
        position:
          allOf: [{ $ref: '#/components/schemas/MarkPosition' }]
          default: bottom-right
        margin_pt: { type: number, minimum: 0, maximum: 300, default: 36 }

    MetadataSetOp:
      type: object
      title: metadata.set
      description: Set document properties. At least one field.
      required: [op]
      additionalProperties: false
      properties:
        op: { type: string, enum: [metadata.set] }
        title: { type: string, maxLength: 500 }
        author: { type: string, maxLength: 500 }
        subject: { type: string, maxLength: 500 }
        keywords: { type: string, maxLength: 500 }
        creator: { type: string, maxLength: 500 }
        producer: { type: string, maxLength: 500 }
        creation_date: { type: string, format: date-time }
        modification_date: { type: string, format: date-time }

    MetadataStripOp:
      type: object
      title: metadata.strip
      description: Clear the document properties and the hidden XMP metadata, and remove the objects from the file.
      required: [op]
      additionalProperties: false
      properties:
        op: { type: string, enum: [metadata.strip] }
        keep:
          type: array
          description: Properties to leave in place.
          items: { type: string, enum: [title, author, subject, keywords, creator, producer] }

    SanitizeOp:
      type: object
      title: sanitize
      description: Remove active content, and report what was removed. Everything except external links by default.
      required: [op]
      additionalProperties: false
      properties:
        op: { type: string, enum: [sanitize] }
        remove:
          type: array
          minItems: 1
          items: { type: string, enum: [javascript, embedded_files, open_action, auto_actions, launch_actions, external_links] }

    FormFillOp:
      type: object
      title: form.fill
      description: |
        Fill form fields by their full name (from `/pdf/inspect` with `for: edit`). Text fields
        take a string, check boxes `true`/`false`, radio groups and dropdowns one of their
        options, list boxes one option or a list. XFA forms are refused.
      required: [op, fields]
      additionalProperties: false
      properties:
        op: { type: string, enum: [form.fill] }
        fields:
          type: object
          minProperties: 1
          additionalProperties:
            oneOf:
              - type: string
              - type: boolean
              - type: array
                items: { type: string }
          example: { applicant.name: Ada Lovelace, agree: true }
        font: { $ref: '#/components/schemas/EditFont' }

    FormFlattenOp:
      type: object
      title: form.flatten
      description: Make fields part of the page, so they can no longer be edited. Every field that can be, or just those named. Cannot be undone.
      required: [op]
      additionalProperties: false
      properties:
        op: { type: string, enum: [form.flatten] }
        fields:
          type: array
          minItems: 1
          items: { type: string }
        font: { $ref: '#/components/schemas/EditFont' }

    AnnotateHighlightOp:
      type: object
      title: annotate.highlight
      description: |
        Highlight either `rects` on one page (up to 200 rectangles, one annotation), or every
        match of `text` (one annotation per match, up to 500; all pages unless `pages` narrows
        it). Give one or the other. `TEXT_NOT_FOUND` if the text is not there.
      required: [op]
      additionalProperties: false
      properties:
        op: { type: string, enum: [annotate.highlight] }
        pages: { $ref: '#/components/schemas/PageSelector' }
        rects:
          type: array
          minItems: 1
          maxItems: 200
          items: { $ref: '#/components/schemas/PdfRect' }
        text: { type: string, maxLength: 500 }
        match_case: { type: boolean, default: false, description: 'Only with `text`.' }
        whole_word: { type: boolean, default: false, description: 'Only with `text`.' }
        color: { $ref: '#/components/schemas/HexColor' }
        opacity: { type: number, minimum: 0.05, maximum: 1, default: 1 }
        contents: { type: string, maxLength: 2000, description: 'A comment shown with the highlight.' }
        author: { type: string, maxLength: 120 }

    AnnotateNoteOp:
      type: object
      title: annotate.note
      description: A sticky note at a point on exactly one page.
      required: [op, pages, x, y, contents]
      additionalProperties: false
      properties:
        op: { type: string, enum: [annotate.note] }
        pages: { $ref: '#/components/schemas/PageSelector' }
        x: { type: number, minimum: -20000, maximum: 20000 }
        y: { type: number, minimum: -20000, maximum: 20000 }
        contents: { type: string, maxLength: 2000 }
        icon: { type: string, enum: [Note, Comment, Key, Help, NewParagraph, Paragraph, Insert], default: Note }
        color: { $ref: '#/components/schemas/HexColor' }
        author: { type: string, maxLength: 120 }
        open: { type: boolean, default: false }

    AnnotateStampOp:
      type: object
      title: annotate.stamp
      description: An image stamp — a signature, an approval mark — as an annotation that can be removed later. On exactly one page. Size it with `width`/`height` or `scale`, not both.
      required: [op, pages, data, x, y]
      additionalProperties: false
      properties:
        op: { type: string, enum: [annotate.stamp] }
        pages: { $ref: '#/components/schemas/PageSelector' }
        data: { $ref: '#/components/schemas/ImageData' }
        x: { type: number, minimum: -20000, maximum: 20000 }
        y: { type: number, minimum: -20000, maximum: 20000 }
        width: { type: number, minimum: 1, maximum: 14400 }
        height: { type: number, minimum: 1, maximum: 14400 }
        scale: { type: number, minimum: 0.01, maximum: 20 }
        opacity: { type: number, minimum: 0.05, maximum: 1, default: 1 }
        contents: { type: string, maxLength: 2000 }
        author: { type: string, maxLength: 120 }

    AnnotationsFlattenOp:
      type: object
      title: annotations.flatten
      description: Burn annotations into the page so they can no longer be moved or removed. Form widgets, links and popups are never flattened; sticky notes are skipped and reported. `NO_ANNOTATIONS` if there is nothing to flatten.
      required: [op]
      additionalProperties: false
      properties:
        op: { type: string, enum: [annotations.flatten] }
        pages: { $ref: '#/components/schemas/PageSelector' }
        types:
          type: array
          minItems: 1
          description: Annotation subtypes to flatten, e.g. `Highlight`, `Stamp`. All of them if left out.
          items: { type: string, maxLength: 40 }

    EditOperationResult:
      type: object
      description: What one operation did (or, in preflight, would do).
      properties:
        index: { type: integer, description: 'Position in the recipe, from 0.' }
        op: { type: string, example: bates.stamp }
        scope: { type: string, enum: [page, document], nullable: true }
        resolved_pages:
          type: array
          description: 1-based page numbers the operation applied to, at that step.
          items: { type: integer }
        effects:
          type: object
          description: Operation-specific detail — for example `next_start` for Bates, `placements` for marks, `matches` for a text highlight, `skipped` for a flatten.
          additionalProperties: true
        ok:
          type: boolean
          nullable: true
          description: Preflight only. `null` when the operation was not evaluated.
        blocking: { type: boolean, description: 'Preflight only. This operation fails and blocks the save.' }
        not_evaluated: { type: boolean, description: 'Preflight only. An earlier operation failed, so this one could not be checked.' }
        reasons:
          type: array
          description: Preflight only. Why a blocking operation fails.
          items:
            type: object
            additionalProperties: true
            properties:
              code: { type: string }
              message: { type: string }

    SanitizeReport:
      type: object
      description: What `sanitize` found and removed, by category.
      additionalProperties: true

    EditPreflightResult:
      type: object
      properties:
        ok: { type: boolean, description: 'Whether the recipe would save.' }
        operations:
          type: array
          items: { $ref: '#/components/schemas/EditOperationResult' }
        document:
          type: object
          properties:
            filename: { type: string, nullable: true }
            size_bytes: { type: integer }
            max_pages: { type: integer, example: 200 }
            page_count_before: { type: integer }
            page_count_after: { type: integer, nullable: true }
            has_form: { type: boolean }
            has_xfa: { type: boolean }
            signatures_invalidated: { type: boolean, description: 'Saving would break an existing digital signature.' }
            bookmarks_lost: { type: boolean, description: 'Bookmarks would not survive a delete or reorder.' }
            warnings:
              type: array
              items: { type: string }
            estimated_output_bytes: { type: integer, description: 'A floor — marks added by the recipe are not counted.' }
            output_over_limit: { type: boolean }
            sanitize: { $ref: '#/components/schemas/SanitizeReport' }
        cost:
          type: number
          description: What saving would cost, in dollars.
          example: 0.0025
        error:
          type: object
          description: Present when `ok` is false.
          properties:
            code: { type: string, example: PAGE_ID_UNKNOWN }
            message: { type: string }
            index: { type: integer, nullable: true }
            op: { type: string, nullable: true }

    EditResult:
      type: object
      properties:
        job_id: { $ref: '#/components/schemas/JobId' }
        operation: { type: string, enum: [pdf_edit] }
        status: { type: string, enum: [completed] }
        repeated: { type: boolean, description: 'This `commit_token` was already saved; this is the first result again, not a new charge.' }
        input_pages: { type: integer }
        output_pages: { type: integer }
        output_bytes: { type: integer }
        operations:
          type: array
          items: { $ref: '#/components/schemas/EditOperationResult' }
        download_url: { type: string, format: uri, description: 'Valid for 1 hour. The file is kept for 7 days — fetch a new link with `GET /jobs/{job_id}/download`.' }
        download_url_expires_at: { type: string, format: date-time }
        warnings:
          type: array
          items: { type: string }
        sanitize: { $ref: '#/components/schemas/SanitizeReport' }

    InspectResult:
      type: object
      properties:
        filename: { type: string, nullable: true }
        size_bytes: { type: integer }
        page_count: { type: integer, nullable: true, description: '`null` for an encrypted file.' }
        encrypted: { type: boolean }
        has_forms: { type: boolean, nullable: true }
        has_signatures: { type: boolean, nullable: true }
        has_xfa: { type: boolean }
        first_page:
          type: object
          properties:
            width: { type: number }
            height: { type: number }
        warnings:
          type: array
          items: { type: string }
        max_pages: { type: integer, description: 'The page limit for the path this file would take.' }
        pages:
          type: array
          description: 'With `for: edit`. The ids every recipe refers to pages by.'
          items:
            type: object
            properties:
              id: { type: string, example: p0 }
              index: { type: integer }
              width: { type: number }
              height: { type: number }
              rotation: { type: integer, enum: [0, 90, 180, 270] }
        form:
          type: object
          description: "With `for: edit`. The document's form fields, up to 500."
          properties:
            has_form: { type: boolean }
            has_xfa: { type: boolean }
            field_count: { type: integer }
            truncated: { type: boolean }
            fields:
              type: array
              items: { $ref: '#/components/schemas/FormField' }

    FormField:
      type: object
      properties:
        name: { type: string, description: 'The full name `form.fill` takes.' }
        type: { type: string, enum: [text, checkbox, radio, dropdown, optionlist, button, signature, unsupported] }
        fillable: { type: boolean }
        value:
          nullable: true
          oneOf:
            - type: string
            - type: boolean
            - type: array
              items: { type: string }
        options:
          type: array
          items: { type: string }
        required: { type: boolean }
        read_only: { type: boolean }
        max_length: { type: integer, nullable: true }
        multiline: { type: boolean }
        multiselect: { type: boolean }
        page_id: { type: string, nullable: true }
        page_index: { type: integer, nullable: true }
        rect: { allOf: [{ $ref: '#/components/schemas/PdfRect' }], nullable: true }
        widgets: { type: integer }
        flattenable: { type: boolean }

    Job:
      type: object
      properties:
        job_id: { $ref: '#/components/schemas/JobId' }
        status:
          type: string
          enum: [draft, queued, processing, completed, partial_failed, failed, timeout, cancelled]
        job_type: { type: string, enum: [quick, long, bulk, pdfop, mailmerge] }
        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 }
        cancel_requested:
          type: boolean
          description: Bulk jobs and mail merge campaigns. True once a cancel is asked for; `status` becomes `cancelled` when the job stops.
        campaign_name: { type: string, nullable: true, description: Mail merge campaigns. }
        valid_row_count: { type: integer, nullable: true, description: Mail merge campaigns. }
        sent_count: { type: integer, nullable: true, description: 'Mail merge campaigns: emails the mail server accepted.' }
        failed_count: { type: integer, nullable: true, description: Mail merge campaigns. }
        rejected_count: { type: integer, nullable: true, description: Mail merge campaigns. }
        unconfirmed_count: { type: integer, nullable: true, description: Mail merge campaigns. }
        skipped_count: { type: integer, nullable: true, description: Mail merge campaigns. }
        report_available: { type: boolean, description: 'Mail merge campaigns: the report CSV can be downloaded.' }
        unsent_available: { type: boolean, description: 'Mail merge campaigns: rows that were not sent can be downloaded or sent again.' }
        unsent_count: { type: integer, nullable: true, description: Mail merge campaigns. }
        unsent_summary: { $ref: '#/components/schemas/MailMergeUnsentSummary' }
        outcome_counts:
          type: object
          nullable: true
          additionalProperties: { type: integer }
          description: 'Mail merge campaigns, once finished: how many rows ended each way, keyed by `sent` or the row''s error code.'
        retry_of: { type: string, nullable: true, description: 'Mail merge campaigns: the campaign whose unsent rows this one sends.' }
        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 }

    MailConnection:
      type: object
      properties:
        connection_id: { type: string, example: 01J9A1B2C3D4E5F6G7H8J9K0LM }
        provider: { type: string, enum: [smtp] }
        label: { type: string, nullable: true, example: Office 365 billing }
        from_email: { type: string, format: email, example: billing@acme.com }
        from_name: { type: string, nullable: true, example: Acme Billing }
        reply_to: { type: string, format: email, nullable: true }
        smtp:
          type: object
          properties:
            host: { type: string, example: smtp.office365.com }
            port: { type: integer, enum: [465, 587, 2525] }
            security: { type: string, enum: [tls, starttls] }
            username: { type: string }
        max_parallel: { type: integer, minimum: 1, maximum: 4, example: 2 }
        max_per_minute: { type: integer, nullable: true }
        status:
          type: string
          enum: [unverified, verified, failing]
          description: Campaigns need `verified`, which a successful test email sets.
        has_secret: { type: boolean }
        secret_hint: { type: string, example: ••••word }
        verified_at: { type: string, format: date-time, nullable: true }
        last_error: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    MailMergeEmail:
      type: object
      required: [subject, body_text]
      properties:
        subject:
          type: string
          maxLength: 500
          example: 'Your statement, {{customer.name}}'
        body_text:
          type: string
          description: The plain-text part. `{{tokens}}` name template fields or columns (lowercased, other characters become `_`).
        body_html:
          type: string
          nullable: true
          description: Optional HTML part, sent as written; filled values are HTML-escaped.
        attachment_filename:
          type: string
          nullable: true
          description: Defaults to the template name; `.pdf` is added.
        list_unsubscribe:
          type: string
          nullable: true
          description: A `mailto:` or `https://` target for the List-Unsubscribe header.
        list_unsubscribe_one_click: { type: boolean, default: false }

    MailMergeOptions:
      type: object
      properties:
        dedupe:
          type: boolean
          default: true
          description: Send once to an address that appears in several rows.

    CreateMailMergeCampaign:
      type: object
      required: [input]
      description: >-
        `template_id`, `connection_id` and `email` are required unless `input.retry_of` is used, in which case
        they default to the first campaign's.
      properties:
        name: { type: string, maxLength: 120 }
        template_id: { type: string }
        connection_id: { type: string }
        input:
          type: object
          description: Exactly one of `rows`, `csv`, `s3_key` or `retry_of`.
          properties:
            retry_of:
              type: string
              description: >-
                A finished campaign of yours: the new campaign sends its unsent rows, with its template, sender,
                mapping and email unless this request gives others. The mapping is used exactly as saved or given.
            include:
              type: array
              description: With `retry_of`, which unsent rows to take. Rows with invalid data are never included.
              default: [failed, not_attempted]
              items: { type: string, enum: [failed, not_attempted, rejected, unconfirmed] }
            rows:
              type: array
              items: {}
              description: Objects keyed by column name, or arrays together with `columns`.
            columns:
              type: array
              items: { type: string }
            csv: { type: string, description: 'A CSV string, up to 5 MB.' }
            s3_key: { type: string, description: 'From `POST /mailmerge/uploads`.' }
        email_column:
          type: string
          description: Detected from the headers when left out.
        mapping:
          type: object
          additionalProperties: { type: string }
          description: Template field key to column name. Unmapped fields are matched by key or label.
        email: { $ref: '#/components/schemas/MailMergeEmail' }
        options: { $ref: '#/components/schemas/MailMergeOptions' }
        email_notification:
          type: boolean
          description: false skips the "campaign finished" email to you.
        start:
          type: boolean
          description: Queue the campaign now (needs `consent`).
        consent:
          type: object
          properties:
            confirmed: { type: boolean }

    MailMergeCampaign:
      type: object
      properties:
        campaign_id: { type: string }
        job_id: { type: string }
        status: { type: string, enum: [draft, queued, processing, completed, partial_failed, failed, cancelled] }
        name: { type: string, nullable: true }
        template_id: { type: string }
        template_name: { type: string }
        connection_id: { type: string }
        row_count: { type: integer }
        valid_row_count: { type: integer }
        invalid_row_count: { type: integer }
        duplicate_count: { type: integer }
        processed_count: { type: integer }
        sent_count: { type: integer }
        failed_count: { type: integer }
        rejected_count: { type: integer }
        unconfirmed_count: { type: integer }
        skipped_count: { type: integer }
        pages_total: { type: integer }
        report_available: { type: boolean }
        unsent_available: { type: boolean, description: Rows that were not sent can be downloaded or sent again. }
        unsent_count: { type: integer }
        unsent_summary: { $ref: '#/components/schemas/MailMergeUnsentSummary' }
        outcome_counts:
          type: object
          nullable: true
          additionalProperties: { type: integer }
          description: 'Once finished: how many rows ended each way, keyed by `sent` or the row''s error code.'
        retry_of: { type: string, nullable: true, description: The campaign whose unsent rows this one sends. }
        cancel_requested: { type: boolean }
        error_code: { type: string, nullable: true }
        error_message: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        email_column: { type: string }
        mapping:
          type: object
          additionalProperties: { type: string }
        email: { $ref: '#/components/schemas/MailMergeEmail' }
        options: { $ref: '#/components/schemas/MailMergeOptions' }

    MailMergeUnsentSummary:
      type: object
      nullable: true
      description: Rows a finished campaign did not send, by reason. Null when every row was sent.
      properties:
        failed: { type: integer, description: 'The email could not be sent, or its PDF could not be made.' }
        not_attempted: { type: integer, description: The campaign stopped or was cancelled first. }
        rejected: { type: integer, description: The mail server refused the address. }
        unconfirmed: { type: integer, description: The connection dropped after sending; the email may have arrived. }
        invalid: { type: integer, description: Invalid address or missing data. In the CSV only. }

    MailMergeRowIssue:
      type: object
      properties:
        row_index: { type: integer, description: '0 is the first data row' }
        row: { type: integer, description: 'Spreadsheet row number (the header is row 1)' }
        email: { type: string, nullable: true }
        errors:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              column: { type: string }
              error: { type: string }
        duplicate_of: { type: integer }

    MailMergeValidation:
      type: object
      properties:
        row_count: { type: integer }
        valid_row_count: { type: integer }
        invalid_row_count: { type: integer }
        duplicate_count: { type: integer }
        columns:
          type: array
          items: { type: string }
        column_keys:
          type: object
          additionalProperties: { type: string }
          description: Column name to the token key email text can use.
        email_column: { type: string }
        mapping:
          type: object
          additionalProperties: { type: string }
        suggested_mapping:
          type: object
          additionalProperties: { type: string }
        unmapped_required_fields:
          type: array
          items: { type: string }
        unknown_tokens:
          type: array
          items: { type: string }
        invalid_rows:
          type: array
          items: { $ref: '#/components/schemas/MailMergeRowIssue' }
        duplicates:
          type: array
          items: { $ref: '#/components/schemas/MailMergeRowIssue' }

    MailMergePreview:
      type: object
      properties:
        row_index: { type: integer }
        row: { type: integer }
        to: { type: string }
        valid: { type: boolean }
        row_count: { type: integer }
        subject: { type: string }
        text: { type: string }
        html: { type: string }
        attachment_filename: { type: string }
        headers:
          type: object
          additionalProperties: { type: string }
        template_html: { type: string }
        page: { type: object, additionalProperties: true }
        errors:
          type: array
          items: { type: object, additionalProperties: true }

    MailMergeRecipient:
      type: object
      properties:
        row_index: { type: integer }
        row: { type: integer }
        email: { type: string }
        status:
          type: string
          enum: [rendering, sending, sent, failed, rejected, unconfirmed, skipped]
          description: '`unconfirmed`: the connection dropped after the message went out; it may have been delivered and is not sent again.'
        error_code: { type: string, nullable: true }
        error_message: { type: string, nullable: true }
        message_id: { type: string, nullable: true }
        pages: { type: integer, nullable: true }
        attempts: { type: integer }
        finished_at: { type: string, format: date-time, nullable: true }

    CancelJobResult:
      type: object
      properties:
        job_id: { $ref: '#/components/schemas/JobId' }
        status: { type: string, enum: [cancelled, processing] }
        cancel_requested: { type: boolean, example: true }
        message: { type: string, example: The job stops before its next file. PDFs already made are kept in the output and charged. }

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

  requestBodies:
    EditRequest:
      required: true
      content:
        application/json:
          schema: { $ref: '#/components/schemas/EditRequest' }
        multipart/form-data:
          schema: { $ref: '#/components/schemas/EditMultipartRequest' }

  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 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' }
    PdfOpTimeout:
      description: '`PDFOP_TIMEOUT` — the request took longer than 25 seconds. Nothing is charged.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PdfUnprocessable:
      description: |
        The request is well formed but this PDF or password cannot satisfy it: `INVALID_PDF`,
        `PDF_NO_PAGES`, `PDF_ENCRYPTED` (already protected; unlock it first),
        `PDF_PASSWORD_INCORRECT` (also when the owner password is needed:
        `details.needs`), `PDF_NOT_ENCRYPTED`, `PDF_ENCRYPTION_UNSUPPORTED` or
        `PDF_SECURITY_FAILED`.
      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' }
