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

# Mint a short-lived, single-use URL for an embedded signer.

> THE ONE ENDPOINT IN THIS API THAT RETURNS A BEARER CREDENTIAL FOR A LEGAL ACT. Anyone holding the `url` can open the document as that signer. **Do not log it, cache it, store it or put it in a support ticket** — put it in an `iframe src` and nowhere else.

⚠️ 10 MINUTES, AND SINGLE USE. The first GET spends it. A second GET within the 10 minutes renders a page that posts `signing_url_invalid` to your host page and signs nothing. A URL — opened or not — or the session it began, loaded after its lifetime is up (which the envelope’s deadline can shorten), renders the same kind of page and posts `signing_url_invalid` with `reason: "expired"` and `can_remint: true`, for 1 hour after that expiry; later than that it is refused a frame and posts nothing. Mint it when the signer arrives, not when the envelope is created — Dropbox Sign’s own advice, and the reason a recipient id is durable while a URL is not.

⚠️ THE URL AND THE SESSION HAVE DIFFERENT LIVES. `expires_at` is when the URL stops being redeemable; a signer who redeems it just before then gets 1 hour from that moment to read and sign (or less, if the envelope’s deadline is sooner). Reload the iframe on this timer only if nobody ever opened it.

FOUR THINGS MUST BE TRUE, and each has its own code: the envelope is open for signing, the recipient was created with `"embedded": true`, they have not already acted, and it is their turn (a sequential envelope holds later positions back — `recipient_not_yet_turn` is a 409 and the identical call succeeds once the person in front finishes).

⚠️ A `vsk_test_` key may mint only on a sandbox envelope — one a test key created — and is refused `test_key_cannot_mint` on any other.

## Framing

The page is served with `Content-Security-Policy: frame-ancestors ` naming the origins registered against the brand this envelope was sent under, and with **no** `X-Frame-Options`. Register those origins in Settings first; they must be bare — `https://example.com`, no trailing slash, no path, no wildcard — and an unregistered parent cannot frame the page at all. An expired URL or session we minted is framed for the same origins for 1 hour after it expires, so its refusal can reach you; it grants nothing, and after that it is refused. A URL we cannot verify — unreadable or altered — gets `X-Frame-Options: DENY` instead, and the ordinary emailed signing page keeps `X-Frame-Options: DENY` unconditionally.

## Talking to your page

The iframe posts `{ action, ...payload }` to each registered origin, never to `*`. Actions: `ready`, `signed`, `declined`, `delegated`, `signing_url_invalid`. `signing_url_invalid` carries `can_remint` — branch on that, not on `reason`, or a signer who finishes in another tab turns your retry into an infinite loop.

⚠️ `delegated` MEANS THEY HANDED IT TO SOMEBODY ELSE: this recipient is retired and a replacement holds their position, so take the iframe down — but the envelope is not finished and **there may be no webhook behind this one for a long time**, because a sequential replacement is not invited until its turn and an embedded one is never emailed at all. Re-read the roster with `GET /api/v1/envelopes/{envelopeId}` and mint a URL for the new recipient id when you want them to sign in your page.

⚠️ **NEITHER A postMessage NOR A RETURN URL IS A COMPLETION SIGNAL, AND THERE IS NO `return_url` ON THIS ENDPOINT FOR EXACTLY THAT REASON.** `signed` is a browser event: the signer can close the tab, lose connectivity, or have any script on your own page forge it. **The webhook is authoritative.** Use these messages to move your interface — close the modal, navigate onwards — and use `recipient.completed` and `envelope.completed` to decide that a document was signed, to release goods, or to bill anybody. This is the mistake every first integration makes, and it is the one that is expensive.

NO `Idempotency-Key`, unlike `POST /api/v1/envelopes`. A retry mints a second URL, which emails nobody and bills nothing — and the response IS the credential, so "I do not know which state I am in" is not reachable. ⚠️ Redeeming a second URL does revoke the session a first one began.



## OpenAPI

