# GingerDocs public API — the contract.
#
# This file is not documentation *about* the API; it is the API's definition,
# and two things enforce that. Spectral lints it in CI (`.github/workflows/
# backend-tests.yml`), and every `/api/v1` JSON response the backend test suite
# produces is validated against it by `Tests\Support\OpenApi\ValidatesOpenApi`
# — so a resource that grows a key, an endpoint that answers an undocumented
# status, or a route that never gets written down here fails the suite by name.
#
# Design source: docs/embed-platform-design.md §4.3 (request conventions and
# the error envelope) and §6 (the endpoint surface). Where the two disagree,
# the design doc wins and the code is changed to match — that is how this file
# came to exist rather than being written from the controllers.
#
# Additive change is non-breaking (§4.3): a new endpoint, a new optional
# parameter, a new key on a resource, a new member of an error enum. Removing
# or renaming anything here is a breaking change to every integrator at once.
openapi: 3.1.0

info:
  title: GingerDocs API
  version: '1.0.0'
  summary: Documents, one signer, and the signature that closes them.
  description: |
    The REST surface an integrating company's servers call with a **secret key**
    (`gd_sk_test_…` / `gd_sk_live_…`) — plus one read-only endpoint its
    frontend may call with a **publishable key** (`gd_pk_test_…` /
    `gd_pk_live_…`): `GET /browser/documents/{document}/status`. Each key is
    refused on the other's surface, with `403 SECRET_KEY_REQUIRED` or
    `403 PUBLISHABLE_KEY_REQUIRED`.

    Three things shape every endpoint here and are worth reading once:

    - **One signer per document.** There is no recipient list, no signing order
      and no hand-off. `PUT /documents/{id}/recipient` replaces rather than
      appends, and a payload carrying a `recipients` array is refused with
      `SINGLE_RECIPIENT_ONLY` rather than partially honoured.
    - **Two environments, fully separated.** A key carries `test` or `live`, and
      an id from the other environment answers `404 NOT_FOUND` — the same answer
      an id belonging to another company gets. Test documents send no email and
      may be purged; live documents are records.
    - **Errors branch on `code`, never on `message`.** `message` is display text
      translated into the caller's language via `X-Locale`. Every field of every
      error envelope other than `message` is machine-stable. Every failure
      carries the envelope — including an unknown path (`404 NOT_FOUND`), a
      method the path does not take (`405 METHOD_NOT_ALLOWED`), a body that is
      not valid JSON (`400 INVALID_JSON`) or over the size limit
      (`413 PAYLOAD_TOO_LARGE`), and our own failures (`5xx SERVER_ERROR`).

    Signing URLs returned by this API are **bearer credentials**: anyone holding
    one can open the signing page as that recipient. Treat them as secrets.
  contact:
    name: GingerDocs API support
    url: https://gingerdocs.com/developers
    email: developers@gingerdocs.com
  license:
    name: Proprietary
    url: https://gingerdocs.com/legal/integrator-terms

servers:
  - url: https://api.gingerdocs.com/api/v1
    description: Production. The key decides whether it answers for the test or the live environment.

security:
  - secretKey: []

tags:
  - name: Account
    description: What this key is, what it may do, and how much volume is left.
  - name: Users
    description: The integrating company's own end users — who every document and template belongs to.
  - name: Documents
    description: Upload, send, correct, void, and download.
  - name: Recipient
    description: The one signer of a document, and the link they sign through.
  - name: Fields
    description: The boxes on the page — what the signer fills, and what the sender bakes in.
  - name: Templates
    description: Reusable documents, and instantiating one into a real document.
  - name: Test mode
    description: Fabricating a signature so an integrator's own suite can reach COMPLETED. Test keys only.
  - name: Events
    description: The 90-day record of everything that happened — and the recovery path for a webhook you missed.
  - name: Webhooks
    description: Where your events get pushed, how they are signed, and what to do when an endpoint goes dark.
  - name: Browser API
    description: The one endpoint a company's own frontend may call cross-origin, with a publishable key.
  - name: Embed sessions
    description: The short-lived handle that opens the GingerDocs iframe on your own page — for a signer, or for one of your users.

# The two groups the /docs guides use, in their order, so the reference at
# /docs/reference and the chapters read as one site. A vendor extension that
# reference viewers (Scalar, Redoc) honour and code generators ignore.
x-tagGroups:
  - name: Resources
    tags: [Account, Users, Documents, Recipient, Fields, Templates, Test mode, Webhooks, Events]
  - name: 'Frontend & Embedding'
    tags: [Browser API, Embed sessions]

