> ## 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.

# Send a document that is not a template, and optionally send it.

> THE ENDPOINT FOR A DOCUMENT THAT EXISTS ONCE — a Letter of Authority, an offer of employment, anything addressed to one person and never reused.

⚠️ IT MAKES NO TEMPLATE, WHICH IS THE POINT. `template_id` comes back `null`. Registering a single-use document as a template to send it would leave one behind on every send, and this API has no way to delete one.

LAYOUT COMES FROM THE DOCUMENT’S OWN TEXT TAGS. Write `<<sig:Employee>>` where a signature goes and give a recipient the `role` `Employee`; the field is placed where the tag is. A tag naming a role no recipient holds is refused, with the tag, its page, and the roles the request does declare — because `fields.role_name` is matched to `recipients.role_name` by nothing the database checks, so a tag naming the PERSON would bind a field to a slot nobody holds, silently.

EVERY TAG REFUSAL COMES BACK AT ONCE, not the first, so a document with four bad tags is one correction round rather than four. And a refusal creates nothing: no envelope, no recipients, no stored documents.

✅ A PDF OR A WORD `.docx`, CHECKED BY THE BYTES. A Word document is accepted and converted to PDF on our side, so a docxtemplater or Word pipeline uploads the `.docx` it already has — text tags survive that render, and it is now our render. The envelope is over the converted PDF: that is what page numbers and field coordinates refer to, and it is what a signer sees. The `.docx` you sent is retained unchanged and is not what anybody signs. The older `.doc` format is not accepted.

A REPLAY RETURNS THE SAME ENVELOPE AND CREATES NOTHING, exactly as `POST /api/v1/envelopes` does. ⚠️ Reordering `recipients` is a DIFFERENT request and will not replay across, because that order is the routing order.

NO SIGNING URLS ARE RETURNED. Ask for one with `POST /api/v1/envelopes/{envelopeId}/recipients/{recipientId}/signing-url` when that signer is ready — minting them here would start every single-use URL’s expiry clock at creation, for people who may be days apart in the order.



## OpenAPI

