# EasyDocForms Partner API — OpenAPI 3.1
#
# HAND-WRITTEN, single source of truth for the docs hub (easydocforms.com/docs/api) and both
# SDKs (easydocforms-ruby, easydocforms-go). No codegen: when the Go handlers change shape,
# this file is edited in the same commit. Wire shapes are copied verbatim from
# backend-go/internal/api/partner_*.go — if this file and the code disagree, the code is the
# bug or this file is; fix whichever drifted.
openapi: 3.1.0

info:
  title: EasyDocForms Partner API
  version: 1.0.0-beta
  description: |
    Turn a blank PDF intake form into a hosted, mobile-friendly fillable form — and get the
    completed, pixel-exact PDF back. The API wraps the same pipeline EasyDocForms uses in
    production: import a blank PDF, poll (or receive a webhook) until the template is ready,
    mint a hosted fill link, hand it to a patient, then retrieve structured answers and the
    completed PDF.

    ## Authentication
    Every request carries `Authorization: Bearer edfk_live_...`. Keys are created by an
    organization admin in the EasyDocForms app (Settings → Integrations → Partner API) and are
    shown exactly once. Each key carries scopes; a request without the required scope fails
    with `403 SCOPE_REQUIRED`.

    ## PHI boundary (read this first)
    * **Imports are blank forms only.** Every import requires
      `blank_form_attestation: true`, asserting the uploaded PDF is a blank template with no
      patient-identifiable information. Do not upload filled forms.
    * **`external_ref` must never contain PHI.** It is an opaque correlation id (your visit
      or order id) that is echoed back on submissions and webhook events.
    * **Webhook payloads are PHI-minimized by design.** Events carry ids and retrieve URLs,
      never patient answers. Answers are only available over the authenticated API.

    ## Async imports
    `POST /imports` returns `202` immediately with an `import_id`. Processing typically takes
    1–10 minutes depending on the document. Poll `GET /imports/{import_id}` or subscribe to
    the `import.completed` / `import.failed` webhooks. Imports never fail for quality
    reasons: the template is always created, and `review_required` / `review_reasons` tell
    your staff what to double-check in the EasyDocForms editor.

    ## Errors
    Errors are `{"error": "human-readable message"}`, with a machine-readable `code` on
    authorization failures (`PARTNER_API_NOT_ENABLED`, `SCOPE_REQUIRED`). Requests are
    rate-limited; expect `429` under sustained load and back off.

    ## Non-goals of v1
    Usage metering/billing per call, iframe embedding, FHIR Questionnaire export
    (fast-follow), a sandbox environment with test keys, and a partner review console are
    deliberately out of scope for this version.

servers:
  - url: https://form.easydocforms.com/api/v1

security:
  - apiKey: []

tags:
  - name: Meta
  - name: Imports
  - name: Templates
  - name: Fill Links
  - name: Submissions
  - name: Webhooks

