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.
    Authored from the shipping Gin handlers — this spec records reality.
  version: 2.6.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: templates
    description: Reusable document templates
  - name: webhooks
    description: Outbound event subscriptions (HMAC-signed deliveries)
  - 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"

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

  /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. Ordered by updated_at descending.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, pending, completed, declined, cancelled, expired]
        - 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]
      responses:
        "200":
          description: Documents
          content:
            application/json:
              schema:
                type: object
                required: [documents]
                properties:
                  documents:
                    type: array
                    items: { $ref: "#/components/schemas/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 }
                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/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 }
                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" }
    delete:
      operationId: deleteDocument
      tags: [documents]
      summary: Delete a document
      description: Owner-scoped delete; recipients, fields, and audit events cascade.
      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" }
        "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", PDF only, 25 MB max). Page count and
        SHA-256 are computed server-side and replace the client-asserted
        values. Drafts only.
      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, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "415": { description: Not a PDF, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: Unreadable/encrypted PDF, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: Storage 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.
      parameters:
        - name: which
          in: query
          schema:
            type: string
            enum: [original, final]
      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}/recipients:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      operationId: addRecipient
      tags: [documents]
      summary: Add a recipient to a draft
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                name: { type: string }
                email: { type: string }
                role: { type: string, description: signer | viewer | cc — defaults to "signer" }
                orderIndex: { type: integer, description: Defaults to the end of the list }
                accessCode: { type: string, description: "Optional signer access code (max 64 chars) — stored hashed, communicated out-of-band" }
      responses:
        "201":
          description: Document with the new recipient
          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" } } } }

  /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
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                email: { type: string }
                role: { type: string }
                orderIndex: { type: integer }
                accessCode: { type: string, description: "Set or replace the access code — empty string clears it" }
      responses:
        "200":
          description: Document after the update
          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" } } } }
    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}/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; field ids
        are regenerated.
      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:
                      type: { type: string, enum: [signature, initial, date, text, checkbox] }
                      x: { type: number }
                      y: { type: number }
                      width: { type: number }
                      height: { type: number }
                      page: { type: integer }
                      required: { type: boolean }
                      label: { type: string, nullable: true }
                      recipientId: { type: string, nullable: true }
      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" }

  /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 }
                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" } } } }

  /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" } } } }
        "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}/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/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", PDF only, 25 MB max) — the document a
        template's fields sit on. 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, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "415": { description: Not a PDF, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "422": { description: Unreadable/encrypted PDF, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: Storage 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 records a "viewed" audit event. 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: {name, status}, recipient:
        {name}} — 200 with no code supplied, 401 with a wrong one. All
        /public/signing action endpoints then require the same header (401
        with {locked: true} otherwise).
      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" }
        "404": { description: Invalid or invalidated link, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: Rate limited, 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 }
                  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, 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, or out of turn", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: Rate limited, 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.
      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 }
        "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" } } } }
        "429": { description: Rate limited, 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 }
        "404": { description: Invalid link or no file, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: Rate limited, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "502": { description: Storage error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /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 }
                category: { type: string }
                description: { type: string }
      responses:
        "201":
          description: Template created
          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" }
        "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.
      responses:
        "200":
          description: Templates
          content:
            application/json:
              schema:
                type: object
                required: [templates]
                properties:
                  templates:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/ToolDisabled" }
    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 }
                description: { type: string }
                category: { type: string, 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 }
                description: { type: string }
                category: { type: string }
                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" }


  # ------------------------- 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
  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 }
        signature: { type: string, nullable: true, description: PNG data URL }
    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] }
        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 }
        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 }
        recipientId: { type: string, nullable: true }
    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 }
        pages: { type: integer, description: Real page count once a file is attached }
        templateId: { type: string, nullable: true }
        hasFile: { type: boolean }
        hasFinalFile: { type: boolean, description: True once the stamped final PDF with certificate exists }
        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 }
        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 }
    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 }
            url: { type: string, description: Presigned file URL ("" when storage is disabled) }
        recipient:
          type: object
          description: The link holder (full detail)
          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 }
        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 }
        fields:
          type: array
          items:
            allOf:
              - $ref: "#/components/schemas/DocumentField"
              - type: object
                properties:
                  mine: { type: boolean }
        myTurn: { type: boolean }
    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 }
    TemplateField:
      type: object
      properties:
        type: { type: string }
        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 }
    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" }
    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 }
    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 }
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
  responses:
    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" }
