openapi: 3.0.3
info:
  title: Easerix Sign API
  description: |
    E-signatures (backend/services/products/sign): documents with real PDF
    storage (Cloudflare R2), ordered recipients, positioned per-signer fields,
    an explicit send step that mints tokenized signing links, a public
    signing surface for external signers (view/fill/sign with ESIGN consent,
    or decline), an append-only hash-chained audit trail, and — on
    completion — a final PDF with signatures stamped in plus a certificate
    of completion page.

    Field coordinates are fractional (0..1) of the page with a top-left
    origin. Responses are camelCase; documents/templates use
    "created"/"modified" (not "updated"). All /v1 routes require a bearer
    JWT with org claims and the "sign" tool enabled (authkit). The
    /public/signing routes are unauthenticated — the per-recipient access
    token in the path is the credential — and are IP rate-limited.

    When R2 storage is not configured (local dev) the service runs in
    metadata-only mode: file upload returns 503 and /send does not require
    an attached file.
    Easerix Notary (Phase A) rides on this service: a public verified-notary
    directory (/public/notary, unauthenticated, rate-limited), an
    authenticated notary portal under /v1/notary (queue, sessions, and the
    practice OS: a hash-chained e-journal, money ledger, mileage, and
    compliance tracking), and one new envelope state — a document with
    requiresNotarization parks at "awaiting_notarization" after the last
    signature and completes when the notary uploads the sealed PDF. Sessions
    run on the notary's own registered RON technology in Phase A; Easerix is
    the marketplace and document rail.

    EAS-122 signer trust surface: e-signing brands are a heavily abused
    phishing lure, and a correct logo or domain in an email proves nothing.
    Every signature-request email now carries a short human-shareable
    verification code (independent of, and never derivable from, the
    recipient's actual signing token) alongside instructions to check it —
    not by clicking anything in that email — at the public, unauthenticated
    /public/verify/{code} endpoint. It confirms only that a request record
    matching that code is genuine (sender, document name, sent date, current
    status); it deliberately cannot and does not claim the sender is
    trustworthy or that their account hasn't been compromised — that
    judgment still rests on the recipient confirming the sender through a
    channel they already have. Rate-limited tighter than the rest of the
    public surface, since resolving a short code by exact match is the one
    public route here where guessing is a realistic attack.
    Authored from the shipping Gin handlers — this spec records reality.
  version: 2.8.0
  contact:
    name: Perizer Labs
    url: https://easerix.com
servers:
  - url: https://api.easerix.com/sign
    description: Production
tags:
  - name: system
    description: Health and liveness
  - name: documents
    description: Documents, files, recipients, and fields
  - name: lifecycle
    description: Send, remind, void, sign, audit
  - name: signing
    description: Public tokenized signing surface for external recipients
  - name: verification
    description: "EAS-122: public, unauthenticated request-verification lookup"
  - name: payments
    description: "EAS-119: collect payment at the moment of signing (Stripe Connect)"
  - name: public-links
    description: "EAS-117: publish a template as a public, unauthenticated self-serve signing link"
  - name: templates
    description: Reusable document templates
  - name: webhooks
    description: Outbound event subscriptions (HMAC-signed deliveries)
  - name: retention
    description: "EAS-115: retention and scheduled deletion policies"
  - name: notary-directory
    description: "Easerix Notary: public directory (state hubs, profiles) — unauthenticated"
  - name: notary
    description: "Easerix Notary: the notary portal, claims, and the practice OS"
  - name: engagement
    description: >-
      EAS-123: document engagement — read-only, aggregated from the existing
      documents/recipients data (first viewed, signed, time to sign, who is
      currently blocking, completion rate over a period). No new event
      tracking; view counts and last-viewed timestamps are out of scope,
      since the system only ever records a recipient's first view.

security:
  - bearer: []

paths:
  /health:
    get:
      operationId: health
      tags: [system]
      summary: Liveness probe
      description: Unauthenticated liveness check used by the platform and monitors.
      security: []
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object

  /docs:
    get:
      operationId: apiReference
      tags: [system]
      summary: API reference UI (password-protected)
      description: >-
        Scalar API reference for the running instance, gated by HTTP Basic
        Auth (DOCS_USER/DOCS_PASSWORD) — not linked from any product surface.
        The route is only registered at all when both env vars are set; an
        unconfigured deployment 404s here instead of serving it open. Served
        at the root, never under BASE_PATH, so the URL is the same however
        the service is routed.
      security:
        - docsBasic: []
      responses:
        "200":
          description: The reference UI
          content:
            text/html:
              schema:
                type: string

  /docs/openapi.yaml:
    get:
      operationId: openAPISpec
      tags: [system]
      summary: This contract, served by the running instance (password-protected)
      description: >-
        The embedded copy of this OpenAPI document — backs the reference UI
        above and is handy for local codegen against a running instance. Same
        Basic Auth gate as /docs.
      security:
        - docsBasic: []
      responses:
        "200":
          description: The OpenAPI document
          content:
            text/plain:
              schema:
                type: string

  /v1/dev-storage/file:
    get:
      operationId: devStorageFile
      tags: [system]
      summary: Local-disk storage dev fallback (never present in production)
      description: >-
        Only registered when the service falls back to local-disk file
        storage because no R2 credentials are configured AND the service is
        not running in production (see internal/storage/local.go) — the
        route does not exist at all otherwise. Serves the file an HMAC-signed
        "key"/"exp"/"sig" query string authorizes, standing in for a real R2
        presigned URL. Unauthenticated by design: the signature is the
        credential, scoped to a single key and short TTL, and this whole
        code path never runs in production.
      security: []
      parameters:
        - name: key
          in: query
          required: true
          schema: { type: string }
        - name: exp
          in: query
          required: true
          schema: { type: string }
        - name: sig
          in: query
          required: true
          schema: { type: string }
        - name: cd
          in: query
          required: false
          description: Content-Disposition header value for the response.
          schema: { type: string }
      responses:
        "200":
          description: File bytes
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "403":
          description: Bad/expired/mismatched signature, or local-disk mode is off
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/documents:
    get:
      operationId: listDocuments
      tags: [documents]
      summary: List documents
      description: >-
        Documents the caller can read: their own (active-org-scoped; legacy
        rows without org_id stay visible) plus any shared with the
        organization by a teammate. status/q/needsMe filter the page and
        `total`; counts/toSignCount/waitingCount/hasShared always describe
        the scope-only set, unaffected by status/q/needsMe/sort/limit/offset
        — same convention as Templates' `categories`, so switching tabs or
        typing a search never makes the other tabs' counts move. The result
        is paged only when limit or offset is sent; without either, every
        matching document is returned (as before this endpoint was
        paginated), up to 1000, and limit reports how many came back —
        total > that count means the list was cut off.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, pending, completed, declined, cancelled, expired, awaiting_notarization]
        - name: scope
          in: query
          description: >-
            all (default) — everything readable · mine — only the caller's own
            · shared — only teammates' org-shared documents (excludes own).
          schema:
            type: string
            enum: [all, mine, shared]
        - name: q
          in: query
          description: >-
            Case-insensitive substring match against the document name or a
            recipient's name/email. "%" and "_" match literally.
          schema: { type: string }
        - name: sort
          in: query
          description: >-
            activity (default) — updated_at descending · created — created_at
            descending · name — alphabetical · status — in-flight work first,
            dead envelopes last, ties broken by activity.
          schema:
            type: string
            enum: [activity, created, name, status]
        - name: needsMe
          in: query
          description: >-
            true narrows to pending documents where the caller is a signer
            who hasn't signed or declined yet (not turn-order aware — just
            "am I owed a signature"). Matches nothing for a token with no
            email claim.
          schema: { type: boolean }
        - name: limit
          in: query
          required: false
          description: >-
            Page size, default 10, capped at 100. Omit both limit and offset
            to get every matching document unpaged (up to 1000).
          schema: { type: integer }
        - name: offset
          in: query
          required: false
          description: Page start. Default 0. Sending it alone pages at the default size.
          schema: { type: integer }
      responses:
        "200":
          description: Documents
          content:
            application/json:
              schema:
                type: object
                required: [documents, total, limit, offset, counts, toSignCount, waitingCount, hasShared]
                properties:
                  documents:
                    type: array
                    items: { $ref: "#/components/schemas/Document" }
                  total:
                    type: integer
                    description: Count matching status/q/needsMe within scope, ignoring limit and offset.
                  limit: { type: integer }
                  offset: { type: integer }
                  counts:
                    type: object
                    description: Per-status document counts (plus "all") over the scope-only set, ignoring status/q/needsMe.
                    additionalProperties: { type: integer }
                  toSignCount:
                    type: integer
                    description: Pending documents in scope where the caller still owes a signature, ignoring status/q/needsMe.
                  waitingCount:
                    type: integer
                    description: Pending documents in scope where someone other than the caller still owes a signature, ignoring status/q/needsMe.
                  hasShared:
                    type: boolean
                    description: Whether the caller has switched to a non-"all" scope, or the scope-only set contains an org-shared document.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createDocument
      tags: [documents]
      summary: Create a draft
      description: >-
        Creates a draft. The PDF is attached separately (POST
        /v1/documents/{id}/file) and nothing is emailed until /send. With a
        templateId (public or owned), template fields are copied and — if no
        recipients were sent — template recipient slots are created with
        empty emails; template usageCount increments. An unresolvable
        templateId is a 400. Recipients passed here are created with status
        "pending".
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 200 }
                pages: { type: integer, description: Coerced to at least 1; replaced by the real page count on upload }
                size: { type: integer, format: int64, description: Replaced by the real byte size on upload }
                templateId: { type: string, nullable: true }
                recipients:
                  type: array
                  items:
                    type: object
                    required: [name, email]
                    properties:
                      name: { type: string }
                      email: { type: string }
                      role: { type: string, description: signer | viewer | cc — defaults to "signer" }
      responses:
        "201":
          description: Draft created
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/events:
    get:
      operationId: streamEvents
      tags: [documents]
      summary: Live events (SSE)
      description: >-
        A Server-Sent Events stream of live events for the caller. Today it carries
        file-conversion events, and further event types may be added, so clients
        must ignore event names they do not know. It tells the caller when a file they
        uploaded finishes converting to PDF (status "ready" or "failed"),
        replacing per-document polling. Scoped to the caller: only files on
        documents they own are reported, taken from the verified token. Frames
        carry ids and the new status only; re-read the document
        (GET /v1/documents/{id}) for the data.

        The stream emits `event: ready` once on connect (re-check any file you
        are waiting on — you may have missed an event while disconnected), then
        `event: conversion` with `{"documentId","fileId","status"}` as files
        settle, plus `: ping` heartbeat comments every 25s. The server ends the
        stream after 15 minutes; reconnect with a fresh token. Delivery is
        best-effort — keep a slow fallback poll of the document. Answers 503
        when the server has no event listener; fall back to polling.
      responses:
        "200":
          description: An event stream
          content:
            text/event-stream:
              schema:
                type: string
                example: |
                  event: conversion
                  data: {"documentId":"7f3e…","fileId":"a91c…","status":"ready"}
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "503": { description: Conversion events are unavailable on this instance, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: "This server cannot stream responses (Server-Sent Events)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/conversion-events:
    get:
      operationId: streamConversionEvents
      deprecated: true
      tags: [documents]
      summary: Live file-conversion events (SSE) — deprecated alias of GET /v1/events
      description: >-
        A Server-Sent Events stream telling the caller when a file they
        uploaded finishes converting to PDF (status "ready" or "failed"),
        replacing per-document polling. Scoped to the caller: only files on
        documents they own are reported, taken from the verified token. Frames
        carry ids and the new status only; re-read the document
        (GET /v1/documents/{id}) for the data.

        The stream emits `event: ready` once on connect (re-check any file you
        are waiting on — you may have missed an event while disconnected), then
        `event: conversion` with `{"documentId","fileId","status"}` as files
        settle, plus `: ping` heartbeat comments every 25s. The server ends the
        stream after 15 minutes; reconnect with a fresh token. Delivery is
        best-effort — keep a slow fallback poll of the document. Answers 503
        when the server has no event listener; fall back to polling.
      responses:
        "200":
          description: An event stream
          content:
            text/event-stream:
              schema:
                type: string
                example: |
                  event: conversion
                  data: {"documentId":"7f3e…","fileId":"a91c…","status":"ready"}
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "503": { description: Conversion events are unavailable on this instance, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: "This server cannot stream responses (Server-Sent Events)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getDocument
      tags: [documents]
      summary: Document detail
      description: One document with recipients (ordered) and fields.
      responses:
        "200":
          description: Document
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateDocument
      tags: [documents]
      summary: Update a document
      description: >-
        Rename, share (visibility), or set notarization on a draft. Sending a
        status is rejected with 400 — lifecycle transitions go through /send
        and /void so they stay audited. requiresNotarization/notaryState are
        draft-only (409 otherwise); notaryState must be a two-letter US state.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 200 }
                visibility: { type: string, description: private | org }
                requiresNotarization: { type: boolean }
                notaryState: { type: string }
      responses:
        "200":
          description: Updated document
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "requiresNotarization/notaryState was set on a non-draft document", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      operationId: deleteDocument
      tags: [documents]
      summary: Delete a document
      description: >-
        Owner-scoped delete; recipients, fields, and audit events cascade.
        Only drafts and closed documents (completed, declined, cancelled,
        expired) can be deleted, and never one under legal hold — a live
        document (pending, awaiting notarization, finalizing) is voided
        instead.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "The document is live (void it instead) or under legal hold", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/documents/{id}/file:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: uploadDocumentFile
      tags: [documents]
      summary: Attach the PDF to a draft
      description: >-
        Multipart upload (field "file", 25 MB max). Accepts PDF, Word
        (.doc/.docx), Excel (.xls/.xlsx), CSV (comma-, semicolon- or
        pipe-separated), PowerPoint (.ppt/.pptx), and image (JPG/PNG/GIF)
        files, detected from content, not the filename — anything
        that isn't already a PDF is converted to one server-side before it's
        stored. Page count and SHA-256 are computed from the resulting PDF and
        replace the client-asserted values. Drafts only. An empty file is a
        400, and the stored file name is cut to 255 characters.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: Document with the file attached
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Not a draft, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "413": { description: "Over the 25 MB limit, whether the file itself or the PDF it converts to. A request that declares its length past the limit is refused without its body being read", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "415": { description: Unsupported file type, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: "Unreadable/encrypted PDF, corrupted image, a conversion that didn't produce a readable PDF, a CSV saved as UTF-16 (a supported type whose encoding can't be read), or a spreadsheet too wide or too long to convert, estimated at more than 500 pages (the message says what to do instead)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: The Word/Excel/CSV/PowerPoint conversion failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "Storage not configured, or (for a Word/Excel/CSV/PowerPoint upload) the conversion service is not configured", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    get:
      operationId: downloadDocumentFile
      tags: [documents]
      summary: Get a download URL
      description: >-
        Returns a 15-minute presigned URL. Defaults to the final signed
        artifact when it exists, else the original; force one with ?which.
        The URL carries an attachment disposition by default, so the browser
        saves it; ?disposition=inline makes the browser render it instead,
        which is what printing and in-tab previews need.
      parameters:
        - name: which
          in: query
          schema:
            type: string
            enum: [original, final]
        - name: disposition
          in: query
          description: >-
            Content-Disposition on the presigned URL. "attachment" (default)
            downloads; "inline" renders in the browser.
          schema:
            type: string
            enum: [attachment, inline]
            default: attachment
      responses:
        "200":
          description: Presigned download URL
          content:
            application/json:
              schema:
                type: object
                required: [url, expiresIn]
                properties:
                  url: { type: string }
                  expiresIn: { type: integer, description: Seconds }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { description: Storage error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/files:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: addDocumentFile
      tags: [documents]
      summary: Append another file to a draft (EAS-114)
      description: >-
        Adds one more file to the document's ordered collection — a real
        onboarding pack or closing set is several PDFs signed as one package.
        POST /v1/documents/{id}/file (singular) still attaches/replaces the
        FIRST file, for every caller written before this endpoint existed;
        this is purely additive. Same upload rules as the singular endpoint
        (multipart field "file", 25 MB max, non-PDF formats converted
        server-side). Fields are placed per file — see DocumentField.fileId —
        and the completed download merges every file in order with the
        certificate of completion last. Drafts only.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "201":
          description: Document with the file appended
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Not a draft, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "413": { description: "Over the 25 MB limit, whether the file itself or the PDF it converts to. A request that declares its length past the limit is refused without its body being read", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "415": { description: Unsupported file type, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: "Unreadable/encrypted PDF, corrupted image, a conversion that didn't produce a readable PDF, a CSV saved as UTF-16 (a supported type whose encoding can't be read), or a spreadsheet too wide or too long to convert, estimated at more than 500 pages (the message says what to do instead)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: The Word/Excel/CSV/PowerPoint conversion failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "Storage not configured, or (for a Word/Excel/CSV/PowerPoint upload) the conversion service is not configured", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/files/{fileId}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: fileId, in: path, required: true, schema: { type: string } }
    get:
      operationId: downloadDocumentFileById
      tags: [documents]
      summary: Get a download URL for one file in the collection (EAS-114)
      description: >-
        Companion to GET /v1/documents/{id}/file, which only ever serves
        "file #1" (the legacy single-file columns) — this works for any file
        in the ordered collection, including the first. Returns a 15-minute
        presigned URL.
      parameters:
        - name: disposition
          in: query
          description: >-
            Content-Disposition on the presigned URL. "attachment" (default)
            downloads; "inline" renders in the browser.
          schema:
            type: string
            enum: [attachment, inline]
            default: attachment
      responses:
        "200":
          description: Presigned download URL
          content:
            application/json:
              schema:
                type: object
                required: [url, expiresIn]
                properties:
                  url: { type: string }
                  expiresIn: { type: integer, description: Seconds }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { description: Could not create a download link, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      operationId: deleteDocumentFile
      tags: [documents]
      summary: Remove one file from a draft's collection (EAS-114)
      description: >-
        Deletes one file; its own fields go with it. Remaining files are
        renumbered to a contiguous 0..n-1 order, and the legacy single-file
        columns (as read by GET .../file "original", and by every pre-EAS-114
        caller) follow the new first file — or clear if none remain. Drafts
        only.
      responses:
        "200":
          description: Document with the file removed
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Not a draft, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/fields/{fid}/upload:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: fid, in: path, required: true, schema: { type: string } }
    get:
      operationId: downloadFieldUpload
      tags: [documents]
      summary: Get a download URL for a signer's uploaded file (EAS-110)
      description: >-
        Same permission model as every other file download here — anyone
        who can load the document at all can fetch it. 404 when the field
        isn't a file field, doesn't belong to this document, or has no
        upload yet. Returns a 15-minute presigned URL.
      parameters:
        - name: disposition
          in: query
          schema: { type: string, enum: [attachment, inline], default: attachment }
      responses:
        "200":
          description: Presigned download URL
          content:
            application/json:
              schema:
                type: object
                required: [url, expiresIn]
                properties:
                  url: { type: string }
                  expiresIn: { type: integer, description: Seconds }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { description: Could not create a download link, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/files/order:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    put:
      operationId: reorderDocumentFiles
      tags: [documents]
      summary: Reorder a draft's file collection (EAS-114)
      description: >-
        Body lists every current file id exactly once, in the new order —
        the same order files are stamped, merged, and rendered in. Drafts
        only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order]
              properties:
                order:
                  type: array
                  items: { type: string }
                  description: Every file id on the document, in the desired order.
      responses:
        "200":
          description: Document with files reordered
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { description: Order doesn't list every file exactly once, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Not a draft, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/recipients:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: addRecipient
      tags: [documents]
      summary: Add a recipient, or a signer-group slot, to a draft
      description: >-
        An individual recipient (the default) needs an email. Setting
        isGroup true (EAS-125) instead creates a signer-group slot — one
        signing slot any of several people can satisfy — which starts with
        no email and no members; members are added with POST
        .../recipients/{rid}/members, and at least one is required before
        the document can be sent. A group's role must be "signer".
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 200 }
                email: { type: string, description: Required unless isGroup is true }
                role: { type: string, enum: [signer, viewer, cc], description: Defaults to "signer" when omitted; any other value is a 400 }
                orderIndex: { type: integer, description: Defaults to the end of the list }
                accessCode: { type: string, description: "Optional signer access code: 4 to 64 printable ASCII characters (letters, digits, spaces, standard punctuation; no accented, non-Latin or emoji characters, which a browser cannot send back in a header). Stored as a salted bcrypt hash, communicated out-of-band. A common code (1234, 0000, abcd, password, one repeated character, a run such as 4321) or one that is the recipient's name or the part of their email before the @ is refused with 400 access_code_too_common. Shared by every member when isGroup is true. A code set before the ASCII rule can still be entered by the signer." }
                isGroup: { type: boolean, description: "EAS-125: create a signer-group slot instead of an individual recipient. Requires role \"signer\"." }
                groupLabel: { type: string, description: "Display name for a group slot (defaults to name, then \"Group\")" }
                verificationMethod: { $ref: "#/components/schemas/VerificationMethod" }
      responses:
        "201":
          description: Document with the new recipient
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { description: "Invalid or incomplete request body, or the access code is refused: access_code_too_common (a common code, or one that is the recipient's name or the part of their email before the @). The error code is in the body's code field", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Not a draft, or (isGroup) another group on this document already has that label (case-insensitive)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "The recipient's verification method needs a provider this deployment has not configured (verification_unavailable), or the provider is failing temporarily", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/recipients/{rid}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: rid, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateRecipient
      tags: [documents]
      summary: Update a recipient on a draft, or correct one on a pending document
      description: >-
        On a draft, the usual full edit (name, email, role, order, access
        code). On a pending document, only name and email may be
        corrected, and only for a recipient who hasn't signed yet — role,
        order, access code, and isGroup are structural, draft-only settings.
        Correcting email invalidates that recipient's current signing link
        (only theirs; every other recipient's token lives on their own row)
        and emails them a fresh one. Every correction is recorded in the
        document's audit trail with what changed. A correction can change a
        name but never blank it (a group slot always needs one too; only a
        draft individual may be nameless), and on a pending document cannot
        move a recipient onto an address another individual recipient already
        has, and once a payment has started on the slot the email cannot change at all (409 email_blocked_payment), because the charge belongs to the person who started it. Re-casing a recipient's own address is not a correction: it
        changes nothing and issues no new link. Names refuse control
        characters and invisible formatting characters (bidi overrides,
        zero-width space). Draft-only, isGroup true
        additionally converts an individual slot that has no email yet into
        a signer group (name becomes its label) — one-way, and only while
        the slot is still unaddressed; a group can't be converted back.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 200 }
                email: { type: string }
                role: { type: string, description: Draft-only }
                orderIndex: { type: integer, description: Draft-only }
                accessCode: { type: string, description: "Draft-only. Set or replace the access code (4 to 64 printable ASCII characters) — empty string clears it. A common code, or one that is the recipient's name or email local part as it will be after this update, is refused with 400 access_code_too_common" }
                isGroup: { type: boolean, description: "Draft-only. true converts an unaddressed individual slot into a signer group; false on an already-individual slot is a no-op. Converting a group back to an individual isn't supported." }
                verificationMethod: { allOf: [{ $ref: "#/components/schemas/VerificationMethod" }], description: "Draft-only. Once sent, the method is fixed — it cannot be loosened or newly imposed (restating the current value is a no-op). Changing it discards any challenge or verified session and is audited as verification_method_changed." }
      responses:
        "200":
          description: Document after the update
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { description: "Invalid or incomplete request body, a blank name on a pending document or a group, a name with control or invisible formatting characters, an access code that is refused (access_code_too_common: a common code, or one that is the recipient's name or the part of their email before the @), or a verification change that is refused: verification_method_locked (the method is fixed once the document is sent and cannot be loosened or newly imposed) or verification_group_unsupported (a signer-group slot cannot require verification)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Not draft/pending, this recipient has already signed, the signing deadline has passed, (pending) another individual recipient already uses that email address, or (renaming a group, or converting to one) another group on this document already has that label", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "The recipient's verification method needs a provider this deployment has not configured (verification_unavailable), or the provider is failing temporarily", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      operationId: deleteRecipient
      tags: [documents]
      summary: Remove a recipient from a draft
      description: Their assigned fields become unassigned.
      responses:
        "200":
          description: Document after removal
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Not a draft, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/recipients/{rid}/members:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: rid, in: path, required: true, schema: { type: string } }
    post:
      operationId: addGroupMember
      tags: [documents]
      summary: Add one or several candidate members to a signer-group slot (EAS-125)
      description: >-
        Draft-only, and only on a recipient created with isGroup true and
        role "signer". Duplicate emails within the same group are rejected,
        case-insensitively, both here and at the database level.


        Send either name/email (one member) or members (several at once —
        e.g. adding a suggested roster in one action instead of one call per
        person) — never both. A batch is all-or-nothing: any invalid entry,
        or any entry colliding with an existing member or with another entry
        in the same batch, fails the whole call and nothing lands, so the
        caller never has to reconcile a partial result. Capped at 50 members
        per request either way.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 200, description: "Single-member form — required with email, and only when members is omitted" }
                email: { type: string, description: "Single-member form — required with name, and only when members is omitted" }
                members:
                  type: array
                  maxItems: 50
                  description: Batch form — required instead of name/email, not alongside it
                  items:
                    type: object
                    required: [name, email]
                    properties:
                      name: { type: string, maxLength: 200 }
                      email: { type: string }
      responses:
        "201":
          description: Document with the new member(s)
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Not a draft, this recipient is not a signer group, or one or more emails is already a member of it", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/recipients/{rid}/members/{mid}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: rid, in: path, required: true, schema: { type: string } }
      - { name: mid, in: path, required: true, schema: { type: string } }
    delete:
      operationId: deleteGroupMember
      tags: [documents]
      summary: Remove a member from a signer-group slot (EAS-125)
      description: Draft-only.
      responses:
        "200":
          description: Document after removal
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Not a draft, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/fields:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    put:
      operationId: putFields
      tags: [documents]
      summary: Replace the field set on a draft
      description: >-
        The editor saves its whole canvas in one shot. Types, page range,
        fractional geometry, and recipient bindings are validated. A field's
        id is regenerated UNLESS the client echoes back one this document
        already has (EAS-110) — do that for any field a formula's config
        references, or the reference breaks on the next save.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fields]
              properties:
                fields:
                  type: array
                  items:
                    type: object
                    required: [type, x, y, width, height, page]
                    properties:
                      id: { type: string, description: "Echo back an id this document already has to keep that field's identity (EAS-110); omitted/unknown always mints a new one." }
                      type: { type: string, enum: [signature, initial, date, text, checkbox, dropdown, radio, number, formula, file, payment] }
                      x: { type: number }
                      y: { type: number }
                      width: { type: number }
                      height: { type: number }
                      page: { type: integer, description: "Page within fileId's file (EAS-114), not a running count across files" }
                      required: { type: boolean }
                      label: { type: string, nullable: true }
                      recipientId: { type: string, nullable: true, description: "Never set for a formula field — it's computed, not signer input." }
                      fileId: { type: string, nullable: true, description: "Which file (EAS-114) this field sits on; omitted/empty means the document's first file" }
                      config: { type: object, nullable: true, additionalProperties: true, description: "See DocumentField.config." }
      responses:
        "200":
          description: Document with the new field set
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Not a draft, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: addField
      tags: [documents]
      summary: Add one field to a pending document
      description: >-
        Adds a single field while pending, unlike PUT on this same
        path (draft-only, whole-canvas replace). If recipientId is set, that
        recipient must not have signed yet. A required field must be assigned
        to a signer (the same rule Send applies; a formula is exempt), so a
        document can't be corrected into one nobody can complete. A field is
        at least 0.5% of the page in each direction, and its label is at most
        200 characters with no control or invisible formatting characters.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, x, y, width, height, page]
              properties:
                type: { type: string, enum: [signature, initial, date, text, checkbox, dropdown, radio, number, formula, file, payment] }
                x: { type: number }
                y: { type: number }
                width: { type: number }
                height: { type: number }
                page: { type: integer, description: "Page within fileId's file (EAS-114), not a running count across files" }
                required: { type: boolean }
                label: { type: string, nullable: true }
                recipientId: { type: string, nullable: true, description: "Never set for a formula field — it's computed, not signer input." }
                fileId: { type: string, nullable: true, description: "Which file (EAS-114) this field sits on; omitted/empty means the document's first file" }
                config: { type: object, nullable: true, additionalProperties: true, description: "See DocumentField.config." }
      responses:
        "201":
          description: Document with the new field
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
                  field: { $ref: "#/components/schemas/DocumentField" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Not pending, the target recipient has already signed, or the signing deadline has passed", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/fields/{fid}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: fid, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateField
      tags: [documents]
      summary: Correct one field on a pending document
      description: >-
        Moves, resizes, relabels, retoggles required, or reassigns
        one field, while the document is pending and that field's (and, on
        reassignment, the target's) recipient hasn't signed yet. Unlike
        PUT .../fields, this never touches any other field, never
        regenerates an id, and never drops a value. Recorded in the audit
        trail with what changed. The field must still satisfy the rule Send
        applies once edited: a required field stays assigned to a signer, so
        unassigning one, or marking an unassigned one required, is a 400. A
        field's type can't be changed (restating it is a no-op); remove the
        field and add a new one.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                x: { type: number }
                y: { type: number }
                width: { type: number }
                height: { type: number }
                page: { type: integer }
                required: { type: boolean }
                label: { type: string, nullable: true, maxLength: 200 }
                recipientId: { type: string, nullable: true, description: "Empty string unassigns, unless the field is required. Rejected for a formula field — it's never assignable." }
                type: { type: string, description: "Only restating the field's current type is accepted; a different type is a 400." }
                config: { type: object, nullable: true, additionalProperties: true, description: "See DocumentField.config. Re-validated in full, including formula reference/cycle checks, whenever supplied." }
      responses:
        "200":
          description: Document after the correction
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
                  field: { $ref: "#/components/schemas/DocumentField" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Not pending, this field's (or target's) recipient has already signed, or the signing deadline has passed", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      operationId: deleteField
      tags: [documents]
      summary: Remove one field from a pending document
      description: Only while its recipient (if any) hasn't signed yet.
      responses:
        "200":
          description: Document after removal
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Not pending, this field's recipient has already signed, the signing deadline has passed, or a calculated field still reads this field (remove or edit that field first)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/send:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: sendDocument
      tags: [lifecycle]
      summary: Send for signature
      description: >-
        Validates the draft (at least one signer, every recipient has an
        email, a file is attached when storage is enabled, required fields
        are assigned), mints per-recipient signing tokens, emails signature
        requests (sequential mode notifies only the current order group;
        viewers/cc get view links), and moves the document to "pending".
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                message: { type: string, description: Included in the request email }
                signingMode: { type: string, enum: [parallel, sequential], description: Defaults to parallel when omitted; any other value is a 400 }
                expiresInDays: { type: integer, description: "0 (never, default) to 365 — after this many days the document expires and links stop working" }
                autoRemindDays: { type: integer, description: "0 (off, default) to 30 — unsigned signers are automatically re-emailed on this cadence" }
      responses:
        "200":
          description: Document after sending
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Already sent, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "The recipient's verification method needs a provider this deployment has not configured (verification_unavailable), or the provider is failing temporarily", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/bulk-send:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: bulkSendDocument
      tags: [lifecycle]
      summary: Fan a draft out as one envelope per row
      description: >-
        The draft is the master: its file, fields, and recipient slots
        (roles, order, access codes) are cloned per row, with each row's
        names/emails mapped to the slots in order. Every clone goes through
        the standard send mechanics (tokens, emails, audit, webhooks). The
        master stays a draft for reuse. At most 100 rows; each row must
        supply exactly one recipient per slot, every one with an email.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rows]
              properties:
                rows:
                  type: array
                  items:
                    type: object
                    required: [recipients]
                    properties:
                      recipients:
                        type: array
                        items:
                          type: object
                          required: [email]
                          properties:
                            name: { type: string }
                            email: { type: string }
                message: { type: string }
                signingMode: { type: string, enum: [parallel, sequential] }
                expiresInDays: { type: integer }
                autoRemindDays: { type: integer }
      responses:
        "200":
          description: Envelopes created and sent
          content:
            application/json:
              schema:
                type: object
                required: [sent, documentIds]
                properties:
                  sent: { type: integer }
                  documentIds:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Not a draft, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "The recipient's verification method needs a provider this deployment has not configured (verification_unavailable), or the provider is failing temporarily", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/documents/{id}/remind:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: remindDocument
      tags: [lifecycle]
      summary: Re-email unsigned signers
      description: Re-sends the signing link to every unsigned signer whose turn it is. Pending documents only.
      responses:
        "200":
          description: Reminder count
          content:
            application/json:
              schema:
                type: object
                required: [reminded]
                properties:
                  reminded: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Not pending, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/void:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: voidDocument
      tags: [lifecycle]
      summary: Void a draft or pending document
      description: >-
        Status becomes "cancelled", every signing token is invalidated
        immediately, unsigned recipients are notified by email, and the void
        (with reason) is audited.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        "200":
          description: Voided document
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Already closed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/legal-hold:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      operationId: setDocumentLegalHold
      tags: [retention]
      summary: Place or lift a legal hold
      description: >-
        A hold blocks the EAS-115 retention purge sweep unconditionally,
        regardless of age, and clears scheduledPurgeAt immediately if the
        document was already queued. Lifting a hold does not re-queue it —
        the next sweep tick re-evaluates it like any other document.
        Owner-scoped, same as void/delete.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hold]
              properties:
                hold: { type: boolean }
      responses:
        "200":
          description: Updated document
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/documents/scheduled-deletions:
    get:
      operationId: listScheduledDeletions
      tags: [retention]
      summary: Documents queued for retention purge
      description: >-
        Every document the caller can see (readable scope — owned rows plus
        the workspace's org-shared ones) currently flagged for deletion
        (scheduledPurgeAt set), soonest first — the EAS-115 "see what's
        scheduled before it happens" surface. Sign has no separate admin role
        today, so this is scoped like every other list, not gated any tighter.
      parameters:
        - name: q
          in: query
          required: false
          description: Case-insensitive substring match against the document's name.
          schema: { type: string }
        - name: limit
          in: query
          required: false
          description: Page size. Default 10, capped at 100.
          schema: { type: integer }
        - name: offset
          in: query
          required: false
          description: Page start. Default 0.
          schema: { type: integer }
      responses:
        "200":
          description: Scheduled deletions
          content:
            application/json:
              schema:
                type: object
                required: [documents, total, limit, offset]
                properties:
                  documents:
                    type: array
                    items:
                      type: object
                      required: [id, name, status, scheduledPurgeAt, legalHold]
                      properties:
                        id: { type: string }
                        name: { type: string }
                        status: { type: string }
                        scheduledPurgeAt: { type: string, format: date-time }
                        legalHold: { type: boolean }
                  total: { type: integer, description: "Total rows matching the filter, for pagination." }
                  limit: { type: integer }
                  offset: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }

  /v1/retention-policy:
    get:
      operationId: getRetentionPolicy
      tags: [retention]
      summary: The caller's workspace retention policy
      description: >-
        No policy configured yet reports the same "keep forever" default a
        fresh one would (retentionDays: null, graceDays: 14) — a workspace
        never has to opt in to get default behavior.
      responses:
        "200":
          description: Retention policy
          content:
            application/json:
              schema:
                type: object
                required: [policy]
                properties:
                  policy: { $ref: "#/components/schemas/RetentionPolicy" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    put:
      operationId: putRetentionPolicy
      tags: [retention]
      summary: Set the workspace retention policy
      description: >-
        retentionDays omitted or null means keep forever. Once set, a
        terminal document (completed/declined/cancelled/expired) older than
        retentionDays past its terminal timestamp is flagged for purge and
        given graceDays before it is actually deleted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                retentionDays: { type: integer, nullable: true, minimum: 1, maximum: 36500, description: "1 to 36500, or null/omitted for keep forever" }
                graceDays: { type: integer, minimum: 1, maximum: 36500, description: "1 to 36500; defaults to 14" }
      responses:
        "200":
          description: Updated retention policy
          content:
            application/json:
              schema:
                type: object
                required: [policy]
                properties:
                  policy: { $ref: "#/components/schemas/RetentionPolicy" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/documents/{id}/sign:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: signDocument
      tags: [lifecycle]
      summary: In-person signing (owner session)
      description: >-
        Records a signature for a signer on the owner's device — self-sign or
        hand-the-device signing. External signers use their tokenized link
        instead. Requires a PNG data-URL signature; targets the given
        recipientId or the first unsigned signer; enforces sequential order;
        zero-signer documents cannot complete. Completion triggers the same
        finalize path as remote signing (stamped final PDF + certificate +
        emails).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signature]
              properties:
                recipientId: { type: string }
                signature: { type: string, description: PNG data URL from the signature pad }
      responses:
        "200":
          description: Document after signing
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Closed, already signed, or out of turn", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/documents/{id}/audit:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: auditTrail
      tags: [lifecycle]
      summary: Full audit trail
      description: >-
        Every lifecycle event in order — created, file_attached, sent,
        viewed, field_filled, signed, declined, reminded, voided, renamed,
        completed — with actor, IP, user agent, and the per-document SHA-256
        hash chain.
      responses:
        "200":
          description: Audit events
          content:
            application/json:
              schema:
                type: object
                required: [events]
                properties:
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/AuditEvent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/documents/{id}/certificate:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: auditCertificate
      tags: [lifecycle]
      summary: Certificate of completion as a standalone PDF
      description: >-
        The same certificate that is appended to the signed document at
        completion — original SHA-256, every signer with their consent and
        signing timestamps, IP and user agent, and the full hash-chained
        event log — rendered on its own so it can be filed or sent to a
        counterparty without the agreement itself. Generated per request
        from the stored rows, so it stays current as the trail grows, and it
        is available before completion.
      responses:
        "200":
          description: Certificate PDF
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/documents/{id}/engagement:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: documentEngagement
      tags: [engagement]
      summary: Per-recipient engagement detail for one document
      description: >-
        First viewed, signed, time to sign, and who is currently blocking
        (sequential/parallel/group-aware, reusing the same turn-order logic
        the signing flow itself uses) — for one document. firstViewedAt is
        exactly that: the recipient's first open, since the platform never
        records a repeat view. timeToSignSeconds falls back to
        sentAt→signedAt when viewedAt is null (e.g. the owner signed this
        recipient in person), reported via timeToSignBasis
        ("fromView"|"fromSend"|"fromTurn") so they are never silently mixed.
      responses:
        "200":
          description: Document engagement detail
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DocumentEngagement" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/documents/waiting:
    get:
      operationId: waitingDocuments
      tags: [engagement]
      summary: Pending documents and who is currently blocking each
      description: >-
        Every PENDING document the caller can see (same readable scope as
        every other document list), with who is currently holding it up —
        the lowest-order unsigned signer for a sequential document, or every
        unsigned signer for a parallel one, group-aware. No idle-day
        threshold is applied: there is no existing product rule for what
        counts as "stalled" versus merely pending, so this reports the
        unconditional blocking state and leaves any staleness judgement to
        the caller.
      parameters:
        - name: limit
          in: query
          required: false
          description: Page size. Default 10, capped at 100.
          schema: { type: integer }
        - name: offset
          in: query
          required: false
          description: Page start. Default 0.
          schema: { type: integer }
      responses:
        "200":
          description: Waiting documents
          content:
            application/json:
              schema:
                type: object
                required: [documents, total, limit, offset]
                properties:
                  documents:
                    type: array
                    items: { $ref: "#/components/schemas/WaitingDocument" }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }

  /v1/documents/completion-stats:
    get:
      operationId: completionStats
      tags: [engagement]
      summary: Completion rate and time over a date range
      description: >-
        Every sent document the caller can see, optionally narrowed to a
        sent_at date range (from/to — RFC3339 or YYYY-MM-DD), with a summary
        (sentCount, completedCount, completionRate, avgCompletionSeconds)
        computed over the FULL filtered cohort plus the underlying documents
        for drill-down. completionRate is null (not 0) when sentCount is 0 —
        an empty period must never render as "0% completion". The cohort is
        always "documents sent in this window" for both the numerator and
        denominator; it is never silently swapped to "documents completed in
        this window" under a different sent_at filter.
      parameters:
        - name: from
          in: query
          required: false
          description: Start of the sent_at range (inclusive). RFC3339 or YYYY-MM-DD.
          schema: { type: string }
        - name: to
          in: query
          required: false
          description: >-
            End of the sent_at range (inclusive). RFC3339 or YYYY-MM-DD. A
            bare YYYY-MM-DD date includes the entire day — e.g. to=2026-09-17
            covers everything sent through 2026-09-17T23:59:59.999999999,
            not just up to midnight at the start of that day. An explicit
            RFC3339 timestamp with a time component is used exactly as given.
          schema: { type: string }
        - name: limit
          in: query
          required: false
          description: Page size for the documents drill-down list. Default 10, capped at 100.
          schema: { type: integer }
        - name: offset
          in: query
          required: false
          description: Page start for the documents drill-down list. Default 0.
          schema: { type: integer }
      responses:
        "200":
          description: Completion statistics
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CompletionStats" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }

  /v1/documents/completion-stats.csv:
    get:
      operationId: completionStatsExport
      tags: [engagement]
      summary: Completion statistics as CSV
      description: >-
        The same from/to-filtered document rows as GET
        /v1/documents/completion-stats, streamed as CSV instead of paginated
        JSON (capped, with a trailing "truncated" row if the cap is hit — the
        same convention the links service's exports use). Recorded in the
        platform audit log before streaming.
      parameters:
        - name: from
          in: query
          required: false
          schema: { type: string }
        - name: to
          in: query
          required: false
          schema: { type: string }
      responses:
        "200":
          description: CSV download
          content:
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }

  /v1/documents/{id}/duplicate:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: duplicateDocument
      tags: [documents]
      summary: Copy a document into a new draft
      description: >-
        Creates a fresh draft owned by the caller with the same file,
        recipients and field placements, and none of the signing state —
        no tokens, signatures, or viewed/signed timestamps, and filled
        values dropped. The file blob is copied rather than shared, so
        deleting either document cannot affect the other. For re-running an
        agreement without rebuilding its layout.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: Name for the copy — defaults to "<name> (copy)"
      responses:
        "201":
          description: The new draft
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/documents/{id}/recipients/{rid}/signing-link:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: rid, in: path, required: true, schema: { type: string } }
    get:
      operationId: recipientSigningLink
      tags: [documents]
      summary: The tokenised signing URL for one recipient (owner only)
      description: >-
        Returns the link a recipient received by email, so the owner can
        hand it over another way. Restricted to the document's owner and
        never included in the document payload: the token in this URL is the
        recipient's authority to sign, and org sharing widens read access,
        so exposing it on the document would let any teammate who can view
        the envelope sign in a recipient's name. Retrieval is written to the
        audit trail as signing_link_copied. For a signer-group slot
        (EAS-125), url/email mirror the first still-live member and members
        lists every member with an active (not yet declined or invalidated)
        link.
      responses:
        "200":
          description: The signing link
          content:
            application/json:
              schema:
                type: object
                required: [url, recipientId, email]
                properties:
                  url: { type: string, format: uri }
                  recipientId: { type: string }
                  email: { type: string, format: email }
                  members:
                    type: array
                    description: Present only when the recipient is a signer group.
                    items:
                      type: object
                      required: [memberId, name, email, url]
                      properties:
                        memberId: { type: string }
                        name: { type: string }
                        email: { type: string, format: email }
                        url: { type: string, format: uri }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The caller can read this document but does not own it
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Not yet sent (no token minted), or the recipient already signed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/documents/{id}/recipients/{rid}/verification-report:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: rid, in: path, required: true, schema: { type: string } }
    get:
      operationId: recipientVerificationReport
      tags: [documents]
      summary: A recipient's Government ID result, read from Easerix (owner only)
      description: >-
        Returns the result of a recipient's Government ID verification as
        Persona last reported it. The result is stored in Easerix when Persona's
        webhook delivers it (or when a reconciliation read settles an inquiry no
        webhook did), so this call is served from the database and never calls
        Persona. It is never cached by clients (Cache-Control no-store) and is
        limited to the fields below: the verified name, per-verification document
        type, issuing country and check results, and the names of the images that
        exist. It carries no images (those are fetched one at a time from the
        photos endpoint) and the document number, birth date and address are never
        stored or returned. Until Persona has reported anything the status is
        not_started or in_progress and the other fields are empty. updatedAt is
        when the stored result last changed. Restricted to the document's owner,
        because org sharing widens who can read a document and must not widen who
        can read a signer's identity result. Views are not written to the
        document's audit trail. A recipient with no Government ID inquiry has no
        report (404).
      responses:
        "200":
          description: The report
          headers:
            Cache-Control: { schema: { type: string, example: no-store } }
          content:
            application/json:
              schema:
                type: object
                required: [provider, inquiryId, status, name, verifications, updatedAt]
                properties:
                  provider: { type: string, example: persona }
                  inquiryId: { type: string, description: The provider's inquiry id (opaque) }
                  status:
                    type: string
                    enum: [not_started, in_progress, awaiting_decision, approved, declined, expired, unknown]
                  name:
                    type: string
                    description: The name read from the verified document; empty if none was read.
                  nameMatch:
                    type: string
                    enum: [match, mismatch, unknown]
                    description: >-
                      Whether the name read from the ID plausibly belongs to the recipient the link
                      was sent to. Persona proves that someone passed its check, not who, so
                      anyone holding the signing link can pass with their own ID: a mismatch is how
                      the sender sees that. Order, case, accents, middle names and initials are
                      ignored. unknown when there is nothing to compare (the recipient has only an
                      email address, or no name was read). It is a flag for the sender and never
                      blocks signing.
                  completedAt: { type: string, format: date-time }
                  updatedAt: { type: string, format: date-time, description: When the stored result last changed. }
                  verifications:
                    type: array
                    items:
                      type: object
                      required: [id, kind, status, documentType, country, checks, photos]
                      properties:
                        id: { type: string, description: "The provider's opaque id for this verification." }
                        photos:
                          type: array
                          description: Which images exist; fetch each from the photos endpoint.
                          items: { type: string, enum: [front, back, selfie, left, center, right] }
                        kind:
                          type: string
                          description: government_id, selfie, or the provider's own kind.
                        status: { type: string, description: "The provider's status for this verification, e.g. passed or failed." }
                        documentType:
                          type: string
                          description: driver_license, passport, id_card, or the provider's own code; empty for a selfie.
                        country: { type: string, description: "Issuing country, ISO 3166-1 alpha-2" }
                        checks:
                          type: array
                          items:
                            type: object
                            required: [name, status]
                            properties:
                              name: { type: string }
                              status: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The caller can read this document but does not own it
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: No such document or recipient, or the recipient has no verification report
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/documents/{id}/recipients/{rid}/verification-report/photos/{vid}/{side}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: rid, in: path, required: true, schema: { type: string } }
      - { name: vid, in: path, required: true, description: The verification id from the report, schema: { type: string } }
      - name: side
        in: path
        required: true
        schema: { type: string, enum: [front, back, selfie, left, center, right] }
    get:
      operationId: recipientVerificationPhoto
      tags: [documents]
      summary: One captured ID or selfie image, read from Persona on request (owner only)
      description: >-
        Streams one image the signer captured (the ID's front or back, a
        selfie) from Persona to the owner. This is the one read that still goes
        to Persona: images are never stored, so each view is an explicit owner
        action that fetches the image then. Only an image the stored report
        lists is fetched (anything else is a 404 without a call to Persona). It
        is never stored or cached (Cache-Control no-store), the bytes are checked
        to be an image, and it is only ever fetched from a Persona host.
        Restricted to the document's owner. Views are not written to the document's
        audit trail. Which images exist is
        listed in the report's verifications[].photos.
      responses:
        "200":
          description: The image
          headers:
            Cache-Control: { schema: { type: string, example: no-store } }
          content:
            image/jpeg: { schema: { type: string, format: binary } }
            image/png: { schema: { type: string, format: binary } }
            image/webp: { schema: { type: string, format: binary } }
            image/gif: { schema: { type: string, format: binary } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The caller can read this document but does not own it
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: No such document, recipient, verification or image
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: Persona could not be reached or refused the request; try again shortly
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/recipients/suggest:
    get:
      operationId: suggestRecipients
      tags: [documents]
      summary: Suggest recipients as the sender types
      description: >-
        The recipient picker's memory. Merges two sources and de-duplicates them
        by lowercased email: people the caller has sent to before, read out of
        the recipients of documents the caller may READ (their own, plus
        documents a teammate shared with the workspace — a teammate's PRIVATE
        document never contributes), and the members of the caller's workspace,
        which the auth service owns and is asked for with the caller's own
        bearer token.


        Document-agnostic on purpose: the editor already knows who is on the
        document in front of it, so filtering out people already added is the
        client's job.


        Ranking puts email matches ahead of name matches and prefixes ahead of
        infixes, then someone already sent to ahead of a colleague who hasn't
        been, then most recently used. An empty q returns the most recently used
        recipients, newest first, with workspace members filling the remainder.


        Degrades rather than fails: if the auth service is slow or unreachable
        the response is still 200 with send history alone, so the field the
        caller is typing into never breaks.


        Not paginated, deliberately. limit is a ceiling, not a window: there is
        no cursor and no offset, and a larger limit is capped rather than paged.
        Send history is stored as one row per time someone was added to a
        document, so an offset would skip rows rather than people and a second
        page would mostly repeat the first after de-duplication — and the member
        half has no cursor to page at all. Narrow with q instead. Browsing the
        full contact list is a separate surface, not a page of this one.


        kind=group switches this same endpoint to a different resource
        entirely: previously-used signer-group labels (EAS-125+) instead of
        people, matched by label text only. There is no workspace-roster
        merge for this kind — a label isn't an identity — so the response is
        shaped differently (GroupLabelSuggestion, not RecipientSuggestion):
        each entry also carries the member roster from that label's most
        recent use, offered as one-click additions once the new group exists,
        never added by this endpoint itself. Same tenancy boundary as the
        person path: a label (and its roster) is only ever suggested from a
        document the caller may read.
      parameters:
        - name: q
          in: query
          description: >-
            What the sender has typed. Matched case-insensitively against
            email and name (kind=person, the default) or against the label
            alone (kind=group). Omit or leave blank for the most recently used.
          schema: { type: string }
        - name: limit
          in: query
          description: >-
            Maximum suggestions to return. A larger value is accepted and
            silently capped at 25 — the ceiling is the server's, so no
            maximum is declared here that would make a spec-validating client
            refuse to send what the server handles.
          schema: { type: integer, minimum: 1, default: 10 }
        - name: kind
          in: query
          description: >-
            person (default) suggests people. group suggests previously-used
            signer-group labels instead — see the endpoint description.
          schema: { type: string, enum: [person, group], default: person }
      responses:
        "200":
          description: >-
            Matching suggestions, best match first. kind=person (default)
            items are RecipientSuggestion; kind=group items are
            GroupLabelSuggestion.
          content:
            application/json:
              schema:
                type: object
                required: [suggestions]
                properties:
                  suggestions:
                    type: array
                    items:
                      oneOf:
                        - { $ref: "#/components/schemas/RecipientSuggestion" }
                        - { $ref: "#/components/schemas/GroupLabelSuggestion" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "429": { description: Rate limited, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/templates/{id}/instantiate:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: instantiateTemplate
      tags: [templates]
      summary: Render customized content into a ready draft
      description: >-
        Template Studio's create step: takes the template's content (or the
        caller's edited version), substitutes {{placeholder}} values (unfilled
        ones render as visible blanks), renders a fresh PDF, and creates a
        draft with the file attached, recipients from the template's slots
        (optionally addressed), and signature/date fields placed from the
        rendered layout and bound per slot. Public templates render with the
        SAMPLE banner.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, description: Document name — defaults to the template name }
                content: { $ref: "#/components/schemas/TemplateContent" }
                placeholders:
                  type: object
                  additionalProperties: { type: string }
                state:
                  type: string
                  description: >-
                    Optional two-letter US state code. Fills the {{state}}
                    placeholder with the state's name and appends a "State
                    notes" section: per-topic advisories plus a small curated
                    set of stable, dated state rules — never fabricated law,
                    and an explicit notice when no adaptation exists.
                recipients:
                  type: array
                  description: Exactly one per slot, or omit to address later
                  items:
                    type: object
                    required: [email]
                    properties:
                      name: { type: string }
                      email: { type: string }
      responses:
        "201":
          description: Ready draft (file attached, fields placed)
          content:
            application/json:
              schema:
                type: object
                required: [document]
                properties:
                  document: { $ref: "#/components/schemas/Document" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { description: Storage error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: Storage not configured, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/templates/{id}/state-preview:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: templateStatePreview
      tags: [templates]
      summary: Preview state notes before instantiating
      description: >-
        What choosing a state does for this template: the resolved
        jurisdiction, the topics the template touches, and the exact notes
        that will be appended as the "State notes" section.
      parameters:
        - name: state
          in: query
          required: true
          schema: { type: string, description: Two-letter US state code }
      responses:
        "200":
          description: State preview
          content:
            application/json:
              schema:
                type: object
                required: [state, stateName, topics, notes]
                properties:
                  state: { type: string }
                  stateName: { type: string }
                  topics:
                    type: array
                    items: { type: string }
                  notes:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/templates/{id}/file:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: uploadTemplateFile
      tags: [templates]
      summary: Attach the template's PDF
      description: >-
        Multipart upload (field "file", 25 MB max) — the document a
        template's fields sit on. Accepts PDF, Word (.doc/.docx), Excel
        (.xls/.xlsx), CSV (comma-, semicolon- or pipe-separated), PowerPoint
        (.ppt/.pptx), and image (JPG/PNG/GIF) files, detected from content,
        not the filename — anything that isn't already
        a PDF is converted to one server-side before it's stored. Owner-only.
        "Use template" copies this file into the new draft, making it ready
        to send.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: Template with the file attached
          content:
            application/json:
              schema:
                type: object
                required: [template]
                properties:
                  template: { $ref: "#/components/schemas/Template" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { description: "Over the 25 MB limit, whether the file itself or the PDF it converts to. A request that declares its length past the limit is refused without its body being read", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "415": { description: Unsupported file type, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: "Unreadable/encrypted PDF, corrupted image, a conversion that didn't produce a readable PDF, a CSV saved as UTF-16 (a supported type whose encoding can't be read), or a spreadsheet too wide or too long to convert, estimated at more than 500 pages (the message says what to do instead)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: The Word/Excel/CSV/PowerPoint conversion failed, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "Storage not configured, or (for a Word/Excel/CSV/PowerPoint upload) the conversion service is not configured", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    get:
      operationId: downloadTemplateFile
      tags: [templates]
      summary: Get the template's PDF URL
      description: 15-minute presigned URL — public built-ins, owned, or org-shared templates.
      responses:
        "200":
          description: Presigned URL
          content:
            application/json:
              schema:
                type: object
                required: [url, expiresIn]
                properties:
                  url: { type: string }
                  expiresIn: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { description: Not found or no document yet, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: Storage error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/me/signature:
    get:
      operationId: getSavedSignature
      tags: [documents]
      summary: The caller's saved signature
      description: The adopted signature (PNG data URL) reused for in-person signing, or null.
      responses:
        "200":
          description: Saved signature or null
          content:
            application/json:
              schema:
                type: object
                required: [signature]
                properties:
                  signature:
                    nullable: true
                    allOf: [{ $ref: "#/components/schemas/SavedSignature" }]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    put:
      operationId: putSavedSignature
      tags: [documents]
      summary: Adopt (or replace) the saved signature
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                kind: { type: string, enum: [draw, type], description: Defaults to draw }
                data: { type: string, description: "PNG data URL, 200 KB max" }
      responses:
        "200":
          description: Saved
          content:
            application/json:
              schema:
                type: object
                required: [signature]
                properties:
                  signature: { $ref: "#/components/schemas/SavedSignature" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "413": { description: Image too large, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      operationId: deleteSavedSignature
      tags: [documents]
      summary: Remove the saved signature
      responses:
        "204":
          description: Removed (empty body)
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }

  /v1/webhooks:
    get:
      operationId: listWebhooks
      tags: [webhooks]
      summary: List webhooks
      description: The caller's webhooks (org window). Secrets are never returned after creation.
      responses:
        "200":
          description: Webhooks
          content:
            application/json:
              schema:
                type: object
                required: [webhooks]
                properties:
                  webhooks:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    post:
      operationId: createWebhook
      tags: [webhooks]
      summary: Create a webhook
      description: >-
        HTTPS endpoints only, 10 per owner. The response includes the signing
        secret ONCE — deliveries carry X-Easerix-Event and an HMAC-SHA256 of
        the raw body in X-Easerix-Signature ("sha256=<hex>"). Three delivery
        attempts with backoff; 20 consecutive failures auto-disable the hook.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string }
                events:
                  type: array
                  description: Event filter — ["*"] (default) or any of document.sent/viewed/signed/declined/completed/voided/expired
                  items: { type: string }
      responses:
        "201":
          description: Webhook created (includes the one-time secret)
          content:
            application/json:
              schema:
                type: object
                required: [webhook]
                properties:
                  webhook:
                    allOf:
                      - { $ref: "#/components/schemas/Webhook" }
                      - type: object
                        properties:
                          secret: { type: string, description: Shown only in this response }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/webhooks/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    patch:
      operationId: updateWebhook
      tags: [webhooks]
      summary: Update a webhook
      description: Change the URL or event filter, or toggle active (re-enabling resets the failure count).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string }
                events:
                  type: array
                  items: { type: string }
                active: { type: boolean }
      responses:
        "200":
          description: Updated webhook
          content:
            application/json:
              schema:
                type: object
                required: [webhook]
                properties:
                  webhook: { $ref: "#/components/schemas/Webhook" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteWebhook
      tags: [webhooks]
      summary: Delete a webhook
      responses:
        "204":
          description: Deleted (empty body)
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /public/signing/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    get:
      operationId: signingSession
      tags: [signing]
      summary: Signing session
      description: >-
        The recipient's view of the document: metadata, a presigned file URL
        (final artifact once completed), all fields (own fields flagged
        "mine"), redacted co-recipients, and whether it is their turn. The
        first open marks the recipient viewed and fires document.viewed (it is
        not written to the audit trail). Unauthenticated — the
        token is the credential. IP rate-limited (60/min).

        When the recipient has an access code, the session is LOCKED until
        the X-Access-Code header carries the right code: the response is
        {locked: true, codeIncorrect, document: {status}, recipient:
        {name}} — 200 with no code supplied, 401 with a wrong one. The
        document name is NOT in this payload (nor in the lockedOut one): anyone
        holding the link can read it, and it only appears once the code is
        accepted. Once any
        wrong code has been used the response also carries attemptsRemaining,
        the tries left before the lockout. After five
        wrong codes the slot is locked out for a cooldown: 429 with
        {locked: true, lockedOut: true, retryAfterSeconds, document, recipient,
        security}, where retryAfterSeconds is how long is left. Repeat lockouts
        last longer: 15 minutes, then 1 hour, then 24 hours from then on, so
        retryAfterSeconds can be as large as 86400. A new run of five wrong
        codes made after a lock has ended starts the next one, and a guess
        made while locked is refused without lengthening the lock. The count
        resets after a correct code, or after 7 days with no wrong code. All
        /public/signing action endpoints then require the same header (401
        with {locked: true} otherwise).

        A recipient can ALSO require verification — a code by text message or
        voice call, or a Government ID check run by Persona — independent of
        the access code: both must pass, access code first. The sender only
        chooses the method; the SIGNER enters their own phone number, or
        completes Persona's flow. Once the access code is accepted, an
        unverified session is still LOCKED: 200 with {locked: true,
        verificationRequired: true, document: {name, status}, recipient:
        {name, declinedAt}, security}. This GET never sends a text or places a call — the
        signer starts verification explicitly with POST
        .../verification/start. Verification is proven by an HttpOnly session
        cookie set by POST .../verification/verify, not by anything the page
        reports; send credentials (fetch credentials: "include") on every
        /public/signing request. Every action endpoint re-checks it
        server-side (403 verification_required).
      security: []
      parameters:
        - name: X-Access-Code
          in: header
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SigningSession" }
        "401": { description: "Wrong access code: the body is the locked payload (locked, codeIncorrect, attemptsRemaining) and nothing about the document beyond its status", content: { application/json: { schema: { type: object } } } }
        "404": { description: Invalid or invalidated link, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: "Either the request rate limit (a bare error body, retry after a short wait) or the link is locked after too many wrong access codes (lockedOut true with retryAfterSeconds, the same locked payload as the 401). Only lockedOut means a lockout.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "The access code could not be checked just now, so it was neither accepted nor counted as a wrong guess. Try again in a moment", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /public/signing/{token}/complete:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    post:
      operationId: signingComplete
      tags: [signing]
      summary: Fill fields and sign
      description: >-
        One atomic action: writes the signer's field values, applies their
        PNG data-URL signature, records ESIGN consent (required), and
        advances the document — enforcing turn order, validating required
        fields, and rejecting values for fields that aren't theirs. When the
        last signer completes, the final PDF is built (signatures stamped,
        certificate appended) and everyone is emailed.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [consent]
              properties:
                consent: { type: boolean, description: Must be true }
                signature: { type: string, description: PNG data URL — required when the signer has signature/initial fields (or none at all) }
                values:
                  type: array
                  items:
                    type: object
                    required: [fieldId, value]
                    properties:
                      fieldId: { type: string }
                      value: { type: string }
      responses:
        "200":
          description: Signed
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string }
                  consentDisclosure:
                    type: object
                    nullable: true
                    description: >-
                      The disclosure actually recorded on this signature
                      (EAS-116) — the one pinned at first view, not
                      necessarily whatever is ACTIVE by the time this
                      response is built. Same shape as SigningSession's
                      field of the same name.
                    properties:
                      version: { type: integer }
                      content: { type: string }
                  document:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      status: { type: string }
                      completedAt: { type: string, format: date-time, nullable: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { description: "View-only link, or verification required (code verification_required) — see VerificationError", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }
        "401": { description: "Access code missing or incorrect", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "402": { description: "A required payment has not been paid (code payment_required, fieldId names the field). Pay through the payment-intent route, then retry — the signature is not recorded until the payment has succeeded. If the payment was refunded, disputed or cancelled the code is payment_closed and paymentStatus says which: it can no longer be paid, and the sender has to send a new request.", content: { application/json: { schema: { $ref: "#/components/schemas/PaymentError" } } } }
        "404": { description: Invalid link, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: "Closed, already signed/declined, or out of turn", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: "Rate limited (retry after a short wait), or the link is locked after too many wrong access codes (the body has lockedOut true)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "The access code could not be checked just now, so it was neither accepted nor counted as a wrong guess. Try again in a moment", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /public/signing/{token}/decline:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    post:
      operationId: signingDecline
      tags: [signing]
      summary: Decline to sign
      description: >-
        Closes the document with status "declined", records the reason in
        the audit trail, and emails the owner. Needs the access code when the
        recipient has one. Phone verification is also required. A recipient who
        must pass a Government ID check can decline without having passed it, so
        a stranger sent someone else's link can say "this is not for me" without
        handing a government ID to a third party; the audit line then says the
        decline came without identity verification.
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        "200":
          description: Declined
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string }
        "401": { description: "Access code missing or incorrect", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Invalid link, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: Not open for signing, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { $ref: "#/components/responses/VerificationRequired" }
        "429": { description: "Rate limited (retry after a short wait), or the link is locked after too many wrong access codes (the body has lockedOut true)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "The access code could not be checked just now, so it was neither accepted nor counted as a wrong guess. Try again in a moment", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /public/signing/{token}/reassign:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    post:
      operationId: signingReassign
      tags: [signing]
      summary: Reassign this slot to someone else
      description: >-
        Hands the signing slot to a different person, with a reason: the
        recipient row is updated in place to the new name/email and a fresh
        token is minted, the original invitee is preserved
        (reassignedFromName/Email on the recipient), the substitution is
        recorded in the audit trail, the new signer is emailed their own
        signing link, and the document owner is notified. One hop only — a
        slot that has already been reassigned once cannot be reassigned
        again — and not available on a signer-group slot (EAS-125; a group
        member steps back by declining instead).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email]
              properties:
                name: { type: string, maxLength: 200 }
                email: { type: string, maxLength: 320 }
                reason: { type: string, maxLength: 2000 }
      responses:
        "200":
          description: Reassigned
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { description: "View-only link, or verification required (code verification_required) — see VerificationError", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }
        "401": { description: "Access code missing or incorrect", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Invalid link, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: "Closed, already signed/declined/reassigned, a group slot, a slot with OTP verification (reassign_blocked_verification), or out of turn", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: "Rate limited (retry after a short wait), or the link is locked after too many wrong access codes (the body has lockedOut true)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "The access code could not be checked just now, so it was neither accepted nor counted as a wrong guess. Try again in a moment", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /public/signing/{token}/verification/start:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    post:
      operationId: signingVerificationStart
      tags: [signing]
      summary: Send a verification code, or start a Government ID check
      description: >-
        For a Government ID recipient (method government_id) this starts or
        resumes the signer's identity check with Persona instead of sending a
        code. It creates a Persona inquiry (once — a repeated call reuses it and
        never buys another), parks the calling browser's session secret in an
        HttpOnly cookie, and returns only what Persona's embedded flow needs:
        inquiryId, environmentId and, when resuming an in-progress inquiry, a
        short-lived sessionToken (never stored by Easerix). The response says
        nothing about whether the signer is verified: Persona's completion
        callback in the browser is not evidence. The verdict reaches Easerix only
        on POST /webhooks/persona, where it is verified by its signature and
        stored in Easerix, and the
        signer is verified only once Persona has APPROVED the inquiry (a merely
        completed or in-review inquiry is not). Poll the session GET until
        security.verificationStatus is verified. Calling this again after a
        failed or expired inquiry begins a new one, subject to the per-recipient
        cooldown and hourly cap and the organization's hourly allowance of new
        inquiries (PERSONA_ORG_HOURLY_CAP). The rest of this description is
        about the phone methods.

        Sends a one-time code to the phone number the SIGNER supplies — by text
        message (method sms) or a spoken voice call (method voice), whichever
        the sender configured for this recipient. Only ever called by an
        explicit signer action: loading the session never sends anything.
        Requires the access code (X-Access-Code) first when one is set. The
        code is generated, delivered and checked by the verification provider;
        Easerix neither generates nor stores it, and the number is passed to
        the provider without being persisted (only its last four digits are
        kept, for display and the audit trail). A resend — to the same or a
        corrected number — is refused server-side for 30 seconds after the
        previous send and after 5 sends an hour for this recipient (429 with
        Retry-After). A caller already holding a valid verified session gets
        {status: "verified"} back and nothing is sent. A caller WITHOUT it (another
        browser) may start its own verification at any time: that never ends the
        existing session, which stays valid until it lapses or a verification
        succeeds. The full number is never
        returned.
      security: []
      parameters:
        - name: X-Access-Code
          in: header
          required: false
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [method]
              properties:
                method: { type: string, enum: [sms, voice, government_id], description: Must equal the recipient's configured method }
                phone: { type: string, description: "Required for sms and voice, ignored for government_id. International number starting with + (8 to 15 digits). Spaces, dashes and brackets are accepted and stripped." }
      responses:
        "200":
          description: Code sent (or already verified)
          content:
            application/json:
              schema:
                type: object
                required: [status, method]
                properties:
                  status: { type: string, enum: [challenged, verified] }
                  method: { type: string, enum: [sms, voice, government_id] }
                  provider: { type: string, enum: [persona], description: Government ID only }
                  inquiryId: { type: string, description: "Government ID only. The Persona inquiry to open in the embedded flow. Absent when already verified or while a decision is pending." }
                  environmentId: { type: string, description: "Government ID only. The Persona environment id for the embedded flow (client-safe)." }
                  sessionToken: { type: string, description: "Government ID only. Persona's short-lived session credential, present when resuming an in-progress inquiry and absent for one that was never opened. Never stored by Easerix." }
                  awaitingDecision: { type: boolean, description: "Government ID only. The signer finished the flow and Persona has not yet approved or declined: open nothing and keep polling the session." }
                  channel: { type: string, enum: [sms, call] }
                  destination: { type: string, description: "Masked number, e.g. ******3210" }
                  expiresAt: { type: string, format: date-time, nullable: true, description: When the code stops being accepted }
                  resendAvailableAt: { type: string, format: date-time, description: Earliest time a resend will be accepted }
        "400": { description: "Malformed number (verification_phone_invalid), a method other than the configured one (unsupported_method), or an invalid body", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }
        "401": { description: Access code missing or incorrect, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Invalid or invalidated link, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: "This recipient has no verification configured (verification_not_configured), or the signing session is closed (document no longer pending, past its deadline, or this recipient already signed or declined) — a code is never sent and no Persona inquiry is created in either case. For Government ID also: the inquiry the signer had open can no longer be used (verification_inquiry_unavailable — start again), or the identity check is open in another browser or was started too recently for another browser to take over (verification_inquiry_in_use — finish it there, or try again later)", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }
        "422": { description: "The provider cannot send to that number (verification_destination_invalid) or refused the request, e.g. a blocked destination (verification_rejected)", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }
        "429": { description: "Resend cooldown (verification_cooldown), hourly send cap (verification_send_limit), the organization-wide hourly cap (verification_org_limit), the provider throttling us (verification_provider_busy), or the per-link / per-IP rate limit (rate_limited). Retry-After is set.", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }
        "502": { description: "The provider answered unexpectedly (verification_provider_error) — retryable", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }
        "503": { description: "Verification provider unavailable (verification_unavailable) or failing temporarily (verification_temporarily_unavailable) — retryable, never a successful verification. Also returned when Persona will not reopen an unfinished inquiry that is still open: nothing is expired, try again in a moment. Also returned when the access code could not be checked just now (try again)", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }

  /public/signing/{token}/events:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    get:
      operationId: signingEvents
      tags: [signing]
      summary: Live signing events (SSE)
      security: []
      description: >-
        A Server-Sent Events stream that tells the signer's open page their
        Government ID check has been decided, so it can re-read the session at
        once instead of polling. The token is the credential and the stream is
        routed by the recipient it resolves to. Frames carry no result, only a
        nudge: the session endpoint (GET /public/signing/{token}) remains the
        sole decision and the only place the verified-session cookie is set.

        The stream emits `event: ready` once on connect (re-read the session, an
        event may have been missed while disconnected), then `event: verification`
        with `{"status":"verified"|"failed"|"expired"}` when the check settles,
        plus `: ping` heartbeat comments every 25s. The server ends the stream
        after 15 minutes. Delivery is best-effort, so keep a slow fallback poll of
        the session. 404 when the link has no Government ID check to follow; 503
        when the server has no event listener (poll instead).
      responses:
        "200":
          description: An event stream
          content:
            text/event-stream:
              schema:
                type: string
                example: |
                  event: verification
                  data: {"status":"verified"}
        "404": { description: "Unknown link, or no Government ID check on it", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: "Too many requests from this client", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "Events are unavailable on this instance — poll the session instead", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: "This server cannot stream responses (Server-Sent Events)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /public/signing/{token}/verification/verify:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    post:
      operationId: signingVerificationVerify
      tags: [signing]
      summary: Check a verification code
      description: >-
        Phone methods only: a government_id recipient has no code to check
        (400 unsupported_method) — that verdict arrives from Persona on
        POST /webhooks/persona.

        Checks the code with the verification provider. On success the server
        records the verification and sets an HttpOnly session cookie scoped to
        this signing link (valid 2 hours); the cookie, not the response body,
        is what later authorizes signing actions, and a page refresh keeps
        it. A successful verification replaces any earlier session for this
        link — there is one active session — so a signer who verifies from a
        second browser ends the first browser session at that moment. Five
        wrong codes per sent code exhaust the attempt budget
        (verification_too_many_attempts) until a new code is sent. Requires
        the access code first when one is set.
      security: []
      parameters:
        - name: X-Access-Code
          in: header
          required: false
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [method, code]
              properties:
                method: { type: string, enum: [sms, voice] }
                code: { type: string, description: The digits received }
      responses:
        "200":
          description: Verified — the session cookie is set
          headers:
            Set-Cookie:
              description: HttpOnly verification session for this signing link
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [verified]
                properties:
                  verified: { type: boolean }
                  expiresAt: { type: string, format: date-time, description: When the verified session ends }
        "400": { description: "Incorrect code (verification_code_invalid, with attemptsRemaining) or unsupported method", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }
        "401": { description: Access code missing or incorrect, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Invalid or invalidated link, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: "No code has been sent (verification_no_challenge), verification is not configured, the signing session is closed, or this code was already accepted by a concurrent request (verification_already_verified — nothing changed, reload the session)", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }
        "410": { description: "The code has expired (verification_code_expired)", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }
        "429": { description: "Too many incorrect codes (verification_too_many_attempts), the provider throttling us (verification_provider_busy, retryable), or the per-link / per-IP rate limit (rate_limited)", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }
        "502": { description: "The provider answered unexpectedly (verification_provider_error) — retryable; the attempt is not counted", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }
        "503": { description: "Verification provider unavailable (verification_unavailable) or failing temporarily (verification_temporarily_unavailable) — retryable; the attempt is not counted. Also returned when the access code could not be checked just now (try again)", content: { application/json: { schema: { $ref: "#/components/schemas/VerificationError" } } } }

  /webhooks/persona:
    post:
      operationId: personaWebhook
      tags: [signing]
      summary: Persona inquiry webhook (Government ID verification)
      description: >-
        Receives Persona's inquiry events. Called by Persona only — there is no
        session, and the request is authenticated by the Persona-Signature
        header (t=<unix time>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, one
        space-separated pair per secret while a secret is being rotated),
        checked against PERSONA_WEBHOOK_SECRET over the raw body before it is
        parsed.

        The event is only a signal. The inquiry it names is read back from
        Persona, and that answer — never the payload — decides. The inquiry must
        be held by exactly one verification row, with the same recipient as its
        reference id and the configured Inquiry Template. Only the approved
        decision verifies the recipient: declined and failed move it to failed,
        expired to expired, and completed, needs_review, or any status this
        service does not know changes nothing. A settled verification never
        moves again, so redeliveries and out-of-order events are harmless, and an
        event about an inquiry that has since been replaced matches no row. An
        event for a document that is no longer signable (completed, declined,
        voided, expired, or this recipient already signed or declined) is
        ignored.

        Events that are not for this service are acknowledged with 200 so
        Persona stops retrying them. Only a failure a retry could fix (503: Persona
        could not be read, 500) asks for redelivery.
      security: []
      parameters:
        - name: Persona-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Persona's JSON:API event. Only data.attributes.name and the inquiry id in data.attributes.payload.data are read.
      responses:
        "200":
          description: Accepted, or acknowledged and ignored (ignored true)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  ignored: { type: boolean }
        "400": { description: "Signed but not a readable event", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { description: "Missing or invalid Persona-Signature", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: "Government ID verification is not configured on this deployment", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "413": { description: "Body larger than 256 KiB", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: "Too many requests from this client", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: "Unexpected server error — Persona will retry", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "Persona could not be read just now — Persona will retry", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /public/signing/{token}/file:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    get:
      operationId: signingFile
      tags: [signing]
      summary: Recipient download URL
      description: Presigned URL for the recipient — the final artifact once completed, else the original.
      security: []
      responses:
        "200":
          description: Presigned download URL
          content:
            application/json:
              schema:
                type: object
                required: [url, expiresIn]
                properties:
                  url: { type: string }
                  expiresIn: { type: integer }
        "401": { description: "Access code missing or incorrect", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Invalid link or no file, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { $ref: "#/components/responses/VerificationRequired" }
        "429": { description: "Rate limited (retry after a short wait), or the link is locked after too many wrong access codes (the body has lockedOut true)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: Storage error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "The access code could not be checked just now, so it was neither accepted nor counted as a wrong guess. Try again in a moment", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /public/signing/{token}/fields/{fieldId}/upload:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
      - { name: fieldId, in: path, required: true, schema: { type: string } }
    post:
      operationId: uploadSigningField
      tags: [signing]
      summary: Upload a file to a file-upload field (EAS-110)
      description: >-
        One active upload per field — a second upload to the same field
        replaces the first. Requires the same signing state PublicComplete
        does: pending document, before the deadline, this recipient's turn
        (sequential mode), not yet signed or declined. The field must be
        assigned to the caller's recipient/group slot and be of type
        "file". Content is validated by its actual bytes, never the
        multipart Content-Type or filename — narrower per-field
        allowedTypes/maxSizeBytes (DocumentField.config) can restrict this
        further than the system default (PDF, JPEG, PNG; 25 MB).
      security: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        "201":
          description: Upload stored
          content:
            application/json:
              schema:
                type: object
                required: [upload]
                properties:
                  upload:
                    type: object
                    required: [id, fieldId, name, size, contentType, status]
                    properties:
                      id: { type: string }
                      fieldId: { type: string }
                      name: { type: string, description: "Sanitized, display-only — never used as a storage path." }
                      size: { type: integer }
                      contentType: { type: string }
                      status: { type: string, enum: [uploaded] }
        "400": { description: Invalid field/ownership/state, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { description: Access code required or wrong, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Invalid link or unknown field, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: "Not open for signing, wrong turn, or already signed/declined", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "413": { description: Exceeds the system or field size limit, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: Content doesn't match an allowed type, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { $ref: "#/components/responses/VerificationRequired" }
        "429": { description: Rate limited, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: Storage error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "The access code could not be checked just now, so it was neither accepted nor counted as a wrong guess. Try again in a moment", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/payments/account:
    get:
      operationId: getPaymentsAccount
      tags: [payments]
      summary: Whether this workspace can take payments (EAS-119)
      description: >-
        The workspace's Stripe Connect state. A workspace connects its own
        Standard Stripe account, and payments are charged directly on it, so the
        money goes to the workspace and not through Easerix. Pass refresh=1 to
        read the account back from Stripe first, which is how the settings page
        picks up a finished onboarding without waiting for a webhook. A
        document with a payment field can be sent only when chargesEnabled.
      parameters:
        - { name: refresh, in: query, required: false, schema: { type: string, enum: ["1"] } }
      responses:
        "200":
          description: Account state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentAccount" }
        "500": { description: Could not load the account, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      operationId: disconnectPaymentsAccount
      tags: [payments]
      summary: Disconnect the workspace's Stripe account
      description: >-
        Ends the workspace's use of its connected Stripe account so it can connect
        another with startPaymentsOnboarding. Workspace owners and admins only.
        Refused with 409 payments_in_use while a sent document that is still
        waiting for signatures has a payment field, or while money on any
        document is still in motion (an open PaymentIntent, or a payment on a
        document that did not complete whose refund is not yet requested); the
        response names up to five of those documents. Nothing is deleted at
        Stripe, and payments already taken keep the account they were taken on,
        so refunds still reach it. Returns the new state, which is not connected.
      responses:
        "200":
          description: Disconnected. The new account state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentAccount" }
        "403": { description: Not a workspace owner or admin, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: "A document or payment still needs the account (code payments_in_use), or the workspace has no connected account (code payments_not_connected)", content: { application/json: { schema: { $ref: "#/components/schemas/PaymentError" } } } }
        "500": { description: Could not disconnect, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "Payments are not configured on this deployment (code payments_not_configured)", content: { application/json: { schema: { $ref: "#/components/schemas/PaymentError" } } } }

  /v1/payments/account/onboarding:
    post:
      operationId: startPaymentsOnboarding
      tags: [payments]
      summary: Connect (or resume connecting) the workspace's Stripe account
      description: >-
        Creates the workspace's connected account on first use and returns a
        Stripe-hosted onboarding link. After disconnectPaymentsAccount it creates
        a new account (never the one that was disconnected). Workspace owners and
        admins only.
        Stripe returns the user to the settings page afterwards; the account is
        then read back with getPaymentsAccount?refresh=1.
      responses:
        "200":
          description: Onboarding link
          content:
            application/json:
              schema:
                type: object
                required: [url]
                properties:
                  url: { type: string, format: uri }
        "403": { description: Not a workspace owner or admin, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: Stripe could not be reached — retry, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "Payments are not configured on this deployment (code payments_not_configured)", content: { application/json: { schema: { $ref: "#/components/schemas/PaymentError" } } } }

  /v1/documents/{id}/payments:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listDocumentPayments
      tags: [payments]
      summary: A document's payments, with refund and failure state (EAS-119)
      description: >-
        What the sender needs to see: each payment's status (requires_payment,
        processing, succeeded, failed, canceled, refunded, partially_refunded,
        disputed), the Stripe reference, any failure reason, and the refunded
        amount. Empty for a document with no payment field.
      responses:
        "200":
          description: Payments
          content:
            application/json:
              schema:
                type: object
                required: [payments]
                properties:
                  payments:
                    type: array
                    items: { $ref: "#/components/schemas/DocumentPayment" }
        "404": { description: Document not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /public/signing/{token}/fields/{fieldId}/payment-intent:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
      - { name: fieldId, in: path, required: true, schema: { type: string } }
    post:
      operationId: startSigningPayment
      tags: [signing, payments]
      summary: Start, or resume, the payment for a payment field (EAS-119)
      description: >-
        Idempotent: a double click, a reload, or a retry after a declined card
        all land on the same PaymentIntent. The amount and currency are the
        field's own, snapshotted when the payment starts — nothing in the
        request can change them. Requires the same signing state as
        signingComplete (pending, before the deadline, this signer's turn, not
        yet signed or declined), and the field must be a payment field assigned
        to the caller. The response carries the client secret that Stripe.js
        confirms in the browser; whether the payment succeeded is never reported
        by the browser — it is learned from Stripe's webhook, or by reading the
        PaymentIntent server-side.
      security: []
      responses:
        "200":
          description: Payment ready to confirm
          content:
            application/json:
              schema:
                type: object
                required: [clientSecret, publishableKey, stripeAccount, payment]
                properties:
                  clientSecret: { type: string }
                  publishableKey: { type: string, description: Not a secret }
                  stripeAccount: { type: string, description: "The connected account to pass to Stripe.js (stripeAccount option)" }
                  payment: { $ref: "#/components/schemas/SigningPayment" }
        "401": { description: Access code required or wrong, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Invalid link or unknown payment field, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: "Not open for signing, wrong turn, already paid (already_paid), the payment can't be made any more (payment_closed), the sender can't take payments right now (payments_not_enabled), or Stripe refused to create the charge and retrying will not help (payment_rejected)", content: { application/json: { schema: { $ref: "#/components/schemas/PaymentError" } } } }
        "429": { description: Rate limited, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: Stripe could not be reached — retry, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "Payments are not configured (code payments_not_configured)", content: { application/json: { schema: { $ref: "#/components/schemas/PaymentError" } } } }

  /public/signing/{token}/fields/{fieldId}/payment:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
      - { name: fieldId, in: path, required: true, schema: { type: string } }
    get:
      operationId: getSigningPayment
      tags: [signing, payments]
      summary: Where a payment stands (EAS-119)
      description: >-
        Polled by the signer's page after the card is confirmed. When Stripe's
        webhook has not landed yet and the payment is still open, the
        PaymentIntent is read from Stripe, so a signer who has just paid is
        never left waiting on webhook delivery.
      security: []
      responses:
        "200":
          description: Payment state
          content:
            application/json:
              schema:
                type: object
                required: [payment]
                properties:
                  payment: { $ref: "#/components/schemas/SigningPayment" }
        "401": { description: Access code required or wrong, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Invalid link or unknown payment field, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: Rate limited, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: "Payments are not configured (code payments_not_configured)", content: { application/json: { schema: { $ref: "#/components/schemas/PaymentError" } } } }

  /webhooks/stripe:
    post:
      operationId: stripeWebhook
      tags: [payments]
      summary: Stripe webhook (payments at signing)
      description: >-
        Called by Stripe only — there is no session, and the request is
        authenticated by the Stripe-Signature header (t=<unix time>,v1=<hex
        HMAC-SHA256 of "<t>.<raw body>">) checked over the raw body, against
        every configured signing secret (the platform endpoint and the Connect
        endpoint have different ones), before the body is parsed. A delivery
        more than five minutes from now is refused.

        Each event is claimed by its id in the same transaction that applies it,
        so a redelivery changes nothing and a failed apply rolls the claim back.
        A payment's status only moves along the allowed transitions, and a
        terminal state (canceled, refunded) never moves again, so out-of-order
        delivery is harmless. An event about something this service does not know
        is acknowledged with 200 so Stripe stops retrying it. Only a failure a
        retry could fix (500, including an event that arrived before the row it
        depends on) asks for redelivery.
      security: []
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200": { description: Event applied or ignored, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean } } } } } }
        "400": { description: Unreadable event, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { description: Invalid signature, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Payments are not configured, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "413": { description: Body too large, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: Rate limited (Stripe retries), content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Could not apply the event — Stripe redelivers, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /public/verify/{code}:
    parameters:
      - { name: code, in: path, required: true, schema: { type: string }, description: "The human-shareable code from a signature-request email, e.g. K7QX-2M9B" }
    get:
      operationId: verifyRequest
      tags: [verification]
      summary: Confirm a signing request is genuine
      description: >-
        EAS-122. Resolves a verification code — printed in every
        signature-request email, independent of and never derivable from the
        recipient's actual signing token — to the minimal facts needed to
        recognize the request: who really sent it, which document, when, and
        its current status. Never returns document contents, field values, a
        file URL, or any recipient's email address.

        This confirms the request RECORD is genuine; it does not and cannot
        vouch that the sender is trustworthy or that their account hasn't
        been compromised. Unauthenticated, IP rate-limited tighter than the
        rest of the public surface (resolving a short code by exact match is
        the one public route here where guessing is realistic). An unknown
        code and a malformed one return the identical 404 — there is no way
        to distinguish "never issued" from "garbage input".
      security: []
      responses:
        "200":
          description: A genuine request matches this code
          content:
            application/json:
              schema:
                type: object
                required: [found, senderName, senderDomain, documentName, sentAt, status]
                properties:
                  found: { type: boolean }
                  senderName: { type: string, description: "The sender's display name as of when the request was sent (snapshot — a later account rename does not change this)" }
                  senderDomain: { type: string, description: "Only the @domain portion of the sender's email — never the full address" }
                  documentName: { type: string, description: "Snapshot of the document's name as of when the request was sent" }
                  sentAt: { type: string, format: date-time }
                  status: { type: string, enum: ["in progress", completed, declined, voided, expired], description: "Live — reflects the document's CURRENT state, not a snapshot" }
        "404": { description: No request matches this code, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: Rate limited, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  # --------------- EAS-117: public self-serve signing links -----------------

  /public/links/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    get:
      operationId: publicLinkGet
      tags: [public-links]
      summary: Check a public link's availability
      description: >-
        Never creates anything. One generic 404 for every pre-verification
        failure — bad token, paused, expired, full (submission cap reached),
        or never existed — so there is no oracle for probing which case it
        is. A full link closes as soon as the cap is reached, whether or not
        the visitors who filled it have signed; they finish from the signing
        link emailed to them. Unauthenticated, IP rate-limited.
      security: []
      responses:
        "200":
          description: Available
          content:
            application/json:
              schema:
                type: object
                required: [available, name, pages]
                properties:
                  available: { type: boolean }
                  name: { type: string, description: The frozen snapshot's document name }
                  pages: { type: integer }
        "404": { description: Not available, content: { application/json: { schema: { type: object, properties: { available: { type: boolean }, error: { type: string } } } } } }
        "429": { description: Rate limited, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /public/links/{token}/start:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    post:
      operationId: publicLinkStart
      tags: [public-links]
      summary: Start email verification for a visitor
      description: >-
        Never creates a document — issues (or reissues) a one-time code to
        the given email. Rate-limited on two independent dimensions: by IP
        (the whole public-links surface) and by the normalized email address
        itself, so a distributed attack aimed at one target inbox isn't just
        an IP problem. One-submission-per-email (when the link has it on) is
        never checked here — only after the code is verified, so this
        endpoint can't be used as an email-enumeration oracle. Unauthenticated.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email]
              properties:
                name: { type: string }
                email: { type: string }
      responses:
        "200":
          description: A code was sent (or an already-live one was left in place)
          content:
            application/json:
              schema:
                type: object
                required: [challengeId]
                properties:
                  challengeId: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { description: Not available, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: Rate limited, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /public/links/{token}/verify:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    post:
      operationId: publicLinkVerify
      tags: [public-links]
      summary: Verify a code and open (or resume) the visitor's document
      description: >-
        The only place a document is ever created from a public link. A
        visitor who verified before and abandoned it gets the same
        unfinished document back rather than a duplicate. Unauthenticated.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challengeId, code]
              properties:
                challengeId: { type: string }
                code: { type: string }
      responses:
        "200":
          description: Verified — the visitor's own signing link
          content:
            application/json:
              schema:
                type: object
                required: [documentId, signingUrl]
                properties:
                  documentId: { type: string }
                  signingUrl: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { description: Not available, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: "Expired code, too many attempts, submission cap reached, or already responded (one-submission-per-email: a completed, finalizing or declined submission counts)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: Rate limited, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/documents/{id}/save-as-template:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: saveAsTemplate
      tags: [templates]
      summary: Snapshot a document as a template
      description: >-
        Snapshots recipients ({name, role, orderIndex}) and fields (geometry
        without values; per-signer assignment survives as recipientIndex)
        into a private template. name defaults to the document's name;
        category defaults to "Other".
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 200 }
                category: { type: string, maxLength: 100 }
                description: { type: string, maxLength: 2000 }
      responses:
        "201":
          description: Template created
          content:
            application/json:
              schema:
                type: object
                required: [template]
                properties:
                  template: { $ref: "#/components/schemas/Template" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/templates:
    get:
      operationId: listTemplates
      tags: [templates]
      summary: List templates
      description: >-
        Public templates plus the caller's own, ordered by usageCount
        descending. category, q and ids are optional filters. The result is
        paged only when limit or offset is sent (default 10, capped at 100);
        without either, every matching template is returned, up to 1000, and
        limit reports how many came back (total > that count means the list
        was cut off). categories always describes the full readable set
        regardless of the filters or page applied, so the category chips
        never depend on what the current page shows; total counts what the
        filters match, ignoring the page.
      parameters:
        - name: category
          in: query
          description: Exact category name, or "Uncategorised" for templates with none.
          schema: { type: string }
        - name: q
          in: query
          description: Case-insensitive substring match against name, description or category. "%" and "_" match literally.
          schema: { type: string }
        - name: ids
          in: query
          description: >-
            Comma-separated template ids (at most 100) to narrow to. Ids
            outside the caller's readable set are silently absent from the
            result.
          schema: { type: string }
        - name: limit
          in: query
          required: false
          description: >-
            Page size, default 10, capped at 100. Omit both limit and offset
            to get every matching template unpaged (up to 1000).
          schema: { type: integer }
        - name: offset
          in: query
          required: false
          description: Page start. Default 0. Sending it alone pages at the default size.
          schema: { type: integer }
      responses:
        "200":
          description: Templates
          content:
            application/json:
              schema:
                type: object
                required: [templates, total, categories, limit, offset]
                properties:
                  templates:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
                  total:
                    type: integer
                    description: Count matching category/q/ids, ignoring limit and offset.
                  categories:
                    type: array
                    description: >-
                      Every category in the caller's readable set, with counts
                      unaffected by category/q/ids/limit/offset — alphabetical,
                      "Uncategorised" last.
                    items:
                      type: object
                      required: [name, count]
                      properties:
                        name: { type: string }
                        count: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }
    post:
      operationId: createTemplate
      tags: [templates]
      summary: Create a template
      description: Creates a private template; recipients/fields are stored as opaque JSON arrays.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 200 }
                description: { type: string, maxLength: 2000 }
                category: { type: string, maxLength: 100, description: Defaults to "Other" }
                recipients:
                  type: array
                  items: { $ref: "#/components/schemas/TemplateRecipient" }
                fields:
                  type: array
                  items: { $ref: "#/components/schemas/TemplateField" }
      responses:
        "201":
          description: Template created
          content:
            application/json:
              schema:
                type: object
                required: [template]
                properties:
                  template: { $ref: "#/components/schemas/Template" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/templates/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getTemplate
      tags: [templates]
      summary: Template detail
      description: One template — public built-ins, the caller's own, or a teammate's org-shared one.
      responses:
        "200":
          description: Template
          content:
            application/json:
              schema:
                type: object
                required: [template]
                properties:
                  template: { $ref: "#/components/schemas/Template" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateTemplate
      tags: [templates]
      summary: Update a template
      description: >-
        Owner-scoped. Built-ins and teammates' shared templates read as not
        found — visible, but not editable. Besides metadata and visibility,
        the recipient slots and field layout are editable (the template
        editor saves its whole canvas), with the same validation as document
        fields.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 200 }
                description: { type: string, maxLength: 2000 }
                category: { type: string, maxLength: 100 }
                recipients:
                  type: array
                  items: { $ref: "#/components/schemas/TemplateRecipient" }
                fields:
                  type: array
                  items: { $ref: "#/components/schemas/TemplateField" }
                visibility: { type: string, enum: [private, org] }
      responses:
        "200":
          description: Updated template
          content:
            application/json:
              schema:
                type: object
                required: [template]
                properties:
                  template: { $ref: "#/components/schemas/Template" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deleteTemplate
      tags: [templates]
      summary: Delete a template
      description: >-
        Owner-scoped — public/built-in templates read as not found. Returns
        204 with no body (the one delete in the suite that does).
      responses:
        "204":
          description: Deleted (empty body)
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/public-signing:
    get:
      operationId: listPublicSigningLinks
      tags: [public-links]
      summary: All of the caller's public signing links
      description: >-
        EAS-117. The "Public Signing" page's list — every one of the
        caller's links, owner-scoped. Unlike the old template-joined design,
        this is a plain read of the links' own rows — no join through
        Template.
      parameters:
        - name: status
          in: query
          required: false
          description: >-
            Comma-separated subset of draft|active|paused|expired. Omit for
            every status. An unrecognized value is a 400, not a silent no-op.
          schema: { type: string }
        - name: q
          in: query
          required: false
          description: Case-insensitive substring match against the link's name.
          schema: { type: string }
        - name: limit
          in: query
          required: false
          description: Page size. Default 10, capped at 100.
          schema: { type: integer }
        - name: offset
          in: query
          required: false
          description: Page start. Default 0.
          schema: { type: integer }
      responses:
        "200":
          description: The caller's public signing links
          content:
            application/json:
              schema:
                type: object
                required: [publicSigningLinks, total, limit, offset]
                properties:
                  publicSigningLinks:
                    type: array
                    items: { $ref: "#/components/schemas/PublicSigningLink" }
                  total: { type: integer, description: "Total rows matching the filter, for pagination." }
                  limit: { type: integer }
                  offset: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    post:
      operationId: createPublicSigningLink
      tags: [public-links]
      summary: Create a public signing link draft
      description: >-
        EAS-117. Name only required — the file, recipient slots, fields, and
        publish settings are all filled in by later calls before publishing.
        A standalone entity from the start: it never references a Template.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 200, description: "At most 200 characters; no control or invisible formatting characters" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [publicSigningLink]
                properties:
                  publicSigningLink: { $ref: "#/components/schemas/PublicSigningLink" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/public-signing/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: getPublicSigningLink
      tags: [public-links]
      summary: One public signing link, with its editable content
      description: Owner-scoped. Includes recipients/fields, for a manage/edit view.
      responses:
        "200":
          description: The link
          content:
            application/json:
              schema:
                type: object
                required: [publicSigningLink]
                properties:
                  publicSigningLink: { $ref: "#/components/schemas/PublicSigningLink" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updatePublicSigningLink
      tags: [public-links]
      summary: Edit a draft, reconfigure publish settings, or pause/resume
      description: >-
        Owner-scoped. name/recipients/fields/visitorSlotIndex are editable
        only while status is "draft" — once published, nothing else can
        concurrently change what an already-shared link produces, so a
        content edit post-publish is rejected (409); unpublish first.
        expiresAt/maxSubmissions/oneSubmissionPerEmail and status
        (active/paused) are editable while the link is live or paused.
        Resuming a paused link re-mints its token: the pre-pause URL never
        quietly comes back to life, and "url" is included in that response
        only. An expired link (stored as expired, or past its expiresAt) is
        reactivated by sending status "active" and/or a new future
        expiresAt (EAS-300). It must say how long to extend, so a bare
        status "active" or clearExpiresAt is rejected (400) and a
        reactivated link can never be made to never expire. expiresAt may
        be at most 10 years ahead. A link that expired because it filled up
        needs maxSubmissions raised above its submissionCount. A
        reactivation re-mints the token and returns "url" once, like a
        resume. Any other change to an expired link (pausing it, or a
        settings change on its own) is rejected (409). Setting an expiresAt
        that is already past expires the link immediately and wins over a
        status change in the same request. A status change on a draft is
        rejected (409): publish it first.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 200 }
                recipients:
                  type: array
                  description: Opaque JSON echoed from storage; the service writes TemplateRecipient shapes
                  items: { $ref: "#/components/schemas/TemplateRecipient" }
                fields:
                  type: array
                  description: Opaque JSON echoed from storage; the service writes TemplateField shapes
                  items: { $ref: "#/components/schemas/TemplateField" }
                visitorSlotIndex: { type: integer, description: Index into recipients that a visitor fills in }
                status: { type: string, enum: [active, paused] }
                expiresAt: { type: string, format: date-time }
                clearExpiresAt: { type: boolean }
                maxSubmissions: { type: integer }
                oneSubmissionPerEmail: { type: boolean }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [publicSigningLink]
                properties:
                  publicSigningLink: { $ref: "#/components/schemas/PublicSigningLink" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Rejected — a content edit on a published link, a change to an expired link that is not a reactivation, a status change on a draft, or another request changed the link at the same moment (reload and try again). A 400 also covers asking for paused while reactivating an expired link, resuming a link whose submission cap is full (raise maxSubmissions first), and a name with control or invisible characters", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { $ref: "#/components/responses/ServerError" }
    delete:
      operationId: deletePublicSigningLink
      tags: [public-links]
      summary: Unpublish a link (or discard a draft)
      description: >-
        Owner-scoped. Retires the URL for good — documents already created
        through it are untouched. Also removes a draft that was never
        published.
      responses:
        "204":
          description: Unpublished/discarded (empty body)
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/public-signing/{id}/file:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: uploadPublicSigningLinkFile
      tags: [public-links]
      summary: Attach (or replace) a draft's PDF
      description: >-
        Draft-only — once published, the link's file is what's already live
        for any visitor holding the URL, so it can't be swapped out from
        under them. PDF, Word, Excel, CSV, PowerPoint, and image uploads are all
        accepted; anything that isn't already a PDF is converted (EAS-113).
        The "file" field may repeat up to 10 times in one request — each part
        is resolved to a PDF and the results are merged, in submission order,
        into the single PDF every visitor signs. Each file may be up to 25 MB,
        the files together up to 50 MB, and the merged PDF up to 25 MB.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: array
                  items: { type: string, format: binary }
                  maxItems: 10
      responses:
        "200":
          description: File attached
          content:
            application/json:
              schema:
                type: object
                required: [publicSigningLink]
                properties:
                  publicSigningLink: { $ref: "#/components/schemas/PublicSigningLink" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: The link is already published, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "413": { description: "A file, the files together, or the merged PDF is over its size limit", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: Could not store the file, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    get:
      operationId: downloadPublicSigningLinkFile
      tags: [public-links]
      summary: Presigned URL for the link's PDF
      description: >-
        Owner-scoped. For the sender-side editor — field placement needs to
        render the real pages. Mirrors downloadTemplateFile's shape.
      responses:
        "200":
          description: A short-lived download URL
          content:
            application/json:
              schema:
                type: object
                required: [url, expiresIn]
                properties:
                  url: { type: string }
                  expiresIn: { type: integer, description: Seconds until the URL expires }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { description: Could not create a download link, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /v1/public-signing/{id}/publish:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: publishPublicSigningLink
      tags: [public-links]
      summary: Publish a draft as a live public self-serve signing link
      description: >-
        EAS-117. Requires a file and at least one recipient slot. Every
        non-visitor recipient slot must be given a real email now; it can't
        be filled in later. expiresAt, when given, must be in the future and
        at most 10 years ahead. Settings saved on the draft with PATCH
        (expiresAt, maxSubmissions, oneSubmissionPerEmail) are kept unless
        this request names them. Only a draft can be published: a paused
        link is resumed with PATCH, and an expired link is reactivated with
        PATCH. The plaintext token is returned only in this response's "url"
        field — never recoverable from a later read.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [visitorSlotIndex]
              properties:
                visitorSlotIndex: { type: integer, description: Index into the link's recipient slots that a visitor fills in }
                recipients:
                  type: array
                  description: Email for every slot except visitorSlotIndex
                  items:
                    type: object
                    required: [orderIndex, email]
                    properties:
                      orderIndex: { type: integer }
                      email: { type: string }
                expiresAt: { type: string, format: date-time, nullable: true }
                maxSubmissions: { type: integer, nullable: true, description: Null = unlimited }
                oneSubmissionPerEmail: { type: boolean, default: false }
      responses:
        "201":
          description: Published
          content:
            application/json:
              schema:
                type: object
                required: [publicSigningLink]
                properties:
                  publicSigningLink: { $ref: "#/components/schemas/PublicSigningLink" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "This link is already published, paused or expired", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/public-signing/{id}/submissions:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: listPublicSigningLinkSubmissions
      tags: [public-links]
      summary: Visitors who have submitted through this public signing link
      description: >-
        Owner-scoped. Each row's documentStatus is the underlying document's
        real, live status — verifying a code only starts a document, it
        doesn't finish it, so this is what tells "verified but hasn't
        touched it yet" apart from "actually finished".
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 10, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
        - { name: status, in: query, description: "Comma-separated subset of document statuses to filter by (e.g. completed,declined)", schema: { type: string } }
      responses:
        "200":
          description: Submissions
          content:
            application/json:
              schema:
                type: object
                required: [submissions, total, resolved]
                properties:
                  submissions:
                    type: array
                    items: { $ref: "#/components/schemas/PublicLinkSubmission" }
                  total: { type: integer }
                  resolved: { type: integer, description: "Submissions whose document has reached a resolved state (completed or declined)" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
        "404": { $ref: "#/components/responses/NotFound" }


  # ------------------------- Easerix Notary: public -------------------------

  /public/notary/states:
    get:
      operationId: notaryStates
      tags: [notary-directory]
      summary: States with notary listings
      description: >-
        Jurisdictions that have listings, with listed and verified counts —
        the data behind the state hub index. Unauthenticated, IP rate-limited.
      security: []
      responses:
        "200":
          description: States
          content:
            application/json:
              schema:
                type: object
                required: [states]
                properties:
                  states:
                    type: array
                    items:
                      type: object
                      properties:
                        state: { type: string }
                        stateName: { type: string }
                        listed: { type: integer }
                        verified: { type: integer }

  /public/notary/notaries:
    get:
      operationId: notarySearch
      tags: [notary-directory]
      summary: Search the directory
      description: >-
        Public directory search. Verified (claimed) profiles rank first, then
        by session volume. Removed, suspended, and opted-out listings never
        appear. Registry addresses are never rendered — city and county only.
      security: []
      parameters:
        - { name: state, in: query, required: false, schema: { type: string }, description: Two-letter state filter }
        - { name: city, in: query, required: false, schema: { type: string } }
        - { name: q, in: query, required: false, schema: { type: string }, description: Name or city substring }
      responses:
        "200":
          description: Listings (60 max)
          content:
            application/json:
              schema:
                type: object
                required: [notaries]
                properties:
                  notaries:
                    type: array
                    items: { $ref: "#/components/schemas/NotaryPublicListing" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /public/notary/notaries/{slug}:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
    get:
      operationId: notaryProfile
      tags: [notary-directory]
      summary: One notary profile
      description: >-
        A profile page. Bio, languages, specialties, and availability appear
        only on verified (claimed) profiles — unclaimed pages carry registry
        facts and the sync date.
      security: []
      responses:
        "200":
          description: Profile
          content:
            application/json:
              schema:
                type: object
                required: [notary]
                properties:
                  notary: { $ref: "#/components/schemas/NotaryPublicListing" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ------------------------- Easerix Notary: portal -------------------------

  /v1/notary/me:
    get:
      operationId: notaryMe
      tags: [notary]
      summary: My notary profile
      description: The caller's notary profile — 404 when the account has none.
      responses:
        "200":
          description: Profile
          content:
            application/json:
              schema:
                type: object
                required: [profile]
                properties:
                  profile: { $ref: "#/components/schemas/NotaryProfileMe" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: notaryUpdateMe
      tags: [notary]
      summary: Update my profile
      description: >-
        Self-editable fields only. Registry facts (name, state, commission)
        change through claim verification or registry sync, never here.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                bio: { type: string }
                city: { type: string }
                availabilityNote: { type: string }
                accepting: { type: boolean }
                languages: { type: array, items: { type: string } }
                specialties: { type: array, items: { type: string } }
      responses:
        "200":
          description: Updated profile
          content:
            application/json:
              schema:
                type: object
                required: [profile]
                properties:
                  profile: { $ref: "#/components/schemas/NotaryProfileMe" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/notary/profiles:
    post:
      operationId: notaryCreateProfile
      tags: [notary]
      summary: Self-serve listing
      description: >-
        Create a listing for a notary not in the seeded registry. Goes to
        pending_claim until an operator verifies it against the state
        commission record. One profile per account (409 otherwise).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fullName, state, commissionNumber]
              properties:
                fullName: { type: string }
                state: { type: string }
                commissionNumber: { type: string }
                commissionExpiresAt: { type: string, format: date-time }
                city: { type: string }
                onlineAttested: { type: boolean, description: "Notary attests they hold state online (RON) authorization — recorded in claim evidence and granted (provenance verified_claim) only when the operator confirms it at approval, never written to the profile directly" }
                evidence: { type: string }
      responses:
        "201":
          description: Pending listing
          content:
            application/json:
              schema:
                type: object
                required: [profile]
                properties:
                  profile: { $ref: "#/components/schemas/NotaryProfileMe" }
                  message: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: The account already has a notary profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/notary/notaries/{slug}/claim:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
    post:
      operationId: notaryClaimProfile
      tags: [notary]
      summary: Claim an unclaimed listing
      description: >-
        Authenticated claim of a registry-seeded listing. The commission
        number must match the registry record (403 otherwise), and the claim
        stays pending until an operator verifies it — impersonation is the
        attack this flow exists to beat.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [commissionNumber]
              properties:
                commissionNumber: { type: string }
                evidence: { type: string, description: "How the operator can verify — certificate, SOS record link, etc." }
                onlineAttested: { type: boolean, description: "Notary attests they hold state online (RON) authorization — recorded in claim evidence and granted (provenance verified_claim) only when the operator confirms it at approval" }
      responses:
        "200":
          description: Claim received
          content:
            application/json:
              schema:
                type: object
                required: [profile]
                properties:
                  profile: { $ref: "#/components/schemas/NotaryProfileMe" }
                  message: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Commission number does not match the state record
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Profile is not open to claims, or the account already has one
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/notary/queue:
    get:
      operationId: notaryQueue
      tags: [notary]
      summary: Open requests in my state
      description: >-
        The state queue. Requires an ACTIVE profile — verified claim, online
        authorization on record, unexpired commission; otherwise requests is
        empty and active=false with a reason.
      responses:
        "200":
          description: Queue
          content:
            application/json:
              schema:
                type: object
                required: [requests]
                properties:
                  requests:
                    type: array
                    items: { $ref: "#/components/schemas/NotarizationRequest" }
                  active: { type: boolean }
                  reason: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/notary/sessions:
    get:
      operationId: notarySessions
      tags: [notary]
      summary: My claimed and completed work
      description: Scheduled first, then claimed, then history.
      responses:
        "200":
          description: Sessions
          content:
            application/json:
              schema:
                type: object
                required: [requests]
                properties:
                  requests:
                    type: array
                    items: { $ref: "#/components/schemas/NotarizationRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/notary/requests/{id}/claim:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: notaryClaimRequest
      tags: [notary]
      summary: Claim an open request
      description: >-
        Guarded transition — two notaries racing get one winner (409 for the
        loser). Requires an active profile in the request's state.
      responses:
        "200":
          description: Claimed
          content:
            application/json:
              schema:
                type: object
                required: [request]
                properties:
                  request: { $ref: "#/components/schemas/NotarizationRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Profile is not active
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Request is no longer open
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/notary/requests/{id}/release:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: notaryReleaseRequest
      tags: [notary]
      summary: Release a claimed request
      description: Puts the request back in the state queue and clears the schedule.
      responses:
        "200":
          description: Released
          content:
            application/json:
              schema:
                type: object
                required: [request]
                properties:
                  request: { $ref: "#/components/schemas/NotarizationRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Request is not claimed or scheduled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/notary/requests/{id}/schedule:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: notaryScheduleRequest
      tags: [notary]
      summary: Schedule the session
      description: Sets the session time (RFC3339, future) and an optional note for the customer.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scheduledAt]
              properties:
                scheduledAt: { type: string, format: date-time }
                sessionNote: { type: string }
      responses:
        "200":
          description: Scheduled
          content:
            application/json:
              schema:
                type: object
                required: [request]
                properties:
                  request: { $ref: "#/components/schemas/NotarizationRequest" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Request cannot be scheduled in its current state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/notary/requests/{id}/file:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: notaryRequestFile
      tags: [notary]
      summary: Download the document for the session
      description: >-
        The signed envelope PDF, available to the claiming notary only once
        every signer has signed (the document is awaiting notarization) —
        before that the notary has no business reading it (409).
      responses:
        "200":
          description: The PDF
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Signers have not finished
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/notary/requests/{id}/complete:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: notaryCompleteRequest
      tags: [notary]
      summary: Return the sealed document
      description: >-
        The Phase A handoff. The session ran on the notary's own registered
        RON technology; uploading the sealed PDF (multipart field "file", 25
        MB max, validated as PDF) completes the envelope — it becomes the
        final artifact, the audit chain records the notary's commission, and
        a journal entry auto-files for the notary to review.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        "200":
          description: Completed
          content:
            application/json:
              schema:
                type: object
                required: [request]
                properties:
                  request: { $ref: "#/components/schemas/NotarizationRequest" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Request or document is not in a completable state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: File storage is not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/notary/journal:
    get:
      operationId: notaryJournal
      tags: [notary]
      summary: My e-journal
      description: >-
        Entries newest-first (200 max) plus the money summary: act count,
        notarial fees, non-notarial fees, and logged miles. The journal
        records ALL the notary's acts, not just Easerix sessions — it is the
        notary's record, exportable in full, never mined.
      responses:
        "200":
          description: Journal
          content:
            application/json:
              schema:
                type: object
                required: [entries, summary]
                properties:
                  entries:
                    type: array
                    items: { $ref: "#/components/schemas/JournalEntry" }
                  summary:
                    type: object
                    properties:
                      actCount: { type: integer }
                      feeCents: { type: integer, format: int64 }
                      nonNotarialFeeCents: { type: integer, format: int64 }
                      miles: { type: number }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: notaryJournalCreate
      tags: [notary]
      summary: Record an act
      description: >-
        One notarial act, appended to the profile's tamper-evident hash chain
        (each entry's hash covers the previous hash). idMethod records the ID
        type and issuer — never ID numbers. Fees are integer cents; the
        notarial vs non-notarial split is the SE-tax separation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [actType]
              properties:
                actType:
                  type: string
                  description: acknowledgment | jurat | oath | copy_certification | signature_witnessing | other
                documentDesc: { type: string }
                signerName: { type: string }
                idMethod: { type: string, description: ID type and issuer — never numbers }
                method: { type: string, description: in_person | online }
                location: { type: string }
                feeCents: { type: integer, format: int64 }
                nonNotarialFeeCents: { type: integer, format: int64 }
                notes: { type: string }
                performedAt: { type: string, format: date-time }
      responses:
        "201":
          description: Entry
          content:
            application/json:
              schema:
                type: object
                required: [entry]
                properties:
                  entry: { $ref: "#/components/schemas/JournalEntry" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/notary/journal/export:
    get:
      operationId: notaryJournalExport
      tags: [notary]
      summary: Export the full journal
      description: >-
        The complete journal as CSV, including hashes. One click, always,
        everything — the journal is never a hostage.
      responses:
        "200":
          description: CSV download
          content:
            text/csv:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/notary/expenses:
    get:
      operationId: notaryExpenses
      tags: [notary]
      summary: Practice expenses
      description: Bond, E&O, supplies — newest first (500 max).
      responses:
        "200":
          description: Expenses
          content:
            application/json:
              schema:
                type: object
                required: [expenses]
                properties:
                  expenses:
                    type: array
                    items: { $ref: "#/components/schemas/PracticeExpense" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: notaryExpenseCreate
      tags: [notary]
      summary: Record an expense
      description: One practice cost in integer cents.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amountCents]
              properties:
                category: { type: string }
                amountCents: { type: integer, format: int64 }
                note: { type: string }
                spentAt: { type: string, format: date-time }
      responses:
        "201":
          description: Expense
          content:
            application/json:
              schema:
                type: object
                required: [expense]
                properties:
                  expense: { $ref: "#/components/schemas/PracticeExpense" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/notary/expenses/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      operationId: notaryExpenseDelete
      tags: [notary]
      summary: Delete an expense
      description: Removes one expense from the ledger.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/notary/mileage:
    get:
      operationId: notaryMileage
      tags: [notary]
      summary: Mileage logs
      description: Appointment drives for the IRS deduction — newest first (500 max).
      responses:
        "200":
          description: Mileage
          content:
            application/json:
              schema:
                type: object
                required: [mileage]
                properties:
                  mileage:
                    type: array
                    items: { $ref: "#/components/schemas/MileageLog" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: notaryMileageCreate
      tags: [notary]
      summary: Log a drive
      description: One appointment drive (0-2000 miles).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [miles]
              properties:
                miles: { type: number, description: 0 < miles <= 2000 }
                purpose: { type: string }
                drivenAt: { type: string, format: date-time }
      responses:
        "201":
          description: Logged
          content:
            application/json:
              schema:
                type: object
                required: [mileage]
                properties:
                  mileage: { $ref: "#/components/schemas/MileageLog" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /v1/notary/mileage/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      operationId: notaryMileageDelete
      tags: [notary]
      summary: Delete a mileage log
      description: Removes one drive from the mileage log.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/notary/compliance:
    get:
      operationId: notaryCompliance
      tags: [notary]
      summary: Compliance items
      description: Commission, bond, and E&O tracking with expiries, soonest first.
      responses:
        "200":
          description: Items
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/ComplianceItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      operationId: notaryCompliancePut
      tags: [notary]
      summary: Replace compliance items
      description: Replaces the tracked set (20 items max). Kinds are commission, bond, eo, other.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  items:
                    type: object
                    required: [kind]
                    properties:
                      kind: { type: string, description: commission | bond | eo | other }
                      label: { type: string }
                      reference: { type: string }
                      expiresAt: { type: string, format: date-time }
                      note: { type: string }
      responses:
        "200":
          description: Items
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/ComplianceItem" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/notary/admin/import:
    post:
      operationId: notaryAdminImport
      tags: [notary]
      summary: Import a state registry (operator)
      description: >-
        Upserts registry rows for a state (1-5000 per call) and records a
        registry_syncs row. Gated by NOTARY_ADMIN_EMAILS — 403 for everyone
        else. Existing rows refresh registry facts only; claimed profiles
        keep their edited fields. registryAddress is stored for claim
        verification and never rendered.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [state, rows]
              properties:
                state: { type: string }
                source: { type: string, description: e.g. tx-sos }
                onlineSource:
                  type: string
                  description: "Provenance stamped on rows asserting onlineAuthorized (default state_registry)"
                rows:
                  type: array
                  items:
                    type: object
                    required: [fullName, commissionNumber]
                    properties:
                      fullName: { type: string }
                      commissionNumber: { type: string }
                      commissionExpiresAt: { type: string, format: date-time }
                      onlineAuthorized: { type: boolean }
                      city: { type: string }
                      county: { type: string }
                      registryAddress: { type: string }
      responses:
        "200":
          description: Import result
          content:
            application/json:
              schema:
                type: object
                properties:
                  imported: { type: integer }
                  updated: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Caller is not a notary operator
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/notary/admin/claims:
    get:
      operationId: notaryAdminClaims
      tags: [notary]
      summary: Pending claims (operator)
      description: Claims awaiting verification against state commission records.
      responses:
        "200":
          description: Pending claims with evidence
          content:
            application/json:
              schema:
                type: object
                required: [claims]
                properties:
                  claims:
                    type: array
                    items: { $ref: "#/components/schemas/NotaryProfileMe" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Caller is not a notary operator
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/notary/admin/claims/{id}/approve:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: notaryAdminApprove
      tags: [notary]
      summary: Approve a claim (operator)
      description: The operator verified the claim against the state commission record.
      responses:
        "200":
          description: Approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  approved: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Caller is not a notary operator
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/notary/admin/claims/{id}/reject:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: notaryAdminReject
      tags: [notary]
      summary: Reject a claim (operator)
      description: >-
        Claim did not verify. Registry-seeded rows return to unclaimed;
        self-serve rows are removed.
      responses:
        "200":
          description: Rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  rejected: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Caller is not a notary operator
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/documents/{id}/notarization:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      operationId: documentNotarization
      tags: [notary]
      summary: The envelope's notarization request
      description: >-
        The owner's view of the request behind an envelope that requires
        notarization — status, schedule, and the assigned notary's public
        identity (name, commission, state).
      responses:
        "200":
          description: Request
          content:
            application/json:
              schema:
                type: object
                required: [request]
                properties:
                  request: { $ref: "#/components/schemas/NotarizationRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
    docsBasic:
      type: http
      scheme: basic
      description: >-
        Gates /docs and /docs/openapi.yaml only. Credentials come from the
        DOCS_USER/DOCS_PASSWORD env vars; if either is unset the routes are
        not registered at all (404), never served open.
  schemas:
    Recipient:
      type: object
      required: [id, name, email, role, status, orderIndex]
      properties:
        id: { type: string }
        name: { type: string }
        email: { type: string }
        role: { type: string, description: signer | viewer | cc }
        status:
          type: string
          description: draft | pending | sent | viewed | signed | declined
        orderIndex: { type: integer }
        signedAt: { type: string, format: date-time, nullable: true }
        viewedAt: { type: string, format: date-time, nullable: true }
        declinedAt: { type: string, format: date-time, nullable: true }
        declineReason: { type: string }
        hasAccessCode: { type: boolean, description: True when the signer must supply an access code }
        verificationMethod: { $ref: "#/components/schemas/VerificationMethod" }
        signature: { type: string, nullable: true, description: PNG data URL }
        isGroup: { type: boolean, description: "EAS-125: true when this slot is a signer group — any one member can satisfy it" }
        groupLabel: { type: string, nullable: true, description: Display name for a group slot; null for an individual recipient }
        signedByName: { type: string, nullable: true, description: "Which group member actually signed (individual recipients leave this null — see name/email instead)" }
        signedByEmail: { type: string, nullable: true }
        members:
          type: array
          items: { $ref: "#/components/schemas/RecipientGroupMember" }
          description: Present only on a group slot (isGroup true). Never includes a member's access token.
        reassignedFromName: { type: string, nullable: true, description: "EAS-125: the original invitee, once this slot has been reassigned to someone else — name/email above are already the new signer" }
        reassignedFromEmail: { type: string, nullable: true }
        reassignReason: { type: string, nullable: true }
        reassignedAt: { type: string, format: date-time, nullable: true }
    RecipientGroupMember:
      type: object
      required: [id, name, email]
      description: EAS-125 — one candidate person under a signer-group Recipient slot.
      properties:
        id: { type: string }
        name: { type: string }
        email: { type: string }
        viewedAt: { type: string, format: date-time, nullable: true }
        declinedAt: { type: string, format: date-time, nullable: true, description: "Set when this member declines — the group slot itself only declines once every member has" }
    RecipientSuggestion:
      type: object
      required: [email, name, source]
      description: >-
        A person the workspace already knows, offered while the sender types.
        Not a Recipient — nothing here belongs to a document yet.
      properties:
        email:
          type: string
          format: email
          description: Always lowercased — this is also the de-duplication key
        name:
          type: string
          description: >-
            May be empty (a colleague with no display name, or an address only
            ever sent to without one). Still pickable.
        source:
          type: string
          enum: [history, member]
          description: >-
            history — the caller has sent to this address before · member — a
            member of the caller's workspace. Someone who is both is reported
            once, as history.
        lastUsedAt:
          type: string
          format: date-time
          nullable: true
          description: When this address was last used as a recipient. Null for member-only entries.

    GroupLabelSuggestion:
      type: object
      required: [label, members]
      description: >-
        A signer-group label (EAS-125+) the caller has used before, offered
        while typing a new group's name (GET /v1/recipients/suggest?kind=group).
        Not a Recipient — nothing here belongs to a document yet, and members
        are a suggestion only: creating a group from this label does not add
        them, and neither does this endpoint. members is the roster from this
        label's most recent past use, so a group renamed or reused over time
        offers its latest lineup, not a lifetime union of everyone who was
        ever in it.
      properties:
        label:
          type: string
          description: The group's display name, as most recently spelled/cased.
        lastUsedAt:
          type: string
          format: date-time
          nullable: true
        members:
          type: array
          description: >-
            The member roster from this label's most recent use. Always
            present (possibly empty) — never fetched or added automatically.
          items:
            type: object
            required: [name, email]
            properties:
              name: { type: string }
              email: { type: string, format: email }
    DocumentField:
      type: object
      required: [id, type, x, y, width, height, page, required]
      properties:
        id: { type: string }
        type: { type: string, enum: [signature, initial, date, text, checkbox, dropdown, radio, number, formula, file, payment] }
        x: { type: number, description: "Fraction of page width, top-left origin" }
        y: { type: number, description: "Fraction of page height, top-left origin" }
        width: { type: number }
        height: { type: number }
        page:
          type: integer
          description: >-
            Page within fileId's file (EAS-114) — resets to 1 per file, never
            a running count across the document's whole file collection.
        required: { type: boolean }
        label: { type: string, nullable: true }
        value: { type: string, nullable: true, description: "Filled at signing; date fields auto-fill with the signing date. Never set for formula (computed, never persisted) or file (see the field's upload, not this) fields." }
        recipientId: { type: string, nullable: true }
        fileId:
          type: string
          nullable: true
          description: >-
            Which of the document's files (EAS-114, see Document.files) this
            field sits on. Null only for a field written before this feature
            that was never backfilled; every field on a multi-file document
            has one.
        config:
          type: object
          nullable: true
          additionalProperties: true
          description: >-
            EAS-110 per-type configuration — null for signature/initial/
            date/text/checkbox. Shape depends on type: dropdown/radio take
            {"options":[{"label","value"}]}; number takes optional
            {"min","max","decimals"}; formula takes {"expression",
            "refs":{token: fieldId}} — refs maps each identifier the
            expression references to the field id it means, and a formula
            field is never assignable to a recipient (it's computed, not
            signer input); file takes optional {"allowedTypes","maxSizeBytes"}.
    DocumentFile:
      type: object
      description: One file in a document's ordered collection (EAS-114) — a sender adds several, orders them, and they sign as one continuous package.
      required: [id, name, position, size, pages, created, status, conversionError]
      properties:
        id: { type: string }
        name: { type: string, description: The uploaded filename }
        position: { type: integer, description: "Sender-chosen order, 0-based — the same order files are stamped, merged, and rendered in" }
        size: { type: integer, format: int64 }
        pages: { type: integer }
        created: { type: string, format: date-time }
        status:
          type: string
          enum: [ready, converting, failed]
          description: >-
            "ready" for a PDF/image (always was, and always immediate) or an
            Office document once its background conversion has finished
            (EAS-113/EAS-206). "converting" means size/pages/download aren't
            available yet — poll GET on the document or this file. "failed"
            means conversion could not complete; conversionError has a
            user-safe reason, and the file must be replaced.
        conversionError: { type: string, description: Set only when status is "failed"; empty otherwise. }
    Document:
      type: object
      required: [id, name, url, status, created, modified, size, pages, recipients, fields, hasFile, hasFinalFile, signingMode]
      properties:
        id: { type: string }
        name: { type: string }
        url:
          type: string
          description: Always "" in list/detail — call GET /v1/documents/{id}/file for a presigned URL
        status:
          type: string
          description: >-
            draft | pending | completed | declined | cancelled | expired |
            awaiting_notarization (all signers signed, sealed document pending)
        created: { type: string, format: date-time }
        modified: { type: string, format: date-time }
        size:
          type: integer
          format: int64
          description: >-
            Real byte size once a file is attached. EAS-114: the sum across
            files once more than one is attached.
        pages:
          type: integer
          description: >-
            Real page count once a file is attached. EAS-114: the sum across
            files once more than one is attached — DocumentField.page still
            addresses a page within its own fileId's file, not this total.
        templateId: { type: string, nullable: true }
        hasFile: { type: boolean }
        hasFinalFile: { type: boolean, description: True once the stamped final PDF with certificate exists }
        files:
          type: array
          description: >-
            The document's ordered file collection (EAS-114). A document
            written before this feature, or with no file yet, has an empty
            list here even though hasFile/size/pages (above) still describe
            its one legacy file — read those for compatibility, or this list
            for the real per-file breakdown and names.
          items: { $ref: "#/components/schemas/DocumentFile" }
        signingMode: { type: string, description: parallel | sequential }
        message: { type: string }
        sentAt: { type: string, format: date-time, nullable: true }
        completedAt: { type: string, format: date-time, nullable: true }
        voidedAt: { type: string, format: date-time, nullable: true }
        voidReason: { type: string }
        declinedAt: { type: string, format: date-time, nullable: true }
        expiresAt:
          type: string
          format: date-time
          nullable: true
          description: Set when the document was sent with an expiresInDays > 0 — links stop working after this
        autoRemindDays:
          type: integer
          description: "0 (off) to 30 — unsigned signers are automatically re-emailed on this cadence"
        lastRemindedAt: { type: string, format: date-time, nullable: true, description: When the auto-remind sweep last re-emailed a signer for this document }
        legalHold:
          type: boolean
          description: EAS-115 — blocks the retention purge sweep regardless of age
        scheduledPurgeAt:
          type: string
          format: date-time
          nullable: true
          description: EAS-115 — set once retention flags this document; cleared by a legal hold
        visibility:
          type: string
          description: private = owner only · org = readable by everyone in the organization
        ownerId: { type: string }
        requiresNotarization:
          type: boolean
          description: The envelope completes only after a notary returns the sealed document
        notaryState: { type: string, description: Two-letter commission state for the notarization }
        recipients:
          type: array
          description: Ordered by orderIndex ascending
          items: { $ref: "#/components/schemas/Recipient" }
        fields:
          type: array
          items: { $ref: "#/components/schemas/DocumentField" }
    AuditEvent:
      type: object
      required: [id, actor, event, detail, ip, userAgent, hash, createdAt]
      properties:
        id: { type: string }
        recipientId: { type: string, nullable: true }
        actor: { type: string }
        event: { type: string }
        detail: { type: string }
        ip: { type: string }
        userAgent: { type: string }
        hash: { type: string, description: SHA-256 over the previous hash plus this event — a per-document chain }
        createdAt: { type: string, format: date-time }
    EngagementGroupMember:
      type: object
      properties:
        name: { type: string }
        email: { type: string }
        viewedAt: { type: string, format: date-time, nullable: true }
        declinedAt: { type: string, format: date-time, nullable: true }
    EngagementRecipient:
      type: object
      required: [recipientId, name, email, role, isGroup, orderIndex, status, firstViewedAt, signedAt, isBlocking]
      properties:
        recipientId: { type: string }
        name: { type: string }
        email: { type: string }
        role: { type: string }
        isGroup: { type: boolean }
        orderIndex: { type: integer }
        status: { type: string }
        firstViewedAt:
          type: string
          format: date-time
          nullable: true
          description: The recipient's FIRST open only — the platform never records a repeat view, so there is no view count or "last viewed".
        signedAt: { type: string, format: date-time, nullable: true }
        timeToSignSeconds: { type: integer, nullable: true }
        timeToSignBasis:
          type: string
          nullable: true
          enum: [fromView, fromSend, fromTurn]
          description: >-
            Where timeToSignSeconds was measured from. "fromView" (signedAt −
            the recipient's first view) when they opened their link before
            signing. "fromSend" (signedAt − document.sentAt) when there is no
            usable view (the owner signed in person, which never records one)
            and nothing was ahead of them. "fromTurn" in a sequential document
            when the clock started later than the send: when the last
            lower-order signer finished, if that came after the view or there
            is no view, since a recipient cannot act before their turn. A
            signer group is one slot: its view is the signing member's own, and
            it has one turn start. Null until signed.
        isBlocking:
          type: boolean
          description: True only while the document is pending and this signer's turn has arrived and they haven't signed yet.
        members:
          type: array
          description: Present only when isGroup is true.
          items: { $ref: "#/components/schemas/EngagementGroupMember" }
    DocumentEngagement:
      type: object
      required: [documentId, status, sentAt, completedAt, completionSeconds, recipients]
      properties:
        documentId: { type: string }
        status: { type: string }
        sentAt: { type: string, format: date-time, nullable: true }
        completedAt: { type: string, format: date-time, nullable: true }
        completionSeconds: { type: integer, nullable: true, description: "completedAt − sentAt, null until completed" }
        recipients:
          type: array
          items: { $ref: "#/components/schemas/EngagementRecipient" }
    WaitingBlocker:
      type: object
      required: [recipientId, name, isGroup]
      properties:
        recipientId: { type: string }
        name: { type: string }
        email: { type: string }
        isGroup: { type: boolean }
        viewedAt: { type: string, format: date-time, nullable: true, description: Present when isGroup is false. }
        members:
          type: array
          description: Present only when isGroup is true.
          items: { $ref: "#/components/schemas/EngagementGroupMember" }
    WaitingDocument:
      type: object
      required: [id, name, sentAt, waitingOn]
      properties:
        id: { type: string }
        name: { type: string }
        sentAt: { type: string, format: date-time, nullable: true }
        waitingOn:
          type: array
          items: { $ref: "#/components/schemas/WaitingBlocker" }
    CompletionStatsDocument:
      type: object
      required: [id, name, status, sentAt, completedAt, completionSeconds]
      properties:
        id: { type: string }
        name: { type: string }
        status: { type: string }
        sentAt: { type: string, format: date-time, nullable: true }
        completedAt: { type: string, format: date-time, nullable: true }
        completionSeconds: { type: integer, nullable: true }
    CompletionStats:
      type: object
      required: [summary, documents, total, limit, offset]
      properties:
        summary:
          type: object
          required: [sentCount, completedCount, completionRate, avgCompletionSeconds]
          properties:
            sentCount: { type: integer }
            completedCount: { type: integer }
            completionRate: { type: number, nullable: true, description: "completedCount / sentCount over the range; null (not 0) when sentCount is 0." }
            avgCompletionSeconds: { type: number, nullable: true }
        documents:
          type: array
          items: { $ref: "#/components/schemas/CompletionStatsDocument" }
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
        from: { type: string, format: date-time, nullable: true }
        to: { type: string, format: date-time, nullable: true }
    SigningSession:
      type: object
      required: [document, recipient, recipients, fields, myTurn]
      properties:
        document:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
            status: { type: string }
            pages: { type: integer }
            message: { type: string }
            signingMode: { type: string }
            sentAt: { type: string, format: date-time, nullable: true }
            completedAt: { type: string, format: date-time, nullable: true }
            voidedAt: { type: string, format: date-time, nullable: true }
            expiresAt: { type: string, format: date-time, nullable: true }
            url: { type: string, description: Presigned file URL ("" when storage is disabled) }
            files:
              type: array
              description: >-
                EAS-114: the envelope's original files, in order — populated
                only pre-finalization when there is more than one; empty for
                a single-file document or once "url" above is the merged,
                completed artifact.
              items:
                type: object
                properties:
                  id: { type: string }
                  name: { type: string }
                  position: { type: integer }
                  pages: { type: integer }
                  url: { type: string, description: Presigned file URL ("" when storage is disabled) }
        recipient:
          type: object
          description: The link holder (full detail) — the SLOT, not necessarily one person (see isGroup)
          properties:
            id: { type: string }
            name: { type: string }
            email: { type: string }
            role: { type: string }
            status: { type: string }
            signedAt: { type: string, format: date-time, nullable: true }
            declinedAt: { type: string, format: date-time, nullable: true }
            isGroup: { type: boolean, description: "EAS-125: true when this slot is a signer group" }
            groupLabel: { type: string, nullable: true }
        recipients:
          type: array
          description: Co-recipients, redacted (no emails or signatures except the link holder's own)
          items:
            type: object
            properties:
              id: { type: string }
              name: { type: string }
              role: { type: string }
              status: { type: string }
              orderIndex: { type: integer }
              signedAt: { type: string, format: date-time, nullable: true }
              mine: { type: boolean }
              email: { type: string, description: Present only when this row is the link holder's own (mine true) }
              isGroup: { type: boolean, description: "EAS-125: true when this co-recipient is a signer group" }
              groupLabel: { type: string, nullable: true }
              signedByName: { type: string, nullable: true, description: "Which group member actually signed, once the slot is satisfied" }
              members:
                type: array
                description: Present only when isGroup is true. Never includes an email or access token.
                items:
                  type: object
                  properties:
                    id: { type: string }
                    firstName: { type: string, description: First name only — never a full name or email }
                    mine: { type: boolean }
        actingMember:
          type: object
          nullable: true
          description: >-
            The specific group member holding this token (EAS-125) —
            null unless recipient.isGroup is true. "You are {actingMember},
            signing for {recipient.groupLabel}".
          properties:
            id: { type: string }
            name: { type: string }
            email: { type: string }
            viewedAt: { type: string, format: date-time, nullable: true }
            declinedAt: { type: string, format: date-time, nullable: true }
        fields:
          type: array
          items:
            allOf:
              - $ref: "#/components/schemas/DocumentField"
              - type: object
                properties:
                  mine: { type: boolean }
                  upload:
                    type: object
                    nullable: true
                    description: "EAS-110 — present only for type=file fields; null when nothing has been uploaded yet."
                    properties:
                      id: { type: string }
                      name: { type: string }
                      size: { type: integer }
                      contentType: { type: string }
        consentDisclosure:
          type: object
          nullable: true
          description: >-
            The ESIGN disclosure this session is pinned to (EAS-116) — set on
            first view and never moved afterward, even once a newer version
            is published. Returned on every call (not just the first), so a
            signer returning later still sees exactly what they'll be
            recorded as accepting. Null only on a disclosure lookup failure.
          properties:
            version: { type: integer }
            content: { type: string, description: "Plain text, not HTML" }
        myTurn: { type: boolean }
        canReassign: { type: boolean, description: "EAS-125: true when the link holder may reassign this slot right now — myTurn, not a group, not already reassigned once, and without OTP verification (a verified slot cannot be reassigned)" }
        security: { $ref: "#/components/schemas/SigningSecurity" }
        locked: { type: boolean, description: "Present (true) only on the locked variant of the session — access code not yet accepted, or OTP verification still pending. Only document.name/status, recipient.name and security are populated then." }
        verificationRequired: { type: boolean, description: "Present (true) on the locked variant when the access code is accepted and only OTP verification remains" }
    TemplateContent:
      type: object
      description: Editable text of a content-backed template — Template Studio.
      required: [sections]
      properties:
        sections:
          type: array
          description: Up to 60 sections (heading 300 chars, body 8000 chars max)
          items:
            type: object
            properties:
              heading: { type: string }
              body: { type: string, description: "Paragraphs separated by blank lines; {{key}} placeholders allowed" }
        placeholders:
          type: array
          description: Fill-in fields referenced as {{key}} in the text (max 40)
          items:
            type: object
            properties:
              key: { type: string }
              label: { type: string }
              hint: { type: string }
    TemplateRecipient:
      type: object
      properties:
        name: { type: string }
        role: { type: string }
        orderIndex: { type: integer }
        isGroup: { type: boolean, description: "On updateTemplate only: marks this slot as a signer group once a document is created from the template — groupLabel (or name, if blank) becomes the group's label, and role is forced to \"signer\". Two group slots on the same template can't share a label." }
        groupLabel: { type: string, description: "The group's label when isGroup is true; defaults to name." }
    TemplateField:
      type: object
      properties:
        type: { type: string, enum: [signature, initial, date, text, checkbox, dropdown, radio, number, formula, file, payment] }
        x: { type: number }
        y: { type: number }
        width: { type: number }
        height: { type: number }
        page: { type: integer }
        required: { type: boolean }
        label: { type: string }
        recipientIndex: { type: integer, description: Binds the field to the template recipient slot at this index }
        key:
          type: string
          description: >-
            Stable, template-local identifier (EAS-110), auto-assigned and
            kept verbatim across saves — stays the same when fields are
            reordered, unlike array position. A formula field's config.refs
            targets other fields by this key; template materialization
            (creating a document from the template) rewrites those targets
            to the new document field ids.
        config:
          type: object
          nullable: true
          additionalProperties: true
          description: >-
            See DocumentField.config — identical shape, except a formula's
            refs targets other fields by their template key here, not a
            document field id (that rewrite happens only at materialization).
    Template:
      type: object
      required: [id, name, description, category, created, modified, usageCount, isPublic, recipients, fields]
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string }
        category: { type: string }
        created: { type: string, format: date-time }
        modified: { type: string, format: date-time }
        usageCount: { type: integer }
        isPublic: { type: boolean }
        hasFile: { type: boolean, description: True once the template carries its PDF }
        pages: { type: integer }
        content:
          nullable: true
          allOf: [{ $ref: "#/components/schemas/TemplateContent" }]
        visibility:
          type: string
          description: private = owner only · org = readable by everyone in the organization
        ownerId: { type: string }
        recipients:
          type: array
          description: Opaque JSON echoed from storage; the service writes TemplateRecipient shapes
          items: { $ref: "#/components/schemas/TemplateRecipient" }
        fields:
          type: array
          description: Opaque JSON echoed from storage; the service writes TemplateField shapes
          items: { $ref: "#/components/schemas/TemplateField" }
    PublicSigningLink:
      type: object
      description: >-
        EAS-117. A standalone, self-contained "publish a file as an
        anonymous, OTP-verified self-serve signing link" entity — it owns
        its own file/fields/recipients directly and never references a
        Template. Never carries the plaintext token/URL except right after a
        publish or a resume — see the "url" field. recipients/fields are
        only present on getPublicSigningLink and the create/upload/patch/
        publish responses — the list endpoint omits them.
      required: [id, name, status, hasFile, submissionCount, oneSubmissionPerEmail, visitorSlotIndex, createdAt, updatedAt]
      properties:
        id: { type: string }
        name: { type: string }
        status: { type: string, enum: [draft, active, paused, expired] }
        hasFile: { type: boolean }
        pages: { type: integer }
        recipients:
          type: array
          description: Opaque JSON echoed from storage; the service writes TemplateRecipient shapes
          items: { $ref: "#/components/schemas/TemplateRecipient" }
        fields:
          type: array
          description: Opaque JSON echoed from storage; the service writes TemplateField shapes
          items: { $ref: "#/components/schemas/TemplateField" }
        visitorSlotIndex: { type: integer }
        expiresAt: { type: string, format: date-time, nullable: true }
        maxSubmissions: { type: integer, nullable: true, description: Null = unlimited }
        submissionCount:
          type: integer
          description: >-
            Slots in use against maxSubmissions. A slot is taken when a visitor
            verifies their email and given back when that visitor's document
            ends unsigned (voided, or its signing deadline passed). Signed and
            declined submissions keep theirs. A link whose submissionCount has
            reached maxSubmissions is full and answers like any unavailable link.
        oneSubmissionPerEmail: { type: boolean }
        publishedAt: { type: string, format: date-time, nullable: true }
        pausedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        url: { type: string, description: "Present only on the publish, resume and reactivation responses — never on a plain read" }
    PublicLinkSubmission:
      type: object
      description: "EAS-117. Provenance only — who arrived via which link, and when."
      required: [id, documentId, visitorName, visitorEmail, createdAt, documentStatus, document]
      properties:
        id: { type: string }
        documentId: { type: string }
        visitorName: { type: string }
        visitorEmail: { type: string }
        createdAt: { type: string, format: date-time }
        documentStatus: { type: string, description: "The underlying document's real, live status" }
        document:
          allOf:
            - { $ref: "#/components/schemas/Document" }
          description: "The full document behind this submission — size, recipients, progress — so this list can render the same file row as the main Documents page."
    SavedSignature:
      type: object
      required: [kind, data, updatedAt]
      properties:
        kind: { type: string, description: draw | type }
        data: { type: string, description: PNG data URL }
        updatedAt: { type: string, format: date-time }
    Webhook:
      type: object
      required: [id, url, events, active, failureCount, createdAt]
      properties:
        id: { type: string }
        url: { type: string }
        events:
          type: array
          items: { type: string }
        active: { type: boolean }
        failureCount: { type: integer }
        lastDeliveryAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
    RetentionPolicy:
      type: object
      description: "EAS-115. One per workspace; null retentionDays (the default) means keep forever."
      required: [retentionDays, graceDays, updatedBy]
      properties:
        retentionDays:
          type: integer
          nullable: true
          description: Null = keep forever. Minimum 1 once set.
        graceDays:
          type: integer
          description: Warning window between flagging and purge. Defaults to 14.
        updatedAt: { type: string, format: date-time, nullable: true }
        updatedBy: { type: string }
    NotaryPublicListing:
      type: object
      description: >-
        A public directory listing. City/county only — registry addresses are
        never rendered. Bio, languages, specialties, availability, and
        accepting appear only when verified is true (claimed profiles).
      required: [slug, fullName, state, stateName, verified]
      properties:
        slug: { type: string }
        fullName: { type: string }
        state: { type: string }
        stateName: { type: string }
        city: { type: string }
        county: { type: string }
        verified: { type: boolean, description: True only for claimed and operator-verified profiles }
        commissionNumber: { type: string }
        commissionExpiresAt: { type: string, format: date-time, nullable: true }
        onlineAuthorized: { type: boolean }
        onlineSource:
          type: string
          description: "How online authorization was established: state_registry | verified_claim | (empty when not online)"
        onlineVerifiedAt:
          type: string
          format: date-time
          nullable: true
          description: When online authorization was confirmed against its source
        sessionsCount: { type: integer }
        sourceSyncedAt:
          type: string
          format: date-time
          nullable: true
          description: When the registry record was last synced (shown on unclaimed pages)
        languages: { type: array, items: { type: string } }
        specialties: { type: array, items: { type: string } }
        bio: { type: string }
        availabilityNote: { type: string }
        accepting: { type: boolean }
    NotaryProfileMe:
      type: object
      description: The notary's own view of their profile.
      required: [id, slug, status, fullName, state, active]
      properties:
        id: { type: string }
        slug: { type: string }
        status: { type: string, description: unclaimed | pending_claim | claimed | suspended | removed }
        fullName: { type: string }
        state: { type: string }
        stateName: { type: string }
        commissionNumber: { type: string }
        commissionExpiresAt: { type: string, format: date-time, nullable: true }
        onlineAuthorized: { type: boolean }
        city: { type: string }
        county: { type: string }
        languages: { type: array, items: { type: string } }
        specialties: { type: array, items: { type: string } }
        bio: { type: string }
        availabilityNote: { type: string }
        accepting: { type: boolean }
        sessionsCount: { type: integer }
        active:
          type: boolean
          description: Verified claim + online authorization + unexpired commission
        claimedAt: { type: string, format: date-time, nullable: true }
        created: { type: string, format: date-time }
    NotarizationRequest:
      type: object
      required: [id, state, status, docType, created]
      properties:
        id: { type: string }
        documentId: { type: string, description: Present on the owner-side view only }
        state: { type: string }
        stateName: { type: string }
        status: { type: string, description: open | claimed | scheduled | completed | cancelled }
        docType: { type: string }
        signerCity: { type: string }
        timingNote: { type: string }
        scheduledAt: { type: string, format: date-time, nullable: true }
        sessionNote: { type: string }
        completedAt: { type: string, format: date-time, nullable: true }
        created: { type: string, format: date-time }
        notary:
          type: object
          nullable: true
          description: The assigned notary's public identity
          properties:
            slug: { type: string }
            fullName: { type: string }
            commissionNumber: { type: string }
            state: { type: string }
    JournalEntry:
      type: object
      required: [id, seq, actType, performedAt, hash]
      properties:
        id: { type: string }
        seq: { type: integer, description: Monotonic per profile }
        actType: { type: string }
        documentDesc: { type: string }
        signerName: { type: string }
        idMethod: { type: string, description: ID type and issuer — never ID numbers }
        method: { type: string, description: in_person | online }
        location: { type: string }
        feeCents: { type: integer, format: int64 }
        nonNotarialFeeCents: { type: integer, format: int64 }
        requestId: { type: string, nullable: true, description: Set when auto-filed from an Easerix session }
        notes: { type: string }
        performedAt: { type: string, format: date-time }
        prevHash: { type: string }
        hash: { type: string }
        created: { type: string, format: date-time }
    PracticeExpense:
      type: object
      required: [id, amountCents, spentAt]
      properties:
        id: { type: string }
        category: { type: string }
        amountCents: { type: integer, format: int64 }
        note: { type: string }
        spentAt: { type: string, format: date-time }
    MileageLog:
      type: object
      required: [id, miles, drivenAt]
      properties:
        id: { type: string }
        miles: { type: number }
        purpose: { type: string }
        drivenAt: { type: string, format: date-time }
    ComplianceItem:
      type: object
      required: [id, kind]
      properties:
        id: { type: string }
        kind: { type: string, description: commission | bond | eo | other }
        label: { type: string }
        reference: { type: string }
        expiresAt: { type: string, format: date-time, nullable: true }
        note: { type: string }
    PaymentError:
      type: object
      required: [error]
      properties:
        error: { type: string }
        code:
          type: string
          description: "Machine-readable reason, e.g. payment_required, already_paid, payment_closed, payment_rejected, email_blocked_payment, payments_not_enabled, payments_not_configured, payment_config, payment_unassigned, payment_group, payment_limit, payment_field_locked, payment_requires_signer, reassign_blocked_payment, payment_public_link, payments_in_use, payments_not_connected"
        documents:
          type: array
          description: "On a payments_in_use 409, up to five of the documents that still need the account"
          items:
            type: object
            required: [id, name]
            properties:
              id: { type: string }
              name: { type: string }
        total: { type: integer, description: "On a payments_in_use 409, how many documents still need the account" }
        fieldId: { type: string }
        paymentStatus: { type: string, description: "On a payment_required or payment_closed 402, where the payment stands (requires_payment, processing, failed, canceled, refunded, disputed)" }
        failureCode: { type: string, description: "On a payment_closed 402, amount_mismatch when the charge was the wrong amount and is being refunded" }
    PaymentAccount:
      type: object
      required: [configured, connected, chargesEnabled, payoutsEnabled, detailsSubmitted]
      properties:
        configured: { type: boolean, description: This deployment has Stripe keys }
        connected: { type: boolean, description: The workspace has started connecting an account and has not disconnected it }
        chargesEnabled: { type: boolean }
        payoutsEnabled: { type: boolean }
        detailsSubmitted: { type: boolean }
        accountRef: { type: string, description: "The connected account, shortened for display, e.g. acct_…4f2a. Only while connected" }
        accountLabel: { type: string, description: "What Stripe calls the connected account (its dashboard display name, else business name, else email). Only for a workspace owner or admin, only while connected, and absent until Stripe has one. An account connected before this was stored is filled in the first time an owner or admin loads the page" }
        disconnectedBy: { type: string, enum: [workspace, stripe], description: "Present when the connection ended: an admin disconnected it (workspace), or the owner revoked Easerix in the Stripe dashboard (stripe). Connecting again clears it" }
    DocumentPayment:
      type: object
      required: [id, fieldId, recipientId, status, amountMinor, currency, refundedMinor, createdAt]
      properties:
        id: { type: string }
        fieldId: { type: string }
        recipientId: { type: string }
        status: { type: string, enum: [requires_payment, processing, succeeded, failed, canceled, refunded, partially_refunded, disputed] }
        amountMinor: { type: integer, format: int64, description: Smallest currency unit (cents) }
        currency: { type: string, description: Lower-case ISO 4217 }
        refundedMinor: { type: integer, format: int64 }
        failureCode: { type: string }
        failureMessage: { type: string }
        paymentIntentId: { type: string, description: The Stripe reference recorded in the audit trail }
        succeededAt: { type: string, format: date-time, nullable: true }
        refundRequestedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
    SigningPayment:
      type: object
      required: [status]
      description: What a signer's page needs about one payment field. Never carries the PaymentIntent.
      properties:
        status: { type: string, enum: [requires_payment, processing, succeeded, failed, canceled, refunded, partially_refunded, disputed] }
        paid: { type: boolean }
        amountMinor: { type: integer, format: int64 }
        currency: { type: string }
        description: { type: string }
        failureMessage: { type: string }
        failureCode: { type: string, description: "Stripe's code for a failure, or amount_mismatch when the amount charged was not the amount asked for: that payment is spent and refunded, and cannot be retried" }
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
    VerificationError:
      type: object
      required: [error]
      properties:
        error: { type: string }
        code:
          type: string
          description: Machine-readable reason, e.g. verification_required, verification_code_invalid, verification_code_expired, verification_too_many_attempts, verification_cooldown, verification_send_limit, verification_no_challenge, verification_unavailable, verification_temporarily_unavailable, verification_provider_busy, verification_provider_error, verification_rejected, verification_destination_invalid, verification_already_verified, verification_org_limit, verification_inquiry_unavailable, verification_inquiry_in_use
        attemptsRemaining: { type: integer, description: "Wrong guesses left on the current code (verification_code_invalid)" }
        retryAfterSeconds: { type: integer, description: "Seconds until a resend is accepted (verification_cooldown, verification_send_limit)" }
        retryable: { type: boolean }
    VerificationMethod:
      type: string
      enum: [none, sms, voice, government_id]
      description: >-
        The extra signer check on top of the access code — which is configured
        separately and is unaffected. "sms" sends a one-time code by text
        message and "voice" reads it out in a phone call, in both cases to a
        number the SIGNER enters when they open the link (the sender never
        supplies one). It proves the signer can receive calls or texts on some
        phone, not who they are. "government_id" is an identity check run by
        Persona: the signer photographs a government-issued ID (and whatever
        else the Persona Inquiry Template the deployment is configured with
        asks for) in Persona's embedded flow, and Easerix learns only the
        verdict. The sender configures nothing Persona-specific. It requires
        the deployment to have Persona configured (503 verification_unavailable
        otherwise). Omitted means none. Not available on signer groups: one
        person's ID cannot verify a shared slot.
    SigningSecurity:
      type: object
      description: >-
        Which checks apply to this link and where the signer stands. For the
        frontend to pick a screen only — every action is re-checked
        server-side. Before the access code is accepted only the three flags
        are present.
      required: [accessCodeRequired, verificationRequired, verificationMethod]
      properties:
        accessCodeRequired: { type: boolean }
        verificationRequired: { type: boolean }
        verificationMethod: { $ref: "#/components/schemas/VerificationMethod" }
        verificationStatus: { type: string, enum: [none, pending, challenged, verified, failed, expired] }
        verificationDestination: { type: string, description: "Masked number the signer entered, e.g. ******3210 — present only once a code has been sent" }
        verificationExpiresAt: { type: string, format: date-time, nullable: true, description: When the outstanding code stops being accepted }
        verificationResendAvailableAt: { type: string, format: date-time, nullable: true }
  responses:
    VerificationRequired:
      description: >-
        The recipient requires OTP verification and this caller has not
        completed it (code verification_required, with verificationStatus),
        or the verification setup cannot be satisfied (verification_unsupported)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/VerificationError" }
    BadRequest:
      description: Invalid or incomplete request body
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: >-
        Missing/invalid bearer token, or a token without org context
        ("token missing org context — refresh your session")
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ToolDisabled:
      description: The sign tool is turned off for the caller's organization
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found or not owned by the caller
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServerError:
      description: Internal error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