paths:
  # ── Account ────────────────────────────────────────────────────────────────
  /account:
    get:
      tags: [Account]
      operationId: getAccount
      summary: Describe the calling key
      description: |
        The endpoint an SDK calls first and a developer calls when something is
        confusing, because it answers the three questions that actually cause
        confusion: which environment am I in, what was I granted, and how much
        volume is left.

        It carries **no scope requirement**. A key whose grant is too narrow for
        anything else must still be able to ask what it is — that is exactly the
        key whose owner is asking.
      responses:
        '200':
          description: The key's environment, scopes, plan usage and send defaults.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'
              examples:
                testKey:
                  $ref: '#/components/examples/AccountTestKey'
        default:
          $ref: '#/components/responses/Error'

  # ── Users (§6.2) ───────────────────────────────────────────────────────────
  /users:
    get:
      tags: [Users]
      operationId: listUsers
      summary: List the company's users
      description: The users provisioned in this key's environment. A test key never sees a live user.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/startingAfter'
        - $ref: '#/components/parameters/endingBefore'
        - $ref: '#/components/parameters/createdAfter'
        - $ref: '#/components/parameters/createdBefore'
        - name: external_id
          in: query
          description: Exact match on the company's own id for the user.
          required: false
          example: "crm-user-42"
          schema:
            type: string
            maxLength: 255
      responses:
        '200':
          description: One page of users.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserList'
              examples:
                page:
                  $ref: '#/components/examples/UserList'
        default:
          $ref: '#/components/responses/Error'
    post:
      tags: [Users]
      operationId: createUser
      summary: Provision a user
      description: |
        Creates one of the company's users in this key's environment.

        A person who exists in both environments is **two rows**, and their
        saved signatures do not sync between them — deliberate, Stripe-style
        per-environment isolation.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserCreate'
            examples:
              minimal:
                $ref: '#/components/examples/UserCreateMinimal'
              full:
                $ref: '#/components/examples/UserCreateFull'
      responses:
        '201':
          description: The created user.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiUser'
              examples:
                created:
                  $ref: '#/components/examples/ApiUser'
        '409':
          description: '`USER_EXISTS` — the `external_id` is already live in this environment.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                userExists:
                  $ref: '#/components/examples/ErrorUserExists'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        default:
          $ref: '#/components/responses/Error'

  /users/{user}:
    parameters:
      - $ref: '#/components/parameters/userRef'
    get:
      tags: [Users]
      operationId: getUser
      summary: Fetch a user
      description: The path segment resolves as our `id` first and as the company's `external_id` second.
      responses:
        '200':
          description: The user.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiUser'
              examples:
                user:
                  $ref: '#/components/examples/ApiUser'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'
    patch:
      tags: [Users]
      operationId: updateUser
      summary: Update a user
      description: |
        `external_id` is **immutable**. It is the key the company's own systems
        join on, so re-pointing it would silently re-attribute every document
        already filed under it; sending it answers `422` with `IMMUTABLE`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserUpdate'
            examples:
              update:
                $ref: '#/components/examples/UserUpdate'
      responses:
        '200':
          description: The updated user.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiUser'
              examples:
                updated:
                  $ref: '#/components/examples/ApiUserUpdated'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        default:
          $ref: '#/components/responses/Error'
    delete:
      tags: [Users]
      operationId: deleteUser
      summary: Soft-delete a user
      description: |
        The user stops being addressable for new work; their documents and
        templates are **retained and still listable**, because the record of a
        signature does not belong to the employee who sent it.
      responses:
        '200':
          description: Deletion acknowledged.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Deleted'
              examples:
                deleted:
                  $ref: '#/components/examples/UserDeleted'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'

  /users/{user}/documents:
    parameters:
      - $ref: '#/components/parameters/userRef'
    get:
      tags: [Users]
      operationId: listUserDocuments
      summary: List a user's documents
      description: The same rows `GET /documents?user_id=…` returns, asked as a question about the user.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/startingAfter'
        - $ref: '#/components/parameters/endingBefore'
        - $ref: '#/components/parameters/createdAfter'
        - $ref: '#/components/parameters/createdBefore'
      responses:
        '200':
          description: One page of the user's documents.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentList'
              examples:
                page:
                  $ref: '#/components/examples/DocumentList'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'

  /users/{user}/templates:
    parameters:
      - $ref: '#/components/parameters/userRef'
    get:
      tags: [Users]
      operationId: listUserTemplates
      summary: List a user's usable templates
      description: |
        The **usable** set, which is not the set they authored: templates this
        user created, plus `shared` API templates from anywhere in the company,
        plus app-authored templates the company opted in by toggling sharing in
        the app.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/startingAfter'
        - $ref: '#/components/parameters/endingBefore'
        - $ref: '#/components/parameters/createdAfter'
        - $ref: '#/components/parameters/createdBefore'
      responses:
        '200':
          description: One page of templates this user may instantiate.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateList'
              examples:
                page:
                  $ref: '#/components/examples/TemplateList'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'

  /users/{user}/default_signs:
    parameters:
      - $ref: '#/components/parameters/userRef'
    get:
      tags: [Users]
      operationId: getUserDefaultSigns
      summary: Fetch a user's saved signatures
      description: |
        The signatures and initials this user has saved. They prefill the
        signature modal in an embedded workspace and can be baked into a
        document as sender-side content.
      responses:
        '200':
          description: The user's saved signature and initials sets.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DefaultSigns'
              examples:
                typed:
                  $ref: '#/components/examples/DefaultSigns'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'
    put:
      tags: [Users]
      operationId: replaceUserDefaultSigns
      summary: Replace a user's saved signatures
      description: |
        Replaces both sets wholesale — `[]` is a meaningful value meaning "this
        user has none saved", so both keys must be present.

        `svgData` in any item is re-serialized through the SVG allowlist before
        storage; anything the sanitizer cannot parse is refused with
        `INVALID_SVG` rather than stored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DefaultSignsWrite'
            examples:
              typed:
                $ref: '#/components/examples/DefaultSignsWriteTyped'
              drawn:
                $ref: '#/components/examples/DefaultSignsWriteDrawn'
              clear:
                $ref: '#/components/examples/DefaultSignsWriteClear'
      responses:
        '200':
          description: The stored sets, after sanitization.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DefaultSigns'
              examples:
                stored:
                  $ref: '#/components/examples/DefaultSigns'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        default:
          $ref: '#/components/responses/Error'

  # ── Documents (§6.3) ───────────────────────────────────────────────────────
  /documents:
    get:
      tags: [Documents]
      operationId: listDocuments
      summary: List documents
      description: |
        Only `api`-origin documents in this key's environment. The company's own
        hand-uploaded documents are not on this surface at all.

        Listing rows carry **no `recipient` object**: rendering one mints a
        signing URL, and a reporting job paging through documents would issue a
        bearer credential per row. Fetch the document to get its signer.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/startingAfter'
        - $ref: '#/components/parameters/endingBefore'
        - $ref: '#/components/parameters/createdAfter'
        - $ref: '#/components/parameters/createdBefore'
        - name: status
          in: query
          required: false
          description: Exact match on the document's lifecycle status.
          example: "SENT"
          schema:
            $ref: '#/components/schemas/DocumentStatus'
        - name: user_id
          in: query
          required: false
          description: Our id for the owning user. Mutually exclusive with `external_user_id`.
          example: "7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d"
          schema:
            type: string
            format: uuid
        - name: external_user_id
          in: query
          required: false
          description: The company's own id for the owning user. An id matching nobody returns an empty page.
          example: "crm-user-42"
          schema:
            type: string
            maxLength: 255
        - name: client_reference_id
          in: query
          required: false
          description: Exact match on the integrator's own reference for the document.
          example: "deal-8813"
          schema:
            type: string
            maxLength: 255
      responses:
        '200':
          description: One page of documents.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentList'
              examples:
                page:
                  $ref: '#/components/examples/DocumentList'
        default:
          $ref: '#/components/responses/Error'
    post:
      tags: [Documents]
      operationId: createDocument
      summary: Create a document from a PDF
      description: |
        `multipart/form-data` with the PDF as `file` and everything else as a
        JSON `payload` part.

        **Multipart only — there is no `file_url`.** A URL-fetch parameter is an
        SSRF engine without DNS pinning, redirect bans, non-global address
        blocklists and streamed size caps; it is a future version's feature, not
        a parameter away.

        With `send: true` the document goes out in the same call and the
        response carries the recipient's signing URL.
      # Written out by hand: a generator cannot know that `file` is a path on
      # the caller's disk, and a multipart request built from the example
      # alone would send the file's name as text.
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            curl -X POST https://api.gingerdocs.com/api/v1/documents \
              -H "Authorization: Bearer $GINGERDOCS_SECRET_KEY" \
              -H "Idempotency-Key: create-deal-8813" \
              -F "file=@mutual-nda.pdf;type=application/pdf" \
              -F 'payload={
                "external_user_id": "crm-user-42",
                "title": "Mutual NDA",
                "client_reference_id": "deal-8813",
                "expiry_days": 14,
                "recipient": { "email": "grace@example.com", "name": "Grace Hopper", "locale": "en" },
                "fields": [
                  { "type": "signature", "page": 3, "x": 0.12, "y": 0.72, "width": 0.3, "height": 0.06,
                    "assignee": "recipient", "required": true },
                  { "type": "date", "page": 3, "x": 0.55, "y": 0.72, "width": 0.2, "height": 0.04,
                    "assignee": "recipient", "required": true, "content": { "format": "DD MMM YYYY", "autoFill": true } },
                  { "type": "text", "page": 0, "x": 0.1, "y": 0.18, "width": 0.45, "height": 0.04,
                    "assignee": "sender", "content": { "plainText": "Acme Ltd, 1 Example Street" } }
                ],
                "send": true
              }'
        - lang: node
          label: Node.js
          source: |
            import { openAsBlob } from 'node:fs'

            const form = new FormData()
            form.append('file', await openAsBlob('mutual-nda.pdf'), 'mutual-nda.pdf')
            form.append('payload', JSON.stringify({
              external_user_id: 'crm-user-42',
              title: 'Mutual NDA',
              client_reference_id: 'deal-8813',
              expiry_days: 14,
              recipient: { email: 'grace@example.com', name: 'Grace Hopper', locale: 'en' },
              fields: [
                { type: 'signature', page: 3, x: 0.12, y: 0.72, width: 0.3, height: 0.06, assignee: 'recipient', required: true },
                { type: 'date', page: 3, x: 0.55, y: 0.72, width: 0.2, height: 0.04, assignee: 'recipient', required: true, content: { format: 'DD MMM YYYY', autoFill: true } },
                { type: 'text', page: 0, x: 0.1, y: 0.18, width: 0.45, height: 0.04, assignee: 'sender', content: { plainText: 'Acme Ltd, 1 Example Street' } },
              ],
              send: true,
            }))

            // No Content-Type header: fetch writes the multipart boundary itself.
            const response = await fetch('https://api.gingerdocs.com/api/v1/documents', {
              method: 'POST',
              headers: {
                Authorization: `Bearer ${process.env.GINGERDOCS_SECRET_KEY}`,
                'Idempotency-Key': 'create-deal-8813',
              },
              body: form,
            })
            const document = await response.json()
        - lang: php
          label: PHP (Guzzle)
          source: |
            $client = new \GuzzleHttp\Client([
                'base_uri' => 'https://api.gingerdocs.com/api/v1/',
                'headers'  => ['Authorization' => 'Bearer ' . getenv('GINGERDOCS_SECRET_KEY')],
            ]);

            $response = $client->post('documents', [
                'headers'   => ['Idempotency-Key' => 'create-deal-8813'],
                'multipart' => [
                    ['name' => 'file', 'contents' => fopen('mutual-nda.pdf', 'r'), 'filename' => 'mutual-nda.pdf'],
                    ['name' => 'payload', 'contents' => json_encode([
                        'external_user_id'    => 'crm-user-42',
                        'title'               => 'Mutual NDA',
                        'client_reference_id' => 'deal-8813',
                        'expiry_days'         => 14,
                        'recipient'           => ['email' => 'grace@example.com', 'name' => 'Grace Hopper', 'locale' => 'en'],
                        'fields' => [
                            ['type' => 'signature', 'page' => 3, 'x' => 0.12, 'y' => 0.72, 'width' => 0.3, 'height' => 0.06, 'assignee' => 'recipient', 'required' => true],
                            ['type' => 'date', 'page' => 3, 'x' => 0.55, 'y' => 0.72, 'width' => 0.2, 'height' => 0.04, 'assignee' => 'recipient', 'required' => true, 'content' => ['format' => 'DD MMM YYYY', 'autoFill' => true]],
                            ['type' => 'text', 'page' => 0, 'x' => 0.1, 'y' => 0.18, 'width' => 0.45, 'height' => 0.04, 'assignee' => 'sender', 'content' => ['plainText' => 'Acme Ltd, 1 Example Street']],
                        ],
                        'send' => true,
                    ])],
                ],
            ]);
            $document = json_decode((string) $response->getBody(), true);
        - lang: python
          label: Python (requests)
          source: |
            import json
            import os

            import requests

            payload = {
                "external_user_id": "crm-user-42",
                "title": "Mutual NDA",
                "client_reference_id": "deal-8813",
                "expiry_days": 14,
                "recipient": {"email": "grace@example.com", "name": "Grace Hopper", "locale": "en"},
                "fields": [
                    {"type": "signature", "page": 3, "x": 0.12, "y": 0.72, "width": 0.3, "height": 0.06, "assignee": "recipient", "required": True},
                    {"type": "date", "page": 3, "x": 0.55, "y": 0.72, "width": 0.2, "height": 0.04, "assignee": "recipient", "required": True, "content": {"format": "DD MMM YYYY", "autoFill": True}},
                    {"type": "text", "page": 0, "x": 0.1, "y": 0.18, "width": 0.45, "height": 0.04, "assignee": "sender", "content": {"plainText": "Acme Ltd, 1 Example Street"}},
                ],
                "send": True,
            }

            with open("mutual-nda.pdf", "rb") as pdf:
                response = requests.post(
                    "https://api.gingerdocs.com/api/v1/documents",
                    headers={
                        "Authorization": f"Bearer {os.environ['GINGERDOCS_SECRET_KEY']}",
                        "Idempotency-Key": "create-deal-8813",
                    },
                    files={"file": ("mutual-nda.pdf", pdf, "application/pdf")},
                    data={"payload": json.dumps(payload)},
                )
            document = response.json()
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, payload]
              properties:
                file:
                  type: string
                  format: binary
                  description: The source PDF. Up to 50 MB.
                payload:
                  $ref: '#/components/schemas/DocumentCreate'
            encoding:
              payload:
                contentType: application/json
            examples:
              createAndSend:
                $ref: '#/components/examples/DocumentCreateAndSend'
              draftForNewUser:
                $ref: '#/components/examples/DocumentCreateDraft'
      responses:
        '201':
          description: The created document, with its signer inline when one was supplied.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentWithWarnings'
              examples:
                sent:
                  $ref: '#/components/examples/DocumentCreatedSent'
                draft:
                  $ref: '#/components/examples/DocumentCreatedDraft'
        '403':
          description: '`PLAN_UPLOAD_LIMIT_REACHED` or `PLAN_EXPIRED` — live keys only; the test environment is not metered.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                uploadLimit:
                  $ref: '#/components/examples/ErrorPlanUploadLimitReached'
                planExpired:
                  $ref: '#/components/examples/ErrorPlanExpired'
        '409':
          description: '`SINGLE_RECIPIENT_ONLY` (a `recipients` array was sent) or `NO_RECIPIENT` — a signer-assigned field on a document with no signer, naming it in `fields`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                singleRecipientOnly:
                  $ref: '#/components/examples/ErrorSingleRecipientOnly'
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientForFields'
        '422':
          description: '`VALIDATION_FAILED` (with an `errors` map), `SENDER_FIELDS_INCOMPLETE` (with the offending `fields`), or `NOT_YET_SUPPORTED` for `subject`/`message`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                validationFailed:
                  $ref: '#/components/examples/ErrorCreateValidationFailed'
                senderFieldsIncomplete:
                  $ref: '#/components/examples/ErrorSenderFieldsIncompleteByIndex'
                notYetSupported:
                  $ref: '#/components/examples/ErrorNotYetSupported'
        default:
          $ref: '#/components/responses/Error'

  /documents/{document}:
    parameters:
      - $ref: '#/components/parameters/documentId'
    get:
      tags: [Documents]
      operationId: getDocument
      summary: Fetch a document
      description: Includes the `recipient` object, with a freshly minted `signing_url` when one is live.
      responses:
        '200':
          description: The document.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Document'
              examples:
                sent:
                  $ref: '#/components/examples/DocumentSent'
                completed:
                  $ref: '#/components/examples/DocumentCompleted'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'
    patch:
      tags: [Documents]
      operationId: updateDocument
      summary: Update a document's bookkeeping
      description: |
        `title`, `metadata` and `client_reference_id` are patchable in any state
        — they describe the document rather than change it.

        `expiry_days` is **active only**: it is consumed by the next send, so on
        a document whose signing is over (`COMPLETED`, `VOIDED`, `UNENFORCEABLE`,
        `RE_SIGNED`) it could never take effect and answers `409
        DOCUMENT_NOT_ACTIVE`. `EXPIRED` and `DECLINED` stay patchable, because
        setting the window before the next send is the entire point of recalling
        one.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DocumentUpdate'
            examples:
              bookkeeping:
                $ref: '#/components/examples/DocumentUpdateBookkeeping'
              expiry:
                $ref: '#/components/examples/DocumentUpdateExpiry'
      responses:
        '200':
          description: The updated document.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Document'
              examples:
                updated:
                  $ref: '#/components/examples/DocumentUpdated'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`DOCUMENT_NOT_ACTIVE` — `expiry_days` was sent on a document whose signing is over.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notActive:
                  $ref: '#/components/examples/ErrorDocumentNotActive'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        default:
          $ref: '#/components/responses/Error'
    delete:
      tags: [Documents]
      operationId: deleteDocument
      summary: Delete a draft
      description: |
        **DRAFT only, and permanent** — there is no restore.

        Anything past DRAFT answers `409 DOCUMENT_NOT_DRAFT`. Completed and
        signed documents are undeletable through every surface by the Integrator
        Terms; the way to retire one is `/void`, which keeps the record.
      responses:
        '200':
          description: Deletion acknowledged.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Deleted'
              examples:
                deleted:
                  $ref: '#/components/examples/DocumentDeleted'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`DOCUMENT_NOT_DRAFT` — the document has been sent, signed, voided or expired.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notDraft:
                  $ref: '#/components/examples/ErrorDocumentNotDraft'
        default:
          $ref: '#/components/responses/Error'

  /documents/{document}/send:
    parameters:
      - $ref: '#/components/parameters/documentId'
    post:
      tags: [Documents]
      operationId: sendDocument
      summary: Send a document for signature
      description: |
        Mints the recipient's token and — under `email_delivery: platform` —
        emails them. Under `self` we send nothing and the integrator delivers
        the returned `signing_url` themselves.

        An already-`SENT` document answers `409 ILLEGAL_TRANSITION` rather than
        re-inviting: a same-status transition is legal to the state machine, and
        honouring it would put a second signature request in a human's inbox
        every time a retry landed. `/recipient/remind` is the verb for
        deliberately mailing again.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendOptions'
            examples:
              platform:
                $ref: '#/components/examples/SendPlatform'
              self:
                $ref: '#/components/examples/SendSelf'
              defaults:
                $ref: '#/components/examples/SendDefaults'
      responses:
        '200':
          description: The sent document, its signer inline, and any warnings.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentWithWarnings'
              examples:
                sent:
                  $ref: '#/components/examples/DocumentSentResponse'
                sentWithWarning:
                  $ref: '#/components/examples/DocumentSentWithWarning'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`NO_RECIPIENT` or `ILLEGAL_TRANSITION`. An already-sent document lands here, with its `status` beside the code.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientConflict'
                alreadySent:
                  $ref: '#/components/examples/ErrorIllegalTransitionAlreadySent'
        '422':
          description: '`VALIDATION_FAILED`, `SENDER_FIELDS_INCOMPLETE` (with the offending `fields`), or `NOT_YET_SUPPORTED` for `subject`/`message`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                senderFieldsIncomplete:
                  $ref: '#/components/examples/ErrorSenderFieldsIncompleteById'
                notYetSupported:
                  $ref: '#/components/examples/ErrorNotYetSupported'
        default:
          $ref: '#/components/responses/Error'

  /documents/{document}/void:
    parameters:
      - $ref: '#/components/parameters/documentId'
    post:
      tags: [Documents]
      operationId: voidDocument
      summary: Void a document
      description: |
        Ends the document permanently. Voided documents are **retained, not
        deleted** — the record that something was withdrawn is itself part of
        the record.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VoidRequest'
            examples:
              withReason:
                $ref: '#/components/examples/VoidRequest'
      responses:
        '200':
          description: The voided document.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Document'
              examples:
                voided:
                  $ref: '#/components/examples/DocumentVoided'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`ILLEGAL_TRANSITION` — the state machine does not permit voiding from this status.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                alreadyVoided:
                  $ref: '#/components/examples/ErrorIllegalTransitionAlreadyVoided'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        default:
          $ref: '#/components/responses/Error'

  /documents/{document}/recall:
    parameters:
      - $ref: '#/components/parameters/documentId'
    post:
      tags: [Documents]
      operationId: recallDocument
      summary: Recall a document to draft
      description: |
        The pre-completion correction path: pull the document back from `SENT`,
        `VIEWED`, `PARTIALLY_SIGNED`, `DECLINED` or `EXPIRED`, fix it, send it
        again.

        `COMPLETED` is excluded even though the state machine permits it —
        pulling a finished document back marks its enforceable version
        unenforceable, which is amendment machinery this API deliberately does
        not expose. Void it and recreate instead.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      responses:
        '200':
          description: The document, back in DRAFT.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Document'
              examples:
                recalled:
                  $ref: '#/components/examples/DocumentRecalled'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`ILLEGAL_TRANSITION` — the document is not in a recallable status.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                completed:
                  $ref: '#/components/examples/ErrorIllegalTransitionCompleted'
        default:
          $ref: '#/components/responses/Error'

  /documents/{document}/download:
    parameters:
      - $ref: '#/components/parameters/documentId'
    get:
      tags: [Documents]
      operationId: downloadDocument
      summary: Get a link to the signed PDF
      description: |
        Returns a **short-lived URL**, not bytes.

        Flattening is asynchronous, so a completed document whose signed PDF
        is still being made answers `202` with `DOWNLOAD_PREPARING`, its
        `status` and a `retry_after` rather than `404` — or wait for
        `document.download_ready`. A document that has not completed has no
        signed PDF coming, and answers `409 DOCUMENT_NOT_COMPLETED` with its
        `status`: a draft, one still out for signature, or one that ended
        declined, expired or voided before completing. A document voided
        *after* completing keeps the signed PDF it had.
      responses:
        '200':
          description: A time-limited link to the executed PDF.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DownloadLink'
              examples:
                link:
                  $ref: '#/components/examples/DownloadSigned'
        '202':
          description: '`DOWNLOAD_PREPARING` — the flatten is still running.'
          headers:
            Retry-After:
              description: Seconds to wait before asking again.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                preparing:
                  $ref: '#/components/examples/ErrorDownloadPreparing'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`DOCUMENT_NOT_COMPLETED` — the document has not completed, so there is no signed PDF to prepare.'
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notCompleted:
                  $ref: '#/components/examples/ErrorDocumentNotCompleted'
        default:
          $ref: '#/components/responses/Error'

  /documents/{document}/download/original:
    parameters:
      - $ref: '#/components/parameters/documentId'
    get:
      tags: [Documents]
      operationId: downloadOriginalDocument
      summary: Get a link to the uploaded PDF
      description: The file exactly as it was uploaded, available from creation onwards. The original is never mutated.
      responses:
        '200':
          description: A time-limited link to the original PDF.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DownloadLink'
              examples:
                link:
                  $ref: '#/components/examples/DownloadOriginal'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'

  /documents/{document}/audit:
    parameters:
      - $ref: '#/components/parameters/documentId'
    get:
      tags: [Documents]
      operationId: getDocumentAudit
      summary: Fetch the audit trail
      description: |
        The document's hash-chained trail, oldest first and **unpaginated** — a
        partial evidence chain is not evidence.

        Rows carry machine `event_type` and structured `event_data`, never a
        composed sentence: that is what keeps a trail written today readable in
        another language tomorrow. Verify with
        `sha256(previous_row_hash || canonical_json(event_data))`, starting from
        64 zeroes.
      responses:
        '200':
          description: Every audit row for the document, in chain order.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditList'
              examples:
                trail:
                  $ref: '#/components/examples/AuditTrail'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'

  # ── Recipient (§6.4) ───────────────────────────────────────────────────────
  /documents/{document}/recipient:
    parameters:
      - $ref: '#/components/parameters/documentId'
    get:
      tags: [Recipient]
      operationId: getRecipient
      summary: Fetch the signer
      description: |
        A document with no signer answers `404 NO_RECIPIENT` — the document was
        found and you may read it, but the thing you named does not exist yet.
      responses:
        '200':
          description: The signer, with a freshly minted signing URL when one is live.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Recipient'
              examples:
                invited:
                  $ref: '#/components/examples/RecipientSent'
        '404':
          description: '`NOT_FOUND` (no such document in this key''s scope) or `NO_RECIPIENT`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  $ref: '#/components/examples/ErrorNotFound'
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientNotFound'
        default:
          $ref: '#/components/responses/Error'
    put:
      tags: [Recipient]
      operationId: replaceRecipient
      summary: Set or replace the signer
      description: |
        **Pre-send only.** There is no verb on this API that produces a second
        signer: `PUT` replaces, which is what makes the one-signer rule
        structural rather than merely enforced.

        Once the invitation is out, replacing the signer is not an edit but a
        different document — the link in the first person's inbox would have to
        die and the audit chain already records them as invited. Use `PATCH` for
        an email typo, or `/recall` to start the roster over.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecipientWrite'
            examples:
              signer:
                $ref: '#/components/examples/RecipientWrite'
      responses:
        '200':
          description: The replaced signer.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Recipient'
              examples:
                replaced:
                  $ref: '#/components/examples/RecipientPending'
        '201':
          description: The document's first signer.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Recipient'
              examples:
                created:
                  $ref: '#/components/examples/RecipientPending'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`RECIPIENT_LOCKED`, with `status`, on any document past `DRAFT` — finished ones included.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                locked:
                  $ref: '#/components/examples/ErrorRecipientLocked'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        default:
          $ref: '#/components/responses/Error'
    patch:
      tags: [Recipient]
      operationId: updateRecipient
      summary: Correct the signer's details
      description: |
        The post-send email-correction path, and an operation rather than a
        field assignment: changing the address **rotates the access token**,
        deny-lists the old one, and returns a fresh `signing_url`. The link the
        first address received stops working.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecipientPatch'
            examples:
              email:
                $ref: '#/components/examples/RecipientPatchEmail'
              locale:
                $ref: '#/components/examples/RecipientPatchLocale'
      responses:
        '200':
          description: The corrected signer, with a new signing URL when the document is live.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Recipient'
              examples:
                corrected:
                  $ref: '#/components/examples/RecipientCorrected'
        '404':
          description: '`NOT_FOUND` or `NO_RECIPIENT`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  $ref: '#/components/examples/ErrorNotFound'
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientNotFound'
        '409':
          description: '`RECIPIENT_ALREADY_SIGNED` or `DOCUMENT_NOT_ACTIVE`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                alreadySigned:
                  $ref: '#/components/examples/ErrorRecipientAlreadySigned'
                notActive:
                  $ref: '#/components/examples/ErrorDocumentNotActive'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        default:
          $ref: '#/components/responses/Error'

  /documents/{document}/recipient/revoke:
    parameters:
      - $ref: '#/components/parameters/documentId'
    post:
      tags: [Recipient]
      operationId: revokeRecipient
      summary: Revoke the signer's access
      description: Deny-lists the signer's token so the link they hold stops working. Their status becomes `REVOKED`.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      responses:
        '200':
          description: The signer, with no live link.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Recipient'
              examples:
                revoked:
                  $ref: '#/components/examples/RecipientRevoked'
        '404':
          description: '`NOT_FOUND` or `NO_RECIPIENT`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  $ref: '#/components/examples/ErrorNotFound'
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientNotFound'
        '409':
          description: '`RECIPIENT_ALREADY_SIGNED` or `DOCUMENT_NOT_ACTIVE`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                alreadySigned:
                  $ref: '#/components/examples/ErrorRecipientAlreadySigned'
                notActive:
                  $ref: '#/components/examples/ErrorDocumentNotActive'
        default:
          $ref: '#/components/responses/Error'

  /documents/{document}/recipient/remind:
    parameters:
      - $ref: '#/components/parameters/documentId'
    post:
      tags: [Recipient]
      operationId: remindRecipient
      summary: Re-send the invitation
      description: |
        Only meaningful on a document we mailed. Under `email_delivery: self`
        the platform sends the recipient nothing at all, so there is nothing
        here for us to re-send and it answers `409 EMAIL_DELIVERY_SELF` — the
        integrator re-sends their own mail, re-minting the URL first if the
        proof has aged out.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      responses:
        '200':
          description: The signer, with `last_reminded_at` moved.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Recipient'
              examples:
                reminded:
                  $ref: '#/components/examples/RecipientReminded'
        '404':
          description: '`NOT_FOUND` or `NO_RECIPIENT`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  $ref: '#/components/examples/ErrorNotFound'
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientNotFound'
        '409':
          description: '`EMAIL_DELIVERY_SELF` (checked first), `RECIPIENT_ALREADY_SIGNED` (a `COMPLETED` document answers this), or `RECIPIENT_NOT_INVITED` (not sent, recalled, declined, expired, voided, or a revoked signer).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                selfDelivery:
                  $ref: '#/components/examples/ErrorEmailDeliverySelf'
                alreadySigned:
                  $ref: '#/components/examples/ErrorRecipientAlreadySigned'
                notInvited:
                  $ref: '#/components/examples/ErrorRecipientNotInvited'
        default:
          $ref: '#/components/responses/Error'

  /documents/{document}/recipient/signing_url:
    parameters:
      - $ref: '#/components/parameters/documentId'
    post:
      tags: [Recipient]
      operationId: mintSigningUrl
      summary: Re-mint the signing URL
      description: |
        Issues a fresh `?em=` proof while the signer can still sign: the
        document is `SENT`, `VIEWED` or `PARTIALLY_SIGNED` and the signer has
        not signed, declined or been revoked. Earlier proofs stay valid until
        their own expiry; only a revoke or an email change ends them.

        The proof lives 14 days. Once it expires the link still opens, but the
        signer meets a one-time-code wall — **which emails them**, and that is
        the reason this endpoint exists: a self-delivery integrator re-mints
        rather than letting the platform mail their customer.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      responses:
        '200':
          description: The signer, with a newly minted signing URL.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Recipient'
              examples:
                minted:
                  $ref: '#/components/examples/RecipientReminted'
        '404':
          description: '`NOT_FOUND` or `NO_RECIPIENT`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  $ref: '#/components/examples/ErrorNotFound'
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientNotFound'
        '409':
          description: '`DOCUMENT_NOT_ACTIVE`, with `status`, when signing is over; `RECIPIENT_NOT_INVITED` when the document is not sent (or was recalled), declined or expired, or the signer was revoked.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notActive:
                  $ref: '#/components/examples/ErrorDocumentNotActive'
                notInvited:
                  $ref: '#/components/examples/ErrorRecipientNotInvited'
        default:
          $ref: '#/components/responses/Error'

  # ── Fields (§6.5) ──────────────────────────────────────────────────────────
  /documents/{document}/fields:
    parameters:
      - $ref: '#/components/parameters/documentId'
    get:
      tags: [Fields]
      operationId: listFields
      summary: List a document's fields
      description: |
        **Reads are passthrough for every type**, writable or not. A
        template-instantiated document legitimately carries `radio`, `dropdown`
        and `file` fields, and hiding them would hide required-gating — the
        integrator would see a document that cannot complete and no field
        explaining why. The `content` of a non-writable type is opaque.
      parameters:
        - name: assignee
          in: query
          required: false
          description: Restrict to the sender's baked fields or the signer's.
          example: "recipient"
          schema:
            $ref: '#/components/schemas/Assignee'
      responses:
        '200':
          description: The document's fields.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FieldList'
              examples:
                fields:
                  $ref: '#/components/examples/FieldList'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        default:
          $ref: '#/components/responses/Error'
    post:
      tags: [Fields]
      operationId: createField
      summary: Add a field
      description: |
        Writable types are `signature`, `initials`, `date`, `text` and
        `checkbox`. Anything else answers `422` with `UNWRITABLE_TYPE` against
        the `type` attribute.

        Geometry is normalized `0.0–1.0` — a fraction of the page, never pixels.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FieldCreate'
            examples:
              recipientSignature:
                $ref: '#/components/examples/FieldCreateSignature'
              recipientDate:
                $ref: '#/components/examples/FieldCreateDate'
              recipientCheckbox:
                $ref: '#/components/examples/FieldCreateCheckbox'
              senderText:
                $ref: '#/components/examples/FieldCreateSenderText'
              senderSignature:
                $ref: '#/components/examples/FieldCreateSenderSignature'
              optionalSenderText:
                $ref: '#/components/examples/FieldCreateOptionalSenderText'
      responses:
        '201':
          description: The created field.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Field'
              examples:
                created:
                  $ref: '#/components/examples/FieldSignature'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`NO_RECIPIENT` (a signer-assigned field on a document with no signer) or `DOCUMENT_NOT_ACTIVE`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientConflict'
                notActive:
                  $ref: '#/components/examples/ErrorDocumentNotActive'
        '422':
          description: '`VALIDATION_FAILED` (`UNWRITABLE_TYPE` arrives inside the map, against `type`) or `SENDER_FIELDS_INCOMPLETE`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                unwritableType:
                  $ref: '#/components/examples/ErrorUnwritableType'
                senderFieldIncomplete:
                  $ref: '#/components/examples/ErrorSenderFieldIncomplete'
        default:
          $ref: '#/components/responses/Error'

  /documents/{document}/fields/{field}:
    parameters:
      - $ref: '#/components/parameters/documentId'
      - $ref: '#/components/parameters/fieldId'
    get:
      tags: [Fields]
      operationId: getField
      summary: Fetch a field
      description: One field, in the same shape a write takes.
      responses:
        '200':
          description: The field.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Field'
              examples:
                field:
                  $ref: '#/components/examples/FieldSignature'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'
    patch:
      tags: [Fields]
      operationId: updateField
      summary: Update a field
      description: |
        Carries the `version` read back with the field. A mismatch answers `409
        VERSION_CONFLICT` with the current server row under `server`, so the
        caller can merge rather than guess.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FieldUpdate'
            examples:
              move:
                $ref: '#/components/examples/FieldUpdateMove'
              content:
                $ref: '#/components/examples/FieldUpdateContent'
              optional:
                $ref: '#/components/examples/FieldUpdateOptional'
      responses:
        '200':
          description: The updated field, with its version bumped.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Field'
              examples:
                updated:
                  $ref: '#/components/examples/FieldUpdatedResponse'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`VERSION_CONFLICT` (with the server row under `server`), `NO_RECIPIENT` or `DOCUMENT_NOT_ACTIVE`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                versionConflict:
                  $ref: '#/components/examples/ErrorVersionConflict'
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientConflict'
                notActive:
                  $ref: '#/components/examples/ErrorDocumentNotActive'
        '422':
          description: '`VALIDATION_FAILED` or `SENDER_FIELDS_INCOMPLETE`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                modeRequired:
                  $ref: '#/components/examples/ErrorSignatureModeRequired'
                senderFieldIncomplete:
                  $ref: '#/components/examples/ErrorSenderFieldIncomplete'
        default:
          $ref: '#/components/responses/Error'
    delete:
      tags: [Fields]
      operationId: deleteField
      summary: Delete a field
      description: |
        A field a template author pinned (`locked: true`) cannot be deleted and
        answers `422 FIELD_LOCKED`. Geometry writes against a locked field are
        silently preserved instead of refused — the same contract the app's own
        editor keeps — so deletion is the operation with no fallback.
      responses:
        '200':
          description: Deletion acknowledged.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Deleted'
              examples:
                deleted:
                  $ref: '#/components/examples/FieldDeleted'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`DOCUMENT_NOT_ACTIVE` — the document''s signing is over.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notActive:
                  $ref: '#/components/examples/ErrorDocumentNotActive'
        '422':
          description: '`FIELD_LOCKED` — the field was pinned by the template author.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                locked:
                  $ref: '#/components/examples/ErrorFieldLocked'
        default:
          $ref: '#/components/responses/Error'

  # ── Templates (§6.6) ───────────────────────────────────────────────────────
  /templates:
    get:
      tags: [Templates]
      operationId: listTemplates
      summary: List usable templates
      description: |
        The company's usable set: API templates created in this environment,
        plus `shared` ones from anywhere in the company, plus **app-authored
        templates the company opted in**.

        App templates are not auto-exposed. The company's internal library
        leaking wholesale into every end user's browse list — with no way to
        retract it, since this API refuses to `PATCH` an app template — is what
        opt-in prevents.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/startingAfter'
        - $ref: '#/components/parameters/endingBefore'
        - $ref: '#/components/parameters/createdAfter'
        - $ref: '#/components/parameters/createdBefore'
        - name: user_id
          in: query
          required: false
          description: Narrow to one user's usable set. An id matching nobody returns an empty page.
          example: "7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d"
          schema:
            type: string
            format: uuid
        - name: shared
          in: query
          required: false
          description: Filter on the sharing toggle.
          example: true
          schema:
            type: boolean
      responses:
        '200':
          description: One page of templates.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateList'
              examples:
                page:
                  $ref: '#/components/examples/TemplateList'
        default:
          $ref: '#/components/responses/Error'

  /templates/{template}:
    parameters:
      - $ref: '#/components/parameters/templateId'
    get:
      tags: [Templates]
      operationId: getTemplate
      summary: Fetch a template
      description: |
        Adds the three arrays needed to actually *use* a template: its `roles`
        (which one to map the signer to), its `variables`, and its
        `sender_fields`.

        `sender_fields` exists because `sender_values` on instantiation is keyed
        by **template element id** — without this array the API would specify a
        map the integrator has no way to construct.
      responses:
        '200':
          description: The template, with roles, variables and sender fields.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Template'
              examples:
                template:
                  $ref: '#/components/examples/Template'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'
    patch:
      tags: [Templates]
      operationId: updateTemplate
      summary: Update a template
      description: |
        **API templates only.** An app-authored template answers `403
        APP_TEMPLATE_READ_ONLY` rather than this surface's usual `404`: you can
        read it through the sharing carve-out, so denying its existence would be
        a lie about a row you just listed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TemplateUpdate'
            examples:
              renameAndShare:
                $ref: '#/components/examples/TemplateUpdate'
      responses:
        '200':
          description: The updated template.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Template'
              examples:
                updated:
                  $ref: '#/components/examples/TemplateUpdated'
        '403':
          description: '`APP_TEMPLATE_READ_ONLY` — the template is managed in the app, not here.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                appTemplate:
                  $ref: '#/components/examples/ErrorAppTemplateReadOnly'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        default:
          $ref: '#/components/responses/Error'
    delete:
      tags: [Templates]
      operationId: deleteTemplate
      summary: Archive a template
      description: |
        **Archived, not purged.** Documents already instantiated from a template
        keep pointing at it, so removing the row would orphan their provenance.
      responses:
        '200':
          description: The template is archived.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ArchivedTemplate'
              examples:
                archived:
                  $ref: '#/components/examples/TemplateArchived'
        '403':
          description: '`APP_TEMPLATE_READ_ONLY` — the template is managed in the app, not here.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                appTemplate:
                  $ref: '#/components/examples/ErrorAppTemplateReadOnly'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'

  /templates/{template}/documents:
    parameters:
      - $ref: '#/components/parameters/templateId'
    post:
      tags: [Templates]
      operationId: instantiateTemplate
      summary: Create a document from a template
      description: |
        A template read that produces a document, metered exactly like an
        upload.

        **Exactly one non-sender role must be mapped.** A template authored for
        the app's multi-recipient world has no meaning on a single-signer API:
        two signing roles would need two signers, and mapping one of them would
        silently produce a document whose other role's fields belong to nobody.
        Zero signing roles answers the same `TEMPLATE_MULTI_ROLE` code — it is
        the same defect seen from the other side, a document nobody can ever be
        asked to sign.

        Sender signature and initials fields left out of `sender_values` are
        filled from the user's saved default signs.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TemplateInstantiate'
            examples:
              mapAndSend:
                $ref: '#/components/examples/TemplateInstantiateAndSend'
              draft:
                $ref: '#/components/examples/TemplateInstantiateDraft'
      responses:
        '201':
          description: The created document, with its signer inline.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentWithWarnings'
              examples:
                sent:
                  $ref: '#/components/examples/TemplateDocumentCreated'
        '403':
          description: '`PLAN_UPLOAD_LIMIT_REACHED` or `PLAN_EXPIRED` — live keys only.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                uploadLimit:
                  $ref: '#/components/examples/ErrorPlanUploadLimitReached'
                planExpired:
                  $ref: '#/components/examples/ErrorPlanExpired'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`SINGLE_RECIPIENT_ONLY` for a `recipients` key, or `NO_RECIPIENT` when `send: true` and the mapping produced no signer.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                singleRecipientOnly:
                  $ref: '#/components/examples/ErrorSingleRecipientOnly'
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientConflict'
        '422':
          description: '`TEMPLATE_MULTI_ROLE` when the template does not define exactly one signing role, with its signing `roles` echoed as `{ id, name, signing_order }` (`[]` when it has none); `VALIDATION_FAILED` (a required template variable left empty, with no default, reports as `REQUIRED` against `variable_values.<key>`); `SENDER_FIELDS_INCOMPLETE` (with `send: true`); or `NOT_YET_SUPPORTED` for `subject`/`message`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                multiRole:
                  $ref: '#/components/examples/ErrorTemplateMultiRole'
                variableRequired:
                  $ref: '#/components/examples/ErrorTemplateVariableRequired'
                senderFieldsIncomplete:
                  $ref: '#/components/examples/ErrorSenderFieldsIncompleteByTemplateId'
                notYetSupported:
                  $ref: '#/components/examples/ErrorNotYetSupported'
        default:
          $ref: '#/components/responses/Error'

  # ── Test simulation (§6.9) ─────────────────────────────────────────────────
  /test/documents/{document}/recipient/sign:
    parameters:
      - $ref: '#/components/parameters/documentId'
    post:
      tags: [Test mode]
      operationId: simulateSign
      summary: Simulate the signer signing
      description: |
        Runs the **real** signing path — required signer fields filled with
        fixtures, full audit trail, real events, real flatten — so an
        integrator's suite can reach `COMPLETED` without a human.

        `/test/` is a path prefix rather than a flag, and the environment is
        checked before the id is even resolved. An endpoint whose behaviour is
        "fabricate a signature" must not be reachable by a live key with a stray
        parameter: a live key answers `403 TEST_ONLY`, not a `404`, because
        there is no id here whose existence could leak and "no such endpoint"
        would send you hunting for a typo in a correct path.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      responses:
        '200':
          description: The document after the simulated signature.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Document'
              examples:
                completed:
                  $ref: '#/components/examples/DocumentSimulatedSign'
        '403':
          description: '`TEST_ONLY` — a live key reached a test-simulation endpoint.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                liveKey:
                  $ref: '#/components/examples/ErrorTestOnly'
        '404':
          description: '`NOT_FOUND` or `NO_RECIPIENT`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  $ref: '#/components/examples/ErrorNotFound'
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientNotFound'
        '409':
          description: '`DOCUMENT_NOT_ACTIVE`, with `status`, when the document is finished (a second simulated signature answers this); `RECIPIENT_NOT_INVITED` when it is not sent, recalled, declined or expired, or the signer was revoked.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notActive:
                  $ref: '#/components/examples/ErrorDocumentNotActive'
                notInvited:
                  $ref: '#/components/examples/ErrorRecipientNotInvited'
        default:
          $ref: '#/components/responses/Error'

  /test/documents/{document}/recipient/decline:
    parameters:
      - $ref: '#/components/parameters/documentId'
    post:
      tags: [Test mode]
      operationId: simulateDecline
      summary: Simulate the signer declining
      description: The real decline path, with a real reason on the audit trail. Test keys only.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DeclineRequest'
            examples:
              reason:
                $ref: '#/components/examples/DeclineRequest'
      responses:
        '200':
          description: The declined document.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Document'
              examples:
                declined:
                  $ref: '#/components/examples/DocumentSimulatedDecline'
        '403':
          description: '`TEST_ONLY` — a live key reached a test-simulation endpoint.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                liveKey:
                  $ref: '#/components/examples/ErrorTestOnly'
        '404':
          description: '`NOT_FOUND` or `NO_RECIPIENT`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notFound:
                  $ref: '#/components/examples/ErrorNotFound'
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientNotFound'
        '409':
          description: '`DOCUMENT_NOT_ACTIVE`, with `status`, when the document is finished (a second simulated signature answers this); `RECIPIENT_NOT_INVITED` when it is not sent, recalled, declined or expired, or the signer was revoked.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notActive:
                  $ref: '#/components/examples/ErrorDocumentNotActive'
                notInvited:
                  $ref: '#/components/examples/ErrorRecipientNotInvited'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        default:
          $ref: '#/components/responses/Error'

  # ── Browser API (§6.8) ─────────────────────────────────────────────────────
  /events:
    get:
      tags: [Events]
      operationId: listEvents
      summary: List events
      description: |
        Everything that happened in this key's environment, newest first, for
        **90 days**.

        This is the recovery path. Webhook delivery is at-least-once and gives
        up after about 35 hours of retries, so a consumer that was down — or that
        answered 500 once too often — comes here to catch up. Page forward with
        `ending_before=<the id of the newest event you processed>`: the cursor
        orders by time and id together, so nothing is skipped. `created_after`
        works too: event times are kept to the microsecond, so passing back the
        exact `created_at` of the newest event you processed resumes right
        after it.

        Each row is a **snapshot taken when the event fired**, not a live read.
        A document voided this morning still reads `SENT` in the event that
        announced its send, which is the whole point of a log.

        Events exist only for documents, templates and users created through
        this API. Nothing your team does by hand in the app appears here.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/startingAfter'
        - $ref: '#/components/parameters/endingBefore'
        - $ref: '#/components/parameters/createdAfter'
        - $ref: '#/components/parameters/createdBefore'
        - name: type
          in: query
          required: false
          description: |
            One event type. Validated against the catalog — a misspelling is a
            `422` rather than an empty page, because an empty page from a
            recovery endpoint reads as "nothing happened while you were down".
          example: "document.completed"
          schema:
            $ref: '#/components/schemas/EventType'
        - name: document_id
          in: query
          required: false
          description: Every event for one document. Composes with the two user filters.
          example: "2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b"
          schema:
            type: string
            format: uuid
        - name: user_id
          in: query
          required: false
          description: Our id for one of your users. Mutually exclusive with `external_user_id`.
          example: "7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d"
          schema:
            type: string
            format: uuid
        - name: external_user_id
          in: query
          required: false
          description: Your own id for the same person.
          example: "crm-user-42"
          schema:
            type: string
            maxLength: 255
      responses:
        '200':
          description: One page of events.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventList'
              examples:
                page:
                  $ref: '#/components/examples/EventList'
        default:
          $ref: '#/components/responses/Error'

  /events/{event}:
    parameters:
      - name: event
        in: path
        required: true
        description: The event id, as delivered in `X-GingerDocs-Event-Id` and in the body.
        example: "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b"
        schema:
          type: string
          format: uuid
    get:
      tags: [Events]
      operationId: getEvent
      summary: Fetch one event
      description: |
        The same object a webhook delivery carries — fetch it by the id in
        `X-GingerDocs-Event-Id` to re-process a delivery whose body you no
        longer have.
      responses:
        '200':
          description: The event.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Event'
              examples:
                documentCompleted:
                  $ref: '#/components/examples/EventDocumentCompleted'
                recipientSigned:
                  $ref: '#/components/examples/EventRecipientSigned'
                templateCreated:
                  $ref: '#/components/examples/EventTemplateCreated'
                userCreated:
                  $ref: '#/components/examples/EventUserCreated'
                ping:
                  $ref: '#/components/examples/EventPing'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'

  /webhook_endpoints:
    get:
      tags: [Webhooks]
      operationId: listWebhookEndpoints
      summary: List webhook endpoints
      description: Your endpoints in this key's environment. The signing secret is never in a listing.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/startingAfter'
        - $ref: '#/components/parameters/endingBefore'
        - $ref: '#/components/parameters/createdAfter'
        - $ref: '#/components/parameters/createdBefore'
        - name: is_active
          in: query
          required: false
          description: Narrow to the endpoints that are switched on, or the ones that are not.
          example: true
          schema:
            type: boolean
      responses:
        '200':
          description: One page of endpoints.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointList'
              examples:
                page:
                  $ref: '#/components/examples/WebhookEndpointList'
        default:
          $ref: '#/components/responses/Error'
    post:
      tags: [Webhooks]
      operationId: createWebhookEndpoint
      summary: Register an endpoint
      description: |
        Registers a URL for this key's environment and mints its signing secret.

        **The `secret` is in this response and no other.** Store it now — every
        later read returns only `secret_prefix`, because a `GET` that handed the
        signing key back would put it in every listing response, every log line
        and every SDK debug dump. You can still reveal it in Developer settings.

        The URL must be `https` with a public hostname. Plain `http`, private or
        reserved IP ranges, and credentials in the URL are refused —
        deliveries are POSTs our servers make to an address you choose, and
        redirects are not followed for the same reason.

        Leave `events` empty to receive **every** type, including ones added
        later. A non-empty list is an explicit subscription and will not grow.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointCreate'
            examples:
              subscribed:
                $ref: '#/components/examples/WebhookEndpointCreateSubscribed'
              everything:
                $ref: '#/components/examples/WebhookEndpointCreateEverything'
      responses:
        '201':
          description: The endpoint, with its signing secret.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointWithSecret'
              examples:
                created:
                  $ref: '#/components/examples/WebhookEndpointWithSecret'
        '422':
          description: |
            `VALIDATION_FAILED`, with a per-attribute `errors` map. The one this
            endpoint adds is `INVALID_WEBHOOK_URL` against `url`: plain `http`, a
            private or reserved address, or credentials in the URL.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
              examples:
                invalidUrl:
                  $ref: '#/components/examples/ErrorWebhookUrlInvalid'
        default:
          $ref: '#/components/responses/Error'

  /webhook_endpoints/{endpoint}:
    parameters:
      - $ref: '#/components/parameters/webhookEndpointId'
    get:
      tags: [Webhooks]
      operationId: getWebhookEndpoint
      summary: Fetch an endpoint
      description: |
        One endpoint's configuration and current state. The signing secret is
        not here — only `secret_prefix`, which is enough to tell your endpoints
        apart and not enough to forge a delivery.
      responses:
        '200':
          description: The endpoint.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpoint'
              examples:
                active:
                  $ref: '#/components/examples/WebhookEndpoint'
                autoDisabled:
                  $ref: '#/components/examples/WebhookEndpointAutoDisabled'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'
    patch:
      tags: [Webhooks]
      operationId: updateWebhookEndpoint
      summary: Update an endpoint
      description: |
        Change the URL, the subscription list, or switch the endpoint on and
        off.

        **`is_active: true` clears `disabled_reason`.** That is the path back
        from an endpoint we disabled ourselves after three days of failures: fix
        the consumer, re-enable, then `POST /ping` to confirm.

        There is no way to rotate the secret. Rotation needs an overlap window
        where both the old and the new value verify, or it is a guaranteed
        outage for your consumer — until that exists, create a second endpoint
        and delete the first.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointUpdate'
            examples:
              reenable:
                $ref: '#/components/examples/WebhookEndpointReenable'
              events:
                $ref: '#/components/examples/WebhookEndpointChangeEvents'
              headers:
                $ref: '#/components/examples/WebhookEndpointMergeHeaders'
      responses:
        '200':
          description: The updated endpoint.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpoint'
              examples:
                updated:
                  $ref: '#/components/examples/WebhookEndpoint'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        default:
          $ref: '#/components/responses/Error'
    delete:
      tags: [Webhooks]
      operationId: deleteWebhookEndpoint
      summary: Delete an endpoint
      description: |
        Permanent, and it takes the endpoint's delivery log with it.

        Your **events** are untouched: they live for 90 days in `/v1/events`
        regardless of where they were pushed, so deleting an endpoint removes a
        route and never history.
      responses:
        '200':
          description: Deleted.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Deleted'
              examples:
                deleted:
                  $ref: '#/components/examples/WebhookEndpointDeleted'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'

  /webhook_endpoints/{endpoint}/ping:
    parameters:
      - $ref: '#/components/parameters/webhookEndpointId'
    post:
      tags: [Webhooks]
      operationId: pingWebhookEndpoint
      summary: Send a test delivery
      description: |
        Queues a signed `ping` event to **this endpoint only** — never to your
        others.

        The ping is a real event: it appears in `/v1/events`, it is signed like
        any other delivery, and it is how you check a signature verifier before
        a real document depends on it.

        `202`, because delivery is queued rather than synchronous. The response
        carries the event, so the delivery can be looked up by its id.

        A disabled endpoint answers `409 WEBHOOK_ENDPOINT_INACTIVE` rather than
        accepting a ping that would be delivered nowhere.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      responses:
        '202':
          description: The queued ping event.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Event'
              examples:
                queued:
                  $ref: '#/components/examples/EventPing'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`WEBHOOK_ENDPOINT_INACTIVE` — re-enable it first.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                inactive:
                  $ref: '#/components/examples/ErrorWebhookEndpointInactive'
        default:
          $ref: '#/components/responses/Error'

  # ── Embed sessions ─────────────────────────────────────────────────────────

  /embed_sessions:
    post:
      tags: [Embed sessions]
      operationId: createEmbedSession
      summary: Mint a session and get the URL to frame
      description: |
        Creates the short-lived handle that opens our iframe on your page, and
        returns the `url` to put in it. **Only this response carries the URL**:
        the session keeps a digest of its token, so no later read can
        reproduce it — and a retry with the same `Idempotency-Key` replays this
        response with `url: null`, because the stored copy never keeps it. If
        you lost the URL, mint a new session (and revoke this one).

        Two kinds, and their parameters do not mix — sending one kind's
        parameters with the other's `kind` is a `422` rather than a silent
        ignore.

        **`kind: signing`** lets one named recipient sign without an emailed
        link. This call *is* the identity control: because you resolved your own
        document, in this key's environment, from an origin you registered, the
        Certificate of Completion states that **you** verified the signer
        (`embed_attested`). Treat minting one as the act of vouching for that
        person, because that is what it is.

        **`kind: workspace`** drops one of your users into the embed at one of
        three modes — `read_only` (view a bound document or template),
        `minimal` (create from a template and send), `advanced` (upload and
        place fields as well). Everything that session can reach is scoped to
        that user, not merely to your company: attribution is not authorization.

        `host_origin` must exactly match a domain registered for this key's
        environment under Developer settings — an origin, `scheme://host[:port]`,
        with no path. In the **test** environment `http://localhost` on any port
        is allowed implicitly, so you do not have to register your dev machine;
        that grant is `http`, `localhost`, and test only.

        The token in the returned URL is a **fragment** (`#token=`), which
        browsers never send to a server: it stays out of access logs, proxies
        and `Referer` headers. It must first be exchanged within 15 minutes at
        `POST /api/embed/session/exchange`, which trades it for the header-only
        bearer everything afterwards uses. After that the same URL can be
        exchanged again until the session expires — that is how a reload
        recovers — and each exchange retires the previous bearer. The session
        itself lives 2 hours from this call.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmbedSessionCreate'
            examples:
              signing:
                $ref: '#/components/examples/EmbedSessionCreateSigning'
              workspaceMinimal:
                $ref: '#/components/examples/EmbedSessionCreateWorkspaceMinimal'
              workspaceReadOnly:
                $ref: '#/components/examples/EmbedSessionCreateWorkspaceReadOnly'
              localhost:
                $ref: '#/components/examples/EmbedSessionCreateLocalhost'
      responses:
        '201':
          description: The session, with the one URL that carries its token.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmbedSessionWithUrl'
              examples:
                signing:
                  $ref: '#/components/examples/EmbedSessionSigningCreated'
                workspace:
                  $ref: '#/components/examples/EmbedSessionWorkspaceCreated'
        '403':
          description: |
            `EMBED_ORIGIN_NOT_REGISTERED` — `host_origin` is not a registered
            domain for this environment. Add it under Developer settings; it is
            a setting on your account rather than a correction to this request,
            which is why it is not a validation failure.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                originNotRegistered:
                  $ref: '#/components/examples/ErrorEmbedOriginNotRegistered'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: '`RECIPIENT_NOT_INVITED`, with `status`, when the document has not been sent (or was recalled to `DRAFT`); `NO_RECIPIENT` when it has no signer.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notInvited:
                  $ref: '#/components/examples/ErrorRecipientNotInvitedForEmbed'
                noRecipient:
                  $ref: '#/components/examples/ErrorNoRecipientConflict'
        '422':
          description: |
            `VALIDATION_FAILED`, with a per-attribute `errors` map — for example
            `INVALID_ORIGIN` against `host_origin` when it is a URL with a path
            rather than an origin, or one kind's parameters sent with the other
            `kind`.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
              examples:
                invalidOrigin:
                  $ref: '#/components/examples/ErrorEmbedOriginInvalid'
        default:
          $ref: '#/components/responses/Error'

  /embed_sessions/{session}:
    parameters:
      - $ref: '#/components/parameters/embedSessionId'
    get:
      tags: [Embed sessions]
      operationId: getEmbedSession
      summary: Inspect a session
      description: |
        The session's state — never its token, and never its bearer. Both are
        stored as digests, so neither can be reproduced here even in principle.

        `status` is the derived answer worth polling: `active`, `expired`, or
        `revoked`, with revocation winning over expiry when a session is both.
        Note that a *consumed* session is still `active` — its URL can be
        exchanged again until it expires. `status` does not reflect the
        15-minute first-exchange window: a URL never opened in time still reads
        `active`.
      responses:
        '200':
          description: The session.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmbedSession'
              examples:
                active:
                  $ref: '#/components/examples/EmbedSessionActive'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'

  /embed_sessions/{session}/revoke:
    parameters:
      - $ref: '#/components/parameters/embedSessionId'
    post:
      tags: [Embed sessions]
      operationId: revokeEmbedSession
      summary: End a session now
      description: |
        Kills the session's bearer immediately: the frame's next call fails and
        the token cannot be exchanged again.

        A `POST` rather than a `DELETE` because the row is kept — it is the
        record that a session existed and was ended, and a completed document's
        certificate may still name it.

        **Idempotent.** Revoking an already-revoked session answers `200` with
        the same body; a cleanup path should not have to tell "I ended it" from
        "it was already ended".
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
      responses:
        '200':
          description: The revoked session.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmbedSession'
              examples:
                revoked:
                  $ref: '#/components/examples/EmbedSessionRevoked'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'

  /browser/documents/{document}/status:
    parameters:
      - $ref: '#/components/parameters/documentId'
    get:
      tags: [Browser API]
      operationId: getBrowserDocumentStatus
      summary: Poll a document's signing status
      description: |
        The one endpoint reachable from a browser, with a **publishable** key.

        **Read the threat model before building on it.** A publishable key is
        public by design, and CORS is not access control: a non-browser caller
        sends no `Origin` at all and reads whatever the server returns. So
        everything on this surface is world-readable by anyone who has visited
        your site, and the only thing protecting it is that the document id is a
        random UUID your own frontend handed the caller.

        That is why the payload is four values and will stay four values.
        Signing URLs, download URLs, document content, field data and signature
        artwork are never served here, and neither is anything keyed by a
        company-chosen `external_id` — those ids are typically guessable
        (sequential numbers, email addresses), which behind a public key makes
        an existence oracle.

        A **secret** key is refused here with `PUBLISHABLE_KEY_REQUIRED`, even
        though it can do strictly more elsewhere. This is the one surface that
        answers cross-origin, so a secret key that worked would work pasted into
        frontend code — and we would rather that failure arrive in your console
        than in an inspector's view of your shipped bundle.

        **CORS:** the response carries `Access-Control-Allow-Origin` only when
        the request's `Origin` exactly matches a domain registered for this
        key's company and environment. It never carries
        `Access-Control-Allow-Credentials` — the surface takes no cookies.
        Register domains in Developer settings, per environment.
      security:
        - publishableKey: []
      responses:
        '200':
          description: The document's status, and its signer's.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BrowserDocumentStatus'
              examples:
                viewed:
                  $ref: '#/components/examples/BrowserStatusViewed'
                completed:
                  $ref: '#/components/examples/BrowserStatusCompleted'
        '404':
          $ref: '#/components/responses/NotFound'
        default:
          $ref: '#/components/responses/Error'