````yaml /openapi.json post /api/v1/envelopes/one-off
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/one-off:
    post:
      tags:
        - Envelopes
      summary: Send a document that is not a template, and optionally send it.
      description: >-
        THE ENDPOINT FOR A DOCUMENT THAT EXISTS ONCE — a Letter of Authority, an
        offer of employment, anything addressed to one person and never reused.


        ⚠️ IT MAKES NO TEMPLATE, WHICH IS THE POINT. `template_id` comes back
        `null`. Registering a single-use document as a template to send it would
        leave one behind on every send, and this API has no way to delete one.


        LAYOUT COMES FROM THE DOCUMENT’S OWN TEXT TAGS. Write `<<sig:Employee>>`
        where a signature goes and give a recipient the `role` `Employee`; the
        field is placed where the tag is. A tag naming a role no recipient holds
        is refused, with the tag, its page, and the roles the request does
        declare — because `fields.role_name` is matched to
        `recipients.role_name` by nothing the database checks, so a tag naming
        the PERSON would bind a field to a slot nobody holds, silently.


        EVERY TAG REFUSAL COMES BACK AT ONCE, not the first, so a document with
        four bad tags is one correction round rather than four. And a refusal
        creates nothing: no envelope, no recipients, no stored documents.


        ✅ A PDF OR A WORD `.docx`, CHECKED BY THE BYTES. A Word document is
        accepted and converted to PDF on our side, so a docxtemplater or Word
        pipeline uploads the `.docx` it already has — text tags survive that
        render, and it is now our render. The envelope is over the converted
        PDF: that is what page numbers and field coordinates refer to, and it is
        what a signer sees. The `.docx` you sent is retained unchanged and is
        not what anybody signs. The older `.doc` format is not accepted.


        A REPLAY RETURNS THE SAME ENVELOPE AND CREATES NOTHING, exactly as `POST
        /api/v1/envelopes` does. ⚠️ Reordering `recipients` is a DIFFERENT
        request and will not replay across, because that order is the routing
        order.


        NO SIGNING URLS ARE RETURNED. Ask for one with `POST
        /api/v1/envelopes/{envelopeId}/recipients/{recipientId}/signing-url`
        when that signer is ready — minting them here would start every
        single-use URL’s expiry clock at creation, for people who may be days
        apart in the order.
      operationId: createOneOffEnvelope
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: >-
            Any string that identifies this request; a UUID is the usual choice,
            and at most 255 characters. ⚠️ REQUIRED. Without it a timeout is
            unresolvable: you cannot learn whether the envelope was created, and
            retrying sends the same document a second time.
          schema:
            type: string
            examples:
              - 01960000-0000-4000-8000-00000000ffff
            description: The caller’s own request identifier.
      requestBody:
        required: true
        description: The documents, the people, and whether to send.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOneOffRequest'
              description: The request.
            examples:
              example:
                summary: One tagged PDF, one embedded signer
                value:
                  title: Offer of employment — A. Dlamini
                  documents:
                    - content: >-
                        JVBERi0xLjcKJYGBgYEKCjUgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCAxODAKPj4Kc3RyZWFtCnicjc9LCgIxDAbgfU7RtSCmj/xpQYRxrLhwI/QCIiqKLhTx/HbGlYOCLQkpDf3SG80Lsen2/UiT1f7y3D9Ou+1YOcUQWWMyFqYcyAVT1mT7VmvARmuUK03DEt0SdY5R65oFC2W0WGrCop6yhpkpZyojyoU2dPulJg0O0QmisfarinrVqeKRoMj19VBFp8mxBkTIwBeIb/7THTNHlhTxU5f3n33rW+0n8E0YatnnD+8FYlhOhwplbmRzdHJlYW0KZW5kb2JqCgo2IDAgb2JqCjw8Ci9GaWx0ZXIgL0ZsYXRlRGVjb2RlCi9UeXBlIC9PYmpTdG0KL04gNAovRmlyc3QgMjAKL0xlbmd0aCAyNjMKPj4Kc3RyZWFtCnicZVDRSgMxEHzPV+wP2E1i7i4HpdCWVkFEaYUK4kN6F46UkkgvJ/XvzcbTUiQv2Z3Zmd0RwEGCUnALlQYFouQwnTJ8+fqwgM+msz3DB9f28JZQDht4Z7gMg48g2GzGLtylieYYOvYzBILI14x18JHhdtjHXFJTMFyY3hICeG+Pnza6xjBc+Sa0zneAO+fnvne/jWtFsiLDk6V9siNubB+GU5NWIF5Wps+f+E3Fa600r3Sdjs4jF6yulCy1LEr9H5Occ82LWpcjllbB16f9wTbZgsrVOd5to4l2bFDv0bbOLMI5JcjTK+piIjVoJSZpg5Tm3PsQKd+crI/pFqqKMe0k8Q0eU3JYCmVuZHN0cmVhbQplbmRvYmoKCjcgMCBvYmoKPDwKL1NpemUgOAovUm9vdCAyIDAgUgovRmlsdGVyIC9GbGF0ZURlY29kZQovVHlwZSAvWFJlZgovTGVuZ3RoIDM4Ci9XIFsgMSAyIDIgXQovSW5kZXggWyAwIDggXQo+PgpzdHJlYW0KeJwVxMEJADAMA7GzC/kVOm+WT2M9BMyYgqTkdMQD6W5u+E9oAsIKZW5kc3RyZWFtCmVuZG9iagoKc3RhcnR4cmVmCjYzNAolJUVPRg==
                  recipients:
                    - role: Employee
                      name: A. Dlamini
                      email: a.dlamini@example.test
                      routing_type: sign
                      embedded: true
                      external_ref: usr_88213
                  sender: 01960000-0000-4000-8000-000000000021
                  signing_mode: sequential
                  send: true
                  reference: offer-2026-0042
      responses:
        '201':
          description: >-
            The envelope, as it stands, with `template_id` null.
            `Idempotency-Replayed` says whether this request created it
            (`false`) or is being shown an earlier one’s result (`true`); the
            status is 201 either way, so a client branching on it behaves
            identically on a retry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Envelope'
                description: The envelope.
              examples:
                example:
                  summary: 'Sent, and nobody emailed: the signer is embedded'
                  value:
                    id: 01960000-0000-4000-8000-0000000000e7
                    status: sent
                    template_id: null
                    title: Offer of employment — A. Dlamini
                    reference: offer-2026-0042
                    created_at: '2026-09-02T10:00:00.000Z'
                    sent_at: '2026-09-02T10:00:01.000Z'
                    expires_at: null
                    recipients:
                      - id: 01960000-0000-4000-8000-000000000060
                        role: Employee
                        name: A. Dlamini
                        email: a.dlamini@example.test
                        routing_type: sign
                        status: sent
                        invitation_delivered: null
                        embedded: true
                        external_ref: usr_88213
          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.
        '402':
          description: >-
            `send_allowance_exhausted` — ⚠️ 402, THE ONLY STATUS IN THIS API
            THAT NAMES MONEY. Not 403 (the credential is permitted; the plan is
            not) and not 429 (waiting does not help until the month turns).
            Upgrade the plan, or wait for the period to roll over. ⚠️ RETRYING
            IS POINTLESS BUT NOT HARMFUL — nothing was created, so there is no
            partial envelope to clean up. Only the Free plan (5 documents a
            month) is ever blocked, once that many documents have been sent in
            the month; every other plan is counted and never stopped, and a
            `vsk_test_` key never meets this at all because a sandbox envelope
            consumes no allowance to exhaust.
          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.


            `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.


            `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.
          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: >-
            `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_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.


            `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.


            `template_has_no_fields` — There are no fields, so the envelope
            would ask nobody to do anything. From a template, place at least one
            field on it in the editor. On `POST /api/v1/envelopes/one-off`,
            which has no template, the document you sent carries no field: add a
            text tag to it, or send `fields`. A batch refuses every row for
            this, because every row shares the template.
          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.
        '503':
          description: >-
            `service_unavailable` — Ours, and briefly. Something this operation
            depends on was not answering. Two services can produce it and which
            one it was depends on the operation: on a create, the service that
            renders a Word document to a PDF, so it is reachable only by sending
            a `.docx` and never by sending one that is already a PDF; on `GET
            .../documents/{position}/draft`, the service that renders the draft
            itself. ⚠️ THE DISTINCTION FROM 400 IS THE WHOLE POINT: your request
            is fine and your document is fine, so do not go and re-save it. On a
            create, nothing was created and no allowance was spent. On an
            operation that takes an `Idempotency-Key`, retry the identical
            request with the SAME one — a completed key replays, so a retry can
            never make a second envelope or a second template even if the first
            attempt got further than this answer suggests. A new key could. ⚠️
            ON THE DRAFT READ THE ATTEMPT WAS ALREADY PAID FOR: the render is
            charged to the envelope’s and the organisation’s per-minute draft
            ceilings before the renderer is asked, and a render that then fails
            is not refunded. There is no key to reuse, because there is nothing
            to replay — so retry on the backoff above rather than at once, or an
            outage of ours becomes a `rate_limited` of yours.
          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.
      security:
        - ApiKey: []
