> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vumasign.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Create and send many envelopes from one template, in one request.

> ONE REQUEST, ONE `Idempotency-Key`, UP TO 100 ENVELOPES. What this saves is not our time but your error handling: a thousand loops of `POST /api/v1/envelopes` is a thousand keys to mint and persist, a thousand timeouts to resolve, and a thousand places to be halfway through.

⚠️ **THE STATUS LINE IS THE ANSWER. YOU NEVER HAVE TO WALK THE BODY TO FIND OUT WHETHER IT WORKED.**

- `201` — every row went out (or, with `send: false`, every draft exists). `failed` is 0.
- `207` — at least one row did not. `failed` says how many and `results[i].error` says why. This is the only status that requires reading a body.
- any `4xx` — **nothing was created at all.**

⚠️ ONE BAD ROW REFUSES THE WHOLE BATCH, AND THAT IS DELIBERATE. Every refusal your own data can earn — an unknown role, an address matching no field, a value that cannot be drawn, a mailbox a test key may not write to — is decided in ONE transaction before anything is sent, so a hundred envelopes roll back together and your key is free for a corrected retry. A mistake in mapping code is systematic: if row 7 names a role the template does not have, rows 8 to 99 probably do too, and sending 93 binding documents to prove it is the expensive way to find out. Refusals name the row: `rows[7].recipients[2].role`.

PAST THAT POINT IT IS PER ROW, because an email handed to a provider cannot be recalled. A row whose send is refused KEEPS ITS DRAFT, with its recipients and values already on it, and its id is in the answer — so the remedy is `POST /api/v1/envelopes/{envelopeId}/send` on the ones that failed rather than rebuilding the ones that did not.

⚠️ A REPLAY RETURNS THE SAME BATCH AND CREATES NOTHING, including a replay of a `207` — which returns the same `207`, failed rows and all, rather than retrying them. If a replay could DO something, two replays could give two different answers and a retry after a timeout would tell you nothing.

⚠️ IT IS SYNCHRONOUS. There is no job to poll, because the answer is the response. That is what the 100-row bound buys, and it is why the bound is not larger.

⚠️ **IT COSTS N AGAINST THE RATE LIMIT, NOT 1** — one unit per row, because one row is one envelope and an envelope is what costs us. A batch that does not fit in what the minute has left is refused WHOLE with `rate_limited`, never partly, and is charged 1 rather than N for being refused. `RateLimit-Remaining` tells you how many rows would fit now.



## OpenAPI