components:
  securitySchemes:
    secretKey:
      type: http
      scheme: bearer
      description: |
        A secret API key — `gd_sk_test_…` or `gd_sk_live_…` — as
        `Authorization: Bearer`.

        Every authentication failure answers one undifferentiated `401
        INVALID_API_KEY`: absent, malformed, unknown, revoked, or belonging to a
        deleted account. Distinguishing them would turn the endpoint into an
        oracle for which key strings exist.

        Publishable keys (`gd_pk_…`) are refused here with `403
        SECRET_KEY_REQUIRED`; they belong to the browser surface.

    publishableKey:
      type: http
      scheme: bearer
      description: |
        A publishable API key — `gd_pk_test_…` or `gd_pk_live_…` — as
        `Authorization: Bearer`.

        **Public by design.** It is meant to sit in your frontend source, and it
        grants nothing but the read-only browser surface (§6.8), which serves
        only data the caller already proves entitlement to by holding an
        unguessable document id. It is not scoped and its grant is not
        configurable: the key type *is* the grant.

        Secret keys are refused here with `403 PUBLISHABLE_KEY_REQUIRED`.

  headers:
    XRequestId:
      description: |
        The id of this request, also present in the body of every error. Quote
        it in a support ticket — it is the join key into our own request log.
      required: true
      schema:
        type: string
        format: uuid

  parameters:
    limit:
      name: limit
      in: query
      required: false
      description: Page size.
      example: 25
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    startingAfter:
      name: starting_after
      in: query
      required: false
      description: |
        Cursor: return the page of rows **older** than this id. Mutually
        exclusive with `ending_before`; an id that is not in this collection is
        a validation failure against the parameter rather than a `404`.
      example: "2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b"
      schema:
        type: string
        format: uuid
    endingBefore:
      name: ending_before
      in: query
      required: false
      description: 'Cursor: return the page of rows **newer** than this id.'
      example: "2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b"
      schema:
        type: string
        format: uuid
    createdAfter:
      name: created_after
      in: query
      required: false
      description: |
        Strictly after. Exclusive on purpose — an inclusive bound would
        re-deliver the boundary row on every poll, which is the one thing a
        "what is new since T" loop must not do. Compared to the microsecond,
        in UTC whatever offset you write.
      example: "2026-09-22T00:00:00Z"
      schema:
        type: string
        format: date-time
    createdBefore:
      name: created_before
      in: query
      required: false
      description: Strictly before.
      example: "2026-09-23T00:00:00Z"
      schema:
        type: string
        format: date-time
    webhookEndpointId:
      name: endpoint
      in: path
      required: true
      description: The webhook endpoint's id.
      example: "b7c6d5e4-f3a2-4b1c-9d0e-8f7a6b5c4d3e"
      schema:
        type: string
        format: uuid
    idempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Makes a retry safe. The first request under a key is executed and its
        answer stored for 48 hours; a retry replays it rather than executing
        again — byte for byte, except that a one-time secret is never stored:
        a replay answers `null` for a webhook endpoint's `secret`, an embed
        session's `url` and a recipient's `signing_url` (with
        `signing_url_expires_at`).

        A key still executing answers `409 IDEMPOTENCY_KEY_IN_FLIGHT` — wait and
        retry, and you will get the original answer. A key reused for a
        *different* request answers `422 IDEMPOTENCY_KEY_REUSED` rather than
        executing: the two plausible causes — one key reused across a loop, one
        key reused after the payload was edited — are both bugs whose other
        outcome is silence. Only 2xx and 4xx are stored; a 5xx leaves the key
        free to retry for real.
      example: "order-8813-create-nda"
      schema:
        type: string
        maxLength: 255
    userRef:
      name: user
      in: path
      required: true
      description: |
        Our `id` for the user, or the company's own `external_id`. Resolved as
        ours first. Percent-encode a `/` in an `external_id` (`crm%2F42`); an
        id that itself ends in `/documents`, `/templates` or `/default_signs`
        reads as that sub-resource, so address such a user by `id`.
      example: "crm-user-42"
      schema:
        type: string
        maxLength: 255
    documentId:
      name: document
      in: path
      required: true
      description: The document's id.
      example: "2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b"
      schema:
        type: string
        maxLength: 255
    fieldId:
      name: field
      in: path
      required: true
      description: The field's id.
      example: "c9d8e7f6-a5b4-4c3d-9e2f-1a0b9c8d7e6f"
      schema:
        type: string
        maxLength: 255
    templateId:
      name: template
      in: path
      required: true
      description: The template's id.
      example: "5e4d3c2b-1a09-4f8e-9d7c-6b5a4f3e2d1c"
      schema:
        type: string
        maxLength: 255
    embedSessionId:
      name: session
      in: path
      required: true
      description: The embed session's id.
      example: "d4c3b2a1-f0e9-4d8c-b7a6-958473625140"
      schema:
        type: string
        maxLength: 255

  responses:
    Error:
      description: |
        Any failure, in the one envelope this API answers in. Branch on `code`;
        `message` is display text translated into the caller's language and is
        never stable.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidApiKey:
              $ref: '#/components/examples/ErrorInvalidApiKey'
            insufficientScope:
              $ref: '#/components/examples/ErrorInsufficientScope'
            rateLimited:
              $ref: '#/components/examples/ErrorRateLimitExceeded'
            secretKeyRequired:
              $ref: '#/components/examples/ErrorSecretKeyRequired'
            publishableKeyRequired:
              $ref: '#/components/examples/ErrorPublishableKeyRequired'
            developerModeDisabled:
              $ref: '#/components/examples/ErrorDeveloperModeDisabled'
            termsAcceptanceRequired:
              $ref: '#/components/examples/ErrorTermsAcceptanceRequired'
            idempotencyKeyInFlight:
              $ref: '#/components/examples/ErrorIdempotencyKeyInFlight'
            idempotencyKeyReused:
              $ref: '#/components/examples/ErrorIdempotencyKeyReused'
            invalidJson:
              $ref: '#/components/examples/ErrorInvalidJson'
            methodNotAllowed:
              $ref: '#/components/examples/ErrorMethodNotAllowed'
            payloadTooLarge:
              $ref: '#/components/examples/ErrorPayloadTooLarge'
            notFound:
              $ref: '#/components/examples/ErrorNotFound'
            serverError:
              $ref: '#/components/examples/ErrorServerError'
    NotFound:
      description: |
        `NOT_FOUND`. One code for three situations — no such id, another
        company's id, the other environment's id — because distinguishing them
        would turn a `404` into an existence oracle over ids that are not yours.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notFound:
              $ref: '#/components/examples/ErrorNotFound'
    ValidationFailed:
      description: |
        `VALIDATION_FAILED`, with a per-attribute `errors` map. The classifier is
        always this one; *what* failed is in the map, as codes rather than prose.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ValidationError'
          examples:
            validationFailed:
              $ref: '#/components/examples/ErrorValidationFailed'

  schemas:
    # ── Enumerations ───────────────────────────────────────────────────────
    Environment:
      type: string
      description: |
        Which world an object lives in. Echoed on every resource even though it
        is always the calling key's own, because the one mistake this API
        actually produces is provisioning against the wrong key — and an object
        that states which world it is in gets that noticed in a log rather than
        in production.
      enum: [test, live]

    Scope:
      type: string
      description: One grant on a secret key.
      enum: [documents:read, documents, templates, users, embed, webhooks]

    Locale:
      type: string
      description: A supported UI and email language.
      enum: [en, ja, ko, de, es, fr]

    DocumentStatus:
      type: string
      description: |
        The document's place in the lifecycle. `UNENFORCEABLE`, `RE_SIGNING` and
        `RE_SIGNED` belong to the amendment machinery this API does not expose;
        they are listed because a document created here can be amended in the
        app and read back through it.
      enum:
        - NEW
        - DRAFT
        - SENT
        - VIEWED
        - PARTIALLY_SIGNED
        - COMPLETED
        - DECLINED
        - VOIDED
        - EXPIRED
        - UNENFORCEABLE
        - RE_SIGNING
        - RE_SIGNED

    RecipientStatus:
      type: string
      description: Where the one signer stands.
      enum: [PENDING, SENT, VIEWED, SIGNED, DECLINED, REVOKED, RE_SIGNED]

    EmailDelivery:
      type: string
      description: |
        Who mails the signer. `platform` — we do. `self` — the integrator does,
        using the returned signing URL, and we send the recipient nothing at
        all. Under `self` the Certificate of Completion attests that the
        *company* vouched for the mailbox rather than that we reached it.
      enum: [platform, self]

    SignerVerification:
      type: string
      description: |
        `standard` — the signer may meet a one-time-code wall, which emails
        them. `none` — no wall, and the certificate says so plainly rather than
        implying a verification that did not happen.
      enum: [standard, none]

    Assignee:
      type: string
      description: |
        Who a field belongs to. `sender` fields are **baked**: nobody is ever
        prompted to fill them, so they must carry complete content, unless one
        is written with `required: false`, which lets it stay empty. They never
        gate signing. `recipient` fields are what the signer fills, and the
        required ones are what gate completion.
      enum: [sender, recipient]

    ErrorCode:
      type: string
      description: |
        The machine classifier on every error. This list is the contract: it can
        gain members, but changing or removing one breaks callers silently.
      enum:
        - INVALID_API_KEY
        - DEVELOPER_MODE_DISABLED
        - TERMS_ACCEPTANCE_REQUIRED
        - SECRET_KEY_REQUIRED
        - PUBLISHABLE_KEY_REQUIRED
        - INSUFFICIENT_SCOPE
        - RATE_LIMIT_EXCEEDED
        - NOT_FOUND
        - VALIDATION_FAILED
        - INVALID_JSON
        - METHOD_NOT_ALLOWED
        - PAYLOAD_TOO_LARGE
        - SERVER_ERROR
        - USER_EXISTS
        - IDEMPOTENCY_KEY_IN_FLIGHT
        - IDEMPOTENCY_KEY_REUSED
        - SINGLE_RECIPIENT_ONLY
        - NOT_YET_SUPPORTED
        - DOCUMENT_NOT_DRAFT
        - ILLEGAL_TRANSITION
        - DOCUMENT_NOT_ACTIVE
        - NO_RECIPIENT
        - RECIPIENT_LOCKED
        - RECIPIENT_ALREADY_SIGNED
        - RECIPIENT_NOT_INVITED
        - EMAIL_DELIVERY_SELF
        - VERSION_CONFLICT
        - SENDER_FIELDS_INCOMPLETE
        - FIELD_LOCKED
        - APP_TEMPLATE_READ_ONLY
        - TEMPLATE_MULTI_ROLE
        - TEST_ONLY
        - DOWNLOAD_PREPARING
        - DOCUMENT_NOT_COMPLETED
        - WEBHOOK_ENDPOINT_INACTIVE
        - PLAN_UPLOAD_LIMIT_REACHED
        - PLAN_EXPIRED
        - EMBED_ORIGIN_NOT_REGISTERED
        - EMBED_SESSION_INVALID
        - EMBED_TOKEN_MISMATCH
        - EMBED_ORIGIN_MISMATCH
        - EMBED_KIND_MISMATCH
        - EMBED_MODE_INSUFFICIENT
        - EMBED_DOWNLOAD_NOT_ALLOWED
        - EMBED_PRESET_LOCKED

    FieldErrorCode:
      type: string
      description: |
        The per-attribute codes inside a `VALIDATION_FAILED` envelope's `errors`
        map. Read these rather than the sentence beside them — the sentence is
        translated, these are not.
      enum:
        - REQUIRED
        - INVALID_TYPE
        - INVALID_VALUE
        - INVALID_FORMAT
        - INVALID_EMAIL
        - INVALID_DATE
        - INVALID_JSON
        - INVALID_SVG
        - INVALID_IMAGE_DATA
        - INVALID_ORIGIN
        - INVALID_WEBHOOK_URL
        - INVALID_WEBHOOK_HEADER
        - OUT_OF_RANGE
        - TOO_LARGE
        - TOO_LONG
        - TOO_SHORT
        - DUPLICATE_VALUE
        - ALREADY_EXISTS
        - NOT_FOUND
        - UNKNOWN_FIELD
        - MUTUALLY_EXCLUSIVE
        - UNWRITABLE_TYPE
        - IMMUTABLE
        - EMBED_PRESET_LOCKED

    WarningCode:
      type: string
      description: |
        A code riding on a **2xx**. It explains something about what did happen
        that you probably did not intend; the operation stands either way, and
        nothing here is ever promoted to an error in a later release.
      enum: [RECIPIENT_WITHOUT_FIELDS]

    # ── The error envelope (§4.3) ──────────────────────────────────────────
    Error:
      type: object
      description: The shape of every failure, and of the `202` that says a download is not ready yet.
      required: [message, code, request_id]
      additionalProperties: false
      properties:
        message:
          type: string
          description: Display text, translated via `X-Locale`. Never branch on it.
        code:
          $ref: '#/components/schemas/ErrorCode'
        request_id:
          type: string
          format: uuid
          description: Also on the `X-Request-Id` header. Quote it in a support ticket.
        errors:
          $ref: '#/components/schemas/FieldErrors'
        status:
          $ref: '#/components/schemas/DocumentStatus'
        fields:
          type: array
          description: |
            On `SENDER_FIELDS_INCOMPLETE` and on the `NO_RECIPIENT` raised while
            attaching fields: which fields are at fault.

            **Indices when you supplied the array, ids when you did not.** A
            create carrying twelve fields names the two that are wrong by their
            position in what you sent — you have no ids yet — while a send on an
            existing document names them by element id, which is what `PATCH
            /documents/{id}/fields/{fid}` takes to fix them. A template
            instantiated with `send: true` is never created when the send is
            refused, so there each field is named by its template element id —
            the key it takes in `sender_values`.
          items:
            type: [string, integer]
        roles:
          type: array
          description: 'On `TEMPLATE_MULTI_ROLE`: the signing roles the template actually defines.'
          items:
            type: object
            required: [id, name, signing_order]
            additionalProperties: false
            properties:
              id:
                type: string
                format: uuid
              name:
                type: string
              signing_order:
                type: integer
        server:
          $ref: '#/components/schemas/Field'
        unsupported:
          type: array
          description: 'On `NOT_YET_SUPPORTED`: the payload keys this build does not implement.'
          items:
            type: string
        retry_after:
          type: integer
          description: 'On `DOWNLOAD_PREPARING` and on every `RATE_LIMIT_EXCEEDED`: seconds to wait.'
        terms_version:
          type: string
          description: 'On `TERMS_ACCEPTANCE_REQUIRED`: the Integrator Terms version now in force, to accept under Settings → Developer.'
        scope:
          $ref: '#/components/schemas/Scope'
        recipient_status:
          description: 'On `RECIPIENT_ALREADY_SIGNED` and `RECIPIENT_NOT_INVITED`: the signer''s status.'
          allOf:
            - $ref: '#/components/schemas/RecipientStatus'
        external_id:
          type: string
          description: 'On `USER_EXISTS`: the id already taken in this environment.'
        origin:
          type: string
          description: 'On `APP_TEMPLATE_READ_ONLY`: where the template was authored, and therefore where it is managed.'
          enum: [app, api]
        email_delivery:
          $ref: '#/components/schemas/EmailDelivery'
        plan:
          type: string
          description: 'On a plan refusal: the plan whose limit was enforced.'
        limit:
          type: [integer, 'null']
          description: 'On a plan refusal: that plan''s monthly document allowance, or null when uncapped.'
        used:
          type: integer
          description: 'On a plan refusal: documents created this month.'
        is_expired:
          type: boolean
          description: 'On a plan refusal: whether the cause is expiry rather than volume.'
        mode:
          type: string
          description: 'On `EMBED_MODE_INSUFFICIENT`: the session''s mode.'
          enum: [read_only, minimal, advanced]
        required_mode:
          type: string
          description: 'On `EMBED_MODE_INSUFFICIENT`: the lowest mode the call needs.'
          enum: [read_only, minimal, advanced]
        allow_download:
          type: boolean
          description: 'On `EMBED_DOWNLOAD_NOT_ALLOWED`: whether the session was minted with `allow_download`.'

    FieldErrors:
      type: object
      description: |
        Attribute path → the codes it failed on. Paths are dotted and match the
        request body, so `recipient.email` and `fields.0.content.svgData` both
        appear exactly as you sent them.
      additionalProperties:
        type: array
        items:
          type: object
          required: [code]
          additionalProperties: false
          properties:
            code:
              $ref: '#/components/schemas/FieldErrorCode'

    ValidationError:
      description: A `VALIDATION_FAILED` envelope, which always carries the map.
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          required: [errors]

    Warning:
      type: object
      description: One non-blocking observation about a request that succeeded.
      required: [code, message]
      additionalProperties: false
      properties:
        code:
          $ref: '#/components/schemas/WarningCode'
        message:
          type: string
          description: Display text, translated. Branch on `code`.

    # ── Account ────────────────────────────────────────────────────────────
    Account:
      type: object
      description: What the calling key is and what it may do.
      required: [environment, scopes, plan, defaults]
      additionalProperties: false
      properties:
        environment:
          $ref: '#/components/schemas/Environment'
        scopes:
          type: array
          description: The grants on this key. A publishable key never reaches this endpoint.
          items:
            $ref: '#/components/schemas/Scope'
        plan:
          type: object
          description: |
            The **effective** plan. An expired paid plan enforces Free volume,
            so this reports the limit the next create is actually measured
            against rather than the nominal one.
          required: [key, limit, used, remaining]
          additionalProperties: false
          properties:
            key:
              type: string
            limit:
              type: [integer, 'null']
              description: Monthly document allowance. `null` means uncapped — not zero, and not unknown.
            used:
              type: integer
              description: Documents created this month in the live environment, deleted ones included. Test documents are not counted.
            remaining:
              type: [integer, 'null']
              minimum: 0
              description: '`limit` minus `used`, never below zero. `null` when `limit` is.'
        defaults:
          type: object
          description: Company-wide settings a per-request parameter can override.
          required: [email_delivery]
          additionalProperties: false
          properties:
            email_delivery:
              $ref: '#/components/schemas/EmailDelivery'

    # ── Users ──────────────────────────────────────────────────────────────
    ApiUser:
      type: object
      description: |
        One of the integrating company's own users.

        Saved signatures are deliberately **not** inlined: ten strokes of SVG
        dwarf the rest of this object, and a page of 100 users would carry all
        of it for a caller who wanted names.
      required: [id, external_id, name, email, locale, metadata, environment, created_at, updated_at]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        external_id:
          type: string
          description: The company's own id for this person. Immutable once set.
        name:
          type: string
        email:
          type: [string, 'null']
        locale:
          oneOf:
            - $ref: '#/components/schemas/Locale'
            - type: 'null'
          description: This user's own language, or null when they have not stated one.
        metadata:
          type: [object, 'null']
          additionalProperties: true
        environment:
          $ref: '#/components/schemas/Environment'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    DefaultSign:
      type: object
      description: One saved signature or set of initials.
      required: [id, name, content]
      additionalProperties: false
      properties:
        id:
          type: string
          maxLength: 64
          description: The company's own id for this saved item. Unique within its list.
        name:
          type: string
          maxLength: 80
        content:
          type: object
          description: |
            Required. In `signatures` it is validated as `signature` field
            content, in `initials` as `initials` field content (`value` is
            accepted only there); an unknown key is refused with
            `UNKNOWN_FIELD`. `svgData` is sanitized before storage.
          additionalProperties: true

    DefaultSigns:
      type: object
      description: A user's saved signatures and initials.
      required: [signatures, initials]
      additionalProperties: false
      properties:
        signatures:
          type: array
          maxItems: 10
          items:
            $ref: '#/components/schemas/DefaultSign'
        initials:
          type: array
          maxItems: 10
          items:
            $ref: '#/components/schemas/DefaultSign'

    # ── Documents ──────────────────────────────────────────────────────────
    Document:
      type: object
      description: |
        A document on this surface. Always `api`-origin and always in the
        calling key's environment.

        `page_count` is read from the PDF when it is uploaded, and is `null`
        when the file could not be read — never `0`.
      required:
        - id
        - title
        - page_count
        - status
        - user_id
        - client_reference_id
        - metadata
        - environment
        - email_delivery
        - signer_verification
        - expiry_days
        - expires_at
        - sent_at
        - completed_at
        - voided_at
        - created_at
        - updated_at
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        page_count:
          type: [integer, 'null']
          description: |
            Pages in the uploaded PDF, read from the file itself. `null` when it
            could not be read — never `0`.
        status:
          $ref: '#/components/schemas/DocumentStatus'
        user_id:
          type: [string, 'null']
          description: Our id for the owning user.
        external_user_id:
          type: [string, 'null']
          description: |
            The company's own id for the owning user. Present wherever the
            relation is loaded — you hold one id or the other, rarely both.
        client_reference_id:
          type: [string, 'null']
        metadata:
          type: [object, 'null']
          additionalProperties: true
        environment:
          $ref: '#/components/schemas/Environment'
        email_delivery:
          oneOf:
            - $ref: '#/components/schemas/EmailDelivery'
            - type: 'null'
          description: |
            Frozen at send, so null on anything not yet sent — a statement
            rather than a gap: a document that never went out has made no claim
            about who mails its signer.
        signer_verification:
          oneOf:
            - $ref: '#/components/schemas/SignerVerification'
            - type: 'null'
          description: Frozen at send, like `email_delivery`.
        expiry_days:
          type: integer
          description: How long a signing link lives once sent. Consumed by the next send.
        expires_at:
          type: [string, 'null']
          format: date-time
          description: |
            When *this* document's window closes. Stated alongside `expiry_days`
            because the two disagree the moment the setting is patched after a
            send — one is the rule, the other is the consequence.
        recipient:
          oneOf:
            - $ref: '#/components/schemas/Recipient'
            - type: 'null'
          description: |
            The one signer, on single-document views only. **Absent, not null,
            on listing rows** — absent reads as "not included here", while null
            would state that the document has no signer, which on a listing row
            would be a wrong answer rather than a missing one.
        warnings:
          type: array
          description: Present on create and send responses. Empty when there is nothing to say.
          items:
            $ref: '#/components/schemas/Warning'
        sent_at:
          type: [string, 'null']
          format: date-time
        completed_at:
          type: [string, 'null']
          format: date-time
        voided_at:
          type: [string, 'null']
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    DocumentWithWarnings:
      description: A document from a call that could have something to warn about.
      allOf:
        - $ref: '#/components/schemas/Document'
        - type: object
          required: [warnings]

    Recipient:
      type: object
      description: |
        The one signer. Singular throughout — no `signing_order`, no roster.

        `signing_url` is a **bearer credential**: anyone holding it opens the
        signing page as this person. It is minted, never stored, so every read
        hands back a fresh proof and `signing_url_expires_at` describes the URL
        you were just given rather than one issued earlier. Both keys are
        **absent** whenever the link could not be used — before a send, after a
        signature or a revocation, and while the document is recalled
        (`DRAFT`), voided, declined or expired — because a null URL would read
        as "this person has no way in" rather than "not applicable in this
        state". Both are **null** in exactly one place: an `Idempotency-Key`
        replay, which never carries the credential. Read the recipient or call
        `/recipient/signing_url` for a fresh one.
      required:
        - id
        - email
        - name
        - locale
        - status
        - invited_at
        - last_reminded_at
        - viewed_at
        - signed_at
        - declined_at
        - decline_reason
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        name:
          type: [string, 'null']
        locale:
          oneOf:
            - $ref: '#/components/schemas/Locale'
            - type: 'null'
          description: The signer's own language, never coalesced to the sender's.
        status:
          $ref: '#/components/schemas/RecipientStatus'
        signing_url:
          type: [string, 'null']
          format: uri
          description: Absent when the link could not be used; `null` only on an `Idempotency-Key` replay.
        signing_url_expires_at:
          type: [string, 'null']
          format: date-time
          description: The deadline of the `?em=` proof in `signing_url`. Absent or `null` together with it.
        invited_at:
          type: [string, 'null']
          format: date-time
        last_reminded_at:
          type: [string, 'null']
          format: date-time
        viewed_at:
          type: [string, 'null']
          format: date-time
        signed_at:
          type: [string, 'null']
          format: date-time
        declined_at:
          type: [string, 'null']
          format: date-time
        decline_reason:
          type: [string, 'null']

    Field:
      type: object
      description: |
        One box on the page.

        Geometry is `x`/`y`/`width`/`height` as normalized `0.0–1.0` fractions
        of the page — the same object a write takes, so what you sent is what
        you read back.
      required:
        - id
        - type
        - page
        - x
        - y
        - width
        - height
        - assignee
        - required
        - filled
        - locked
        - content
        - version
        - created_at
        - updated_at
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          description: |
            Writable types are `signature`, `initials`, `date`, `text` and
            `checkbox`. Reads are passthrough for every type a template can
            produce, and the `content` of the others is opaque.
        page:
          type: integer
          minimum: 0
        x:
          type: number
          minimum: 0
          maximum: 1
        y:
          type: number
          minimum: 0
          maximum: 1
        width:
          type: number
          minimum: 0
          maximum: 1
        height:
          type: number
          minimum: 0
          maximum: 1
        assignee:
          oneOf:
            - $ref: '#/components/schemas/Assignee'
            - type: 'null'
          description: |
            Null on a field a template author left assigned to no role.
            Passthrough reads report that rather than guessing, for the same
            reason the non-writable types are visible: a field belonging to
            nobody is why a document will not complete, and hiding it hides the
            explanation.
        required:
          type: boolean
          description: "Whether this field gates the signer's completion. On a `sender` field it says whether the send refuses the field while it is empty: `true` unless the field was written with `required: false`. Lifted out of `content`, and still inside it."
        filled:
          type: boolean
          description: |
            Whether there is actually something in it — the same predicate that
            gates completion, so you can answer "why is this not finished"
            without reimplementing the per-type emptiness rules.
        locked:
          type: boolean
          description: |
            Pinned by a template author: it can be filled but not moved or
            deleted. Geometry writes against it are silently preserved rather
            than refused, which is the app's contract too.
        content:
          type: object
          description: |
            The per-type content object — for the writable types, the shape of
            `SignatureContent`, `InitialsContent`, `DateContent`, `TextContent`
            or `CheckboxContent`; opaque for the rest. `svgData` is
            re-serialized through an allowlist on every write.
          additionalProperties: true
        version:
          type: integer
          minimum: 1
          description: The optimistic-locking token. Send it back on `PATCH`.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    AuditEntry:
      type: object
      description: One link of a document's hash chain.
      required: [id, event_type, actor_id, event_data, row_hash, created_at]
      additionalProperties: false
      properties:
        id:
          type: string
          description: |
            An ordering key you may store and compare. A **string**, because
            JSON numbers are doubles in enough languages that handing one out
            invites a silent rounding bug the day the table passes 2^53.
        event_type:
          type: string
          description: A machine code. The sentence is composed at render time, in the reader's language.
        actor_id:
          type: [string, 'null']
          format: uuid
          description: The company user who acted, or null for a recipient- or system-driven event.
        event_data:
          type: object
          additionalProperties: true
        row_hash:
          type: string
          description: '`sha256(previous_row_hash || canonical_json(event_data))`.'
        created_at:
          type: string
          format: date-time

    DownloadLink:
      type: object
      description: |
        A short-lived link, never bytes. Five minutes on S3, ten on local disk.
        The link itself is the capability — put it in an `<img>` or hand it to a
        browser tab.
      required: [url, expires_at]
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
        expires_at:
          type: string
          format: date-time

    # ── Browser API (§6.8) ─────────────────────────────────────────────────
    BrowserDocumentStatus:
      type: object
      description: |
        A document's signing status, and nothing else.

        **This is a closed list, not a trimmed `Document`.** The surface is
        world-readable to anyone holding the id, so every key here had to be
        worth publishing to the internet on its own — which is why there is no
        title, no metadata, no `client_reference_id`, and no signer email. Your
        own frontend already knows what it asked for; echoing it back would
        publish it.
      required: [id, status, recipient, completed_at]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/DocumentStatus'
        recipient:
          oneOf:
            - $ref: '#/components/schemas/BrowserRecipient'
            - type: 'null'
          description: |
            Null before a signer has been set. Null rather than an absent key
            because this is a polling endpoint — a key that appears partway
            through the lifecycle is a shape change in the middle of a loop.
        completed_at:
          type: [string, 'null']
          format: date-time

    BrowserRecipient:
      type: object
      description: The signer, as much of them as a public surface may state — two values, no identity.
      required: [status, signed_at]
      additionalProperties: false
      properties:
        status:
          $ref: '#/components/schemas/RecipientStatus'
        signed_at:
          type: [string, 'null']
          format: date-time

    # ── Templates ──────────────────────────────────────────────────────────
    Template:
      type: object
      description: |
        A reusable document. `roles`, `variables`, `sender_fields` and
        `field_count` appear on the single-template view only — a page of rows
        never pays for them.
      required: [id, title, description, page_count, user_id, origin, shared, environment, created_at, updated_at]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        description:
          type: [string, 'null']
        page_count:
          type: [integer, 'null']
          description: |
            Pages in the template's PDF, read from the file when it was
            uploaded. `null` when it could not be read — never `0`.
        user_id:
          type: [string, 'null']
          description: |
            **Null on an app-authored template.** Those have no API user: they
            are the company's own library, exposed here only by the sharing
            toggle in the app, and this API cannot rewrite them.
        origin:
          type: string
          description: Where the template was authored.
          enum: [app, api]
        shared:
          type: boolean
          description: The sharing toggle. With `origin` it explains why a template you did not create is in your list.
        environment:
          oneOf:
            - $ref: '#/components/schemas/Environment'
            - type: 'null'
          description: Null on app templates — they are environment-less masters, usable from a test key and a live key alike.
        field_count:
          type: integer
        roles:
          type: array
          items:
            $ref: '#/components/schemas/TemplateRole'
        variables:
          type: array
          items:
            $ref: '#/components/schemas/TemplateVariable'
        sender_fields:
          type: array
          description: The sender-role fields, keyed by the ids `sender_values` expects.
          items:
            $ref: '#/components/schemas/TemplateSenderField'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    TemplateRole:
      type: object
      description: One party the template was authored for.
      required: [id, name, is_sender, signing_order, default_name, default_email, field_count]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        is_sender:
          type: boolean
          description: |
            The flag that decides how a role is used here. A sender role is
            baked through `sender_values`; a signing role is the recipient, and
            this API admits exactly one of those.
        signing_order:
          type: integer
        default_name:
          type: [string, 'null']
          description: |
            The template author's suggestion, for prefilling your own form. Not
            applied by us — a role mapped without an email is left unmapped
            rather than sent to whoever the template happened to name.
        default_email:
          type: [string, 'null']
        field_count:
          type: integer

    TemplateVariable:
      type: object
      description: One value the template asks to be filled in at instantiation.
      required: [key, label, type, default_value, required]
      additionalProperties: false
      properties:
        key:
          type: string
        label:
          type: [string, 'null']
        type:
          type: [string, 'null']
        default_value:
          type: [string, 'null']
        required:
          type: boolean

    TemplateSenderField:
      type: object
      description: One sender-role field. Its `id` is the key `sender_values` is keyed by.
      required: [id, role_id, role_name, type, page, label, index, options, fillable, required]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        role_id:
          type: [string, 'null']
          format: uuid
        role_name:
          type: [string, 'null']
        type:
          type: string
        page:
          type: integer
        label:
          type: [string, 'null']
          description: Set only on a `checkbox` the author labelled; `null` for every other type. Compose a fallback from `type` and `index`.
        index:
          type: integer
          description: This field's ordinal among the sender fields of its type, from 1, in reading order (page, then top to bottom, then left to right).
        options:
          type: array
          description: For `dropdown` and `radio`, the choices; send an option's `id` as the value. Empty for every other type.
          items:
            type: object
            required: [id, label]
            additionalProperties: false
            properties:
              id:
                type: string
              label:
                type: string
        fillable:
          type: boolean
        required:
          type: boolean

    # ── Shared response shapes ─────────────────────────────────────────────
    Deleted:
      type: object
      description: A deletion, acknowledged.
      required: [id, deleted]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        deleted:
          type: boolean

    ArchivedTemplate:
      type: object
      description: A template, archived rather than purged.
      required: [id, deleted, status]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        deleted:
          type: boolean
        status:
          type: string
          enum: [archived]

    # ── Events ─────────────────────────────────────────────────────────────
    EventType:
      type: string
      description: |
        The event catalog. Treat it as open: **ignore a type you do not
        recognise** rather than erroring, because additive changes are
        non-breaking and new types will appear.

        Two absences are deliberate. There is no `document.partially_signed`
        and no `recipient.activated` — this API has exactly one signer per
        document, so neither could ever fire. And there is no
        `document.created`: `POST /v1/documents` hands you the document
        synchronously, so an event announcing it would be noise on every
        endpoint subscribed to everything.
      enum:
        - ping
        - document.sent
        - document.viewed
        - document.completed
        - document.download_ready
        - document.declined
        - document.voided
        - document.expired
        - document.recalled
        - recipient.updated
        - recipient.viewed
        - recipient.signed
        - recipient.declined
        - template.created
        - template.updated
        - user.created

    Event:
      type: object
      description: |
        One event. **This is also the webhook body** — byte for byte, so a
        delivery you replay from here can be fed to the same handler you point
        at your endpoint.

        `data` is a snapshot of the resource as it stood when the event fired.
        Its shape depends on `type`: document and recipient events carry
        `DocumentEventData`, template events `TemplateEventData`,
        `user.created` `UserEventData`, and `ping` `PingEventData`.

        **No URLs appear anywhere in `data`, ever.** Signing URLs are bearer
        credentials and download URLs are short-lived grants; a webhook body is
        retried for about 35 hours and logged by whatever sits between us, so
        neither belongs in one. Fetch them from the REST endpoints instead.
      required: [id, type, api_version, environment, created_at, data]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
          description: Also sent as `X-GingerDocs-Event-Id`. Deduplicate on it — delivery is at-least-once.
        type:
          $ref: '#/components/schemas/EventType'
        api_version:
          type: string
          description: The API version this payload is shaped for.
          enum: [v1]
        environment:
          $ref: '#/components/schemas/Environment'
        created_at:
          type: string
          format: date-time
          description: When the event fired, to the microsecond — pass it back as `created_after` to resume exactly after it.
        data:
          oneOf:
            - $ref: '#/components/schemas/DocumentEventData'
            - $ref: '#/components/schemas/TemplateEventData'
            - $ref: '#/components/schemas/UserEventData'
            - $ref: '#/components/schemas/PingEventData'

    DocumentEventData:
      type: object
      description: |
        The document, as it stood at emission — carried by every `document.*`
        and every `recipient.*` event, so one handler can route on
        `document_id` regardless of which fired.

        `recipient` is present on `recipient.*` events only. Absent, not null:
        a document event says nothing about the signer, while
        `recipient: null` would claim there is none.
      required: [document_id, status, title, api_user_id, external_user_id, client_reference_id, metadata]
      additionalProperties: false
      properties:
        document_id:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/DocumentStatus'
        title:
          type: string
        api_user_id:
          type: [string, 'null']
          format: uuid
          description: Our id for the user this document belongs to.
        external_user_id:
          type: [string, 'null']
          description: Your own id for the same person.
        client_reference_id:
          type: [string, 'null']
        metadata:
          type: [object, 'null']
          additionalProperties: true
        recipient:
          $ref: '#/components/schemas/EventRecipient'

    EventRecipient:
      type: object
      description: |
        The signer, as an event may describe them. Deliberately not the
        `Recipient` resource: that one mints a `signing_url` on every read, and
        a bearer credential has no business in an outbound body.
      required: [id, email, name, status, viewed_at, signed_at, declined_at, decline_reason]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
        name:
          type: [string, 'null']
        status:
          $ref: '#/components/schemas/RecipientStatus'
        viewed_at:
          type: [string, 'null']
          format: date-time
        signed_at:
          type: [string, 'null']
          format: date-time
        declined_at:
          type: [string, 'null']
          format: date-time
        decline_reason:
          type: [string, 'null']

    TemplateEventData:
      type: object
      description: |
        An API template. App-authored templates never appear here even when
        `api_shared` exposes them to this key: an app template is a single
        environment-less master visible to test and live alike, and every event
        belongs to exactly one environment.
      required: [template_id, title, description, shared, api_user_id, external_user_id]
      additionalProperties: false
      properties:
        template_id:
          type: string
          format: uuid
        title:
          type: string
        description:
          type: [string, 'null']
        shared:
          type: boolean
          description: Whether every user in the company may use it.
        api_user_id:
          type: [string, 'null']
          format: uuid
        external_user_id:
          type: [string, 'null']

    PingEventData:
      type: object
      description: |
        A test delivery, carried by `ping`. It names the endpoint it was aimed
        at — a ping goes to exactly one, never to every subscriber.
      required: [endpoint_id]
      additionalProperties: false
      properties:
        endpoint_id:
          type: string
          format: uuid

    UserEventData:
      type: object
      description: One of your users, as provisioned.
      required: [user_id, external_id, name, email, locale, metadata]
      additionalProperties: false
      properties:
        user_id:
          type: string
          format: uuid
          description: Our id for them.
        external_id:
          type: string
          description: Your own.
        name:
          type: string
        email:
          type: [string, 'null']
        locale:
          type: [string, 'null']
        metadata:
          type: [object, 'null']
          additionalProperties: true

    UserList:
      type: object
      required: [data, has_more]
      additionalProperties: false
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ApiUser'
        has_more:
          type: boolean
          description: Whether more rows exist **in the direction being paged**.

    # ── Webhooks ───────────────────────────────────────────────────────────
    EmbedSession:
      type: object
      description: |
        A minted handle for the GingerDocs iframe.

        Which fields are meaningful depends on `kind`. A **signing** session
        carries `document_id` and `recipient_id` and nothing else; a
        **workspace** session carries `api_user_id`, `mode`, `allow_download`,
        and optionally a `preselect` and either party preset.

        `document_id` is deliberately null on a workspace session even when one
        is preselected — there the document is the thing being *looked at*,
        reported under `preselect`, and one key must not mean two things.
      required:
        [id, kind, environment, host_origin, locale, document_id, recipient_id,
         api_user_id, mode, allow_download, preselect, recipient_preset,
         sender_preset, theme, status, expires_at, consumed_at, revoked_at, created_at]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        kind:
          type: string
          enum: [signing, workspace]
        environment:
          $ref: '#/components/schemas/Environment'
        host_origin:
          type: string
          description: The origin this session may be framed by, and only this one.
          maxLength: 255
        locale:
          type: [string, 'null']
          maxLength: 10
        theme:
          description: |
            The frame's colour, as the mint set it with the hex upper-cased, or
            null when it set none. Both kinds.
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/EmbedTheme'
        document_id:
          description: Signing sessions only — the document being signed.
          oneOf:
            - type: 'null'
            - type: string
              format: uuid
        recipient_id:
          description: Signing sessions only — the signer this session is bound to.
          oneOf:
            - type: 'null'
            - type: string
              format: uuid
        api_user_id:
          description: Workspace sessions only — whose workspace this is.
          oneOf:
            - type: 'null'
            - type: string
              format: uuid
        mode:
          oneOf:
            - type: 'null'
            - type: string
              enum: [read_only, minimal, advanced]
        allow_download:
          type: boolean
          description: |
            Whether the session may download the user's `COMPLETED` documents —
            any of them, not only a preselected one. Off unless asked for.
        preselect:
          $ref: '#/components/schemas/EmbedPreselect'
        recipient_preset:
          description: |
            Pre-fills "who signs it" in the embed's two-party form. A preset, not
            an assignment — unless `locked`, in which case the frame renders it
            read-only and the server refuses anything else.
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/EmbedRecipientPreset'
        sender_preset:
          description: |
            Pre-fills "who it is from" in the same form. Null means the document
            is from the workspace user this session was minted for.
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/EmbedSenderPreset'
        status:
          type: string
          description: |
            `revoked` wins over `expired` when a session is both — somebody ended
            this one, and the clock running out afterwards did not change that.
            A *consumed* session is still `active`.
          enum: [active, expired, revoked]
        expires_at:
          type: string
          format: date-time
          description: Two hours from the mint, not from the exchange.
        consumed_at:
          type: [string, 'null']
          format: date-time
          description: When the fragment token was first spent.
        revoked_at:
          type: [string, 'null']
          format: date-time
        created_at:
          type: string
          format: date-time

    EmbedSessionWithUrl:
      type: object
      description: |
        The create response, and the only place a session's URL appears. Frame
        it, or hand it to `GingerDocsEmbed.mount()`.

        The token in its fragment is a bearer credential for a signing ceremony:
        treat it as a secret, and do not log the URL.

        Spelled out in full rather than composed with `allOf` from
        `EmbedSession`, for the reason `WebhookEndpointWithSecret` gives:
        `allOf` and `additionalProperties: false` do not compose — each branch is
        evaluated against the whole object, so the `EmbedSession` branch would
        reject the very `url` the other branch adds. Keeping both schemas closed
        is what makes "the URL appears here and in no other response" a rule the
        validator enforces rather than a sentence in a description.
      required:
        [id, kind, environment, host_origin, locale, document_id, recipient_id,
         api_user_id, mode, allow_download, preselect, recipient_preset,
         sender_preset, theme, status, expires_at, consumed_at, revoked_at, created_at, url]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        kind:
          type: string
          enum: [signing, workspace]
        environment:
          $ref: '#/components/schemas/Environment'
        host_origin:
          type: string
          description: The origin this session may be framed by, and only this one.
          maxLength: 255
        locale:
          type: [string, 'null']
          maxLength: 10
        theme:
          description: |
            The frame's colour, as the mint set it with the hex upper-cased, or
            null when it set none. Both kinds.
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/EmbedTheme'
        document_id:
          description: Signing sessions only — the document being signed.
          oneOf:
            - type: 'null'
            - type: string
              format: uuid
        recipient_id:
          description: Signing sessions only — the signer this session is bound to.
          oneOf:
            - type: 'null'
            - type: string
              format: uuid
        api_user_id:
          description: Workspace sessions only — whose workspace this is.
          oneOf:
            - type: 'null'
            - type: string
              format: uuid
        mode:
          oneOf:
            - type: 'null'
            - type: string
              enum: [read_only, minimal, advanced]
        allow_download:
          type: boolean
          description: |
            Whether the session may download the user's `COMPLETED` documents —
            any of them, not only a preselected one. Off unless asked for.
        preselect:
          $ref: '#/components/schemas/EmbedPreselect'
        recipient_preset:
          description: |
            Pre-fills "who signs it" in the embed's two-party form. A preset, not
            an assignment — unless `locked`, in which case the frame renders it
            read-only and the server refuses anything else.
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/EmbedRecipientPreset'
        sender_preset:
          description: |
            Pre-fills "who it is from" in the same form. Null means the document
            is from the workspace user this session was minted for.
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/EmbedSenderPreset'
        status:
          type: string
          description: |
            `revoked` wins over `expired` when a session is both — somebody ended
            this one, and the clock running out afterwards did not change that.
            A *consumed* session is still `active`.
          enum: [active, expired, revoked]
        expires_at:
          type: string
          format: date-time
          description: Two hours from the mint, not from the exchange.
        consumed_at:
          type: [string, 'null']
          format: date-time
          description: When the fragment token was first spent.
        revoked_at:
          type: [string, 'null']
          format: date-time
        created_at:
          type: string
          format: date-time

        url:
          type: [string, 'null']
          maxLength: 2048
          description: |
            The URL to frame. On the mint response only — and `null` on an
            `Idempotency-Key` replay of the mint, which never carries the
            token. Mint a new session if you lost it.

    EmbedPreselect:
      description: |
        What a workspace session opens on. Exactly one of the two ids is set;
        `null` means the session opens on the user's own list.
      oneOf:
        - type: 'null'
        - type: object
          required: [document_id, template_id]
          additionalProperties: false
          properties:
            document_id:
              oneOf:
                - type: 'null'
                - type: string
                  format: uuid
            template_id:
              oneOf:
                - type: 'null'
                - type: string
                  format: uuid

    EmbedRecipientPreset:
      type: object
      description: |
        Who signs it, as far as you already know at mint time.

        `locked` is the difference between a convenience and a rule. Left off,
        this pre-fills the embed's form and your user may type over any of it.
        Set, the fields you pinned render read-only in the frame **and** are
        enforced server-side: a create carrying a different value is refused
        `422` with `EMBED_PRESET_LOCKED` against the offending key. A key you
        did not set is never locked — pin a name and an email, and the language
        control stays live.
      required: [name, email]
      additionalProperties: false
      properties:
        name:
          type: string
          maxLength: 255
        email:
          type: string
          format: email
          maxLength: 255
        locale:
          oneOf:
            - $ref: '#/components/schemas/Locale'
            - type: 'null'
        locked:
          type: boolean
          description: Defaults to false.

    EmbedSenderPreset:
      type: object
      description: |
        Who it is **from**. Same contract as `EmbedRecipientPreset`, including
        `locked`, minus `locale` — the sender reads the frame, whose language is
        the session's `locale`, while a recipient's language is a durable
        property of somebody we will email.

        `email` is optional here where the recipient's is required, matching
        `POST /v1/users`: it identifies the party, and nothing is delivered to
        it.

        Omit it and the document is from the workspace user this session was
        minted for, which is what every API document has always been from. Set
        it when your user sends on behalf of a desk or a brand rather than under
        their own name. It is stamped on the document at creation and is what
        the signing invitation and the Certificate of Completion then name.
      required: [name]
      additionalProperties: false
      properties:
        name:
          type: string
          maxLength: 255
        email:
          type: [string, 'null']
          format: email
          maxLength: 255
        locked:
          type: boolean
          description: Defaults to false.

    EmbedTheme:
      type: object
      description: |
        The frame's colour, on either kind of session. Any `#RRGGBB` works, and
        we derive the rest from it: the hover shade and the light tints, text on
        it in black or white (whichever reads better), and, where it is used as
        text or an outline on a light surface, a darker shade of it when it
        would read below 4.5:1 there. A mid-tone colour is used exactly as sent.

        What stays ours: the test-mode banner, status colours, errors and
        warnings, and any colour the document itself stores. A key not listed
        here is refused (`UNKNOWN_FIELD`) rather than ignored.
      additionalProperties: false
      properties:
        primary_color:
          type: [string, 'null']
          pattern: '^#[0-9A-Fa-f]{6}$'
          description: |
            Buttons, links, selection and focus rings. Six hex digits: no
            shorthand and no alpha (`INVALID_FORMAT`). Null, like an empty
            `theme`, means our colour.

    EmbedSessionCreate:
      type: object
      description: |
        One body, two shapes. `kind` selects which, and the other kind's
        parameters are refused rather than ignored.
      required: [kind, host_origin]
      additionalProperties: false
      properties:
        kind:
          type: string
          enum: [signing, workspace]
        host_origin:
          type: string
          description: |
            The origin that will frame this session, `scheme://host[:port]` with
            no path — a URL is refused, because a browser never sends one and a
            stored path could only ever fail to match.
          maxLength: 255
        locale:
          description: |
            The language the embed opens in. Defaults to the signer's own on a
            signing session, and to the user's on a workspace one.
          oneOf:
            - $ref: '#/components/schemas/Locale'
            - type: 'null'
        theme:
          description: |
            **Both kinds.** Your colour for the frame's buttons, links, selection
            and focus rings. Omitted, `null` or `{}`: ours.
          oneOf:
            - $ref: '#/components/schemas/EmbedTheme'
            - type: 'null'
        document_id:
          type: string
          format: uuid
          description: |
            **Signing kind, required.** The document to sign. Its one recipient
            is resolved from it — there is no second signer to name.
        user_id:
          type: string
          format: uuid
          description: '**Workspace kind.** Our id for the user. Mutually exclusive with `external_user_id`.'
        external_user_id:
          type: string
          maxLength: 255
          description: |
            **Workspace kind.** Your own id for the user. The user must already
            exist — unlike document creation, minting a session does not create
            one, because a typo would leave a phantom person owning whatever got
            authored.
        mode:
          type: string
          description: '**Workspace kind, required.**'
          enum: [read_only, minimal, advanced]
        preselect:
          type: [object, 'null']
          description: |
            **Workspace kind.** What to open on. Required for `read_only` — a
            viewer with nothing to view is an empty frame. Exactly one id, and
            both are scoped to the session's own user (a template may also come
            from your shared set). It chooses where the frame lands, not what
            the session can reach: any workspace session can read the user's
            documents and usable templates.

            A template opens `read_only` on its pages and fields, `minimal` on
            its fill step, and `advanced` on its editor when the session's user
            authored it — on the fill step otherwise.
          additionalProperties: false
          properties:
            document_id:
              type: [string, 'null']
              format: uuid
            template_id:
              type: [string, 'null']
              format: uuid
        allow_download:
          type: boolean
          description: |
            **Workspace kind.** Lets the session download the user's
            `COMPLETED` documents — any of them, not only a preselected one.
            Defaults to false.

            Not accepted on a signing session: an embedded signing view is the
            emailed signer experience moved into an iframe, and whether a signer
            may keep a copy of what they signed is not a per-session setting.
        recipient:
          oneOf:
            - $ref: '#/components/schemas/EmbedRecipientPreset'
            - type: 'null'
        sender:
          oneOf:
            - $ref: '#/components/schemas/EmbedSenderPreset'
            - type: 'null'

    WebhookEndpoint:
      type: object
      description: |
        A registered destination for this environment's events.

        `secret_prefix` is the displayable head of the signing secret — enough
        to tell which of your endpoints you are looking at, and not enough to
        forge a delivery. The full value appears only in the create response.

        `header_names` lists the extra headers we send when we call your URL.
        The **values are never returned** by any endpoint, including the one
        that set them: you supplied them, you already hold them, and a response
        carrying a bearer token for your own internal API earns nothing. Edit
        them by merging (see `WebhookEndpointUpdate`).
      required: [id, url, name, description, environment, events, api_version, is_active, disabled_reason, secret_prefix, header_names, created_at, updated_at]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
          maxLength: 2048
        name:
          description: Your label for this endpoint, or null if you did not give one.
          oneOf:
            - type: 'null'
            - type: string
              maxLength: 80
        description:
          description: Your note about what this endpoint is for.
          oneOf:
            - type: 'null'
            - type: string
              maxLength: 500
        environment:
          $ref: '#/components/schemas/Environment'
        events:
          type: array
          description: 'Empty means **every** type, including ones added later.'
          items:
            $ref: '#/components/schemas/EventType'
        api_version:
          type: string
          description: The payload shape this endpoint receives.
          enum: [v1]
        is_active:
          type: boolean
          description: |
            Whether deliveries are attempted. While the account's Developer
            Mode is off, deliveries to every endpoint are held instead and sent
            once it is back on.
        disabled_reason:
          description: |
            Null while active — and null beside `is_active: false` when *you*
            switched it off. A code here means we did.
          oneOf:
            - type: 'null'
            - type: string
              enum: [AUTO_DISABLED_FAILURES, ACCOUNT_ENDED]
        secret_prefix:
          type: string
          maxLength: 10
        header_names:
          type: array
          description: 'Sorted names of the custom headers we send. Values are never returned.'
          items:
            type: string
            maxLength: 64
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    WebhookEndpointWithSecret:
      type: object
      description: |
        The create response — the only one that carries the signing secret.

        Spelled out in full rather than composed with `allOf` from
        `WebhookEndpoint`, because `allOf` and `additionalProperties: false` do
        not compose: each branch is evaluated against the whole object, so the
        `WebhookEndpoint` branch would reject the very `secret` the other branch
        adds. Keeping both schemas closed is what makes "the secret appears here
        and in no other response" a rule the validator enforces rather than a
        sentence in a description.
      required: [id, url, name, description, environment, events, api_version, is_active, disabled_reason, secret_prefix, header_names, secret, created_at, updated_at]
      additionalProperties: false
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
          maxLength: 2048
        name:
          oneOf:
            - type: 'null'
            - type: string
              maxLength: 80
        description:
          oneOf:
            - type: 'null'
            - type: string
              maxLength: 500
        environment:
          $ref: '#/components/schemas/Environment'
        events:
          type: array
          items:
            $ref: '#/components/schemas/EventType'
        api_version:
          type: string
          enum: [v1]
        is_active:
          type: boolean
          description: |
            Whether deliveries are attempted. While the account's Developer
            Mode is off, deliveries to every endpoint are held instead and sent
            once it is back on.
        disabled_reason:
          oneOf:
            - type: 'null'
            - type: string
              enum: [AUTO_DISABLED_FAILURES, ACCOUNT_ENDED]
        secret_prefix:
          type: string
          maxLength: 10
        header_names:
          type: array
          description: |
            The custom headers we will send. Their values are **not** echoed
            back here — unlike `secret`, they are yours and you already have
            them.
          items:
            type: string
            maxLength: 64
        secret:
          type: [string, 'null']
          description: |
            The signing key, shown **once**. Sign with it exactly as
            `X-GingerDocs-Signature` describes:
            `HMAC-SHA256(t + "." + rawBody, secret)`, where `t` is the
            timestamp from the header. Compare digests in constant time, and
            reject anything more than five minutes old.

            `null` on an `Idempotency-Key` replay of the create, which never
            carries the secret — reveal it in *Settings → Developer →
            Webhooks* instead.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    WebhookEndpointList:
      type: object
      required: [data, has_more]
      additionalProperties: false
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEndpoint'
        has_more:
          type: boolean
          description: Whether more rows exist **in the direction being paged**.

    WebhookEndpointCreate:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url:
          type: string
          maxLength: 2048
          description: '`https` only, public hostname, no credentials in the URL.'
        name:
          description: A label for your own use. We never interpret it.
          oneOf:
            - type: 'null'
            - type: string
              maxLength: 80
        description:
          description: A longer note about what this endpoint is for.
          oneOf:
            - type: 'null'
            - type: string
              maxLength: 500
        events:
          type: array
          description: Omit or send `[]` to receive every type.
          items:
            $ref: '#/components/schemas/EventType'
        headers:
          $ref: '#/components/schemas/WebhookHeaders'

    WebhookHeaders:
      type: object
      description: |
        Extra request headers to send every time we POST to your URL — a
        gateway token, a tenant id, whatever your side needs to accept the
        call.

        Names must be valid HTTP header names, and the ones we set ourselves
        are refused: `X-GingerDocs-Signature`, `X-GingerDocs-Event-Id`,
        `Content-Type`, `Content-Length`, `Host`, `User-Agent`, and the
        hop-by-hop headers. Names are compared case-insensitively, so you
        cannot send one header twice under two spellings.

        Values are stored encrypted and are **never returned** — reads answer
        with `header_names`.
      maxProperties: 10
      additionalProperties:
        type: string
        minLength: 1
        maxLength: 1024
        description: 'Printable ASCII on a single line.'

    WebhookEndpointUpdate:
      type: object
      additionalProperties: false
      properties:
        url:
          type: string
          maxLength: 2048
        name:
          oneOf:
            - type: 'null'
            - type: string
              maxLength: 80
        description:
          oneOf:
            - type: 'null'
            - type: string
              maxLength: 500
        events:
          type: array
          items:
            $ref: '#/components/schemas/EventType'
        headers:
          type: object
          description: |
            A **merge**, not a replacement — the one field on this resource
            that behaves that way. A name you send is set; a name you send with
            `null` is removed; a name you leave out is untouched.

            The reason is that we never give header values back, so
            "keep the bearer token, change the tenant id" is an edit you could
            not express as a whole-map replacement: you do not hold the token
            to re-send. Everything else here replaces.
          maxProperties: 10
          additionalProperties:
            oneOf:
              - type: 'null'
              - type: string
                minLength: 1
                maxLength: 1024
        is_active:
          type: boolean
          description: '`true` also clears `disabled_reason`.'

    EventList:
      type: object
      required: [data, has_more]
      additionalProperties: false
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Event'
        has_more:
          type: boolean
          description: Whether more rows exist **in the direction being paged**.

    DocumentList:
      type: object
      required: [data, has_more]
      additionalProperties: false
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Document'
        has_more:
          type: boolean

    TemplateList:
      type: object
      required: [data, has_more]
      additionalProperties: false
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Template'
        has_more:
          type: boolean

    FieldList:
      type: object
      description: Unpaginated — a document's fields are bounded by its pages.
      required: [data]
      additionalProperties: false
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Field'

    AuditList:
      type: object
      description: Unpaginated, oldest first. A partial evidence chain is not evidence.
      required: [data]
      additionalProperties: false
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/AuditEntry'

    # ── Request bodies ─────────────────────────────────────────────────────
    UserCreate:
      type: object
      required: [external_id, name]
      properties:
        external_id:
          type: string
          maxLength: 255
          description: Your own id for this person. Unique within the environment.
        name:
          type: string
          maxLength: 255
        email:
          type: [string, 'null']
          format: email
          maxLength: 255
        locale:
          oneOf:
            - $ref: '#/components/schemas/Locale'
            - type: 'null'
        metadata:
          type: [object, 'null']
          description: Free-form JSON object, up to 4 KB encoded. Echoed back on the user and in webhooks.
          additionalProperties: true

    UserUpdate:
      type: object
      description: '`external_id` is immutable — sending it answers `422` with `IMMUTABLE`.'
      properties:
        name:
          type: string
          maxLength: 255
        email:
          type: [string, 'null']
          format: email
          maxLength: 255
        locale:
          oneOf:
            - $ref: '#/components/schemas/Locale'
            - type: 'null'
        metadata:
          type: [object, 'null']
          additionalProperties: true

    DefaultSignsWrite:
      type: object
      description: |
        Both keys must be present. `PUT` replaces wholesale, and `[]` is a
        meaningful value — "this user has none saved" — that a required-and-
        non-empty rule would reject.
      required: [signatures, initials]
      properties:
        signatures:
          type: [array, 'null']
          maxItems: 10
          items:
            $ref: '#/components/schemas/DefaultSign'
        initials:
          type: [array, 'null']
          maxItems: 10
          items:
            $ref: '#/components/schemas/DefaultSign'

    Attribution:
      type: object
      description: |
        Who the document belongs to. Exactly one of the three forms — sending
        two answers `422` with `MUTUALLY_EXCLUSIVE` on both.

        The inline `user` form is find-or-create, which is what keeps a
        one-call flow one call: an integrator creating a document for a customer
        they have never sent one to before does not need a provisioning round
        trip first.
      properties:
        user_id:
          type: string
          format: uuid
          description: Our id for an existing user.
        external_user_id:
          type: string
          maxLength: 255
          description: Your own id for an existing user.
        user:
          type: object
          description: Find-or-create by `external_id`.
          required: [external_id]
          properties:
            external_id:
              type: string
              maxLength: 255
            name:
              type: [string, 'null']
              maxLength: 255
              description: Defaults to `external_id` when the user is created here.
            email:
              type: [string, 'null']
              format: email
              maxLength: 255
            locale:
              oneOf:
                - $ref: '#/components/schemas/Locale'
                - type: 'null'

    SendOptions:
      type: object
      description: |
        The two properties that decide who mails the signer and whether they
        meet a verification wall. `email_delivery` falls back to the account
        default, `signer_verification` to `standard` (the test environment
        forces `none`); an explicit `null` means the same as leaving the key
        out. Both are frozen onto the document at send.

        `subject` and `message` are **refused**, not ignored, with
        `NOT_YET_SUPPORTED`: custom mail copy exists nowhere in the platform, so
        a document created with a subject the mail will not carry would look
        like success and then say something else — discovered by your customer
        rather than by you.
      properties:
        email_delivery:
          oneOf:
            - $ref: '#/components/schemas/EmailDelivery'
            - type: 'null'
        signer_verification:
          oneOf:
            - $ref: '#/components/schemas/SignerVerification'
            - type: 'null'

    DocumentCreate:
      description: |
        The JSON `payload` part of the multipart create.

        A `recipients` **array** is refused with `409 SINGLE_RECIPIENT_ONLY`
        rather than a validation error: the plural is what an SDK author writes
        first, having used every other e-signature API, and "the shape is wrong,
        not the data" is the answer that saves them hunting for a bad address
        inside an array this API will never accept in any form.
      allOf:
        - $ref: '#/components/schemas/Attribution'
        - $ref: '#/components/schemas/SendOptions'
        - type: object
          properties:
            title:
              type: [string, 'null']
              maxLength: 255
            client_reference_id:
              type: [string, 'null']
              maxLength: 255
            metadata:
              type: [object, 'null']
              additionalProperties: true
            expiry_days:
              type: [integer, 'null']
              minimum: 1
              maximum: 365
              description: Days the signer has from each send. Omitted or `null` means 2.
            recipient:
              type: [object, 'null']
              description: The one signer. Supplying them here is equivalent to a follow-up `PUT`.
              required: [email, name]
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 255
                name:
                  type: string
                  maxLength: 255
                locale:
                  oneOf:
                    - $ref: '#/components/schemas/Locale'
                    - type: 'null'
            fields:
              type: [array, 'null']
              description: |
                Fields placed at creation. A `recipient`-assigned field on a
                document with no signer is refused — the whole creation rolls
                back rather than leaving a page of boxes nobody owns.
              items:
                $ref: '#/components/schemas/FieldCreate'
            send:
              type: boolean
              description: Send in the same call.

    DocumentUpdate:
      type: object
      properties:
        title:
          type: string
          maxLength: 255
        client_reference_id:
          type: [string, 'null']
          maxLength: 255
        metadata:
          type: [object, 'null']
          additionalProperties: true
        expiry_days:
          type: integer
          minimum: 1
          maximum: 365
          description: Active documents only.

    VoidRequest:
      type: object
      description: Why the document was withdrawn. Recorded on the audit trail and on the void stamp.
      properties:
        reason:
          type: [string, 'null']
          maxLength: 2000

    DeclineRequest:
      type: object
      description: The reason the simulated signer gives. Required, because a decline with no reason is not a realistic fixture.
      required: [reason]
      properties:
        reason:
          type: string
          maxLength: 2000

    RecipientWrite:
      type: object
      required: [email, name]
      properties:
        email:
          type: string
          format: email
          maxLength: 255
        name:
          type: string
          maxLength: 255
        locale:
          oneOf:
            - $ref: '#/components/schemas/Locale'
            - type: 'null'

    RecipientPatch:
      type: object
      description: Changing `email` after a send rotates the access token and invalidates the link already issued.
      properties:
        email:
          type: string
          format: email
          maxLength: 255
        name:
          type: string
          maxLength: 255
        locale:
          oneOf:
            - $ref: '#/components/schemas/Locale'
            - type: 'null'

    FieldWrite:
      type: object
      description: |
        The geometry and ownership of one field. `content` keys are allowlisted
        per type — an unrecognised key is `UNKNOWN_FIELD`, not silently dropped,
        because a signature whose `ipAddress` a caller could set would be forged
        provenance.
      properties:
        type:
          type: string
          enum: [signature, initials, date, text, checkbox]
        page:
          type: integer
          minimum: 0
        x:
          type: number
          minimum: 0
          maximum: 1
        y:
          type: number
          minimum: 0
          maximum: 1
        width:
          type: number
          minimum: 0
          maximum: 1
        height:
          type: number
          minimum: 0
          maximum: 1
        assignee:
          $ref: '#/components/schemas/Assignee'
        required:
          type: boolean
          description: "On a `recipient` field, `true` makes the field gate the signer's completion. On a `sender` field, leaving it out means required, and `false` lets the field be written and sent empty."
        content:
          description: |
            The per-type content object — one shape per writable `type`, below.
            A `sender` field must carry complete content, because nobody is ever
            prompted to fill one, unless it is written with `required: false`.

            Keys are allowlisted per type: an unrecognised key answers `422`
            with `UNKNOWN_FIELD` against the object holding it (`content`, or
            `content.style`), never silently dropped. A key sent as `null`
            counts as not sent. `signedAt` and `ipAddress` are never writable —
            the server stamps them on the signing path.
          anyOf:
            - $ref: '#/components/schemas/SignatureContent'
            - $ref: '#/components/schemas/InitialsContent'
            - $ref: '#/components/schemas/DateContent'
            - $ref: '#/components/schemas/TextContent'
            - $ref: '#/components/schemas/CheckboxContent'
            - type: 'null'

    # The five writable `content` shapes. Request-side only: reads stay opaque
    # (`Field.content`), because a template can produce types and keys the
    # write surface does not accept. Mirrors `App\Support\FieldSchema`, whose
    # rules are the real validation — these are its documentation, and the
    # examples above are checked against them.
    SignatureContent:
      title: signature
      type: object
      description: |
        `content` of a `signature` field. A `recipient` signature is placed
        **without** a mark — `{ "required": true }` — and the signer provides
        it; a `sender` signature carries the mark itself.

        **Filled when** `svgData`, `drawData`, `typedText` or `imageData` is
        non-empty.
      additionalProperties: false
      properties:
        mode:
          type: [string, 'null']
          enum: [draw, type, upload, null]
          description: Which key carries the mark. **Required whenever one of the four mark keys is sent** — the PDF draws only the key `mode` names, so a mark without it answers `REQUIRED` against `content.mode`.
        svgData:
          type: [string, 'null']
          description: Required when `mode` is `draw`. An SVG of the strokes, up to 512,000 bytes, re-serialised through an allowlist on every write — scripts, foreign objects, event handlers and external references are stripped; anything the sanitizer cannot parse is refused with `INVALID_SVG`.
        drawData:
          type: [string, 'null']
          description: Optional beside `svgData` — an image data URL of the same strokes, preferred by the renderer when present.
        typedText:
          type: [string, 'null']
          maxLength: 255
          description: Required when `mode` is `type`. Rendered in `fontFamily`.
        imageData:
          type: [string, 'null']
          description: Required when `mode` is `upload`. An image data URL — `data:image/…;base64,…` holding a PNG, JPEG, GIF or WebP, up to 2 MB; anything else (an `https://` URL, an SVG) is `INVALID_IMAGE_DATA`.
        pointData:
          type: [array, 'null']
          maxItems: 512
          description: The raw pen strokes as signature_pad's `toData()` returns them — up to 512 strokes of up to 5,000 points each. Kept for redrawing; the PDF never draws it.
          items:
            type: object
        label:
          type: [string, 'null']
          maxLength: 255
          description: A caption. Only a checkbox prints it.
        required:
          type: [boolean, 'null']
          description: The same flag as the field's top-level `required`, which wins when both are sent.
        readOnly:
          type: [boolean, 'null']
          description: The signer sees the field but cannot change it. A read-only field never holds up completion, even when `required`.
        fontFamily:
          type: [string, 'null']
          maxLength: 128
          description: A font name — for a typed mark, the face it is drawn in.
        color:
          type: [string, 'null']
          pattern: '^#[0-9a-fA-F]{6}$'
          description: Six-digit hex; black when unset. Shorthand (`#f00`) and an alpha channel answer `INVALID_FORMAT`.

    InitialsContent:
      title: initials
      type: object
      description: |
        `content` of an `initials` field — the `signature` shape plus `value`.

        **Filled when** `svgData`, `drawData`, `typedText` or `imageData` is
        non-empty; `value` alone does not fill it.
      additionalProperties: false
      properties:
        mode:
          type: [string, 'null']
          enum: [draw, type, upload, null]
          description: Which key carries the mark. Required whenever one of the four mark keys is sent.
        svgData:
          type: [string, 'null']
          description: Required when `mode` is `draw`. Sanitized exactly as on a signature.
        drawData:
          type: [string, 'null']
          description: Optional beside `svgData` — an image data URL of the same strokes.
        typedText:
          type: [string, 'null']
          maxLength: 255
          description: Required when `mode` is `type`.
        imageData:
          type: [string, 'null']
          description: Required when `mode` is `upload`. A PNG, JPEG, GIF or WebP data URL, up to 2 MB.
        pointData:
          type: [array, 'null']
          maxItems: 512
          description: Raw pen strokes, as on a signature.
          items:
            type: object
        value:
          type: [string, 'null']
          maxLength: 255
          description: The typed initials, as the product stores them. It does not fill the field on its own — send `typedText` (with `mode`) as well.
        label:
          type: [string, 'null']
          maxLength: 255
          description: A caption. Only a checkbox prints it.
        required:
          type: [boolean, 'null']
          description: The same flag as the field's top-level `required`, which wins when both are sent.
        readOnly:
          type: [boolean, 'null']
          description: The signer sees the field but cannot change it.
        fontFamily:
          type: [string, 'null']
          maxLength: 128
          description: A font name.
        color:
          type: [string, 'null']
          pattern: '^#[0-9a-fA-F]{6}$'
          description: Six-digit hex; black when unset.

    DateContent:
      title: date
      type: object
      description: |
        `content` of a `date` field.

        **Filled when** `value` is set. `autoFill` does not count, so a
        `sender` date needs a `value`.
      additionalProperties: false
      properties:
        format:
          type: [string, 'null']
          enum: [MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, 'MMM DD, YYYY', DD MMM YYYY, null]
          default: MM/DD/YYYY
          description: How the date is drawn.
        value:
          type: [string, 'null']
          maxLength: 100
          description: The date, for a sender field. An ISO date (`2026-09-22`) is drawn in `format`; any other string is drawn exactly as sent.
        autoFill:
          type: [boolean, 'null']
          description: For a recipient field — fill it with the signing date automatically.
        fontSize:
          type: [number, 'null']
          minimum: 1
          maximum: 400
          description: Points.
        label:
          type: [string, 'null']
          maxLength: 255
          description: A caption. Only a checkbox prints it.
        required:
          type: [boolean, 'null']
          description: The same flag as the field's top-level `required`, which wins when both are sent.
        readOnly:
          type: [boolean, 'null']
          description: The signer sees the field but cannot change it.
        fontFamily:
          type: [string, 'null']
          maxLength: 128
          description: A font name.
        color:
          type: [string, 'null']
          pattern: '^#[0-9a-fA-F]{6}$'
          description: Six-digit hex; black when unset.

    TextContent:
      title: text
      type: object
      description: |
        `content` of a `text` field. Plain text by contract — there is no HTML
        key.

        **Filled when** `plainText` or `value` is non-empty.
      additionalProperties: false
      properties:
        plainText:
          type: [string, 'null']
          maxLength: 10000
          description: The text.
        value:
          type: [string, 'null']
          maxLength: 10000
          description: The same text under its other name — fields placed by a template or the embedded editor carry `value`. `plainText` wins when both are set.
        fontSize:
          type: [number, 'null']
          minimum: 1
          maximum: 400
          description: Points.
        style:
          type: [object, 'null']
          description: The same three keys one level down; the flat keys win when both are set. Anything else under `style` is `UNKNOWN_FIELD`.
          additionalProperties: false
          properties:
            fontFamily:
              type: [string, 'null']
              maxLength: 128
            fontSize:
              type: [number, 'null']
              minimum: 1
              maximum: 400
            color:
              type: [string, 'null']
              pattern: '^#[0-9a-fA-F]{6}$'
        tokenKey:
          type: 'null'
          description: Never writable. A field from a template may carry it as `null`, and writing that back is accepted; a value answers `UNKNOWN_FIELD`.
        tokenLabel:
          type: 'null'
          description: As `tokenKey`.
        label:
          type: [string, 'null']
          maxLength: 255
          description: A caption. Only a checkbox prints it.
        required:
          type: [boolean, 'null']
          description: The same flag as the field's top-level `required`, which wins when both are sent.
        readOnly:
          type: [boolean, 'null']
          description: The signer sees the field but cannot change it.
        fontFamily:
          type: [string, 'null']
          maxLength: 128
          description: A font name.
        color:
          type: [string, 'null']
          pattern: '^#[0-9a-fA-F]{6}$'
          description: Six-digit hex; black when unset.

    CheckboxContent:
      title: checkbox
      type: object
      description: |
        `content` of a `checkbox` field.

        **Filled when** `checked` is `true` — so a required checkbox is one the
        signer must tick, and a `sender` checkbox must be written ticked:
        `checked: false` answers `SENDER_FIELDS_INCOMPLETE`.
      additionalProperties: false
      properties:
        checked:
          type: [boolean, 'null']
        fontSize:
          type: [number, 'null']
          minimum: 1
          maximum: 400
          description: Size of the box and of its `label`.
        label:
          type: [string, 'null']
          maxLength: 255
          description: Printed beside the box.
        required:
          type: [boolean, 'null']
          description: The same flag as the field's top-level `required`, which wins when both are sent.
        readOnly:
          type: [boolean, 'null']
          description: The signer sees the field but cannot change it.
        fontFamily:
          type: [string, 'null']
          maxLength: 128
          description: A font name.
        color:
          type: [string, 'null']
          pattern: '^#[0-9a-fA-F]{6}$'
          description: Six-digit hex; black when unset.

    FieldCreate:
      description: "Everything but `content` and `required` is required. Left out, `required` means `false` on a `recipient` field and required on a `sender` field."
      allOf:
        - $ref: '#/components/schemas/FieldWrite'
        - type: object
          required: [type, page, x, y, width, height, assignee]

    FieldUpdate:
      description: A partial write, plus the optimistic-locking token.
      allOf:
        - $ref: '#/components/schemas/FieldWrite'
        - type: object
          required: [version]
          properties:
            version:
              type: integer
              minimum: 1
              description: The version you last read. A mismatch answers `409 VERSION_CONFLICT` with the server row.

    TemplateUpdate:
      type: object
      description: API templates only.
      properties:
        title:
          type: string
          maxLength: 255
        description:
          type: [string, 'null']
          maxLength: 1000
        api_shared:
          type: boolean
          description: Expose this template to the company's other users on this surface.

    TemplateInstantiate:
      description: Turn a template into a document. The document takes the template's expiry setting, or 2 days; this body has no `expiry_days`.
      allOf:
        - $ref: '#/components/schemas/Attribution'
        - $ref: '#/components/schemas/SendOptions'
        - type: object
          required: [role_mappings]
          properties:
            role_mappings:
              type: array
              description: |
                Exactly one entry, naming the template's one **non-sender** role
                and the person filling it. Sender roles are baked through
                `sender_values`, not mapped.
              minItems: 1
              maxItems: 1
              items:
                type: object
                required: [role_id, email, name]
                properties:
                  role_id:
                    type: string
                    format: uuid
                  email:
                    type: string
                    format: email
                    maxLength: 255
                  name:
                    type: string
                    maxLength: 255
                  locale:
                    oneOf:
                      - $ref: '#/components/schemas/Locale'
                      - type: 'null'
            variable_values:
              type: [object, 'null']
              description: Template variable key → value.
              additionalProperties:
                type: [string, 'null']
                maxLength: 1000
            sender_values:
              type: [object, 'null']
              description: |
                Template **element id** → the value baked into that sender
                field. The ids come from `sender_fields` on `GET
                /templates/{id}`. The value's shape follows the field's type: a
                `signature` or `initials` content object; a string for `text`
                and `date`; `true` or `false` for `checkbox`; the chosen
                option's `id` for `dropdown` and `radio`. Sender signature and
                initials fields left out are filled from the first of the
                user's saved default signs.
              additionalProperties: true
            title:
              type: [string, 'null']
              maxLength: 255
            client_reference_id:
              type: [string, 'null']
              maxLength: 255
            metadata:
              type: [object, 'null']
              additionalProperties: true
            send:
              type: boolean

  # ── Examples ─────────────────────────────────────────────────────────────
  #
  # Every request and response above points here, and so does the webhook
  # section below. They are data, not prose: `ApiV1OpenApiExamplesTest`
  # validates each one against the schema it is used under — with the same
  # validator the suite runs on real responses — so an example that stops
  # matching its schema fails by name. The sample ids are the ones the
  # /docs guides use, so a reader can follow one document across both.
  examples:
    # ── Account ───────────────────────────────────────────────────────────────
    AccountTestKey:
      summary: A test key with every scope
      value:
        environment: test
        scopes:
          - documents:read
          - documents
          - templates
          - users
          - embed
          - webhooks
        plan:
          key: developer_1k
          limit: 1000
          used: 12
          remaining: 988
        defaults:
          email_delivery: platform
    # ── Users ─────────────────────────────────────────────────────────────────
    ApiUser:
      summary: A provisioned user
      value:
        id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_id: crm-user-42
        name: Ada Lovelace
        email: ada@example.com
        locale: en
        metadata:
          plan: agency
          team: sales
        environment: test
        created_at: '2026-09-22T09:41:07+00:00'
        updated_at: '2026-09-22T09:41:07+00:00'
    ApiUserUpdated:
      summary: The user after the update
      value:
        id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_id: crm-user-42
        name: Ada King
        email: ada@example.com
        locale: ja
        metadata:
          plan: agency
          team: partnerships
        environment: test
        created_at: '2026-09-22T09:41:07+00:00'
        updated_at: '2026-09-23T08:12:40+00:00'
    UserList:
      summary: One page of users
      value:
        data:
          - id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
            external_id: crm-user-42
            name: Ada Lovelace
            email: ada@example.com
            locale: en
            metadata:
              plan: agency
              team: sales
            environment: test
            created_at: '2026-09-22T09:41:07+00:00'
            updated_at: '2026-09-22T09:41:07+00:00'
          - id: 8e4f3a2b-6c7d-4e9f-8a0b-1c2d3e4f5a6b
            external_id: crm-user-57
            name: Grace Hopper
            email: grace@example.com
            locale: null
            metadata: null
            environment: test
            created_at: '2026-09-21T16:05:33+00:00'
            updated_at: '2026-09-21T16:05:33+00:00'
        has_more: false
    UserCreateMinimal:
      summary: Only what is required
      value:
        external_id: crm-user-42
        name: Ada Lovelace
    UserCreateFull:
      summary: Every property
      value:
        external_id: crm-user-42
        name: Ada Lovelace
        email: ada@example.com
        locale: en
        metadata:
          plan: agency
          team: sales
    UserUpdate:
      summary: Rename, switch language, replace metadata
      value:
        name: Ada King
        locale: ja
        metadata:
          plan: agency
          team: partnerships
    UserDeleted:
      summary: Deletion acknowledged
      value:
        id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        deleted: true
    DefaultSigns:
      summary: A typed signature and initials
      value:
        signatures:
          - id: primary
            name: Ada Lovelace
            content:
              mode: type
              typedText: Ada Lovelace
              fontFamily: Caveat
        initials:
          - id: primary
            name: AL
            content:
              mode: type
              typedText: AL
              value: AL
              fontFamily: Caveat
    DefaultSignsWriteTyped:
      summary: Save a typed signature and initials
      value:
        signatures:
          - id: primary
            name: Ada Lovelace
            content:
              mode: type
              typedText: Ada Lovelace
              fontFamily: Caveat
        initials:
          - id: primary
            name: AL
            content:
              mode: type
              typedText: AL
              value: AL
              fontFamily: Caveat
    DefaultSignsWriteDrawn:
      summary: Save a drawn signature (SVG strokes)
      description: '`svgData` is re-serialised through the SVG allowlist before it is stored.'
      value:
        signatures:
          - id: drawn
            name: Handwritten
            content:
              mode: draw
              svgData: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 80"><path d="M10 60 C 40 10, 70 10, 90 50 S 140 90, 170 40" fill="none" stroke="#1A1A1A" stroke-width="2.5"/></svg>
        initials: []
    DefaultSignsWriteClear:
      summary: Clear both sets
      description: '`[]` is meaningful: this user has none saved.'
      value:
        signatures: []
        initials: []
    # ── Documents ─────────────────────────────────────────────────────────────
    DocumentCreateAndSend:
      summary: Upload, add the signer and their fields, and send
      description: '`payload` is a JSON part beside the `file` part. Pick your PDF for `file` when you try it.'
      value:
        file: '@mutual-nda.pdf'
        payload:
          external_user_id: crm-user-42
          title: Mutual NDA
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
          expiry_days: 14
          recipient:
            email: grace@example.com
            name: Grace Hopper
            locale: en
          fields:
            - type: signature
              page: 3
              x: 0.12
              y: 0.72
              width: 0.3
              height: 0.06
              assignee: recipient
              required: true
            - type: date
              page: 3
              x: 0.55
              y: 0.72
              width: 0.2
              height: 0.04
              assignee: recipient
              required: true
              content:
                format: DD MMM YYYY
                autoFill: true
            - type: text
              page: 0
              x: 0.1
              y: 0.18
              width: 0.45
              height: 0.04
              assignee: sender
              content:
                plainText: Acme Ltd, 1 Example Street
                style:
                  fontFamily: Inter
                  fontSize: 11
                  color: '#1A1A1A'
          email_delivery: platform
          send: true
    DocumentCreateDraft:
      summary: Create a draft for a new user (find-or-create)
      description: No `send`, so the document stays in `DRAFT` — place fields and send it later.
      value:
        file: '@mutual-nda.pdf'
        payload:
          user:
            external_id: crm-user-42
            name: Ada Lovelace
            email: ada@example.com
          title: Mutual NDA
          recipient:
            email: grace@example.com
            name: Grace Hopper
    DocumentCreatedDraft:
      summary: Created as a draft
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        title: Mutual NDA
        status: DRAFT
        page_count: 4
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_user_id: crm-user-42
        client_reference_id: null
        metadata: {}
        environment: test
        email_delivery: null
        signer_verification: null
        expiry_days: 2
        expires_at: null
        recipient:
          id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
          email: grace@example.com
          name: Grace Hopper
          locale: null
          status: PENDING
          invited_at: null
          last_reminded_at: null
          viewed_at: null
          signed_at: null
          declined_at: null
          decline_reason: null
        warnings: []
        sent_at: null
        completed_at: null
        voided_at: null
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T09:42:15+00:00'
    DocumentCreatedSent:
      summary: Created and sent in the same call
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        title: Mutual NDA
        status: SENT
        page_count: 4
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_user_id: crm-user-42
        client_reference_id: deal-8813
        metadata:
          crm_opportunity: 0065g00000Xyz
        environment: test
        email_delivery: platform
        signer_verification: none
        expiry_days: 14
        expires_at: '2026-10-06T09:43:02+00:00'
        recipient:
          id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
          email: grace@example.com
          name: Grace Hopper
          locale: en
          status: SENT
          signing_url: https://app.gingerdocs.com/sign/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzZjljOGQyZSIsInN1YiI6ImExYjJjM2Q0In0.Qm7vK2xZp4sT9wN1cR6yH3dJ8fA5eU0iO2qL?em=MTc5MTM2NzAyMnw3ZjNhOWMxZTViMmQ.Xq3v8K1mZ7pR2wB9cL5yH0dJ4sF6gA
          signing_url_expires_at: '2026-10-06T09:43:02+00:00'
          invited_at: '2026-09-22T09:43:02+00:00'
          last_reminded_at: null
          viewed_at: null
          signed_at: null
          declined_at: null
          decline_reason: null
        warnings: []
        sent_at: '2026-09-22T09:43:02+00:00'
        completed_at: null
        voided_at: null
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T09:43:02+00:00'
    DocumentSent:
      summary: Out for signature
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        title: Mutual NDA
        status: SENT
        page_count: 4
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_user_id: crm-user-42
        client_reference_id: deal-8813
        metadata:
          crm_opportunity: 0065g00000Xyz
        environment: test
        email_delivery: platform
        signer_verification: none
        expiry_days: 14
        expires_at: '2026-10-06T09:43:02+00:00'
        recipient:
          id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
          email: grace@example.com
          name: Grace Hopper
          locale: en
          status: SENT
          signing_url: https://app.gingerdocs.com/sign/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzZjljOGQyZSIsInN1YiI6ImExYjJjM2Q0In0.Qm7vK2xZp4sT9wN1cR6yH3dJ8fA5eU0iO2qL?em=MTc5MTM2NzAyMnw3ZjNhOWMxZTViMmQ.Xq3v8K1mZ7pR2wB9cL5yH0dJ4sF6gA
          signing_url_expires_at: '2026-10-06T09:43:02+00:00'
          invited_at: '2026-09-22T09:43:02+00:00'
          last_reminded_at: null
          viewed_at: null
          signed_at: null
          declined_at: null
          decline_reason: null
        sent_at: '2026-09-22T09:43:02+00:00'
        completed_at: null
        voided_at: null
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T09:43:02+00:00'
    DocumentCompleted:
      summary: Signed and completed
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        title: Mutual NDA
        status: COMPLETED
        page_count: 4
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_user_id: crm-user-42
        client_reference_id: deal-8813
        metadata:
          crm_opportunity: 0065g00000Xyz
        environment: test
        email_delivery: platform
        signer_verification: none
        expiry_days: 14
        expires_at: '2026-10-06T09:43:02+00:00'
        recipient:
          id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
          email: grace@example.com
          name: Grace Hopper
          locale: en
          status: SIGNED
          invited_at: '2026-09-22T09:43:02+00:00'
          last_reminded_at: null
          viewed_at: '2026-09-22T09:44:02+00:00'
          signed_at: '2026-09-22T09:44:30+00:00'
          declined_at: null
          decline_reason: null
        sent_at: '2026-09-22T09:43:02+00:00'
        completed_at: '2026-09-22T09:44:30+00:00'
        voided_at: null
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T09:44:30+00:00'
    DocumentList:
      summary: One page of documents (rows carry no recipient)
      value:
        data:
          - id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
            title: Mutual NDA
            status: SENT
            page_count: 4
            user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
            external_user_id: crm-user-42
            client_reference_id: deal-8813
            metadata:
              crm_opportunity: 0065g00000Xyz
            environment: test
            email_delivery: platform
            signer_verification: none
            expiry_days: 14
            expires_at: '2026-10-06T09:43:02+00:00'
            sent_at: '2026-09-22T09:43:02+00:00'
            completed_at: null
            voided_at: null
            created_at: '2026-09-22T09:42:15+00:00'
            updated_at: '2026-09-22T09:43:02+00:00'
          - id: 3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d
            title: Statement of work
            status: COMPLETED
            page_count: 4
            user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
            external_user_id: crm-user-42
            client_reference_id: deal-8790
            metadata: {}
            environment: test
            email_delivery: platform
            signer_verification: none
            expiry_days: 14
            expires_at: '2026-10-03T14:02:10+00:00'
            sent_at: '2026-09-19T14:02:10+00:00'
            completed_at: '2026-09-19T15:20:44+00:00'
            voided_at: null
            created_at: '2026-09-19T14:01:37+00:00'
            updated_at: '2026-09-19T15:20:44+00:00'
        has_more: true
    DocumentUpdateBookkeeping:
      summary: Rename it and re-tag it
      value:
        title: Mutual NDA — Acme / Globex
        client_reference_id: deal-8813-b
        metadata:
          crm_opportunity: 0065g00000Xyz
          region: EMEA
    DocumentUpdateExpiry:
      summary: Give the next send a 30-day window
      description: Only on an active document — it is consumed by the next send.
      value:
        expiry_days: 30
    DocumentUpdated:
      summary: The document after the update
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        title: Mutual NDA — Acme / Globex
        status: SENT
        page_count: 4
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_user_id: crm-user-42
        client_reference_id: deal-8813-b
        metadata:
          crm_opportunity: 0065g00000Xyz
          region: EMEA
        environment: test
        email_delivery: platform
        signer_verification: none
        expiry_days: 14
        expires_at: '2026-10-06T09:43:02+00:00'
        recipient:
          id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
          email: grace@example.com
          name: Grace Hopper
          locale: en
          status: SENT
          signing_url: https://app.gingerdocs.com/sign/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzZjljOGQyZSIsInN1YiI6ImExYjJjM2Q0In0.Qm7vK2xZp4sT9wN1cR6yH3dJ8fA5eU0iO2qL?em=MTc5MTM2NzAyMnw3ZjNhOWMxZTViMmQ.Xq3v8K1mZ7pR2wB9cL5yH0dJ4sF6gA
          signing_url_expires_at: '2026-10-06T09:43:02+00:00'
          invited_at: '2026-09-22T09:43:02+00:00'
          last_reminded_at: null
          viewed_at: null
          signed_at: null
          declined_at: null
          decline_reason: null
        sent_at: '2026-09-22T09:43:02+00:00'
        completed_at: null
        voided_at: null
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T10:05:19+00:00'
    DocumentDeleted:
      summary: Deletion acknowledged
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        deleted: true
    SendPlatform:
      summary: We email the signer
      description: In the test environment `signer_verification` is forced to `none` and no email is sent.
      value:
        email_delivery: platform
        signer_verification: standard
    SendSelf:
      summary: You deliver the signing URL yourself
      value:
        email_delivery: self
        signer_verification: none
    SendDefaults:
      summary: Account defaults
      description: An empty body sends with your account defaults.
      value: {}
    DocumentSentResponse:
      summary: Sent
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        title: Mutual NDA
        status: SENT
        page_count: 4
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_user_id: crm-user-42
        client_reference_id: deal-8813
        metadata:
          crm_opportunity: 0065g00000Xyz
        environment: test
        email_delivery: platform
        signer_verification: none
        expiry_days: 14
        expires_at: '2026-10-06T09:43:02+00:00'
        recipient:
          id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
          email: grace@example.com
          name: Grace Hopper
          locale: en
          status: SENT
          signing_url: https://app.gingerdocs.com/sign/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzZjljOGQyZSIsInN1YiI6ImExYjJjM2Q0In0.Qm7vK2xZp4sT9wN1cR6yH3dJ8fA5eU0iO2qL?em=MTc5MTM2NzAyMnw3ZjNhOWMxZTViMmQ.Xq3v8K1mZ7pR2wB9cL5yH0dJ4sF6gA
          signing_url_expires_at: '2026-10-06T09:43:02+00:00'
          invited_at: '2026-09-22T09:43:02+00:00'
          last_reminded_at: null
          viewed_at: null
          signed_at: null
          declined_at: null
          decline_reason: null
        warnings: []
        sent_at: '2026-09-22T09:43:02+00:00'
        completed_at: null
        voided_at: null
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T09:43:02+00:00'
    DocumentSentWithWarning:
      summary: Sent, with a warning
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        title: Mutual NDA
        status: SENT
        page_count: 4
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_user_id: crm-user-42
        client_reference_id: deal-8813
        metadata:
          crm_opportunity: 0065g00000Xyz
        environment: test
        email_delivery: platform
        signer_verification: none
        expiry_days: 14
        expires_at: '2026-10-06T09:43:02+00:00'
        recipient:
          id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
          email: grace@example.com
          name: Grace Hopper
          locale: en
          status: SENT
          signing_url: https://app.gingerdocs.com/sign/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzZjljOGQyZSIsInN1YiI6ImExYjJjM2Q0In0.Qm7vK2xZp4sT9wN1cR6yH3dJ8fA5eU0iO2qL?em=MTc5MTM2NzAyMnw3ZjNhOWMxZTViMmQ.Xq3v8K1mZ7pR2wB9cL5yH0dJ4sF6gA
          signing_url_expires_at: '2026-10-06T09:43:02+00:00'
          invited_at: '2026-09-22T09:43:02+00:00'
          last_reminded_at: null
          viewed_at: null
          signed_at: null
          declined_at: null
          decline_reason: null
        warnings:
          - code: RECIPIENT_WITHOUT_FIELDS
            message: This document has no fields assigned to its recipient, so there is nothing for them to fill in. It was sent anyway — the signing page can still be completed as an acknowledgement.
        sent_at: '2026-09-22T09:43:02+00:00'
        completed_at: null
        voided_at: null
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T09:43:02+00:00'
    VoidRequest:
      summary: With a reason
      value:
        reason: Superseded by the amended NDA sent on 23 September.
    DocumentVoided:
      summary: Voided
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        title: Mutual NDA
        status: VOIDED
        page_count: 4
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_user_id: crm-user-42
        client_reference_id: deal-8813
        metadata:
          crm_opportunity: 0065g00000Xyz
        environment: test
        email_delivery: platform
        signer_verification: none
        expiry_days: 14
        expires_at: '2026-10-06T09:43:02+00:00'
        recipient:
          id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
          email: grace@example.com
          name: Grace Hopper
          locale: en
          status: SENT
          invited_at: '2026-09-22T09:43:02+00:00'
          last_reminded_at: null
          viewed_at: null
          signed_at: null
          declined_at: null
          decline_reason: null
        sent_at: '2026-09-22T09:43:02+00:00'
        completed_at: null
        voided_at: '2026-09-22T11:02:44+00:00'
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T11:02:44+00:00'
    DocumentRecalled:
      summary: Back in DRAFT
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        title: Mutual NDA
        status: DRAFT
        page_count: 4
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_user_id: crm-user-42
        client_reference_id: deal-8813
        metadata:
          crm_opportunity: 0065g00000Xyz
        environment: test
        email_delivery: platform
        signer_verification: none
        expiry_days: 14
        expires_at: null
        recipient:
          id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
          email: grace@example.com
          name: Grace Hopper
          locale: en
          status: PENDING
          invited_at: null
          last_reminded_at: null
          viewed_at: null
          signed_at: null
          declined_at: null
          decline_reason: null
        sent_at: '2026-09-22T09:43:02+00:00'
        completed_at: null
        voided_at: null
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T09:50:18+00:00'
    DownloadSigned:
      summary: A five-minute link to the signed PDF
      value:
        url: https://files.gingerdocs.com/documents/2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b/final.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260922T094512Z&X-Amz-Expires=300&X-Amz-Signature=3f7a1c9e5b2d8f4a6c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a
        expires_at: '2026-09-22T09:50:12+00:00'
    DownloadOriginal:
      summary: A five-minute link to the uploaded PDF
      value:
        url: https://files.gingerdocs.com/documents/2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b/original.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260922T094512Z&X-Amz-Expires=300&X-Amz-Signature=8b2e4d6f0a1c3e5b7d9f1a3c5e7b9d0f2a4c6e8b0d2f4a6c8e0b1d3f5a7c9e1b
        expires_at: '2026-09-22T09:50:12+00:00'
    AuditTrail:
      summary: Created, sent, viewed, completed
      value:
        data:
          - id: '48211'
            event_type: api.document_created
            actor_id: 6c5b4a39-2817-4f6e-8d9c-0b1a2c3d4e5f
            event_data:
              api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
              environment: test
              api_key_id: 4b3a2918-0706-4f5e-8d4c-3b2a19080706
              client_reference_id: deal-8813
            row_hash: 3b1f9c6d2e8a4f7b0c5d1e9a3f6b8c2d4e7a0f1b3c5d7e9f2a4b6c8d0e1f3a5b
            created_at: '2026-09-22T09:42:15+00:00'
          - id: '48214'
            event_type: api.document_sent
            actor_id: 6c5b4a39-2817-4f6e-8d9c-0b1a2c3d4e5f
            event_data:
              email_delivery: platform
              signer_verification: none
              mail_dispatched: false
              api_key_id: 4b3a2918-0706-4f5e-8d4c-3b2a19080706
            row_hash: 9d4e77a1c3b5f2e8d0a6c4b2e9f1d7a3c5e8b0f2d4a6c8e1b3d5f7a9c2e4b13c
            created_at: '2026-09-22T09:43:02+00:00'
          - id: '48221'
            event_type: document.status_changed
            actor_id: null
            event_data:
              from: SENT
              to: VIEWED
              recipient_id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
              by: recipient
            row_hash: 5a8c1e3f7b9d2a4c6e8f0b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f2a
            created_at: '2026-09-22T09:44:02+00:00'
          - id: '48230'
            event_type: document.status_changed
            actor_id: null
            event_data:
              from: VIEWED
              to: COMPLETED
              recipient_id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
              by: recipient
            row_hash: c02a51d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4a6b8c0d2e4f67f8e
            created_at: '2026-09-22T09:44:30+00:00'
    # ── Recipient ─────────────────────────────────────────────────────────────
    RecipientSent:
      summary: Invited, with a live signing URL
      value:
        id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
        email: grace@example.com
        name: Grace Hopper
        locale: en
        status: SENT
        signing_url: https://app.gingerdocs.com/sign/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzZjljOGQyZSIsInN1YiI6ImExYjJjM2Q0In0.Qm7vK2xZp4sT9wN1cR6yH3dJ8fA5eU0iO2qL?em=MTc5MTM2NzAyMnw3ZjNhOWMxZTViMmQ.Xq3v8K1mZ7pR2wB9cL5yH0dJ4sF6gA
        signing_url_expires_at: '2026-10-06T09:43:02+00:00'
        invited_at: '2026-09-22T09:43:02+00:00'
        last_reminded_at: null
        viewed_at: null
        signed_at: null
        declined_at: null
        decline_reason: null
    RecipientWrite:
      summary: The one signer
      value:
        email: grace@example.com
        name: Grace Hopper
        locale: en
    RecipientPending:
      summary: Set on a draft (no link until it is sent)
      value:
        id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
        email: grace@example.com
        name: Grace Hopper
        locale: en
        status: PENDING
        invited_at: null
        last_reminded_at: null
        viewed_at: null
        signed_at: null
        declined_at: null
        decline_reason: null
    RecipientPatchEmail:
      summary: Correct a typo in the address
      description: 'Rotates the access token: the link sent to the old address stops working.'
      value:
        email: grace.hopper@example.com
    RecipientPatchLocale:
      summary: Change the language their emails use
      value:
        locale: fr
    RecipientCorrected:
      summary: Corrected, with a fresh link
      value:
        id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
        email: grace.hopper@example.com
        name: Grace Hopper
        locale: en
        status: SENT
        signing_url: https://app.gingerdocs.com/sign/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI4YjJkNmUxYSIsInN1YiI6ImExYjJjM2Q0In0.Hn4cT8wQ2mV6xZ1bR9sK3pL7fJ0dY5gA2eN8?em=MTc5MTk3MTc0NHw5YzJmNGU2YTFiOGQ.Lp6wR3nT9vB2xK8mZ1cQ5yH7dJ4sF0gA
        signing_url_expires_at: '2026-10-06T09:47:51+00:00'
        invited_at: '2026-09-22T09:43:02+00:00'
        last_reminded_at: null
        viewed_at: null
        signed_at: null
        declined_at: null
        decline_reason: null
    RecipientRevoked:
      summary: Revoked — the link they hold is dead
      value:
        id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
        email: grace@example.com
        name: Grace Hopper
        locale: en
        status: REVOKED
        invited_at: '2026-09-22T09:43:02+00:00'
        last_reminded_at: null
        viewed_at: '2026-09-22T10:02:11+00:00'
        signed_at: null
        declined_at: null
        decline_reason: null
    RecipientReminded:
      summary: Reminded
      value:
        id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
        email: grace@example.com
        name: Grace Hopper
        locale: en
        status: SENT
        signing_url: https://app.gingerdocs.com/sign/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzZjljOGQyZSIsInN1YiI6ImExYjJjM2Q0In0.Qm7vK2xZp4sT9wN1cR6yH3dJ8fA5eU0iO2qL?em=MTc5MTM2NzAyMnw3ZjNhOWMxZTViMmQ.Xq3v8K1mZ7pR2wB9cL5yH0dJ4sF6gA
        signing_url_expires_at: '2026-10-06T09:43:02+00:00'
        invited_at: '2026-09-22T09:43:02+00:00'
        last_reminded_at: '2026-09-24T08:00:00+00:00'
        viewed_at: null
        signed_at: null
        declined_at: null
        decline_reason: null
    RecipientReminted:
      summary: A newly minted signing URL
      value:
        id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
        email: grace@example.com
        name: Grace Hopper
        locale: en
        status: SENT
        signing_url: https://app.gingerdocs.com/sign/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI4YjJkNmUxYSIsInN1YiI6ImExYjJjM2Q0In0.Hn4cT8wQ2mV6xZ1bR9sK3pL7fJ0dY5gA2eN8?em=MTc5MTk3MTc0NHw5YzJmNGU2YTFiOGQ.Lp6wR3nT9vB2xK8mZ1cQ5yH7dJ4sF0gA
        signing_url_expires_at: '2026-10-13T08:15:44+00:00'
        invited_at: '2026-09-22T09:43:02+00:00'
        last_reminded_at: null
        viewed_at: null
        signed_at: null
        declined_at: null
        decline_reason: null
    # ── Fields ────────────────────────────────────────────────────────────────
    FieldSignature:
      summary: The signer's signature box
      value:
        id: c9d8e7f6-a5b4-4c3d-9e2f-1a0b9c8d7e6f
        type: signature
        page: 3
        x: 0.12
        y: 0.72
        width: 0.3
        height: 0.06
        assignee: recipient
        required: true
        filled: false
        locked: false
        content:
          mode: draw
          required: true
        version: 1
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T09:42:15+00:00'
    FieldUpdatedResponse:
      summary: Moved — the version is bumped
      value:
        id: c9d8e7f6-a5b4-4c3d-9e2f-1a0b9c8d7e6f
        type: signature
        page: 3
        x: 0.14
        y: 0.72
        width: 0.3
        height: 0.06
        assignee: recipient
        required: true
        filled: false
        locked: false
        content:
          mode: draw
          required: true
        version: 2
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T09:58:03+00:00'
    FieldList:
      summary: A signature and a date for the signer, a baked sender text
      value:
        data:
          - id: c9d8e7f6-a5b4-4c3d-9e2f-1a0b9c8d7e6f
            type: signature
            page: 3
            x: 0.12
            y: 0.72
            width: 0.3
            height: 0.06
            assignee: recipient
            required: true
            filled: false
            locked: false
            content:
              mode: draw
              required: true
            version: 1
            created_at: '2026-09-22T09:42:15+00:00'
            updated_at: '2026-09-22T09:42:15+00:00'
          - id: d0e9f8a7-b6c5-4d4e-8f3a-2b1c0d9e8f7a
            type: date
            page: 3
            x: 0.55
            y: 0.72
            width: 0.2
            height: 0.04
            assignee: recipient
            required: true
            filled: false
            locked: false
            content:
              format: DD MMM YYYY
              autoFill: true
              required: true
            version: 1
            created_at: '2026-09-22T09:42:15+00:00'
            updated_at: '2026-09-22T09:42:15+00:00'
          - id: 3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f
            type: text
            page: 0
            x: 0.1
            y: 0.18
            width: 0.45
            height: 0.04
            assignee: sender
            required: true
            filled: true
            locked: false
            content:
              plainText: Acme Ltd, 1 Example Street
              style:
                fontFamily: Inter
                fontSize: 11
                color: '#1A1A1A'
            version: 1
            created_at: '2026-09-22T09:42:15+00:00'
            updated_at: '2026-09-22T09:42:15+00:00'
    FieldCreateSignature:
      summary: A signature for the signer
      value:
        type: signature
        page: 3
        x: 0.12
        y: 0.72
        width: 0.3
        height: 0.06
        assignee: recipient
        required: true
    FieldCreateDate:
      summary: A date the signing fills in
      value:
        type: date
        page: 3
        x: 0.55
        y: 0.72
        width: 0.2
        height: 0.04
        assignee: recipient
        required: true
        content:
          format: DD MMM YYYY
          autoFill: true
    FieldCreateCheckbox:
      summary: A checkbox the signer must tick
      value:
        type: checkbox
        page: 3
        x: 0.12
        y: 0.64
        width: 0.03
        height: 0.02
        assignee: recipient
        required: true
        content:
          label: I accept the terms
    FieldCreateSenderText:
      summary: Text you bake in as the sender
      value:
        type: text
        page: 0
        x: 0.1
        y: 0.18
        width: 0.45
        height: 0.04
        assignee: sender
        content:
          plainText: Acme Ltd, 1 Example Street
          style:
            fontFamily: Inter
            fontSize: 11
            color: '#1A1A1A'
    FieldCreateSenderSignature:
      summary: Your own signature, typed
      value:
        type: signature
        page: 3
        x: 0.55
        y: 0.8
        width: 0.3
        height: 0.06
        assignee: sender
        content:
          mode: type
          typedText: Ada Lovelace
          fontFamily: Caveat
    FieldCreateOptionalSenderText:
      summary: An optional sender note, left empty
      value:
        type: text
        page: 1
        x: 0.1
        y: 0.9
        width: 0.6
        height: 0.04
        assignee: sender
        required: false
    FieldUpdateMove:
      summary: Move it
      value:
        version: 1
        x: 0.14
        y: 0.72
    FieldUpdateContent:
      summary: Fill a sender field
      value:
        version: 1
        content:
          plainText: Acme Ltd, 2 Example Street
    FieldUpdateOptional:
      summary: Make it optional
      value:
        version: 1
        required: false
    FieldDeleted:
      summary: Deletion acknowledged
      value:
        id: c9d8e7f6-a5b4-4c3d-9e2f-1a0b9c8d7e6f
        deleted: true
    # ── Templates ─────────────────────────────────────────────────────────────
    Template:
      summary: With roles, variables and sender fields
      value:
        id: 5e4d3c2b-1a09-4f8e-9d7c-6b5a4f3e2d1c
        title: Freelance agreement
        description: Standard terms, one client signature.
        page_count: 6
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        origin: api
        shared: true
        environment: test
        created_at: '2026-09-10T14:02:51+00:00'
        updated_at: '2026-09-18T08:30:12+00:00'
        field_count: 9
        roles:
          - id: 0f1e2d3c-4b5a-4968-8776-655443322110
            name: Agency
            is_sender: true
            signing_order: 0
            default_name: null
            default_email: null
            field_count: 3
          - id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5e
            name: Client
            is_sender: false
            signing_order: 1
            default_name: null
            default_email: null
            field_count: 6
        variables:
          - key: project_name
            label: Project
            type: text
            default_value: null
            required: true
          - key: daily_rate
            label: Daily rate
            type: text
            default_value: €600
            required: false
        sender_fields:
          - id: c0ffee00-1234-4abc-9def-000000000002
            role_id: 0f1e2d3c-4b5a-4968-8776-655443322110
            role_name: Agency
            type: text
            page: 0
            label: null
            index: 1
            options: []
            fillable: true
            required: false
          - id: c0ffee00-1234-4abc-9def-000000000001
            role_id: 0f1e2d3c-4b5a-4968-8776-655443322110
            role_name: Agency
            type: signature
            page: 5
            label: null
            index: 1
            options: []
            fillable: true
            required: true
    TemplateUpdated:
      summary: The template after the update
      value:
        id: 5e4d3c2b-1a09-4f8e-9d7c-6b5a4f3e2d1c
        title: Freelance agreement (2026)
        description: Standard terms, one client signature. Net 30.
        page_count: 6
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        origin: api
        shared: true
        environment: test
        created_at: '2026-09-10T14:02:51+00:00'
        updated_at: '2026-09-23T10:11:12+00:00'
        field_count: 9
        roles:
          - id: 0f1e2d3c-4b5a-4968-8776-655443322110
            name: Agency
            is_sender: true
            signing_order: 0
            default_name: null
            default_email: null
            field_count: 3
          - id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5e
            name: Client
            is_sender: false
            signing_order: 1
            default_name: null
            default_email: null
            field_count: 6
        variables:
          - key: project_name
            label: Project
            type: text
            default_value: null
            required: true
          - key: daily_rate
            label: Daily rate
            type: text
            default_value: €600
            required: false
        sender_fields:
          - id: c0ffee00-1234-4abc-9def-000000000002
            role_id: 0f1e2d3c-4b5a-4968-8776-655443322110
            role_name: Agency
            type: text
            page: 0
            label: null
            index: 1
            options: []
            fillable: true
            required: false
          - id: c0ffee00-1234-4abc-9def-000000000001
            role_id: 0f1e2d3c-4b5a-4968-8776-655443322110
            role_name: Agency
            type: signature
            page: 5
            label: null
            index: 1
            options: []
            fillable: true
            required: true
    TemplateList:
      summary: An API template and an app template the company shared
      value:
        data:
          - id: 5e4d3c2b-1a09-4f8e-9d7c-6b5a4f3e2d1c
            title: Freelance agreement
            description: Standard terms, one client signature.
            page_count: 6
            user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
            origin: api
            shared: true
            environment: test
            created_at: '2026-09-10T14:02:51+00:00'
            updated_at: '2026-09-18T08:30:12+00:00'
          - id: 8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d
            title: Mutual NDA (company standard)
            description: null
            page_count: 4
            user_id: null
            origin: app
            shared: true
            environment: null
            created_at: '2026-08-02T11:20:00+00:00'
            updated_at: '2026-08-02T11:20:00+00:00'
        has_more: false
    TemplateUpdate:
      summary: Rename and share it
      value:
        title: Freelance agreement (2026)
        description: Standard terms, one client signature. Net 30.
        api_shared: true
    TemplateArchived:
      summary: Archived, not purged
      value:
        id: 5e4d3c2b-1a09-4f8e-9d7c-6b5a4f3e2d1c
        deleted: true
        status: archived
    TemplateInstantiateAndSend:
      summary: Map the signer, fill variables and sender fields, send
      description: '`sender_values` is keyed by the template element ids in `sender_fields`.'
      value:
        external_user_id: crm-user-42
        role_mappings:
          - role_id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5e
            email: grace@example.com
            name: Grace Hopper
            locale: en
        variable_values:
          project_name: Website redesign
          daily_rate: €650
        sender_values:
          c0ffee00-1234-4abc-9def-000000000002: Acme Studio Ltd
          c0ffee00-1234-4abc-9def-000000000001:
            mode: type
            typedText: Ada Lovelace
            fontFamily: Caveat
        title: Freelance agreement — Website redesign
        client_reference_id: deal-8813
        email_delivery: platform
        send: true
    TemplateInstantiateDraft:
      summary: Create a draft, fill in the rest later
      value:
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        role_mappings:
          - role_id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5e
            email: grace@example.com
            name: Grace Hopper
        variable_values:
          project_name: Website redesign
    TemplateDocumentCreated:
      summary: The document, sent
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        title: Freelance agreement — Website redesign
        status: SENT
        page_count: 6
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_user_id: crm-user-42
        client_reference_id: deal-8813
        metadata: {}
        environment: test
        email_delivery: platform
        signer_verification: none
        expiry_days: 2
        expires_at: '2026-09-24T09:43:02+00:00'
        recipient:
          id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
          email: grace@example.com
          name: Grace Hopper
          locale: en
          status: SENT
          signing_url: https://app.gingerdocs.com/sign/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzZjljOGQyZSIsInN1YiI6ImExYjJjM2Q0In0.Qm7vK2xZp4sT9wN1cR6yH3dJ8fA5eU0iO2qL?em=MTc5MTM2NzAyMnw3ZjNhOWMxZTViMmQ.Xq3v8K1mZ7pR2wB9cL5yH0dJ4sF6gA
          signing_url_expires_at: '2026-10-06T09:43:02+00:00'
          invited_at: '2026-09-22T09:43:02+00:00'
          last_reminded_at: null
          viewed_at: null
          signed_at: null
          declined_at: null
          decline_reason: null
        warnings: []
        sent_at: '2026-09-22T09:43:02+00:00'
        completed_at: null
        voided_at: null
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T09:43:02+00:00'
    # ── Test mode ─────────────────────────────────────────────────────────────
    DocumentSimulatedSign:
      summary: Completed by the simulated signature
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        title: Mutual NDA
        status: COMPLETED
        page_count: 4
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_user_id: crm-user-42
        client_reference_id: deal-8813
        metadata:
          crm_opportunity: 0065g00000Xyz
        environment: test
        email_delivery: platform
        signer_verification: none
        expiry_days: 14
        expires_at: '2026-10-06T09:43:02+00:00'
        recipient:
          id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
          email: grace@example.com
          name: Grace Hopper
          locale: en
          status: SIGNED
          invited_at: '2026-09-22T09:43:02+00:00'
          last_reminded_at: null
          viewed_at: '2026-09-22T09:44:02+00:00'
          signed_at: '2026-09-22T09:44:30+00:00'
          declined_at: null
          decline_reason: null
        sent_at: '2026-09-22T09:43:02+00:00'
        completed_at: '2026-09-22T09:44:30+00:00'
        voided_at: null
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T09:44:30+00:00'
    DeclineRequest:
      summary: A realistic reason
      value:
        reason: The liability cap in clause 7 is too low for us.
    DocumentSimulatedDecline:
      summary: Declined
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        title: Mutual NDA
        status: DECLINED
        page_count: 4
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        external_user_id: crm-user-42
        client_reference_id: deal-8813
        metadata:
          crm_opportunity: 0065g00000Xyz
        environment: test
        email_delivery: platform
        signer_verification: none
        expiry_days: 14
        expires_at: '2026-10-06T09:43:02+00:00'
        recipient:
          id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
          email: grace@example.com
          name: Grace Hopper
          locale: en
          status: DECLINED
          invited_at: '2026-09-22T09:43:02+00:00'
          last_reminded_at: null
          viewed_at: null
          signed_at: null
          declined_at: '2026-09-22T09:45:12+00:00'
          decline_reason: The liability cap in clause 7 is too low for us.
        sent_at: '2026-09-22T09:43:02+00:00'
        completed_at: null
        voided_at: null
        created_at: '2026-09-22T09:42:15+00:00'
        updated_at: '2026-09-22T09:45:12+00:00'
    # ── Events ────────────────────────────────────────────────────────────────
    EventPing:
      summary: A test delivery from POST /webhook_endpoints/{id}/ping
      value:
        id: e9d8c7b6-a5f4-4e3d-8c2b-1a0f9e8d7c6b
        type: ping
        api_version: v1
        environment: test
        created_at: '2026-09-22T10:15:00.693697+00:00'
        data:
          endpoint_id: b7c6d5e4-f3a2-4b1c-9d0e-8f7a6b5c4d3e
    EventDocumentSent:
      summary: document.sent — the invitation went out (or was minted, under self-delivery)
      value: &id004
        id: a3b4c5d6-e7f8-4a9b-8c1d-2e3f4a5b6c7d
        type: document.sent
        api_version: v1
        environment: test
        created_at: '2026-09-22T09:43:02.118340+00:00'
        data:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
          status: SENT
          title: Mutual NDA
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
    EventDocumentViewed:
      summary: document.viewed — the signer opened it
      value:
        id: b4c5d6e7-f8a9-4b0c-9d2e-3f4a5b6c7d8e
        type: document.viewed
        api_version: v1
        environment: test
        created_at: '2026-09-22T09:44:02.402113+00:00'
        data:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
          status: VIEWED
          title: Mutual NDA
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
    EventDocumentCompleted:
      summary: document.completed — signed and finished
      value: &id002
        id: e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b
        type: document.completed
        api_version: v1
        environment: test
        created_at: '2026-09-22T09:44:30.262415+00:00'
        data:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
          status: COMPLETED
          title: Mutual NDA
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
    EventDocumentDownloadReady:
      summary: document.download_ready — the signed PDF can be fetched
      value:
        id: c5d6e7f8-a9b0-4c1d-8e3f-4a5b6c7d8e9f
        type: document.download_ready
        api_version: v1
        environment: test
        created_at: '2026-09-22T09:44:41.631642+00:00'
        data:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
          status: COMPLETED
          title: Mutual NDA
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
    EventDocumentDeclined:
      summary: document.declined — the signer said no
      value:
        id: d6e7f8a9-b0c1-4d2e-9f4a-5b6c7d8e9f0a
        type: document.declined
        api_version: v1
        environment: test
        created_at: '2026-09-22T09:45:12.518207+00:00'
        data:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
          status: DECLINED
          title: Mutual NDA
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
    EventDocumentVoided:
      summary: document.voided — you withdrew it
      value:
        id: e7f8a9b0-c1d2-4e3f-8a5b-6c7d8e9f0a1b
        type: document.voided
        api_version: v1
        environment: test
        created_at: '2026-09-22T11:02:44.090158+00:00'
        data:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
          status: VOIDED
          title: Mutual NDA
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
    EventDocumentExpired:
      summary: document.expired — the signing window closed unsigned
      value:
        id: f8a9b0c1-d2e3-4f4a-9b6c-7d8e9f0a1b2c
        type: document.expired
        api_version: v1
        environment: test
        created_at: '2026-10-06T09:43:05.771904+00:00'
        data:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
          status: EXPIRED
          title: Mutual NDA
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
    EventDocumentRecalled:
      summary: document.recalled — pulled back to DRAFT for a correction
      value:
        id: a9b0c1d2-e3f4-4a5b-8c7d-8e9f0a1b2c3d
        type: document.recalled
        api_version: v1
        environment: test
        created_at: '2026-09-22T09:50:18.334076+00:00'
        data:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
          status: DRAFT
          title: Mutual NDA
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
    EventRecipientUpdated:
      summary: recipient.updated — the signer's details were corrected
      value:
        id: b0c1d2e3-f4a5-4b6c-9d8e-9f0a1b2c3d4e
        type: recipient.updated
        api_version: v1
        environment: test
        created_at: '2026-09-22T09:47:51.207733+00:00'
        data:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
          status: SENT
          title: Mutual NDA
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
          recipient:
            id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
            email: grace.hopper@example.com
            name: Grace Hopper
            status: SENT
            viewed_at: null
            signed_at: null
            declined_at: null
            decline_reason: null
    EventRecipientViewed:
      summary: recipient.viewed — the signer opened their link
      value:
        id: c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f
        type: recipient.viewed
        api_version: v1
        environment: test
        created_at: '2026-09-22T09:44:02.398517+00:00'
        data:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
          status: VIEWED
          title: Mutual NDA
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
          recipient:
            id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
            email: grace@example.com
            name: Grace Hopper
            status: VIEWED
            viewed_at: '2026-09-22T09:44:02+00:00'
            signed_at: null
            declined_at: null
            decline_reason: null
    EventRecipientSigned:
      summary: recipient.signed — the signer finished signing
      value: &id003
        id: f2a3b4c5-d6e7-4f8a-9b0c-1d2e3f4a5b6c
        type: recipient.signed
        api_version: v1
        environment: test
        created_at: '2026-09-22T09:44:30.251876+00:00'
        data:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
          status: VIEWED
          title: Mutual NDA
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
          recipient:
            id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
            email: grace@example.com
            name: Grace Hopper
            status: SIGNED
            viewed_at: '2026-09-22T09:44:02+00:00'
            signed_at: '2026-09-22T09:44:30+00:00'
            declined_at: null
            decline_reason: null
    EventRecipientDeclined:
      summary: recipient.declined — the signer declined, with their reason
      value:
        id: d2e3f4a5-b6c7-4d8e-9f0a-1b2c3d4e5f6a
        type: recipient.declined
        api_version: v1
        environment: test
        created_at: '2026-09-22T09:45:12.509341+00:00'
        data:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
          status: DECLINED
          title: Mutual NDA
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
          client_reference_id: deal-8813
          metadata:
            crm_opportunity: 0065g00000Xyz
          recipient:
            id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
            email: grace@example.com
            name: Grace Hopper
            status: DECLINED
            viewed_at: '2026-09-22T09:44:02+00:00'
            signed_at: null
            declined_at: '2026-09-22T09:45:12+00:00'
            decline_reason: The liability cap in clause 7 is too low for us.
    EventTemplateCreated:
      summary: template.created — an API template was created
      value:
        id: e3f4a5b6-c7d8-4e9f-8a1b-2c3d4e5f6a7b
        type: template.created
        api_version: v1
        environment: test
        created_at: '2026-09-10T14:02:51.846120+00:00'
        data: &id001
          template_id: 5e4d3c2b-1a09-4f8e-9d7c-6b5a4f3e2d1c
          title: Freelance agreement
          description: Standard terms, one client signature.
          shared: true
          api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_user_id: crm-user-42
    EventTemplateUpdated:
      summary: template.updated — an API template changed
      value:
        id: f4a5b6c7-d8e9-4f0a-9b2c-3d4e5f6a7b8c
        type: template.updated
        api_version: v1
        environment: test
        created_at: '2026-09-18T08:30:12.559214+00:00'
        data: *id001
    EventUserCreated:
      summary: user.created — one of your users was provisioned
      value:
        id: a5b6c7d8-e9f0-4a1b-8c3d-4e5f6a7b8c9d
        type: user.created
        api_version: v1
        environment: test
        created_at: '2026-09-22T09:41:07.021988+00:00'
        data:
          user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
          external_id: crm-user-42
          name: Ada Lovelace
          email: ada@example.com
          locale: en
          metadata:
            plan: agency
            team: sales
    EventList:
      summary: Newest first
      value:
        data:
          - *id002
          - *id003
          - *id004
        has_more: false
    # ── Webhooks ──────────────────────────────────────────────────────────────
    WebhookEndpoint:
      summary: An active endpoint
      value:
        id: b7c6d5e4-f3a2-4b1c-9d0e-8f7a6b5c4d3e
        url: https://api.example.com/gingerdocs/webhook
        name: Order fulfilment
        description: Marks orders complete when the agreement is signed.
        environment: test
        events:
          - document.completed
          - document.download_ready
          - document.declined
        api_version: v1
        is_active: true
        disabled_reason: null
        secret_prefix: whsec_8Kj3
        header_names:
          - x-tenant
        created_at: '2026-09-01T08:00:00+00:00'
        updated_at: '2026-09-01T08:00:00+00:00'
    WebhookEndpointAutoDisabled:
      summary: Disabled by us after three days of failures
      value:
        id: b7c6d5e4-f3a2-4b1c-9d0e-8f7a6b5c4d3e
        url: https://api.example.com/gingerdocs/webhook
        name: Order fulfilment
        description: Marks orders complete when the agreement is signed.
        environment: test
        events:
          - document.completed
          - document.download_ready
          - document.declined
        api_version: v1
        is_active: false
        disabled_reason: AUTO_DISABLED_FAILURES
        secret_prefix: whsec_8Kj3
        header_names:
          - x-tenant
        created_at: '2026-09-01T08:00:00+00:00'
        updated_at: '2026-09-26T08:00:00+00:00'
    WebhookEndpointWithSecret:
      summary: Created — the secret appears here only
      value:
        id: b7c6d5e4-f3a2-4b1c-9d0e-8f7a6b5c4d3e
        url: https://api.example.com/gingerdocs/webhook
        name: Order fulfilment
        description: Marks orders complete when the agreement is signed.
        environment: test
        events:
          - document.completed
          - document.download_ready
          - document.declined
        api_version: v1
        is_active: true
        disabled_reason: null
        secret_prefix: whsec_8Kj3
        header_names:
          - x-tenant
        secret: whsec_8Kj3mQ2vXb7LpR9sT4wZn6cH1dF5gY8kA0eU3iO2qNlM
        created_at: '2026-09-01T08:00:00+00:00'
        updated_at: '2026-09-01T08:00:00+00:00'
    WebhookEndpointList:
      summary: One page of endpoints
      value:
        data:
          - id: b7c6d5e4-f3a2-4b1c-9d0e-8f7a6b5c4d3e
            url: https://api.example.com/gingerdocs/webhook
            name: Order fulfilment
            description: Marks orders complete when the agreement is signed.
            environment: test
            events:
              - document.completed
              - document.download_ready
              - document.declined
            api_version: v1
            is_active: true
            disabled_reason: null
            secret_prefix: whsec_8Kj3
            header_names:
              - x-tenant
            created_at: '2026-09-01T08:00:00+00:00'
            updated_at: '2026-09-01T08:00:00+00:00'
          - id: c8d7e6f5-a4b3-4c2d-8e1f-9a0b8c7d6e5f
            url: https://hooks.example.com/gingerdocs/audit
            name: Audit archive
            description: null
            environment: test
            events: []
            api_version: v1
            is_active: true
            disabled_reason: null
            secret_prefix: whsec_Qm7v
            header_names: []
            created_at: '2026-09-05T12:30:00+00:00'
            updated_at: '2026-09-05T12:30:00+00:00'
        has_more: false
    WebhookEndpointCreateSubscribed:
      summary: Three event types and a custom header
      value:
        url: https://api.example.com/gingerdocs/webhook
        name: Order fulfilment
        description: Marks orders complete when the agreement is signed.
        events:
          - document.completed
          - document.download_ready
          - document.declined
        headers:
          X-Tenant: acme
    WebhookEndpointCreateEverything:
      summary: Every event type, including future ones
      description: No `events` (or `[]`) subscribes to everything.
      value:
        url: https://hooks.example.com/gingerdocs/audit
        name: Audit archive
    WebhookEndpointReenable:
      summary: Switch it back on
      description: Also clears `disabled_reason`.
      value:
        is_active: true
    WebhookEndpointChangeEvents:
      summary: Change the subscription
      value:
        events:
          - document.completed
          - document.declined
          - document.expired
    WebhookEndpointMergeHeaders:
      summary: Set one header, remove another
      description: '`headers` merges: a name set to `null` is removed, a name left out is untouched.'
      value:
        headers:
          X-Tenant: acme-eu
          X-Legacy-Token: null
    WebhookEndpointDeleted:
      summary: Deleted
      value:
        id: b7c6d5e4-f3a2-4b1c-9d0e-8f7a6b5c4d3e
        deleted: true
    # ── Embed sessions ────────────────────────────────────────────────────────
    EmbedSessionCreateSigning:
      summary: Let the signer sign inside your page
      value:
        kind: signing
        host_origin: https://app.example.com
        document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        locale: en
        theme:
          primary_color: '#0F766E'
    EmbedSessionCreateWorkspaceMinimal:
      summary: 'Workspace: create from a template and send'
      value:
        kind: workspace
        host_origin: https://app.example.com
        external_user_id: crm-user-42
        mode: minimal
        preselect:
          template_id: 5e4d3c2b-1a09-4f8e-9d7c-6b5a4f3e2d1c
        recipient:
          name: Grace Hopper
          email: grace@example.com
          locked: true
        sender:
          name: Acme Sales Desk
          email: sales@acme.example
    EmbedSessionCreateWorkspaceReadOnly:
      summary: 'Workspace: view one document, allow downloads'
      value:
        kind: workspace
        host_origin: https://app.example.com
        user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        mode: read_only
        preselect:
          document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        allow_download: true
    EmbedSessionCreateLocalhost:
      summary: 'Test environment: framed from your dev machine'
      description: In the test environment `http://localhost` on any port is allowed without registering it.
      value:
        kind: workspace
        host_origin: http://localhost:3000
        external_user_id: crm-user-42
        mode: advanced
    EmbedSessionSigningCreated:
      summary: A signing session, with its URL
      value:
        id: d4c3b2a1-f0e9-4d8c-b7a6-958473625140
        kind: signing
        environment: test
        host_origin: https://app.example.com
        locale: en
        theme:
          primary_color: '#0F766E'
        document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        recipient_id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
        api_user_id: null
        mode: null
        allow_download: false
        preselect: null
        recipient_preset: null
        sender_preset: null
        status: active
        expires_at: '2026-09-22T11:43:02+00:00'
        consumed_at: null
        revoked_at: null
        created_at: '2026-09-22T09:43:02+00:00'
        url: https://app.gingerdocs.com/embed?fa=aHR0cHM6Ly9hcHAuZXhhbXBsZS5jb20#token=gdet_9Kj3mQ2vXb7LpR9sT4wZn6cH1dF5gY8kA0eU3iO2qNl
    EmbedSessionWorkspaceCreated:
      summary: A workspace session, with its URL
      value:
        id: f5e4d3c2-b1a0-4f9e-8d7c-6b5a4f3e2d1c
        kind: workspace
        environment: test
        host_origin: https://app.example.com
        locale: en
        theme: null
        document_id: null
        recipient_id: null
        api_user_id: 7d3f2c1a-5b6e-4f8a-9c0d-1e2f3a4b5c6d
        mode: minimal
        allow_download: false
        preselect:
          document_id: null
          template_id: 5e4d3c2b-1a09-4f8e-9d7c-6b5a4f3e2d1c
        recipient_preset:
          name: Grace Hopper
          email: grace@example.com
          locked: true
        sender_preset:
          name: Acme Sales Desk
          email: sales@acme.example
        status: active
        expires_at: '2026-09-22T12:10:00+00:00'
        consumed_at: null
        revoked_at: null
        created_at: '2026-09-22T10:10:00+00:00'
        url: https://app.gingerdocs.com/embed?fa=aHR0cHM6Ly9hcHAuZXhhbXBsZS5jb20#token=gdet_Qm7vK2xZp4sT9wN1cR6yH3dJ8fA5eU0iO2qLb7Xn4
    EmbedSessionActive:
      summary: Active, and already opened once
      value:
        id: d4c3b2a1-f0e9-4d8c-b7a6-958473625140
        kind: signing
        environment: test
        host_origin: https://app.example.com
        locale: en
        theme: null
        document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        recipient_id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
        api_user_id: null
        mode: null
        allow_download: false
        preselect: null
        recipient_preset: null
        sender_preset: null
        status: active
        expires_at: '2026-09-22T11:43:02+00:00'
        consumed_at: '2026-09-22T09:43:40+00:00'
        revoked_at: null
        created_at: '2026-09-22T09:43:02+00:00'
    EmbedSessionRevoked:
      summary: Revoked
      value:
        id: d4c3b2a1-f0e9-4d8c-b7a6-958473625140
        kind: signing
        environment: test
        host_origin: https://app.example.com
        locale: en
        theme: null
        document_id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        recipient_id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
        api_user_id: null
        mode: null
        allow_download: false
        preselect: null
        recipient_preset: null
        sender_preset: null
        status: revoked
        expires_at: '2026-09-22T11:43:02+00:00'
        consumed_at: '2026-09-22T09:43:40+00:00'
        revoked_at: '2026-09-22T10:01:05+00:00'
        created_at: '2026-09-22T09:43:02+00:00'
    # ── Browser API ───────────────────────────────────────────────────────────
    BrowserStatusViewed:
      summary: Opened, not yet signed
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        status: VIEWED
        recipient:
          status: VIEWED
          signed_at: null
        completed_at: null
    BrowserStatusCompleted:
      summary: Signed and completed
      value:
        id: 2f1c9a7e-3b4d-4e5f-8a6b-7c8d9e0f1a2b
        status: COMPLETED
        recipient:
          status: SIGNED
          signed_at: '2026-09-22T09:44:30+00:00'
        completed_at: '2026-09-22T09:44:30+00:00'
    # ── Errors ────────────────────────────────────────────────────────────────
    ErrorInvalidApiKey:
      summary: 401 — the key is missing, malformed, unknown or revoked
      value:
        message: Invalid API key. Check the key you are sending in the Authorization header.
        code: INVALID_API_KEY
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorSecretKeyRequired:
      summary: 403 — a publishable key on a secret-key endpoint
      value:
        message: This endpoint requires a secret API key. Publishable keys can only be used with the browser API.
        code: SECRET_KEY_REQUIRED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorPublishableKeyRequired:
      summary: 403 — a secret key on the browser endpoint
      value:
        message: This endpoint requires a publishable API key. Secret keys must never be used from a browser.
        code: PUBLISHABLE_KEY_REQUIRED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorDeveloperModeDisabled:
      summary: 403 — Developer Mode is off for the account
      value:
        message: Developer Mode is not enabled for this account.
        code: DEVELOPER_MODE_DISABLED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorTermsAcceptanceRequired:
      summary: 403 — new Integrator Terms to accept
      value:
        message: Updated Integrator Terms are in force. Accept them under Settings → Developer to keep using the API.
        code: TERMS_ACCEPTANCE_REQUIRED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        terms_version: '2026-08-27'
    ErrorInsufficientScope:
      summary: 403 — the key lacks the scope this endpoint needs
      value:
        message: This API key does not have the scope required for this endpoint.
        code: INSUFFICIENT_SCOPE
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        scope: webhooks
    ErrorRateLimitExceeded:
      summary: 429 — slow down
      value:
        message: Too many requests. Retry after the interval given in the Retry-After header.
        code: RATE_LIMIT_EXCEEDED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        retry_after: 17
    ErrorIdempotencyKeyInFlight:
      summary: 409 — the first request with this key is still running
      value:
        message: An earlier request with this Idempotency-Key is still being processed. Retry shortly to receive its result.
        code: IDEMPOTENCY_KEY_IN_FLIGHT
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorIdempotencyKeyReused:
      summary: 422 — the key was used for a different request
      value:
        message: This Idempotency-Key was already used for a different request. Use a new key, or resend the original request unchanged.
        code: IDEMPOTENCY_KEY_REUSED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorInvalidJson:
      summary: 400 — the body is not valid JSON
      value:
        message: The request body is not valid JSON. Correct it and send the request again.
        code: INVALID_JSON
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorMethodNotAllowed:
      summary: 405 — the path does not take this method
      value:
        message: This path does not accept this HTTP method. The Allow header lists the methods it does accept.
        code: METHOD_NOT_ALLOWED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorPayloadTooLarge:
      summary: 413 — the body is over the size limit
      value:
        message: The request body is too large to accept. A document's PDF can be up to 50 MB.
        code: PAYLOAD_TOO_LARGE
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorServerError:
      summary: 500 — our failure; retry with backoff
      value:
        message: Something went wrong on our side. Retry with backoff — an Idempotency-Key makes retrying a POST safe — and quote request_id if it keeps happening.
        code: SERVER_ERROR
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorNotFound:
      summary: 404 — no such id in this environment
      value:
        message: No such resource in this environment.
        code: NOT_FOUND
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorValidationFailed:
      summary: 422 — per-attribute codes
      value:
        message: The request body did not pass validation. See `errors` for the per-field codes.
        code: VALIDATION_FAILED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        errors:
          recipient.email:
            - code: INVALID_EMAIL
          expiry_days:
            - code: OUT_OF_RANGE
    ErrorUserExists:
      summary: 409 — that external_id is taken
      value:
        message: A user with this external_id already exists in this environment.
        code: USER_EXISTS
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        external_id: crm-user-42
    ErrorPlanUploadLimitReached:
      summary: 403 — the plan’s monthly allowance is used up
      value:
        message: You've reached the Developer plan limit of 1000 document uploads this month. Upgrade your plan to keep uploading.
        code: PLAN_UPLOAD_LIMIT_REACHED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        plan: developer_1k
        limit: 1000
        used: 1000
        is_expired: false
    ErrorPlanExpired:
      summary: 403 — the paid plan lapsed back to Free volume
      value:
        message: Your Developer plan has expired, so you're back on Free limits of 30 uploads this month. Restart your plan to keep uploading.
        code: PLAN_EXPIRED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        plan: free
        limit: 30
        used: 30
        is_expired: true
    ErrorSingleRecipientOnly:
      summary: 409 — a `recipients` array was sent
      value:
        message: This API handles exactly one signer per document. Send `recipient` as a single object, not `recipients` as a list.
        code: SINGLE_RECIPIENT_ONLY
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorNoRecipientForFields:
      summary: 409 — a signer field on a document with no signer
      value:
        message: This document has no recipient. Add one with PUT /v1/documents/{id}/recipient.
        code: NO_RECIPIENT
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        fields:
          - 0
    ErrorNoRecipientConflict:
      summary: 409 — nobody to sign it
      value:
        message: This document has no recipient. Add one with PUT /v1/documents/{id}/recipient.
        code: NO_RECIPIENT
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorNoRecipientNotFound:
      summary: 404 — the document has no signer yet
      value:
        message: This document has no recipient. Add one with PUT /v1/documents/{id}/recipient.
        code: NO_RECIPIENT
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorCreateValidationFailed:
      summary: 422 — a bad address and an unwritable field type
      value:
        message: The request body did not pass validation. See `errors` for the per-field codes.
        code: VALIDATION_FAILED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        errors:
          recipient.email:
            - code: INVALID_EMAIL
          fields.1.type:
            - code: UNWRITABLE_TYPE
    ErrorSenderFieldsIncompleteByIndex:
      summary: 422 — sender fields without content, by position in `fields`
      value:
        message: Every sender field must carry its final content — nobody is ever prompted to fill one. See `fields` for the incomplete ones.
        code: SENDER_FIELDS_INCOMPLETE
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        fields:
          - 2
    ErrorSenderFieldsIncompleteById:
      summary: 422 — sender fields without content, by field id
      value:
        message: Every sender field must carry its final content — nobody is ever prompted to fill one. See `fields` for the incomplete ones.
        code: SENDER_FIELDS_INCOMPLETE
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        fields:
          - 3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f
    ErrorSenderFieldsIncompleteByTemplateId:
      summary: 422 — sender fields without content, by template element id
      value:
        message: Every sender field must carry its final content — nobody is ever prompted to fill one. See `fields` for the incomplete ones.
        code: SENDER_FIELDS_INCOMPLETE
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        fields:
          - c0ffee00-1234-4abc-9def-000000000001
    ErrorSenderFieldIncomplete:
      summary: 422 — this sender field has no content
      value:
        message: Every sender field must carry its final content — nobody is ever prompted to fill one. See `fields` for the incomplete ones.
        code: SENDER_FIELDS_INCOMPLETE
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorNotYetSupported:
      summary: 422 — `subject` and `message` are not accepted yet
      value:
        message: This build does not yet accept the payload keys listed in `unsupported`. Remove them and retry — everything else in the request is supported.
        code: NOT_YET_SUPPORTED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        unsupported:
          - subject
          - message
    ErrorDocumentNotActive:
      summary: 409 — its signing is over
      value:
        message: This document is no longer active — its signing is over, so it can no longer be modified.
        code: DOCUMENT_NOT_ACTIVE
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        status: COMPLETED
    ErrorDocumentNotDraft:
      summary: 409 — only a draft can be deleted
      value:
        message: Only a draft can be deleted. Void the document instead — completed and signed documents are never deletable.
        code: DOCUMENT_NOT_DRAFT
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        status: SENT
    ErrorIllegalTransitionAlreadySent:
      summary: 409 — it has already been sent
      value:
        message: The document's current status does not allow this operation.
        code: ILLEGAL_TRANSITION
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        status: SENT
    ErrorIllegalTransitionAlreadyVoided:
      summary: 409 — it is already voided
      value:
        message: The document's current status does not allow this operation.
        code: ILLEGAL_TRANSITION
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        status: VOIDED
    ErrorIllegalTransitionCompleted:
      summary: 409 — a completed document cannot be recalled
      value:
        message: The document's current status does not allow this operation.
        code: ILLEGAL_TRANSITION
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        status: COMPLETED
    ErrorDownloadPreparing:
      summary: 202 — the signed PDF is still being made
      value:
        message: The signed PDF is still being generated. Retry after the interval given in the Retry-After header.
        code: DOWNLOAD_PREPARING
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        retry_after: 5
        status: COMPLETED
    ErrorDocumentNotCompleted:
      summary: 409 — nothing to download until it completes
      value:
        message: This document has not been completed, so there is no signed PDF to download. It is produced once the document completes; `status` says where it is now.
        code: DOCUMENT_NOT_COMPLETED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        status: SENT
    ErrorRecipientLocked:
      summary: 409 — past DRAFT, the signer can only be corrected
      value:
        message: The recipient can no longer be replaced — this document has already been sent. Correct their email with PATCH, or recall the document first.
        code: RECIPIENT_LOCKED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        status: SENT
    ErrorRecipientAlreadySigned:
      summary: 409 — they have already signed
      value:
        message: This recipient has already signed. A completed signature cannot be revoked, corrected or re-invited.
        code: RECIPIENT_ALREADY_SIGNED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        recipient_status: SIGNED
    ErrorRecipientNotInvited:
      summary: 409 — the document has not been sent
      value:
        message: The recipient has not been invited yet. Send the document first — there is no signing link to remind about or re-mint.
        code: RECIPIENT_NOT_INVITED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        status: DRAFT
        recipient_status: PENDING
    ErrorRecipientNotInvitedForEmbed:
      summary: 409 — send the document before minting a signing session
      value:
        message: The recipient has not been invited yet. Send the document first — there is no signing link to remind about or re-mint.
        code: RECIPIENT_NOT_INVITED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        status: DRAFT
    ErrorEmailDeliverySelf:
      summary: '409 — self-delivery: you send the reminder'
      value:
        message: This document is set to self-delivery, so we send its recipient no email. Re-send the signing URL yourself, re-minting it first if its proof has expired.
        code: EMAIL_DELIVERY_SELF
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        email_delivery: self
    ErrorVersionConflict:
      summary: 409 — someone else changed the field; merge onto `server`
      value:
        message: This field was changed by someone else since you read it. Merge your change onto the server state in `server` and retry with its version.
        code: VERSION_CONFLICT
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        server:
          id: c9d8e7f6-a5b4-4c3d-9e2f-1a0b9c8d7e6f
          type: signature
          page: 3
          x: 0.14
          y: 0.72
          width: 0.3
          height: 0.06
          assignee: recipient
          required: true
          filled: false
          locked: false
          content:
            mode: draw
            required: true
          version: 2
          created_at: '2026-09-22T09:42:15+00:00'
          updated_at: '2026-09-22T09:58:03+00:00'
    ErrorUnwritableType:
      summary: 422 — `radio` cannot be written through the API
      value:
        message: The request body did not pass validation. See `errors` for the per-field codes.
        code: VALIDATION_FAILED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        errors:
          type:
            - code: UNWRITABLE_TYPE
    ErrorSignatureModeRequired:
      summary: 422 — a mark was sent without its `mode`
      value:
        message: The request body did not pass validation. See `errors` for the per-field codes.
        code: VALIDATION_FAILED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        errors:
          content.mode:
            - code: REQUIRED
    ErrorFieldLocked:
      summary: 422 — pinned by the template author
      value:
        message: This field is locked by the template it came from and cannot be deleted.
        code: FIELD_LOCKED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorAppTemplateReadOnly:
      summary: 403 — manage it in the app, where it was authored
      value:
        message: This template was authored in the app, so the API cannot change or delete it. Manage it — including its API sharing — where it was created.
        code: APP_TEMPLATE_READ_ONLY
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        origin: app
    ErrorTemplateMultiRole:
      summary: 422 — the template has two signing roles
      value:
        message: This API creates documents with exactly one signer, and this template does not define exactly one signing role. See `roles` for the signing roles it defines.
        code: TEMPLATE_MULTI_ROLE
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        roles:
          - id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5e
            name: Client
            signing_order: 1
          - id: 2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6f
            name: Guarantor
            signing_order: 2
    ErrorTemplateVariableRequired:
      summary: 422 — a required variable was left empty
      value:
        message: The request body did not pass validation. See `errors` for the per-field codes.
        code: VALIDATION_FAILED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        errors:
          variable_values.project_name:
            - code: REQUIRED
    ErrorTestOnly:
      summary: 403 — a live key reached a test endpoint
      value:
        message: The simulation endpoints exist only in the test environment. Send a test secret key to use them.
        code: TEST_ONLY
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorWebhookEndpointInactive:
      summary: 409 — re-enable the endpoint first
      value:
        message: This webhook endpoint is disabled, so a ping would be delivered nowhere. Re-enable it with PATCH /v1/webhook_endpoints/{id} and try again.
        code: WEBHOOK_ENDPOINT_INACTIVE
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorWebhookUrlInvalid:
      summary: 422 — plain http, or a private address
      value:
        message: The request body did not pass validation. See `errors` for the per-field codes.
        code: VALIDATION_FAILED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        errors:
          url:
            - code: INVALID_WEBHOOK_URL
    ErrorEmbedOriginNotRegistered:
      summary: 403 — register the origin under Settings → Developer
      value:
        message: That host_origin is not a registered domain for this environment. Add it under Settings → Developer before minting a session for it.
        code: EMBED_ORIGIN_NOT_REGISTERED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
    ErrorEmbedOriginInvalid:
      summary: 422 — an origin has no path
      value:
        message: The request body did not pass validation. See `errors` for the per-field codes.
        code: VALIDATION_FAILED
        request_id: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d
        errors:
          host_origin:
            - code: INVALID_ORIGIN