components:
  schemas:
    CreateOneOffRequest:
      type: object
      description: >-
        A document that is not a template, its people, and whether to send.
        Layout comes from the document’s own text tags, never from this body.
      properties:
        title:
          type: string
          examples:
            - Offer of employment — A. Dlamini
          description: >-
            What the signers see naming the document. Required — there is no
            template to borrow a name from.
        documents:
          type: array
          minItems: 1
          maxItems: 10
          description: >-
            The documents, base64-encoded, in the order a signer meets them.
            They are stacked into one scroll on the signing screen, and each is
            sealed on its own with its own Certificate of Completion. ⚠️ A PDF
            OR A WORD `.docx`, DECIDED BY THE BYTES AND NOT BY THE FILENAME — a
            `.docx` is converted to PDF on our side, and the envelope is over
            the converted PDF. Text tags survive that render, which is now OUR
            render rather than a step you take first. A tag addresses a page of
            the document it is written on, so `<<sig:Employee>>` on the annexure
            places a box on the annexure.
          items:
            $ref: '#/components/schemas/OneOffDocument'
            description: One document.
        recipients:
          type: array
          minItems: 1
          maxItems: 100
          description: >-
            ⚠️ THE ORDER OF THIS ARRAY IS THE ROUTING ORDER, and each `role` is
            a slot a text tag can name — `<<sig:Employee>>` finds the recipient
            whose `role` is `Employee`. A tag naming anything else is refused
            rather than creating a role nobody fills.
          items:
            $ref: '#/components/schemas/OneOffRecipient'
            description: One recipient.
        sender:
          type: string
          format: uuid
          examples:
            - 01960000-0000-4000-8000-000000000021
          description: >-
            Which member of this organisation the signer is asked BY — it
            becomes the name on the invitation and on the Certificate of
            Completion. Omitted means the user who minted the key, which is a
            fallback rather than a choice: when that person leaves, every
            document still arrives from them.
        signing_mode:
          type: string
          enum:
            - parallel
            - sequential
          examples:
            - sequential
          description: >-
            Whether everyone is asked at once, or each in turn. Defaults to
            `parallel`.
        send:
          type: boolean
          examples:
            - false
          description: >-
            Optional, default `false`. False creates a draft and emails nobody;
            true sends it, consuming the plan’s allowance and delivering to
            whoever’s turn it is. Embedded recipients are never emailed either
            way.
        expires_at:
          type:
            - string
            - 'null'
          format: date-time
          examples:
            - '2026-10-31T23:59:59Z'
          description: >-
            When the envelope stops being signable. Omitted means it does not
            expire.
        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.
        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.
      required:
        - title
        - documents
        - recipients
    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
    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
    OneOffDocument:
      type: object
      description: One document — a PDF or a Word `.docx` — base64-encoded.
      properties:
        content:
          type: string
          examples:
            - JVBERi0xLjcKJc...
          description: >-
            The document’s bytes, base64-encoded with the standard alphabet — no
            `data:` prefix, no whitespace, no URL-safe `-` or `_`. ⚠️ AT MOST 6
            MB OF DOCUMENT, whichever format you send, because the whole JSON
            body is capped at 8 MB and base64 costs a third on top. That bound
            is on the REQUEST, so it is what refuses an over-large file here —
            not the 20 MB the upload screen allows a PDF, and not the 10 MB it
            allows a `.docx`. Send it through the dashboard, or split it.
      required:
        - content
    OneOffRecipient:
      type: object
      description: >-
        One recipient of a one-off envelope. ⚠️ THE SAME SHAPE AS
        `RecipientInput` PLUS `routing_type`, which a one-off needs and a
        template-backed envelope does not: there is no template here to declare
        whether a role signs, approves or is merely copied in, so the request
        says.
      properties:
        role:
          type: string
          examples:
            - Employee
          description: >-
            The SLOT this person fills, and what a text tag names —
            `<<sig:Employee>>` finds the recipient whose role is `Employee`. A
            tag naming anything else is refused rather than creating a role
            nobody fills. At most one recipient per role may sign or approve — a
            second is refused as `recipient_role_duplicated` — and any number
            may be `cc`.
        name:
          type: string
          examples:
            - A. Dlamini
          description: The person. Printed on the Certificate of Completion.
        email:
          type: string
          format: email
          examples:
            - a.dlamini@example.test
          description: >-
            ⚠️ REQUIRED EVEN WHEN `embedded` IS TRUE. The address is never
            written to for an embedded recipient — no path emails one — but it
            is on the record and the certificate prints it.
        routing_type:
          type: string
          enum:
            - sign
            - approve
            - cc
          examples:
            - sign
          description: What this person does with the document. Defaults to `sign`.
        embedded:
          type: boolean
          examples:
            - true
          description: >-
            Reached by a minted URL rather than by email. Decided per recipient,
            so one envelope may have an embedded signer and emailed ones.
            Requires the sending brand to have an embedding origin registered.
        external_ref:
          type:
            - string
            - 'null'
          examples:
            - usr_88213
          description: >-
            Your own key for your own user, echoed back in Unicode NFC so a
            webhook or an envelope read can be matched to your record without a
            side table — compare it in NFC. Never read by this system. 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.
        job_title:
          type:
            - string
            - 'null'
          examples:
            - Financial Manager
          description: >-
            A fact about this person, seeded into a `signer_title` field if one
            exists.
        company:
          type:
            - string
            - 'null'
          examples:
            - Acme (Pty) Ltd
          description: As `job_title`.
      required:
        - role
        - name
        - email
    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.

````