paths:
  /ping:
    get:
      operationId: ping
      tags: [Meta]
      summary: Verify a key
      description: >
        Proves the key authenticates, names the organization it is bound to, and echoes its
        scopes. Requires no scope — every valid key can call it.
      responses:
        "200":
          description: The authenticated key's identity.
          content:
            application/json:
              schema:
                type: object
                properties:
                  org_id: { type: string }
                  key_name: { type: string }
                  scopes:
                    type: array
                    items: { $ref: "#/components/schemas/Scope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /imports:
    post:
      operationId: createImport
      tags: [Imports]
      summary: Import a blank PDF (async)
      description: >
        Stages the PDF and queues processing. Supply the document as `pdf_base64` OR a
        publicly reachable HTTPS `pdf_url` (exactly one; private/internal addresses are
        rejected). Maximum PDF size 10 MB. Requires scope `imports:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename, blank_form_attestation]
              properties:
                pdf_base64:
                  type: string
                  description: The blank PDF, standard base64. Exactly one of pdf_base64 / pdf_url.
                pdf_url:
                  type: string
                  format: uri
                  description: Public HTTPS URL of the blank PDF. Exactly one of pdf_base64 / pdf_url.
                filename:
                  type: string
                  description: Original filename, e.g. "new-patient-intake.pdf".
                title:
                  type: string
                  description: Optional display title for the resulting template.
                blank_form_attestation:
                  type: boolean
                  description: >
                    Must be true: you attest this PDF is a blank form template containing no
                    patient-identifiable information.
      responses:
        "202":
          description: Import accepted and queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  import_id: { type: string }
                  status: { type: string, const: queued }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413":
          description: PDF exceeds the maximum allowed size.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: >
            Rate limit or daily import quota exceeded. Imports are bounded per organization
            per UTC day in addition to the per-minute rate limit; the quota body carries
            `quota` and `retry_after` (seconds until the UTC day rolls over), also sent as a
            `Retry-After` header.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds to wait before retrying.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  quota: { type: integer }
                  retry_after: { type: integer }

  /imports/{import_id}:
    get:
      operationId: getImport
      tags: [Imports]
      summary: Poll an import
      description: Requires scope `imports:write`.
      parameters:
        - { name: import_id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: >
            Current job state. Result fields (template_id, counts, review flags) appear only
            when status is "succeeded"; error only when status is "failed".
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Import" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /templates:
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: List templates
      description: >
        The organization's active PDF templates, newest first — templates created via this
        API and templates created in the EasyDocForms app alike. Requires scope
        `templates:read`.
      responses:
        "200":
          description: Active templates.
          content:
            application/json:
              schema:
                type: object
                properties:
                  templates:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /templates/{template_id}/fields:
    get:
      operationId: listTemplateFields
      tags: [Templates]
      summary: List a template's fields
      description: >
        The template's field map: every PDF field id the import pipeline produced, with the
        human-readable question prompt and detected label bound to it. Submission `answers`
        and fill-link `values` are keyed by these field ids. Field ids do NOT survive a
        re-import — after the template is re-imported, re-fetch this list and rebuild any
        stored mapping (`version` identifies the field map generation). Works on retired
        templates so historical submissions stay interpretable. Requires scope
        `templates:read`.
      parameters:
        - name: template_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The field list, in fill-flow order (unclaimed fields last).
          content:
            application/json:
              schema:
                type: object
                required: [template_id, version, fields]
                properties:
                  template_id: { type: string }
                  version: { type: integer }
                  fields:
                    type: array
                    items: { $ref: "#/components/schemas/TemplateField" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /fill-links:
    post:
      operationId: createFillLink
      tags: [Fill Links]
      summary: Create a hosted fill link
      description: >
        Mints a shareable URL where a patient fills the form — no EasyDocForms account
        needed on their side. Links always serve the template's latest version, so
        re-importing an updated PDF propagates to live links. Requires scope
        `fill_links:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template_id]
              properties:
                template_id: { type: string }
                expires_in_days:
                  type: integer
                  minimum: 0
                  description: Days until the link stops accepting responses. 0 or omitted = no expiry.
                max_responses:
                  type: integer
                  minimum: 0
                  description: Maximum number of submissions. 0 or omitted = unlimited.
                external_ref:
                  type: string
                  maxLength: 256
                  description: >
                    Opaque correlation id echoed on submissions and webhook events. MUST NOT
                    contain PHI.
                values:
                  type: array
                  maxItems: 200
                  description: >
                    Prefill values applied to the fill flow, keyed by field id (see
                    `GET /templates/{template_id}/fields`; only `prefillable` fields are
                    accepted). Non-locked entries are editable defaults the filler can
                    correct. Locked entries are sender-authored terms (contract price,
                    effective date): rendered read-only AND re-asserted server-side at
                    submit, so the signer cannot alter them. Values are stamped onto the
                    PDF verbatim. Unlike `external_ref`, values MAY contain PHI — they are
                    applied server-side and never appear in the link URL.
                  items:
                    type: object
                    required: [field_id, value]
                    properties:
                      field_id: { type: string }
                      value: { type: string, maxLength: 1000, minLength: 1 }
                      locked: { type: boolean, default: false }
      responses:
        "201":
          description: The minted link.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FillLink" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: >
            One or more `values` entries reference fields the template's CURRENT field map
            does not accept. The most common cause is a stale mapping: field ids are
            regenerated on re-import, so stored mappings must be rebuilt from
            `GET /templates/{template_id}/fields` after a template is re-imported.
          content:
            application/json:
              schema:
                type: object
                required: [error, invalid_values]
                properties:
                  error: { type: string }
                  invalid_values:
                    type: array
                    items:
                      type: object
                      required: [field_id, reason]
                      properties:
                        field_id: { type: string }
                        reason:
                          type: string
                          enum: [unknown_field, not_prefillable]
                  hint: { type: string }

  /submissions:
    get:
      operationId: listSubmissions
      tags: [Submissions]
      summary: List submissions
      description: >
        The organization's exact-PDF submissions, newest first, as PHI-light summaries
        (correlation ids + PDF status, never answers — fetch the submission detail for
        those). This is the fallback polling surface for missed webhooks: pass
        `submitted_since` to catch up from a timestamp, then follow `next_cursor` until it
        disappears. A page may carry fewer than `limit` entries (non-exact-PDF responses
        are skipped) while `next_cursor` still advances. Requires scope `submissions:read`.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: submitted_since
          in: query
          schema: { type: string, format: date-time }
          description: Only submissions strictly after this RFC3339 timestamp.
        - name: cursor
          in: query
          schema: { type: string }
          description: Opaque token from a previous page's `next_cursor`.
      responses:
        "200":
          description: One page of submission summaries.
          content:
            application/json:
              schema:
                type: object
                required: [submissions]
                properties:
                  submissions:
                    type: array
                    items: { $ref: "#/components/schemas/SubmissionSummary" }
                  next_cursor:
                    type: string
                    description: Present when more pages may exist; absent on the last page.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /submissions/{submission_id}:
    get:
      operationId: getSubmission
      tags: [Submissions]
      summary: Retrieve a submission
      description: >
        Structured answers plus correlation back to the fill link that produced the
        submission. `answers` maps the template's field ids to the submitted values;
        signatures and drawings ride the completed PDF, not this map. Requires scope
        `submissions:read`.
      parameters:
        - { name: submission_id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The submission.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Submission" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /submissions/{submission_id}/pdf:
    get:
      operationId: downloadSubmissionPdf
      tags: [Submissions]
      summary: Download the completed PDF
      description: >
        Streams the completed, pixel-exact PDF. Serves the artifact frozen at submission
        when available, falling back to an on-demand render — this endpoint works even when
        `completed_pdf_status` is "pending" (the fetch is just slower). Requires scope
        `submissions:read`.
      parameters:
        - { name: submission_id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The completed PDF, as an attachment.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /submissions/{submission_id}/pdf-link:
    get:
      operationId: createSubmissionPdfLink
      tags: [Submissions]
      summary: Get a time-limited download link for the completed PDF
      description: >
        Returns a signed URL (valid ~10 minutes) that downloads the completed PDF without
        any Authorization header — a handoff link safe to pass to a browser, an EMR, or an
        AI agent's user without embedding your API key. Only the artifact frozen at
        submission can be signed: when `completed_pdf_status` is "pending" this returns
        409, and the caller should use `GET /submissions/{submission_id}/pdf` instead.
        Every call is audit-logged. Requires scope `submissions:read`.
      parameters:
        - { name: submission_id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The signed link.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url:
                    type: string
                    format: uri
                    description: Time-limited signed download URL. Treat as a bearer credential for this one document.
                  expires_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The frozen artifact is not available (yet); stream via /pdf instead.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  completed_pdf_status: { type: string, const: pending }
                  hint: { type: string }

  /webhooks:
    post:
      operationId: createWebhook
      tags: [Webhooks]
      summary: Register a webhook
      description: >
        Registers a delivery URL (public HTTPS only) and mints its signing secret. **The
        secret is returned exactly once, here** — store it; it verifies the
        `X-EDF-Signature` header on every delivery (see the webhooks section of this spec).
        At most 10 active webhooks per organization. Requires scope `webhooks:manage`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { $ref: "#/components/schemas/EventType" }
                  description: Event filter. Empty or omitted = all events.
      responses:
        "201":
          description: The subscription plus its one-time secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook: { $ref: "#/components/schemas/Webhook" }
                  secret:
                    type: string
                    description: whsec_* signing secret. Shown only in this response.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: List webhooks
      description: Active subscriptions, newest first, without secrets. Requires scope `webhooks:manage`.
      responses:
        "200":
          description: Active subscriptions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /webhooks/{webhook_id}:
    delete:
      operationId: deleteWebhook
      tags: [Webhooks]
      summary: Delete a webhook
      description: Deactivates the subscription (the record is retained for audit). Requires scope `webhooks:manage`.
      parameters:
        - { name: webhook_id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Deactivated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean, const: true }
                  webhook_id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks/{webhook_id}/test:
    post:
      operationId: testWebhook
      tags: [Webhooks]
      summary: Send a test delivery
      description: >
        Synchronously delivers one signed `test` event to the subscription's URL so you can
        verify your receiver and signature check end to end. Requires scope
        `webhooks:manage`.
      parameters:
        - { name: webhook_id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The delivery attempt's outcome (delivery failure is not an HTTP error here).
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivered: { type: boolean }
                  status_code:
                    type: integer
                    description: Your endpoint's HTTP status, when it was reachable.
                  error:
                    type: string
                    description: Transport error, when it was not.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

# Outbound event deliveries. Every delivery is an HTTP POST of an Envelope, signed with the
# subscription's whsec_* secret:
#
#   X-EDF-Signature: t=<unix seconds>,v1=<hex hmac-sha256(secret, "<t>.<raw request body>")>
#
# Verify by recomputing the HMAC over the RAW body bytes (before any JSON parsing), comparing
# v1 in constant time, and rejecting timestamps more than 5 minutes from now (replay window).
# Signatures are minted fresh per delivery attempt.
#
# Every delivery also carries X-EDF-Delivery-Id, minted once per event and REUSED VERBATIM on
# retries — dedup on it. Failed deliveries (network errors, 408/429/5xx) are retried with
# exponential backoff (roughly 1 minute doubling toward a 6-hour cap, up to 10 attempts over
# ~1 day); each retry is re-signed fresh, so the timestamp check above keeps working. A non-429
# 4xx response stops retries permanently. Return 2xx fast and reconcile with GET /submissions
# (submitted_since) if you were down longer than the retry window.
webhooks:
  import.completed:
    post:
      operationId: onImportCompleted
      summary: A queued import finished and its template is ready.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Envelope"
                - type: object
                  properties:
                    event: { const: import.completed }
                    data:
                      type: object
                      properties:
                        import_id: { type: string }
                        template_id: { type: string }
                        page_count: { type: integer }
                        field_count: { type: integer }
                        review_required: { type: boolean }
      responses:
        "200": { description: Return any 2xx quickly to acknowledge. }

  import.failed:
    post:
      operationId: onImportFailed
      summary: A queued import failed (invalid PDF, processing error).
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Envelope"
                - type: object
                  properties:
                    event: { const: import.failed }
                    data:
                      type: object
                      properties:
                        import_id: { type: string }
                        error: { type: string }
      responses:
        "200": { description: Return any 2xx quickly to acknowledge. }

  submission.created:
    post:
      operationId: onSubmissionCreated
      summary: A patient submitted a form via one of your fill links.
      description: >
        PHI-minimized: no answers in the payload. Fetch them from retrieve_url with your API
        key. external_ref is the value you set when creating the fill link.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Envelope"
                - type: object
                  properties:
                    event: { const: submission.created }
                    data:
                      type: object
                      properties:
                        submission_id: { type: string }
                        template_id: { type: string }
                        fill_link_id: { type: string }
                        external_ref: { type: string }
                        retrieve_url: { type: string, format: uri }
      responses:
        "200": { description: Return any 2xx quickly to acknowledge. }

  submission.pdf_ready:
    post:
      operationId: onSubmissionPdfReady
      summary: The completed PDF was frozen at submission and is ready to download.
      description: >
        Fires alongside submission.created when the freeze-on-submit render succeeded (the
        common case). When it doesn't fire, the PDF is still retrievable — the /pdf endpoint
        renders on demand.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Envelope"
                - type: object
                  properties:
                    event: { const: submission.pdf_ready }
                    data:
                      type: object
                      properties:
                        submission_id: { type: string }
                        template_id: { type: string }
                        external_ref: { type: string }
                        pdf_url: { type: string, format: uri }
      responses:
        "200": { description: Return any 2xx quickly to acknowledge. }

  test:
    post:
      operationId: onTestEvent
      summary: Synthetic event sent by POST /webhooks/{webhook_id}/test.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/Envelope"
                - type: object
                  properties:
                    event: { const: test }
                    data:
                      type: object
                      properties:
                        webhook_id: { type: string }
      responses:
        "200": { description: Return any 2xx quickly to acknowledge. }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: >
        `Authorization: Bearer edfk_live_...`. Created in the EasyDocForms app; shown once.

  responses:
    BadRequest:
      description: The request is malformed; the error message says exactly what to fix.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing, invalid, or revoked API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: >
        The key authenticated but may not do this: code SCOPE_REQUIRED (key lacks the
        route's scope) or PARTNER_API_NOT_ENABLED (organization not enrolled in the
        partner API).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such resource in your organization.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable message.
        code:
          type: string
          description: Machine-readable code on authorization failures.
          enum: [PARTNER_API_NOT_ENABLED, SCOPE_REQUIRED]

    Scope:
      type: string
      enum: ["imports:write", "templates:read", "fill_links:write", "submissions:read", "webhooks:manage"]

    EventType:
      type: string
      enum: [import.completed, import.failed, submission.created, submission.pdf_ready]

    Import:
      type: object
      required: [import_id, status, filename, created_at, updated_at]
      properties:
        import_id: { type: string }
        status:
          type: string
          enum: [queued, processing, succeeded, failed]
        filename: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        template_id:
          type: string
          description: Present when status is succeeded.
        page_count: { type: integer, description: Present when status is succeeded. }
        field_count: { type: integer, description: Present when status is succeeded. }
        detector:
          type: string
          description: Which detection pipeline produced the template (e.g. acroform, born-digital, azure). Present when succeeded.
        review_required:
          type: boolean
          description: >
            True when the pipeline recommends a human double-check the template in the
            EasyDocForms editor before sending it to patients. Present when succeeded.
        review_reasons:
          type: array
          items: { type: string }
          description: Why review is recommended. Present (possibly empty) when succeeded.
        warnings:
          type: array
          items: { type: string }
          description: Non-blocking notes from processing. Present (possibly empty) when succeeded.
        error:
          type: string
          description: Present when status is failed.

    Template:
      type: object
      properties:
        template_id: { type: string }
        title: { type: string }
        source_filename: { type: string }
        page_count: { type: integer }
        field_count: { type: integer }
        detector: { type: string }
        version: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TemplateField:
      type: object
      required: [field_id, prefillable]
      properties:
        field_id:
          type: string
          description: The key `answers` and fill-link `values` use. Opaque; regenerated on re-import.
        kind:
          type: string
          description: >
            Question kind (`text`, `date`, `select_one`, `select_many`, `signature`, ...) or the
            raw field type for fields no question claims.
        prompt: { type: string, description: The question the fill UI asks. Absent on unclaimed fields. }
        label: { type: string, description: The label text detected next to the field on the PDF. }
        choice_label:
          type: string
          description: For choice fields (`select_one`/`select_many`), the option this field represents.
        question_id: { type: string, description: Groups fields that belong to one question. }
        page: { type: integer, description: 1-based PDF page the field sits on. }
        prefillable:
          type: boolean
          description: Whether this field accepts a value in a fill-link `values` entry (v1 = text and date questions only).

    FillLink:
      type: object
      required: [fill_link_id, template_id, short_code, url, created_at]
      properties:
        fill_link_id: { type: string }
        template_id: { type: string }
        short_code: { type: string }
        url:
          type: string
          format: uri
          description: The hosted form URL to hand to the patient.
        created_at: { type: string, format: date-time }
        external_ref: { type: string, description: Present when set at creation. }
        expires_at: { type: string, format: date-time, description: Present when an expiry was set. }
        max_responses: { type: integer, description: Present when a cap was set. }
        values:
          type: array
          description: Echo of the prefill values set at creation; present only when values were sent.
          items:
            type: object
            required: [field_id, value]
            properties:
              field_id: { type: string }
              value: { type: string }
              locked: { type: boolean, description: Present (true) only on locked entries. }

    SubmissionSummary:
      type: object
      required: [submission_id, submitted_at, template_id, completed_pdf_status]
      properties:
        submission_id: { type: string }
        submitted_at: { type: string, format: date-time }
        template_id: { type: string }
        completed_pdf_status: { type: string, enum: [ready, pending] }
        fill_link_id: { type: string, description: Present when a fill link produced the submission. }
        external_ref: { type: string, description: Present when the fill link carried one. }

    Submission:
      type: object
      required: [submission_id, submitted_at, answers, template_id, completed_pdf_status]
      properties:
        submission_id: { type: string }
        submitted_at: { type: string, format: date-time }
        answers:
          type: object
          additionalProperties: true
          description: >
            Field id → submitted value, keyed by the template's field ids. Signatures and
            drawings are rendered into the completed PDF, not included here.
        template_id: { type: string }
        completed_pdf_status:
          type: string
          enum: [ready, pending]
          description: >
            ready = the frozen completed PDF exists and /pdf-link can sign it. pending =
            fetch via /pdf (on-demand render); never "lost".
        fill_link_id:
          type: string
          description: The fill link that produced this submission. Present for API-minted links.
        external_ref:
          type: string
          description: Echo of the fill link's external_ref. Present when it was set.

    Webhook:
      type: object
      required: [webhook_id, url, events, active, created_at, updated_at]
      properties:
        webhook_id: { type: string }
        organizationId: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/EventType" }
          description: Empty array = subscribed to all events.
        active: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Envelope:
      type: object
      required: [event, timestamp, org_id, data]
      properties:
        event: { type: string }
        timestamp: { type: string, format: date-time }
        org_id: { type: string }
        data: { type: object }