# ── Webhook deliveries (§11) ─────────────────────────────────────────────────
#
# What we send rather than what we answer: OpenAPI 3.1's `webhooks` map. The
# body is the `Event` schema, and every member of `EventType` has an example
# delivery below. `ValidatesOpenApi` reads `paths` only, so this section is held
# to the contract by `ApiV1OpenApiExamplesTest` and Spectral instead.
webhooks:
  event:
    post:
      tags: [Webhooks]
      operationId: receiveEventDelivery
      summary: Receive an event delivery
      description: |
        What we `POST` to each active endpoint whose subscription covers the
        event — **your** server answers this one. The body is the `Event`
        object, byte for byte what `GET /events/{event}` returns, so one
        handler serves live deliveries and replays alike. Any custom `headers`
        you registered on the endpoint are sent as well.

        | Type | Fires when |
        |---|---|
        | `document.sent` | The document was sent to its signer, including each resend. |
        | `document.viewed` | The signer opened the signing page — the first open after each send. |
        | `document.completed` | The signer signed. The document is legally complete, though not yet necessarily downloadable. |
        | `document.download_ready` | The signed PDF can be fetched from `GET /documents/{document}/download`. |
        | `document.declined` | The signer declined. |
        | `document.voided` | The document was voided — through the API, an embedded workspace or the app. |
        | `document.expired` | The signing window closed before the signer signed. |
        | `document.recalled` | The document was recalled to `DRAFT`, which suspends the signer's link until the next send. |
        | `recipient.updated` | The signer's name, email or language was changed with `PUT` or `PATCH` on the recipient. |
        | `recipient.viewed` | The signer opened the signing page — the first open after each send. |
        | `recipient.signed` | The signer signed. |
        | `recipient.declined` | The signer declined. |
        | `template.created` | An API template was created in an embedded workspace. |
        | `template.updated` | An API template was edited in an embedded workspace, or changed with `PATCH /templates/{template}`. |
        | `user.created` | A user was created with `POST /users`. |
        | `ping` | A test delivery was requested for one endpoint. It ignores subscriptions and reaches only that endpoint. |

        **Verify before you trust it.** `X-GingerDocs-Signature` is
        `t=<unix seconds>,v1=<hex digest>`, where the digest is
        `HMAC-SHA256(t + "." + rawBody, secret)`. Compute it over the raw body
        bytes, before any JSON parsing; compare in constant time; reject a `t`
        more than five minutes from your clock. Every retry is signed afresh.

        **Answer fast, work later.** Any `2xx` within 10 seconds acknowledges
        the delivery, and the body you answer with is ignored. Anything else —
        another status, a timeout, a refused connection, a redirect (never
        followed) — is retried: seven attempts in all, the last about 35 hours
        after the first. An endpoint that has failed every delivery for three
        days is switched off with `disabled_reason: AUTO_DISABLED_FAILURES`.
        While the account's Developer Mode is off, deliveries are held and sent
        once it is back on — nothing is skipped.

        **At least once, and not necessarily in order.** Deduplicate on
        `X-GingerDocs-Event-Id`, and take state from `data.status` of the newest
        event by `created_at` rather than from arrival order. Endpoints are per
        environment: a test endpoint receives only test events.
      security: []
      parameters:
        - name: X-GingerDocs-Signature
          in: header
          required: true
          description: '`t=<unix seconds>,v1=<hex digest>` — the HMAC-SHA256 of `t + "." + rawBody`, keyed with the endpoint''s signing secret.'
          example: t=1790246670,v1=5f1e9c7b2a8d4e6f0c3b1a9d8e7f6c5b4a3d2e1f0c9b8a7d6e5f4c3b2a1d0e9f
          schema:
            type: string
        - name: X-GingerDocs-Event-Id
          in: header
          required: true
          description: The event's `id`, as in the body. Deduplicate on it — delivery is at-least-once.
          example: e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b
          schema:
            type: string
            format: uuid
        - name: User-Agent
          in: header
          required: true
          description: The sender, and the payload version it speaks.
          example: GingerDocs-Webhooks/v1
          schema:
            type: string
      requestBody:
        required: true
        description: The event, exactly as `GET /events/{event}` returns it.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Event'
            examples:
              documentCompleted:
                $ref: '#/components/examples/EventDocumentCompleted'
              documentSent:
                $ref: '#/components/examples/EventDocumentSent'
              documentViewed:
                $ref: '#/components/examples/EventDocumentViewed'
              documentDownloadReady:
                $ref: '#/components/examples/EventDocumentDownloadReady'
              documentDeclined:
                $ref: '#/components/examples/EventDocumentDeclined'
              documentVoided:
                $ref: '#/components/examples/EventDocumentVoided'
              documentExpired:
                $ref: '#/components/examples/EventDocumentExpired'
              documentRecalled:
                $ref: '#/components/examples/EventDocumentRecalled'
              recipientUpdated:
                $ref: '#/components/examples/EventRecipientUpdated'
              recipientViewed:
                $ref: '#/components/examples/EventRecipientViewed'
              recipientSigned:
                $ref: '#/components/examples/EventRecipientSigned'
              recipientDeclined:
                $ref: '#/components/examples/EventRecipientDeclined'
              templateCreated:
                $ref: '#/components/examples/EventTemplateCreated'
              templateUpdated:
                $ref: '#/components/examples/EventTemplateUpdated'
              userCreated:
                $ref: '#/components/examples/EventUserCreated'
              ping:
                $ref: '#/components/examples/EventPing'
      responses:
        '2XX':
          description: Acknowledged. Any `2xx` within 10 seconds; the body is ignored.
        default:
          description: Any other outcome — a non-`2xx`, a timeout, a refused connection or a redirect — is a failed attempt, and the delivery is retried.