````yaml /openapi.json post /api/v1/envelopes/batches
openapi: 3.1.0
info:
  title: Vumasign API
  version: 0.1.0
  description: >-
    The Vumasign API. South African e-signature: templates, the addresses an

    integration writes values to, an idempotent send, and webhooks.


    **This document describes what exists today and nothing else.** It is not a

    roadmap, and there is deliberately no entry here for anything that has not

    shipped — a spec you can generate a client from and then discover was

    aspirational is worse than no spec at all.


    ## Authentication

        Authorization: Bearer vsk_live_…

    One header, one step. Keys are minted in Settings. `vsk_live_` and
    `vsk_test_`

    are different keys; the prefix is stored in clear so a leaked key is
    greppable

    and traceable to an organisation, while the secret is stored only as a hash.


    ## Errors


    Every refusal the API returns deliberately, at every status, is

    `{ "error": { "code", "message" } }`, and such a response is a failure **if

    and only if** it carries an `error` key — that test is correct even for a

    client that ignores the status line. An unexpected server failure may not
    have

    this shape: treat a 5xx without an `error` key as an `internal_error`, which

    is retryable. `code` is the

    contract and is stable forever; `message` is prose for a developer reading a

    log and may be reworded in any release.


    The root of this document carries **`x-error-codes`**: the closed enum, with

    `status`, `retryable`, `backoff`, `emitted` and `guidance` for each

    code. Branch on that rather than on our prose.


    ## Rate limits


    **600 requests a minute on any paid plan,

    60 on the free plan — and a test key

    gets the same number as a live one.** Per key, not per organisation, so a
    batch

    job and an interactive integration can be given a key each and neither can

    starve the other.


    The ceiling follows the PLAN rather than the kind of key, because the plan
    is

    what says whether this is production traffic or somebody evaluating us. On
    the

    API plan it is sized so that no honest workload meets it: a thousand
    envelopes

    with a status read each is two thousand requests, which is three and a half

    minutes rather than seventeen. The sandbox deliberately gets the *same*
    number

    rather than a larger one — an integration that passes in rehearsal is then
    an

    integration that passes live, and your `429` handling is exercised where

    meeting a `429` is free.


    Every successful authenticated response carries `RateLimit-Limit`,

    `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the window

    closes, at most 60). Read them on your successes and you will never need to
    be

    refused. A 429 carries them too, and additionally carries `Retry-After`,
    which

    you should honour. A 401 carries none of them; a 403 may not, depending on

    whether it was decided before the request was counted. Each operation's

    responses say which others do.


    ⚠️ **A refused request still counts.** Retrying inside the window pushes the

    counter higher rather than holding it at the ceiling — which is why the
    backoff

    in `x-error-codes` is exponential and why `Retry-After` is worth reading

    rather than guessing at.


    ⚠️ The limits are **per minute**, and the window is fixed rather than
    sliding.

    Spending a whole window at the end of one minute and a whole window at the
    start

    of the next is possible; it is bounded at two windows' worth, which is a
    burst

    rather than an incident.


    ## Webhooks


    Register an endpoint with `POST /api/v1/webhooks`; the response carries the

    signing secret, **once**. The root of this document carries
    **`x-webhooks`**:

    the body we POST, the signature recipe (including the two mistakes that make
    a

    verifier look correct and be worthless), the published retry ladder, and the

    auto-disable rule. Read that before writing a receiver.


    ## What is not here yet


    No organisation-level email exists, so an auto-disabled webhook endpoint is

    discovered by polling rather than by being told. There is no way to change
    an

    envelope after it is created and before it is sent — `PATCH
    /v1/envelopes/{id}`

    is designed and not built — so a draft is created complete or not at all.
    Both

    are stated because a gap named is a gap somebody can plan around.


    ⚠️ **The rate limit above counts authenticated requests only.** A request

    carrying no key, or a key we cannot recognise, is refused before any counter
    is

    touched — a per-key ceiling cannot defend against a caller who has not
    presented

    a key, and pretending otherwise would be a promise we could not keep. That
    half

    belongs to the edge rather than to this API.


    (This section has twice described as missing something that now exists: the

    `uri` on every webhook body, which was `null` until

    `GET /api/v1/envelopes/{envelopeId}` gave it somewhere to point, and the
    rate

    limiter, which nothing enforced until the per-key ceiling above. Both were
    named

    here as gaps first, which is the point of naming them.)
servers:
  - url: https://app.vumasign.com
security:
  - ApiKey: []
tags:
  - name: Templates
    description: >-
      A template is a document with its signing roles and field placements
      authored once. Every envelope is made from one, or from a one-off pack.
  - name: Documents
    description: >-
      The pages of a template’s documents, served as PDF so that a client which
      has to show the paper can do so without rendering it itself.
  - name: Envelopes
    description: >-
      An envelope is one sending of a document to named people. Creating one is
      not sending it: a draft emails nobody until it is sent.
  - name: Webhooks
    description: >-
      Endpoints we POST signed events to when an envelope changes, with the
      health of each endpoint and a secret you can rotate without a gap.
  - name: Specification
    description: >-
      This document, served without a credential so that anybody can read what
      the API does before they hold a key.
paths:
  /api/v1/envelopes/batches:
    post:
      tags:
        - Envelopes
      summary: Create and send many envelopes from one template, in one request.
      description: >-
        ONE REQUEST, ONE `Idempotency-Key`, UP TO 100 ENVELOPES. What this saves
        is not our time but your error handling: a thousand loops of `POST
        /api/v1/envelopes` is a thousand keys to mint and persist, a thousand
        timeouts to resolve, and a thousand places to be halfway through.


        ⚠️ **THE STATUS LINE IS THE ANSWER. YOU NEVER HAVE TO WALK THE BODY TO
        FIND OUT WHETHER IT WORKED.**


        - `201` — every row went out (or, with `send: false`, every draft
        exists). `failed` is 0.

        - `207` — at least one row did not. `failed` says how many and
        `results[i].error` says why. This is the only status that requires
        reading a body.

        - any `4xx` — **nothing was created at all.**


        ⚠️ ONE BAD ROW REFUSES THE WHOLE BATCH, AND THAT IS DELIBERATE. Every
        refusal your own data can earn — an unknown role, an address matching no
        field, a value that cannot be drawn, a mailbox a test key may not write
        to — is decided in ONE transaction before anything is sent, so a hundred
        envelopes roll back together and your key is free for a corrected retry.
        A mistake in mapping code is systematic: if row 7 names a role the
        template does not have, rows 8 to 99 probably do too, and sending 93
        binding documents to prove it is the expensive way to find out. Refusals
        name the row: `rows[7].recipients[2].role`.


        PAST THAT POINT IT IS PER ROW, because an email handed to a provider
        cannot be recalled. A row whose send is refused KEEPS ITS DRAFT, with
        its recipients and values already on it, and its id is in the answer —
        so the remedy is `POST /api/v1/envelopes/{envelopeId}/send` on the ones
        that failed rather than rebuilding the ones that did not.


        ⚠️ A REPLAY RETURNS THE SAME BATCH AND CREATES NOTHING, including a
        replay of a `207` — which returns the same `207`, failed rows and all,
        rather than retrying them. If a replay could DO something, two replays
        could give two different answers and a retry after a timeout would tell
        you nothing.


        ⚠️ IT IS SYNCHRONOUS. There is no job to poll, because the answer is the
        response. That is what the 100-row bound buys, and it is why the bound
        is not larger.


        ⚠️ **IT COSTS N AGAINST THE RATE LIMIT, NOT 1** — one unit per row,
        because one row is one envelope and an envelope is what costs us. A
        batch that does not fit in what the minute has left is refused WHOLE
        with `rate_limited`, never partly, and is charged 1 rather than N for
        being refused. `RateLimit-Remaining` tells you how many rows would fit
        now.
      operationId: createEnvelopeBatch
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: >-
            Any string that identifies this request; at most 255 characters. ⚠️
            REQUIRED, and this is the endpoint it matters most on: without it a
            timeout is unresolvable and a retry creates a second hundred
            envelopes and emails everybody in them twice.
          schema:
            type: string
            examples:
              - 01960000-0000-4000-8000-00000000ffff
            description: The caller’s own request identifier.
      requestBody:
        required: true
        description: The template, the rows, and whether to send.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateEnvelopeBatchRequest'
              description: The request.
            examples:
              example:
                summary: Two rows, sent
                value:
                  template_id: 01960000-0000-4000-8000-0000000007e1
                  rows:
                    - recipients:
                        - role: Employee
                          name: Thandi Mokoena
                          email: thandi@example.test
                      reference: hire-2026-0917
                    - recipients:
                        - role: Employee
                          name: A. Dlamini
                          email: a.dlamini@example.test
                      reference: hire-2026-0918
                  send: true
      responses:
        '201':
          description: >-
            Every row succeeded. `failed` is 0 and every `results[i].error` is
            null. `Idempotency-Replayed` says whether this request created the
            batch (`false`) or is being shown an earlier one’s result (`true`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeBatch'
                description: The batch.
              examples:
                example:
                  summary: Every row went out
                  value:
                    batch_id: 01960000-0000-4000-8000-0000000000ba
                    template_id: 01960000-0000-4000-8000-0000000007e1
                    count: 2
                    sent: 2
                    failed: 0
                    results:
                      - index: 0
                        envelope:
                          id: 01960000-0000-4000-8000-0000000000e5
                          status: sent
                          template_id: 01960000-0000-4000-8000-0000000007e1
                          title: Employment contract
                          reference: hire-2026-0917
                          created_at: '2026-09-02T09:00:00.000Z'
                          sent_at: '2026-09-02T09:05:00.000Z'
                          expires_at: null
                          recipients:
                            - id: 01960000-0000-4000-8000-00000000005e
                              role: Employee
                              name: Thandi Mokoena
                              email: thandi@example.test
                              routing_type: sign
                              status: sent
                              invitation_delivered: true
                              embedded: false
                              external_ref: null
                        error: null
                      - index: 1
                        envelope:
                          id: 01960000-0000-4000-8000-0000000000e6
                          status: sent
                          template_id: 01960000-0000-4000-8000-0000000007e1
                          title: Employment contract
                          reference: hire-2026-0918
                          created_at: '2026-09-02T09:00:00.000Z'
                          sent_at: '2026-09-02T09:05:00.000Z'
                          expires_at: null
                          recipients:
                            - id: 01960000-0000-4000-8000-00000000005f
                              role: Employee
                              name: A. Dlamini
                              email: a.dlamini@example.test
                              routing_type: sign
                              status: sent
                              invitation_delivered: true
                              embedded: false
                              external_ref: null
                        error: null
          headers:
            RateLimit-Limit:
              description: >-
                How many requests this key may make per minute: 600 on any paid
                plan and 60 on the free plan, the same in both environments. Per
                key, not per organisation — a second key has its own allowance.
                ⚠️ READ IT RATHER THAN ASSUMING IT: it is the number the
                database applied to this request, so it is right even when this
                description is out of date.
              schema:
                type: integer
                description: Requests permitted in the current minute.
            RateLimit-Remaining:
              description: >-
                How many of them are left, this one already counted. Zero on the
                request that is refused, and zero on every further request of
                that minute — ⚠️ a refused request still counts.
              schema:
                type: integer
                description: Requests remaining in the current minute.
            RateLimit-Reset:
              description: >-
                Seconds until the current minute closes and the allowance
                returns. At most 60, which is what makes the published
                exponential backoff converge in about six doublings rather than
                eleven.
              schema:
                type: integer
                description: Seconds until the window resets.
        '207':
          description: >-
            ⚠️ SOME ROWS DID NOT GO OUT. The envelopes that did are sent and
            cannot be unsent; the ones that did not are drafts you now own, each
            named in `results[i].envelope.id` with its reason in
            `results[i].error`. `failed` is the count. The commonest cause is
            `send_allowance_exhausted` partway through — the plan’s allowance is
            consumed one unit per envelope, so a batch larger than what is left
            sends what fits.


            The other cause is `batch_interrupted`, which means our own request
            died partway and you are being shown the batch as it actually
            stands. In both cases the remedy is the same: send the named drafts
            with `POST /api/v1/envelopes/{envelopeId}/send`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvelopeBatch'
                description: The batch, including the rows that failed.
              examples:
                example:
                  summary: One row went out and one stayed a draft
                  value:
                    batch_id: 01960000-0000-4000-8000-0000000000ba
                    template_id: 01960000-0000-4000-8000-0000000007e1
                    count: 2
                    sent: 1
                    failed: 1
                    results:
                      - index: 0
                        envelope:
                          id: 01960000-0000-4000-8000-0000000000e5
                          status: sent
                          template_id: 01960000-0000-4000-8000-0000000007e1
                          title: Employment contract
                          reference: hire-2026-0917
                          created_at: '2026-09-02T09:00:00.000Z'
                          sent_at: '2026-09-02T09:05:00.000Z'
                          expires_at: null
                          recipients:
                            - id: 01960000-0000-4000-8000-00000000005e
                              role: Employee
                              name: Thandi Mokoena
                              email: thandi@example.test
                              routing_type: sign
                              status: sent
                              invitation_delivered: true
                              embedded: false
                              external_ref: null
                        error: null
                      - index: 1
                        envelope:
                          id: 01960000-0000-4000-8000-0000000000e6
                          status: draft
                          template_id: 01960000-0000-4000-8000-0000000007e1
                          title: Employment contract
                          reference: hire-2026-0918
                          created_at: '2026-09-02T09:00:00.000Z'
                          sent_at: null
                          expires_at: null
                          recipients:
                            - id: 01960000-0000-4000-8000-00000000005f
                              role: Employee
                              name: A. Dlamini
                              email: a.dlamini@example.test
                              routing_type: sign
                              status: pending
                              invitation_delivered: null
                              embedded: false
                              external_ref: null
                        error:
                          code: send_allowance_exhausted
                          message: >-
                            You have sent 5 of the 5 documents the Free plan
                            includes each month, so this one was not sent.
                            Nothing already sent is affected, and every signed
                            document stays available. A paid plan lifts the
                            limit and never blocks a send.
          headers:
            RateLimit-Limit:
              description: >-
                How many requests this key may make per minute: 600 on any paid
                plan and 60 on the free plan, the same in both environments. Per
                key, not per organisation — a second key has its own allowance.
                ⚠️ READ IT RATHER THAN ASSUMING IT: it is the number the
                database applied to this request, so it is right even when this
                description is out of date.
              schema:
                type: integer
                description: Requests permitted in the current minute.
            RateLimit-Remaining:
              description: >-
                How many of them are left, this one already counted. Zero on the
                request that is refused, and zero on every further request of
                that minute — ⚠️ a refused request still counts.
              schema:
                type: integer
                description: Requests remaining in the current minute.
            RateLimit-Reset:
              description: >-
                Seconds until the current minute closes and the allowance
                returns. At most 60, which is what makes the published
                exponential backoff converge in about six doublings rather than
                eleven.
              schema:
                type: integer
                description: Seconds until the window resets.
        '400':
          description: >-
            `invalid_request` — The request is malformed, or breaks a rule about
            its own shape: a body that is not JSON or does not match the
            operation’s schema (a missing or mistyped property, a value out of
            range), a document that is not a PDF or a Word file we can read, a
            text tag that cannot be placed, an `Idempotency-Key` that is present
            but not a valid key, a query parameter outside its range, or a
            cursor that names nothing. The `message` says which, and for a body
            names the property path (`recipients[0].email`) — there is no
            separate `param` field. Fix the request; retrying it unchanged will
            fail identically.


            `idempotency_key_required` — Send `Idempotency-Key: <uuid>` — this
            operation takes one and the request carried none. A distinct code
            from `invalid_request` because the remedy is to ADD a header, and an
            integrator reading it in a log should not have to work that out. (A
            key that is present but malformed is `invalid_request`, and its
            `message` names the header.)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
                description: The refusal.
        '401':
          description: >-
            `unauthenticated` — The credential was absent, unreadable, unknown,
            wrong or revoked — this answer is deliberately identical for all of
            them, so it cannot be used to probe which keys exist. Retrying the
            same request changes nothing. Check the key in Settings or issue a
            new one.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
                description: The refusal.
        '403':
          description: >-
            `forbidden` — The key is valid and the organisation’s plan does not
            grant what this operation does. Nothing about the credential needs
            to change, and it is deliberately MORE specific than 401 because the
            caller has already proved they hold the key. TWO SITUATIONS PRODUCE
            IT. (1) THE PLAN EXCLUDES THE OPERATION: `POST /api/v1/templates` on
            the Free plan, which does not include authoring templates. A
            template that already exists stays readable at `GET
            /api/v1/templates`; authoring one needs a plan that includes
            templates. (2) WE CANNOT ESTABLISH WHAT THE PLAN INCLUDES, on any
            operation, so nothing is granted. That one is ours rather than
            yours, an ordinary account does not meet it, and it means ask us.


            `insufficient_scope` — This key is scoped and does not hold the
            scope this operation requires — `x-required-scope` on the operation
            names it, and the `WWW-Authenticate` header on the refusal repeats
            it (RFC 6750 §3.1). ⚠️ Retrying cannot help and neither can editing
            the key: scopes are fixed when a key is minted and no endpoint or
            screen can widen them. Issue a new key with the access it needs. A
            key created before scopes existed carries an empty scope list, which
            means EVERY capability, so this refusal cannot reach an integration
            that was already working.


            `test_key_cannot_send` — A `vsk_test_` key tried a send it may not
            make. TWO SITUATIONS PRODUCE IT. (1) `POST
            /api/v1/envelopes/{envelopeId}/send` on an envelope a LIVE key
            created: sending it would email its real recipients and spend real
            allowance, so send it with a live key. (2) Far more often, a
            recipient who would be EMAILED and whose address is not one this
            organisation may send rehearsals to. THE RULE IS ABOUT WHO, NOT
            ABOUT WHETHER: a test key may email anybody who is a MEMBER of the
            sending organisation, and any other address that has CONFIRMED a
            verification link sent to it (Settings → Test recipients). Nobody
            else at all. So a mixed envelope — one `"embedded": true` signer in
            your application and one emailed counterparty — is fully
            rehearsable, provided that counterparty is on the list. An
            `"embedded": true` recipient is never checked against it, because an
            embedded recipient is issued no invitation and is emailed by no code
            path at all. ⚠️ WHY THE LIMIT EXISTS: a sandbox send consumes no
            billing allowance, so an unbounded one would be an unmetered way to
            email strangers from a free account, over a sending domain every
            customer shares. The message names each address that was refused.
            Your options are: add and verify the address; make the recipient
            `"embedded": true`; create the draft with `"send": false`; or send
            with a live key, which may email anybody. What a test key still
            never does is consume billing allowance or produce a sealed artefact
            anybody can be held to — its documents are watermarked on every page
            and sealed by a certificate issued for the sandbox and for nothing
            else, so a PDF reader reports the signature as intact and its issuer
            as untrusted, and everyone who receives one is told so before they
            can open it.


            `api_key_owner_removed` — The person who created this key has left
            the organisation, and everything the key creates or sends — an
            envelope, a batch, a template — is attributed to that person, who
            must still be a member. The key is not revoked and its reads still
            work, and so does managing webhooks; mint a new key from a current
            member and use that.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
                description: The refusal.
        '404':
          description: >-
            `not_found` — No such resource — or none this key’s organisation can
            see, or one that has been archived, or an id that is not a uuid.
            Those four are one answer on purpose: "it exists but is not yours"
            is itself a disclosure. Do not retry; check the id against the list
            endpoint.


            `template_archived` — The template exists and has been retired. It
            has a code of its own because a bare 404 tells an integrator whose
            template was archived yesterday nothing. It carries the same 404
            status and it only ever fires for a template their own key could
            otherwise have read — discoverability without disclosure. Use a live
            template.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
                description: The refusal.
        '409':
          description: >-
            `idempotency_key_reused` — This key was already used for a DIFFERENT
            request. ⚠️ The request itself may be perfectly valid — what
            conflicts is the key against state we already hold, which is what
            409 means and why it is not a 400. Mint a new key. Do not retry with
            this one; it will conflict forever.


            `request_in_progress` — A request carrying this key has not
            finished. We deliberately do not block waiting for it — that would
            hold a connection across a send that calls an email provider N
            times. Retry the identical request; when the first one lands you
            will get its result, replayed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
                description: The refusal.
        '422':
          description: >-
            `template_unusable` — The template cannot produce an envelope at
            all. The message says which way.


            `recipient_role_unknown` — A recipient names a role the template
            does not declare. Read the roles from `GET
            /api/v1/templates/{templateId}` — they are matched by NAME, exactly,
            because a positional address breaks when a sender reorders.


            `recipient_role_missing` — A role that has fields on it was given
            nobody to fill them. Every role the template declares with work to
            do needs a recipient.


            `recipient_role_duplicated` — Two recipients were given one role
            that the template does not route as `cc`. Everyone on a role is
            served the same fields and every one of them must sign, so both
            would answer the same boxes and the completed envelope could never
            be sealed. Give each person a role of their own; several `cc`
            readers may share a role. Answered by the create endpoints, naming
            each `recipients[i].role` that holds the role, and by the send
            endpoint for a draft saved before this was refused.


            `value_subject_unknown` — A value names a subject the template does
            not declare. The subjects are in the template response; `null` is
            the subject for a field belonging to nobody in particular.


            `value_address_unknown` — The `(subject, key)` pair addresses no
            field of this template. ⚠️ This is the refusal that most often means
            the template has UNADDRESSED boxes rather than that you mistyped:
            `data_key` is null on any field the sender never gave an address to.
            Check the template response before blaming the value.


            `value_not_writable` — The address names real fields and none of
            them is one a caller may fill in — a `date_signed`, a `signer_name`,
            a signature. Those are written by the server or by the signer, and
            supplying them would be a forgery with extra steps.


            `value_not_lockable` — The address names a `signer_title` or a
            `signer_company`, and `locked` cannot ride on a value for one. ⚠️
            THE VALUE ITSELF IS FINE — send the same triple without `locked` and
            it is accepted. What is refused is FIXING the answer on the field: a
            title is a fact about whoever fills the slot, so it is seeded per
            recipient from the `job_title` and `company` you give on the
            recipient, while a locked answer is one value on one box — assert it
            and every party holding that role signs under the same title. Set
            `job_title` / `company` on each recipient instead. (A sender may
            still tick “Signer cannot change” on such a field in the editor:
            there the lock says *the roster’s value is final* and carries no
            answer of its own.)


            `locked_answer_missing` — A field on this envelope is locked,
            required and carries no answer — three statements that cannot all be
            honoured. A locked answer is the sender’s, so the signer is refused
            if they supply one and the completeness check does not ask them for
            it; once sent, nobody could fill the box and it would seal blank. ⚠️
            THE LOCK MAY NOT HAVE COME FROM YOUR REQUEST — it can be authored on
            the template — so the message names the FIELD rather than a
            `values[i]` path. Send a value for that address, send `{"value": "",
            "locked": false}` to hand it back to the signer, or have the
            template make it optional. ⚠️ THE SAME CODE ALSO MEANS A QUESTION
            NOBODY LEFT COULD ANSWER. Grouped tickboxes — a Race or Gender
            question — are never individually `required`; the question’s own
            `min_selected` is what makes it compulsory. Lock every option of
            such a question with no tick in any of them and it asks for more
            answers than remain within anybody’s reach, which is the same
            unsatisfiable sentence and refuses the same way. There the message
            names the QUESTION, and the remedies are to tick one of the locked
            options, unlock one, or lower the question’s minimum.


            `locked_answer_unusable` — A field on this envelope carries a locked
            answer the field itself will not accept: a value its validation rule
            rejects, one its input format cannot carry, one longer than the
            printed cells of a comb or holding a character one of those cells
            does not accept, a `date` that is not a calendar day, or a dropdown
            answer that is not among the options it offers. ⚠️ A `date` DRAWN AS
            PRINTED CELLS IS MEASURED IN ITS OWN SPELLING: it holds the digits
            its boxes print — `15092026`, not `2026-09-15` — and those digits
            must still name a day that exists, read in the order the field’s
            label states (`Date of birth (DD/MM/YYYY)`). ⚠️ THIS IS NOT
            `locked_answer_missing` — that one is a box with NO answer. This is
            a box with an answer nobody can use: a locked answer is the
            sender’s, the signer may not change it, and their very first save
            would be refused over it on an envelope nobody can edit any more.
            Refused here so that it is still a draft. ⚠️ THE ANSWER MAY NOT HAVE
            COME FROM YOUR REQUEST — a lock and its value can be authored on the
            template, and a rule can be tightened after the answer was fixed —
            so the message names the FIELD rather than a `values[i]` path. Send
            a value that satisfies the field, send `{"locked": false}` to hand
            the box back to the signer, or change the rule on the template. ⚠️
            THE SAME CODE ALSO MEANS A QUESTION THE SENDER ANSWERED MORE TIMES
            THAN IT PERMITS, AND THEN NO FIELD RULE IS BROKEN AT ALL. Grouped
            tickboxes — a Marital status question at `max_selected: 1` — can
            each carry a perfectly good locked tick while together they assert
            more than the question allows; every one of them would seal, side by
            side, on a question that says fewer can be true, and the signer may
            clear none of them. There the message names the QUESTION rather than
            a field, and sending a field-valid value resolves nothing because
            every value already is one. The remedies are to untick one of the
            locked options, to unlock one so the signer chooses, or to raise the
            question’s `max_selected`. ⚠️ AND THAT LAST ONE IS NOT SOMETHING YOU
            CAN SEND: `max_selected` is published by `GET
            /api/v1/templates/{templateId}` and NO ENDPOINT HERE SETS IT — a
            question’s bounds are authored on the template, and its `id` is a
            join key within that response rather than an address this API
            accepts. ⚠️ AND ONE ADDRESS MAY TICK EVERY OPTION AT ONCE, which is
            the commonest way a request reaches this: the options of one
            question often share a `data_key` (all six of Race are `race`), and
            a `values` entry fills EVERY box at its address — so `{"value":
            "true", "locked": true}` there locks a tick into all of them. Give
            the options their own addresses on the template if you need to fix
            exactly one.


            `cannot_be_drawn` — A value, a name or a title contains something
            the sealer cannot draw into the PDF. Asked BEFORE the send rather
            than discovered after everybody has signed. The message names which
            string.


            `brand_unknown` — `brand_id` names no live brand of your
            organisation — it may belong to another account, or have been
            archived. Read the ids from Settings, or omit the field to send
            under the default. ⚠️ IT IS REFUSED RATHER THAN IGNORED because the
            branding an envelope went out under is frozen the moment it is sent:
            a wrong constant would put somebody else’s letterhead on every
            envelope you send, permanently, and nothing else would say so. (The
            product’s own send screen falls through to the default instead,
            because there the id came from a list a person was shown rather than
            from a program.)


            `brand_has_no_embed_origins` — The brand has no embedding origins
            registered, so a signing URL for it could not be framed by anything
            — and an embedded recipient is never emailed, so they would be
            unreachable. Register the domains you embed on against that brand in
            Settings; they must be bare origins (`https://example.com`, no
            trailing slash and no path). ⚠️ THE BRAND IS THE ONE THE ENVELOPE
            GOES OUT UNDER — the `brand_id` you sent, or the organisation’s
            default when you sent none. It is NOT always the default:
            registering an origin against the default while sending under
            another brand is the mistake this refusal most often means.


            `recipient_cannot_be_embedded` — A recipient was declared
            `"embedded": true` on a role the template routes as `cc`. A
            copied-in reader is never asked to sign and is never issued a
            signing credential, so an embedded one could be reached by nothing
            at all — no email, and no URL. Either drop the flag, or give the
            role a routing type that signs, in the template editor.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
                description: The refusal.
        '429':
          description: >-
            `rate_limited` — This key has spent its minute. The limit is per KEY
            and per minute, and it follows your PLAN rather than the kind of key
            — 600 requests a minute on every paid plan, 60 on the free plan, and
            the sandbox gets the same number as production so that an
            integration which passes in rehearsal passes live. Every successful
            authenticated response, and this one, carries `RateLimit-Limit`,
            `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client
            can slow down before it is made to. TWO OPERATIONS ALSO ANSWER IT
            FOR A REASON OF THEIR OWN. `POST /api/v1/envelopes/batches` costs
            one request per row and is refused WHOLE when its rows do not fit
            what is left of the key’s minute — `RateLimit-Remaining` says how
            many would. `GET
            /api/v1/envelopes/{envelopeId}/documents/{position}/draft` has its
            own per-envelope and per-organisation ceilings on renders each
            minute, and the `message` says which one refused; there
            `Retry-After` is that ceiling’s, while the `RateLimit-*` headers
            still describe the key. `Retry-After` on this refusal is at most 60
            and is read from the stored window rather than computed, so two of
            our instances refusing the same key quote the same instant. ⚠️ A
            REFUSED REQUEST STILL COUNTS: hammering a limit you have already
            exceeded pushes the counter higher rather than holding it. Honour
            `Retry-After`. If one key genuinely needs more throughput, issue a
            second one in Settings — the ceiling is a fairness and blast-radius
            control, not a commercial meter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
                description: The refusal.
          headers:
            RateLimit-Limit:
              description: >-
                How many requests this key may make per minute: 600 on any paid
                plan and 60 on the free plan, the same in both environments. Per
                key, not per organisation — a second key has its own allowance.
                ⚠️ READ IT RATHER THAN ASSUMING IT: it is the number the
                database applied to this request, so it is right even when this
                description is out of date.
              schema:
                type: integer
                description: Requests permitted in the current minute.
            RateLimit-Remaining:
              description: >-
                How many of them are left, this one already counted. Zero on the
                request that is refused, and zero on every further request of
                that minute — ⚠️ a refused request still counts.
              schema:
                type: integer
                description: Requests remaining in the current minute.
            RateLimit-Reset:
              description: >-
                Seconds until the current minute closes and the allowance
                returns. At most 60, which is what makes the published
                exponential backoff converge in about six doublings rather than
                eleven.
              schema:
                type: integer
                description: Seconds until the window resets.
            Retry-After:
              description: >-
                Seconds to wait before retrying, from the stored end of the
                window. Always present on a 429, and never more than 60. On the
                per-key limit it equals `RateLimit-Reset`; a narrower limit,
                such as the draft render allowance, has a window of its own and
                may ask for a different wait. `x-error-codes` tells you to
                honour it; this is it.
              schema:
                type: integer
                description: Seconds to wait before retrying.
        '500':
          description: >-
            `internal_error` — Ours, not yours. ⚠️ RETRY WITH THE SAME
            `Idempotency-Key` YOU SENT THE FIRST TIME, on any operation that
            takes one — that is what makes a retry safe here, and sending a NEW
            key would create a second envelope or send the same one twice. The
            safe GETs in this document can be retried freely. (This entry used
            to say every operation here was a GET; the write endpoints have
            since shipped, and the reasoning moved with them.)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
                description: The refusal.
      security:
        - ApiKey: []
components:
  schemas:
    CreateEnvelopeBatchRequest:
      type: object
      description: One template, many recipient sets, one answer.
      properties:
        template_id:
          type: string
          format: uuid
          examples:
            - 01960000-0000-4000-8000-0000000007e1
          description: >-
            The template every row instantiates. ⚠️ BATCH-LEVEL, AND ONE
            TEMPLATE IS THE FEATURE — a request that could name a different
            template per row would be `POST /api/v1/envelopes` in a loop with
            the loop moved inside our process.
        rows:
          type: array
          description: >-
            The recipient sets, one envelope each, in order. ⚠️ AT MOST 100 ROWS
            AND AT MOST 1000 RECIPIENTS IN TOTAL ACROSS THEM; either bound is
            `invalid_request` and nothing is created. `results[i]` in the
            response is what happened to `rows[i]`, and every result also
            carries its own `index`.
          items:
            $ref: '#/components/schemas/EnvelopeBatchRow'
            description: 'One row: the people for one copy of the template.'
        send:
          type: boolean
          description: >-
            Optional, default `false`, and it applies to the whole batch. False
            creates the drafts and emails nobody; true sends every row,
            consuming one unit of the plan’s allowance per envelope.
        expires_at:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            Optional, default `null`. One deadline for every envelope in the
            batch. Same rules as on `POST /api/v1/envelopes`: an absolute ISO
            8601 instant, at least 1 hour and at most 365 days out.
        brand_id:
          type:
            - string
            - 'null'
          format: uuid
          description: >-
            Optional, default `null` — the organisation’s default brand. Which
            brand the envelope goes out under: the letterhead, the colours and
            the sending name a signer sees. ⚠️ THIS IS THE FIELD FOR SENDING ON
            BEHALF OF MANY CUSTOMER BRANDS FROM ONE ACCOUNT — change it between
            calls and the same integration sends Customer A’s paperwork under
            Customer A’s brand and Customer B’s under Customer B’s.


            ⚠️ AN ID THAT NAMES NO LIVE BRAND OF YOUR ORGANISATION IS REFUSED
            WITH `brand_unknown`, NOT IGNORED — including one that has been
            archived. The branding an envelope went out under is frozen the
            moment it is sent and cannot be corrected afterwards, so a wrong
            constant in your configuration would otherwise put somebody else’s
            letterhead on every envelope you ever send and nothing would say so.


            It is recorded on the envelope, so a draft created with `send:
            false` still goes out under this brand when it is sent later.


            ⚠️ PER BATCH, NOT PER ROW. One request sends under one brand — which
            is the reseller case exactly, since a batch already names one
            template and two customers do not share one. Two brands means two
            batches. A row-level override remains additive if that ever changes.
        completion_delivery:
          type: string
          enum:
            - vumasign
            - integrator
          description: >-
            Who tells the recipients the envelope is done and sends them the
            executed document. `vumasign` (the default for a send from the
            dashboard) means we email the sender and every recipient we are able
            to email, with the sealed PDF attached. `integrator` (the default
            for a send made with an API key) means we send nothing at completion
            and you do it. Omit it and the origin of the **send** decides — an
            envelope created with `send: false` and then sent from the dashboard
            is `vumasign`. It cannot be changed once the envelope has been sent.


            ⚠️ AN `embedded` RECIPIENT IS NEVER EMAILED, WHATEVER THIS SAYS. We
            issue one no signing link and send them no invitation, because their
            address may be an identifier of yours rather than a mailbox — so
            they are not sent the completed document either, even under
            `vumasign`. **Delivering the executed document to an embedded
            recipient is yours on every envelope**, and this field only decides
            who tells everybody else.


            ⚠️ IF YOU TAKE THIS ON, SUBSCRIBE TO `envelope.sealed` RATHER THAN
            `envelope.completed`. `envelope.completed` fires when the last
            person signs, which is about a minute before the executed document
            exists, and `envelope.sealing_failed` tells you it never will.


            ⚠️ PER BATCH, NOT PER ROW. One request is one answer, written onto
            every envelope it creates. Two answers means two batches. A
            row-level override remains additive if that ever changes.
      required:
        - template_id
        - rows
    EnvelopeBatch:
      type: object
      description: A bulk send, and what became of every row of it.
      properties:
        batch_id:
          type: string
          format: uuid
          description: >-
            This request’s own identifier, recorded on every envelope it
            created. There is no endpoint that takes it back — the answer is in
            your hand, and a caller who lost it replays their `Idempotency-Key`.
        template_id:
          type: string
          format: uuid
          description: The template every row was made from.
        count:
          type: integer
          description: >-
            How many envelopes exist. Always the number of rows in the request:
            every row produces an envelope, or the whole request is refused.
        sent:
          type: integer
          description: How many went out. Zero when `send` was false.
        failed:
          type: integer
          description: >-
            ⚠️ THE ONE FIELD THAT ANSWERS "DID IT WORK", AND IT IS AN INTEGER
            RATHER THAN A WALK. Zero on a `201` and non-zero on a `207`, always
            — so the status line and this number never disagree, and a client
            that reads neither the body nor this field still gets the truth from
            the status.
        results:
          type: array
          description: One entry per row, in the order the request listed them.
          items:
            $ref: '#/components/schemas/EnvelopeBatchResult'
            description: What happened to one row.
      required:
        - batch_id
        - template_id
        - count
        - sent
        - failed
        - results
    Error:
      type: object
      description: >-
        The body of every refusal the API returns deliberately, at every status,
        from every endpoint. Such a response is a failure if and only if it
        carries an `error` key — that test is correct even for a client that
        ignores the status line, which is a thing real clients do. An unexpected
        server failure may not have this shape: treat a 5xx without an `error`
        key as an `internal_error`, which is retryable.
      properties:
        error:
          type: object
          description: The refusal. Anything added in future is added INSIDE this object.
          properties:
            code:
              type: string
              description: >-
                A stable snake_case identifier that will never change meaning or
                spelling. This is the contract; branch on it. See
                `x-error-codes` at the root of this document for retry semantics
                per code.
              enum:
                - unauthenticated
                - forbidden
                - insufficient_scope
                - not_found
                - invalid_request
                - rate_limited
                - internal_error
                - service_unavailable
                - idempotency_key_required
                - idempotency_key_reused
                - request_in_progress
                - template_archived
                - template_unusable
                - template_has_no_fields
                - recipient_role_unknown
                - recipient_role_missing
                - recipient_role_duplicated
                - value_subject_unknown
                - value_address_unknown
                - value_not_writable
                - value_not_lockable
                - locked_answer_missing
                - locked_answer_unusable
                - cannot_be_drawn
                - send_allowance_exhausted
                - test_key_cannot_send
                - test_key_cannot_read_documents
                - test_key_cannot_mint
                - webhook_url_invalid
                - webhook_events_invalid
                - webhook_brand_unknown
                - webhook_limit_reached
                - api_key_owner_removed
                - recipient_not_embedded
                - recipient_not_yet_turn
                - recipient_cannot_sign
                - envelope_not_sent
                - envelope_not_draft
                - brand_has_no_embed_origins
                - recipient_cannot_be_embedded
                - batch_interrupted
                - brand_unknown
                - draft_not_available
            message:
              type: string
              description: >-
                ⚠️ PROSE FOR A DEVELOPER READING A LOG, and NOT part of the
                contract. It may be reworded in any release; anything that
                parses it is broken by design.
          required:
            - code
            - message
      required:
        - error
    EnvelopeBatchRow:
      type: object
      description: >-
        One envelope’s worth of people and values. ⚠️ IT IS THE SAME
        `recipients` AND `values` AS `POST /api/v1/envelopes` — same rules, same
        refusals, same error codes — so an integrator who can send one envelope
        can send a hundred without learning a second vocabulary.
      properties:
        recipients:
          type: array
          description: >-
            ⚠️ THE ORDER OF THIS ARRAY IS THE ROUTING ORDER, per row. At most
            100.
          items:
            $ref: '#/components/schemas/RecipientInput'
            description: One recipient.
        values:
          type: array
          description: Optional. Values to prefill for this row, as triples. At most 500.
          items:
            $ref: '#/components/schemas/ValueInput'
            description: One value at one address.
        reference:
          type:
            - string
            - 'null'
          examples:
            - hire-2026-0917
          description: >-
            Optional, default `null`. Your own name for this envelope — an order
            number, a case id, the id of the row in your database that asked for
            it — opaque to us. Not blank, and at most 255 UTF-16 code units once
            composed to NFC, as JavaScript counts string length: most characters
            count as one, some (outside the Basic Multilingual Plane, such as
            most emoji) count as two. No `maxLength` is declared, because JSON
            Schema counts code points and would admit a value the server
            refuses. Stored and returned in Unicode NFC. `GET
            /api/v1/envelopes?reference=` finds every envelope carrying it,
            which is how you find out whether a request whose answer you lost
            created anything.


            ⚠️ IT IS NOT UNIQUE. We accept the same reference on any number of
            envelopes, and the list returns all of them, newest first. If you
            want one envelope per reference, make your references unique —
            derive it from the record that asked for the envelope, not from the
            attempt.


            ⚠️ IT CANNOT BE SET OR CHANGED AFTER CREATION. Not on a draft, not
            by a later request: a reference that could move would not identify
            anything.


            ⚠️ PER ROW, NOT PER BATCH — unlike `brand_id` and
            `completion_delivery`. A reference names one envelope, and a row is
            one envelope; `batch_id` on the response already names the batch.
      required:
        - recipients
    EnvelopeBatchResult:
      type: object
      description: What happened to one row of the batch.
      properties:
        index:
          type: integer
          description: >-
            This row’s position in the request’s `rows` array. Published even
            though the results are in order, because a client matching by array
            position is one `filter` away from telling the wrong person they
            have a contract waiting.
        envelope:
          $ref: '#/components/schemas/Envelope'
          description: >-
            The envelope this row became, in the state it is actually in. ⚠️
            PRESENT EVEN WHEN THE ROW FAILED: a failed row keeps its draft, with
            its recipients and values already on it, and `POST
            /api/v1/envelopes/{id}/send` is how you finish it once the cause is
            fixed.
        error:
          type:
            - object
            - 'null'
          description: >-
            Why this row was not sent, or `null` when it was. ⚠️ THE SAME `{
            code, message }` AS EVERY REFUSAL IN THIS API, with `code` from the
            same closed enum — so an existing switch statement works unchanged
            on a row. It is deliberately NOT a top-level `error` key: a 207 is
            not a failed request, and "a response is a failure if and only if it
            has an `error` key" stays true.
          properties:
            code:
              type: string
              description: A member of `x-error-codes`.
            message:
              type: string
              description: Prose for a developer. Never parse it; branch on `code`.
          required:
            - code
            - message
      required:
        - index
        - envelope
        - error
    RecipientInput:
      type: object
      description: One person, and the template role they fill.
      required:
        - role
        - name
        - email
      properties:
        role:
          type: string
          examples:
            - Tenant
          description: >-
            A `name` from the template’s `roles`. ⚠️ NOT AN ORDINAL: §3 refuses
            BoldSign’s `roleIndex`, which breaks the moment somebody reorders a
            template. An unknown name is `recipient_role_unknown` and names
            itself. One person per role: two recipients naming the same role are
            refused as `recipient_role_duplicated`, unless the template routes
            that role as `cc`.
        name:
          type: string
          examples:
            - Thandi Mokoena
          description: What the invitation and the certificate will call them.
        email:
          type: string
          format: email
          examples:
            - thandi@example.test
          description: >-
            Where the invitation goes. ⚠️ STILL REQUIRED WHEN `embedded` IS
            TRUE, even though nothing is sent to it: the address is on the
            certificate of completion, in the audit chain, and in any
            `signer_email` field on the document. Embedded signing says your
            application authenticated this person; it does not say they have no
            identity. ⚠️ THE SHAPE IS CHECKED AND ONLY THE SHAPE: one `@`, a
            dotted domain, at most 254 characters. A malformed address is
            `invalid_request` naming the offending index — refused before
            anything is created, because a sent envelope cannot have its
            recipients corrected. Nothing here can tell whether the mailbox
            exists. A mailbox that does not exist surfaces later as a bounce,
            which is recorded in the envelope’s history in the app and is not a
            webhook event. This API reflects it only indirectly: that
            recipient’s `invitation_delivered` stays `false`. ⚠️ `false` IS NOT
            PROOF OF A BOUNCE — it also means "not reported delivered yet".
        job_title:
          type:
            - string
            - 'null'
          examples:
            - Group Executive
          description: >-
            Optional, default `null`. The job title you assert this person
            holds, at most 200 characters. ⚠️ IT IS SEEDED INTO A `signer_title`
            FIELD AT SEND AND THE SIGNER MAY THEN CORRECT IT — they are the
            authority on their own title, and stamping it would mean an
            executive whose title you got slightly wrong must sign a document
            that misstates their role or decline it. Your assertion is not lost:
            the seeded value is recorded with `source: "prefill"` and a
            correction with `source: "signer"`.


            ⚠️ IT APPEARS NOWHERE IF THE DOCUMENT ASKS FOR IT NOWHERE. The
            template defines what is collected and what is shown, so a title
            supplied for a document that places no title field is on neither the
            pages nor the certificate of completion. ⚠️ AND IT IS NOT ECHOED
            BACK: it is read once, at send, and the certificate reports the
            FIELD VALUE rather than this. Blank is refused — send `null`.
        company:
          type:
            - string
            - 'null'
          examples:
            - Vumasign (Pty) Ltd
          description: >-
            Optional, default `null`. The employer you assert this person has,
            at most 200 characters. Seeded into a `signer_company` field at
            send, editable by the signer, absent from the certificate unless the
            document asked, and not echoed back — all four for the same reasons
            as `job_title`. ⚠️ THIS REPLACES THE ADDRESS ROUTE FOR THE COMMON
            CASE: `values` with the address `company` still works and still
            takes precedence, but you no longer have to describe the same person
            twice.
        embedded:
          type: boolean
          description: >-
            Optional, default `false`. ⚠️ TRUE MEANS THIS PERSON IS NEVER
            EMAILED — you reach them by minting a URL with `POST
            /api/v1/envelopes/{envelopeId}/recipients/{recipientId}/signing-url`
            and framing it. An explicit mode rather than DocuSign’s inference
            from `clientUserId` being non-null, and it is returned on every
            recipient we describe, which theirs is not.


            ⚠️ REFUSED IN TWO SITUATIONS, BOTH OF WHICH WOULD LEAVE THE PERSON
            UNREACHABLE: a role the template routes as `cc` (never issued a
            signing credential by any path), and an organisation whose default
            brand has no embedding origins registered.
        external_ref:
          type:
            - string
            - 'null'
          description: >-
            Optional, default `null`. Your own identifier for this human. Not
            blank, and at most 255 UTF-16 code units once composed to NFC, as
            JavaScript counts string length: most characters count as one, some
            (outside the Basic Multilingual Plane, such as most emoji) count as
            two. No `maxLength` is declared, because JSON Schema counts code
            points and would admit a value the server refuses. Stored and echoed
            back in Unicode NFC, so compare it in NFC, and used by us for
            nothing at all — it is deliberately separate from `embedded` because
            they answer different questions and this one is optional.
    ValueInput:
      type: object
      description: One value, at one address. See the note above about triples.
      required:
        - subject
        - key
        - value
      properties:
        subject:
          type:
            - string
            - 'null'
          examples:
            - tenant
          description: >-
            A `key` from the template’s `subjects` — WHOSE datum this is — or
            `null` for a field belonging to nobody in particular. Required as a
            property: `null` and "I did not say" must not be two spellings of
            one thing.
        key:
          type: string
          examples:
            - full_name
          description: The field’s `data_key`, exactly as the template endpoint reports it.
        value:
          type: string
          examples:
            - Thandi Mokoena
          description: >-
            What to write. It lands in EVERY field at this address, which is the
            point. An address that matches nothing is refused rather than
            dropped.
        locked:
          type: boolean
          examples:
            - true
          description: >-
            Optional. `true` fixes this answer: the signer reads it and cannot
            change it, and a submission that tries is refused. Use it when your
            own records are the authority and a signed form contradicting them
            would help nobody.


            ⚠️ IT RIDES ON THE VALUE ON PURPOSE, so you cannot lock an address
            without saying in the same breath what it is locked TO. To hand a
            field back to the signer, send `{"value": "", "locked": false}`: the
            empty string already means *clear this*.


            ⚠️ `{"value": "", "locked": true}` IS ACCEPTED, and it means "this
            box stays empty and the signer may not fill it". It is the spelling
            a grouped tickbox needs — five locked, empty options of a Race
            question, each saying *not this one* — so it is not refused. Earlier
            versions of this text said it could not be expressed at all; that
            was wrong.


            ⚠️ A LOCKED ANSWER SURVIVES BEING HIDDEN AND SHOWN AGAIN. A signer
            can make a section disappear — by answering the question a condition
            reads, or by declaring that a `subject` is not on the document — and
            the answers under it are removed, so nothing unasked is printed. A
            locked answer is yours rather than theirs, so it is kept separately
            and comes back the moment the section does. You do not have to
            re-send it, and no gesture available to the signer can destroy it.


            ⚠️ ON A TICKBOX, AN EMPTY VALUE IS RECORDED AS `false`. The two
            already mean the same thing to everything that reads or draws a
            tickbox; storing one spelling is what keeps *not this one* a single
            sentence. Every other type keeps exactly what you sent.


            ⚠️ OMITTING IT LEAVES THE FIELD AS THE TEMPLATE HAD IT, which is not
            the same as sending `false`. A sender can lock a field in the
            editor; absence respects that, `false` overrides it.


            ⚠️ AN ADDRESS MAY NAME SEVERAL BOXES AND THIS LOCKS ALL OF THEM —
            the same rule `value` follows. Two shapes of empty lock refuse the
            SEND with `locked_answer_missing`, because nobody left could supply
            the answer: a field that is locked, `required` and carries no value;
            and a QUESTION whose `min_selected` is out of reach because its
            options are locked and unticked. An optional, ungrouped box that is
            locked and empty is neither, and seals blank as it says.


            ⚠️ AND NEITHER REFUSAL LOOKS AT A BOX ABOUT SOMEBODY THIS ENVELOPE
            IS NOT ABOUT. A field carrying a `subject` you declared absent in
            `subjects` — or one the template marks optional and nobody declared
            — is hidden from the signer and cannot block the document, so a lock
            on it moved no demand and there is nothing to refuse. This is what
            lets one template carry a locked spouse section and still send to
            somebody who has no spouse: `subjects` decides who the envelope is
            about, and these refusals are about the people it IS about.
    Envelope:
      type: object
      description: 'An envelope: a template instantiated with real people and real values.'
      properties:
        id:
          type: string
          format: uuid
          description: The envelope’s id. A bare uuid.
        status:
          type: string
          description: >-
            `draft` or `sent` from this endpoint. Later states arrive as people
            act.
        template_id:
          type:
            - string
            - 'null'
          format: uuid
          description: >-
            The template this was made from, or `null` when there was none — an
            envelope created by `POST /api/v1/envelopes/one-off` carries the
            document itself and never had a template. Branch on `null`, never on
            the empty string.
        title:
          type: string
          description: >-
            What the signers see naming the document. Defaults to the template’s
            name.
        reference:
          type:
            - string
            - 'null'
          examples:
            - hire-2026-0917
          description: >-
            The `reference` you set when you created it, in Unicode NFC, or
            `null`. Not unique, and never changed after creation.
        created_at:
          type: string
          format: date-time
          description: ISO 8601, UTC.
        sent_at:
          type:
            - string
            - 'null'
          format: date-time
          description: ISO 8601, UTC. Null while it is a draft.
        expires_at:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            ISO 8601, UTC. Null when this envelope has no deadline, which is the
            default. Read back from the stored value rather than echoed, so an
            offset you sent comes back as the same instant in UTC.
        recipients:
          type: array
          description: In routing order.
          items:
            $ref: '#/components/schemas/EnvelopeRecipient'
            description: One recipient.
      required:
        - id
        - status
        - template_id
        - title
        - reference
        - created_at
        - sent_at
        - expires_at
        - recipients
    EnvelopeRecipient:
      type: object
      description: One recipient of an envelope, as it stands.
      properties:
        id:
          type: string
          format: uuid
          description: >-
            This recipient. Published where a template ROLE’s id deliberately is
            not, because a recipient has no natural key — one person may hold
            two roles and two people may hold one.
        role:
          type: string
          description: The template role they were given.
        name:
          type: string
          description: As stored, after normalisation.
        email:
          type: string
          format: email
          description: As stored, after normalisation.
        routing_type:
          type: string
          enum:
            - sign
            - approve
            - cc
          description: >-
            ⚠️ FROM THE TEMPLATE, NEVER FROM THE REQUEST. Whether a role signs,
            approves or is copied in is part of the layout (§3), and a caller
            who could override it could send a document whose signature boxes
            nobody will ever be asked to fill.
        status:
          type: string
          description: >-
            `recipients.status` verbatim. ⚠️ `pending` AFTER A SUCCESSFUL SEND
            IS NORMAL: a sequential template invites only the first position and
            holds the rest back until it empties.
        invitation_delivered:
          type:
            - boolean
            - 'null'
          description: >-
            Whether the email provider reports the invitation as DELIVERED.
            `null` when none was attempted — a draft, a `cc`, an embedded
            recipient, or a position held back.


            ⚠️ ON A READ, THIS NO LONGER ANSWERS `true` AS SOON AS THE SEND
            RETURNS, and it used to. Accepting a message and delivering it are
            different events: we now record delivery only when the provider
            tells us it happened, on the provider's clock. A just-sent
            invitation therefore reads `false` on `GET /api/v1/envelopes/{id}`
            and turns `true` seconds to minutes later.


            ⚠️ THE ONE EXCEPTION IS THE RESPONSE TO `POST .../send` ITSELF,
            where `true` means the provider ACCEPTED the message — the only
            thing anybody knows while the send is still running. That
            operation’s response description says so at length. The same
            recipient can therefore read `true` from the send and `false` from a
            read a second later; the read is the one to decide from.


            ⚠️ `false` IS NOT A BOUNCE. It covers both "the provider refused the
            send" and "we have not been told yet", and you cannot tell those
            apart from this field — do not surface it to a human as a failure.
            `null` is the only value that has never changed meaning: nothing was
            attempted.
        embedded:
          type: boolean
          description: >-
            Whether this recipient is reached by a minted signing URL instead of
            by email. ⚠️ RETURNED HERE PRECISELY BECAUSE DocuSign DOES NOT
            RETURN THEIRS: `clientUserId` is absent from their list endpoints,
            so an integrator who lost their own record of which recipients were
            embedded has no way to ask. When this is true,
            `invitation_delivered` is always `null` — nothing was attempted, and
            nothing was meant to be.
        external_ref:
          type:
            - string
            - 'null'
          description: >-
            Whatever you supplied at creation, normalised to Unicode NFC, or
            `null`: compare it in NFC, not byte for byte. Opaque to us and
            joined on by nothing — it is here so a webhook or this response can
            be matched to your own record of the same human without a side
            table.
      required:
        - id
        - role
        - name
        - email
        - routing_type
        - status
        - invitation_delivered
        - embedded
        - external_ref
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      description: >-
        `Authorization: Bearer vsk_live_…`. Chosen over a bespoke `X-API-KEY`
        header because every client, proxy and log-redaction rule already knows
        this one. What a key may DO is its scopes — see `x-scopes` at the root
        of this document and `x-required-scope` on each operation. The scope
        list is not written here because OpenAPI reserves a requirement’s scope
        array for `oauth2` and `openIdConnect` and requires it to be empty for
        an `http` scheme.

````