````yaml /openapi.json post /api/v1/envelopes/{envelopeId}/recipients/{recipientId}/signing-url
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/{envelopeId}/recipients/{recipientId}/signing-url:
    post:
      tags:
        - Envelopes
      summary: Mint a short-lived, single-use URL for an embedded signer.
      description: >-
        THE ONE ENDPOINT IN THIS API THAT RETURNS A BEARER CREDENTIAL FOR A
        LEGAL ACT. Anyone holding the `url` can open the document as that
        signer. **Do not log it, cache it, store it or put it in a support
        ticket** — put it in an `iframe src` and nowhere else.


        ⚠️ 10 MINUTES, AND SINGLE USE. The first GET spends it. A second GET
        within the 10 minutes renders a page that posts `signing_url_invalid` to
        your host page and signs nothing. A URL — opened or not — or the session
        it began, loaded after its lifetime is up (which the envelope’s deadline
        can shorten), renders the same kind of page and posts
        `signing_url_invalid` with `reason: "expired"` and `can_remint: true`,
        for 1 hour after that expiry; later than that it is refused a frame and
        posts nothing. Mint it when the signer arrives, not when the envelope is
        created — Dropbox Sign’s own advice, and the reason a recipient id is
        durable while a URL is not.


        ⚠️ THE URL AND THE SESSION HAVE DIFFERENT LIVES. `expires_at` is when
        the URL stops being redeemable; a signer who redeems it just before then
        gets 1 hour from that moment to read and sign (or less, if the
        envelope’s deadline is sooner). Reload the iframe on this timer only if
        nobody ever opened it.


        FOUR THINGS MUST BE TRUE, and each has its own code: the envelope is
        open for signing, the recipient was created with `"embedded": true`,
        they have not already acted, and it is their turn (a sequential envelope
        holds later positions back — `recipient_not_yet_turn` is a 409 and the
        identical call succeeds once the person in front finishes).


        ⚠️ A `vsk_test_` key may mint only on a sandbox envelope — one a test
        key created — and is refused `test_key_cannot_mint` on any other.


        ## Framing


        The page is served with `Content-Security-Policy: frame-ancestors `
        naming the origins registered against the brand this envelope was sent
        under, and with **no** `X-Frame-Options`. Register those origins in
        Settings first; they must be bare — `https://example.com`, no trailing
        slash, no path, no wildcard — and an unregistered parent cannot frame
        the page at all. An expired URL or session we minted is framed for the
        same origins for 1 hour after it expires, so its refusal can reach you;
        it grants nothing, and after that it is refused. A URL we cannot verify
        — unreadable or altered — gets `X-Frame-Options: DENY` instead, and the
        ordinary emailed signing page keeps `X-Frame-Options: DENY`
        unconditionally.


        ## Talking to your page


        The iframe posts `{ action, ...payload }` to each registered origin,
        never to `*`. Actions: `ready`, `signed`, `declined`, `delegated`,
        `signing_url_invalid`. `signing_url_invalid` carries `can_remint` —
        branch on that, not on `reason`, or a signer who finishes in another tab
        turns your retry into an infinite loop.


        ⚠️ `delegated` MEANS THEY HANDED IT TO SOMEBODY ELSE: this recipient is
        retired and a replacement holds their position, so take the iframe down
        — but the envelope is not finished and **there may be no webhook behind
        this one for a long time**, because a sequential replacement is not
        invited until its turn and an embedded one is never emailed at all.
        Re-read the roster with `GET /api/v1/envelopes/{envelopeId}` and mint a
        URL for the new recipient id when you want them to sign in your page.


        ⚠️ **NEITHER A postMessage NOR A RETURN URL IS A COMPLETION SIGNAL, AND
        THERE IS NO `return_url` ON THIS ENDPOINT FOR EXACTLY THAT REASON.**
        `signed` is a browser event: the signer can close the tab, lose
        connectivity, or have any script on your own page forge it. **The
        webhook is authoritative.** Use these messages to move your interface —
        close the modal, navigate onwards — and use `recipient.completed` and
        `envelope.completed` to decide that a document was signed, to release
        goods, or to bill anybody. This is the mistake every first integration
        makes, and it is the one that is expensive.


        NO `Idempotency-Key`, unlike `POST /api/v1/envelopes`. A retry mints a
        second URL, which emails nobody and bills nothing — and the response IS
        the credential, so "I do not know which state I am in" is not reachable.
        ⚠️ Redeeming a second URL does revoke the session a first one began.
      operationId: createSigningUrl
      parameters:
        - name: envelopeId
          in: path
          required: true
          description: >-
            The envelope, as returned by `POST /api/v1/envelopes` or `POST
            /api/v1/envelopes/one-off`.
          schema:
            type: string
            format: uuid
            examples:
              - 01960000-0000-4000-8000-0000000000e7
            description: An envelope id.
        - name: recipientId
          in: path
          required: true
          description: >-
            The recipient’s `id` from that same response. ⚠️ Not the role name —
            a recipient has no natural key, because one person may hold two
            roles and two people may hold one.
          schema:
            type: string
            format: uuid
            examples:
              - 01960000-0000-4000-8000-000000000060
            description: A recipient id.
      responses:
        '201':
          description: >-
            The URL and when it stops being redeemable. Not readable again from
            anywhere: only a digest is stored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SigningUrl'
                description: The minted URL.
              examples:
                example:
                  summary: A single-use URL for the embedded signer
                  value:
                    url: >-
                      https://app.vumasign.com/e/specimen-payload.specimen-signature
                    expires_at: '2026-09-02T10:15:00.000Z'
          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.
        '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_mint` — A `vsk_test_` key asked `POST
            /api/v1/envelopes/{envelopeId}/recipients/{recipientId}/signing-url`
            for an envelope that is not a sandbox envelope. A minted URL opens
            that signer’s session on the document, and on a live envelope that
            document is binding, so a test key may mint only on an envelope a
            test key created. Nothing was minted and nothing was recorded. Mint
            this one with a live key, or rehearse on an envelope created and
            sent with your test key. Retrying with the same key fails
            identically.
          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.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
                description: The refusal.
        '409':
          description: >-
            `recipient_not_yet_turn` — A sequential envelope is holding this
            recipient back until everybody at an earlier position has finished.
            Nothing about the request is wrong and the identical call will
            succeed later, which is why it is a 409 rather than a 422 — the same
            answer Dropbox Sign gives.


            `recipient_cannot_sign` — The recipient has signed, approved,
            declined, or been superseded or delegated away — or another member
            of their signing group signed first (`group_resolved`). Every status
            that produces this has no outgoing edge, so a retry cannot help and
            a fresh URL would open onto the same nothing. A signature or an
            approval is announced by the `recipient.completed` webhook, and a
            decline that ends the envelope by `envelope.declined` — a member of
            a signing group who declines does not end it while another member
            can still sign. ⚠️ A `group_resolved` recipient gets no
            `recipient.completed` of their own: the one that fired names the
            member who signed. A recipient who delegated or was superseded has a
            replacement: read it from `GET /api/v1/envelopes/{envelopeId}`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
                description: The refusal.
        '422':
          description: >-
            `envelope_not_sent` — This operation needs an envelope that is out
            for signing, and this one is not: it is still a draft, or it is
            finished (completed, declined, voided or expired). Two operations
            answer it. Minting a signing URL: a draft has not been sent to
            anybody — create it with `"send": true`, or send it first. Voiding:
            a draft has nothing to withdraw, because nobody has been asked to
            sign it, and a finished envelope cannot be withdrawn. The `message`
            says which state it is in. ⚠️ Minting also answers it for an
            envelope whose deadline has passed while it still shows an open
            status: the expiry sweep has not marked it `expired` yet, but it is
            finished all the same. ⚠️ A `vsk_test_` key CAN reach an open
            envelope: it may send one when everybody it would email is a member
            of your organisation or a confirmed test recipient, and an
            `"embedded": true` recipient is never emailed, so is never checked.
            A test key meets this code for the same reasons a live key does —
            most often, a draft that was never sent — except that when minting,
            on an envelope a test key did not create, it is refused first with
            `test_key_cannot_mint`.


            `recipient_not_embedded` — This recipient was created without
            `"embedded": true`, so they have been emailed a link and no URL can
            be minted for them. ⚠️ THE REMEDY IS NOT ON THIS ENDPOINT: embedding
            is declared when the envelope is created, because it decides how a
            human is reached, and an envelope that has gone out has already
            reached them. Create the next one with the flag set.


            `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.
          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:
    SigningUrl:
      type: object
      description: >-
        ⚠️ A BEARER CREDENTIAL FOR A LEGAL ACT. Anyone holding `url` can open
        the document as that signer, once, inside the window below. Put it
        straight in an `iframe src` and nowhere else — not a log, not an
        analytics event, not a database column, not a support ticket.
      properties:
        url:
          type: string
          description: >-
            Single use. The first GET spends it; a second, within the 10
            minutes, renders a page that posts `{ action: "signing_url_invalid",
            reason: "used", can_remint: true }` to your host page, and the
            remedy is to call this endpoint again. There is no endpoint that
            reads this value back — a credential readable twice is one stored
            somewhere readable, and we store only a digest.
        expires_at:
          type: string
          format: date-time
          description: >-
            ISO 8601, UTC. 10 minutes from minting, or the envelope’s own
            deadline if that is sooner. ⚠️ THIS IS THE LIFE OF THE URL, NOT OF
            THE SIGNING SESSION: a signer who opens it just before it expires
            gets a full session of 1 hour (or less, if the envelope’s deadline
            is sooner) from that moment. A host that reloads the iframe on this
            timer rather than only when the URL was never opened will interrupt
            somebody mid-signature.
      required:
        - url
        - expires_at
    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
  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.

````