{"openapi":"3.1.0","info":{"title":"Vumasign API","version":"0.1.0","description":"The Vumasign API. South African e-signature: templates, the addresses an\nintegration writes values to, an idempotent send, and webhooks.\n\n**This document describes what exists today and nothing else.** It is not a\nroadmap, and there is deliberately no entry here for anything that has not\nshipped — a spec you can generate a client from and then discover was\naspirational is worse than no spec at all.\n\n## Authentication\n\n    Authorization: Bearer vsk_live_…\n\nOne header, one step. Keys are minted in Settings. `vsk_live_` and `vsk_test_`\nare different keys; the prefix is stored in clear so a leaked key is greppable\nand traceable to an organisation, while the secret is stored only as a hash.\n\n## Errors\n\nEvery refusal, at every status, is `{ \"error\": { \"code\", \"message\" } }`. A\nresponse is a failure **if and only if** it carries an `error` key — that test\nis correct even for a client that ignores the status line. `code` is the\ncontract and is stable forever; `message` is prose for a developer reading a\nlog and may be reworded in any release.\n\nThe root of this document carries **`x-error-codes`**: the closed enum, with\n`status`, `retryable`, `backoff`, `emitted` and `guidance` for each\ncode. Branch on that rather than on our prose.\n\n## Rate limits\n\n**600 requests a minute on any paid plan,\n60 on the free plan — and a test key\ngets the same number as a live one.** Per key, not per organisation, so a batch\njob and an interactive integration can be given a key each and neither can\nstarve the other.\n\nThe ceiling follows the PLAN rather than the kind of key, because the plan is\nwhat says whether this is production traffic or somebody evaluating us. On the\nAPI plan it is sized so that no honest workload meets it: a thousand envelopes\nwith a status read each is two thousand requests, which is three and a half\nminutes rather than seventeen. The sandbox deliberately gets the *same* number\nrather than a larger one — an integration that passes in rehearsal is then an\nintegration that passes live, and your `429` handling is exercised where\nmeeting a `429` is free.\n\nEvery response, at every status, carries `RateLimit-Limit`,\n`RateLimit-Remaining` and `RateLimit-Reset` (seconds until the window\ncloses, at most 60). Read them on your successes and you will never need to be\nrefused; a 429 additionally carries `Retry-After`, which is the same number\nand which you should honour.\n\n⚠️ **A refused request still counts.** Retrying inside the window pushes the\ncounter higher rather than holding it at the ceiling — which is why the backoff\nin `x-error-codes` is exponential and why `Retry-After` is worth reading\nrather than guessing at.\n\n⚠️ The limits are **per minute**, and the window is fixed rather than sliding.\nSpending a whole window at the end of one minute and a whole window at the start\nof the next is possible; it is bounded at two minutes' worth, which is a burst\nrather than an incident.\n\n## Webhooks\n\nRegister an endpoint with `POST /api/v1/webhooks`; the response carries the\nsigning secret, **once**. The root of this document carries **`x-webhooks`**:\nthe body we POST, the signature recipe (including the two mistakes that make a\nverifier look correct and be worthless), the published retry ladder, and the\nauto-disable rule. Read that before writing a receiver.\n\n## What is not here yet\n\nNo organisation-level email exists, so an auto-disabled webhook endpoint is\ndiscovered by polling rather than by being told. There is no way to change an\nenvelope after it is created and before it is sent — `PATCH /v1/envelopes/{id}`\nis designed and not built — so a draft is created complete or not at all. Both\nare stated because a gap named is a gap somebody can plan around.\n\n⚠️ **The rate limit above counts authenticated requests only.** A request\ncarrying no key, or a key we cannot recognise, is refused before any counter is\ntouched — a per-key ceiling cannot defend against a caller who has not presented\na key, and pretending otherwise would be a promise we could not keep. That half\nbelongs to the edge rather than to this API.\n\n(This section has twice described as missing something that now exists: the\n`uri` on every webhook body, which was `null` until\n`GET /api/v1/envelopes/{envelopeId}` gave it somewhere to point, and the rate\nlimiter, which nothing enforced until the per-key ceiling above. Both were named\nhere as gaps first, which is the point of naming them.)"},"x-error-codes":[{"code":"unauthenticated","status":401,"retryable":false,"backoff":null,"emitted":true,"guidance":"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."},{"code":"forbidden","status":403,"retryable":false,"backoff":null,"emitted":true,"guidance":"The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key."},{"code":"insufficient_scope","status":403,"retryable":false,"backoff":null,"emitted":true,"guidance":"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."},{"code":"not_found","status":404,"retryable":false,"backoff":null,"emitted":true,"guidance":"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."},{"code":"invalid_request","status":400,"retryable":false,"backoff":null,"emitted":true,"guidance":"The request could not be read: a query parameter outside its range, a cursor that names nothing. The `message` names the parameter — there is no `param` field on the envelope yet, because no endpoint validates a body yet. Fix the request; retrying it unchanged will fail identically."},{"code":"rate_limited","status":429,"retryable":true,"backoff":"Exponential from 1s, doubling, with jitter. Honour `Retry-After` when present.","emitted":true,"guidance":"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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."},{"code":"internal_error","status":500,"retryable":true,"backoff":"Exponential from 1s, at most three attempts, then stop and alert a human.","emitted":true,"guidance":"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.)"},{"code":"idempotency_key_required","status":400,"retryable":false,"backoff":null,"emitted":true,"guidance":"Send `Idempotency-Key: <uuid>`. A distinct code from `invalid_request` because it is the one 400 whose remedy is a header rather than a body, and an integrator reading it in a log should not have to work that out."},{"code":"idempotency_key_reused","status":409,"retryable":false,"backoff":null,"emitted":true,"guidance":"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."},{"code":"request_in_progress","status":409,"retryable":true,"backoff":"Exponential from 1s, up to about 30s in total. The first request is still running.","emitted":true,"guidance":"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."},{"code":"template_archived","status":404,"retryable":false,"backoff":null,"emitted":true,"guidance":"The template exists and has been retired. §7 asks for this code by name: a bare 404 tells an integrator whose template was archived yesterday nothing. It carries the same 404 status and it only ever fires for a template their own key could otherwise have read — discoverability without disclosure. Use a live template."},{"code":"template_unusable","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"The template cannot produce an envelope at all. The message says which way."},{"code":"template_has_no_fields","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"The template has no fields, so the envelope would ask nobody to do anything. Place at least one field on it in the editor."},{"code":"recipient_role_unknown","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"A recipient names a role the template does not declare. Read the roles from `GET /api/v1/templates/{templateId}` — they are matched by NAME, exactly, because a positional address breaks when a sender reorders."},{"code":"recipient_role_missing","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"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."},{"code":"value_subject_unknown","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"A value names a subject the template does not declare. The subjects are in the template response; `null` is the subject for a field belonging to nobody in particular."},{"code":"value_address_unknown","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"The `(subject, key)` pair addresses no field of this template. ⚠️ This is the refusal that most often means the template has UNADDRESSED boxes rather than that you mistyped: `data_key` is null on any field the sender never gave an address to. Check the template response before blaming the value."},{"code":"value_not_writable","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"The address names real fields and none of them is one a caller may fill in — a `date_signed`, a `signer_name`, a signature. Those are written by the server or by the signer, and supplying them would be a forgery with extra steps."},{"code":"cannot_be_drawn","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"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."},{"code":"send_allowance_exhausted","status":402,"retryable":false,"backoff":null,"emitted":true,"guidance":"⚠️ 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 a FREE plan is ever blocked, on the send after its fifth; a paying customer is counted and never stopped, and a `vsk_test_` key never meets this at all because a sandbox envelope consumes no allowance to exhaust."},{"code":"test_key_cannot_send","status":403,"retryable":false,"backoff":null,"emitted":true,"guidance":"A `vsk_test_` key named 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 unsigned and watermarked, and everyone who receives one is told so before they can open it."},{"code":"test_key_cannot_read_documents","status":403,"retryable":false,"backoff":null,"emitted":true,"guidance":"A `vsk_test_` key asked `GET /api/v1/envelopes/{id}/documents` for an envelope that is not a sandbox envelope. A test key may read the STATUS of any envelope in its organisation — that emails nobody and spends no allowance — but the documents endpoint hands over the bytes of a signed contract, and a sandbox is meant to be rehearsable without ever touching something real. Fetch this envelope’s documents with a live key, or fetch the documents of an envelope a test key created."},{"code":"webhook_url_invalid","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"The `url` is not one we will POST to: it is not `https://`, it carries credentials, or it names a loopback or private address. Deliveries are made from inside our network, so a private address would make this API a request-forgery tool. Fix the URL; retrying it unchanged fails identically."},{"code":"webhook_events_invalid","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"`events` was empty, or named a type outside the published vocabulary. ⚠️ An unknown name is REFUSED rather than accepted and silently never delivered — that is how an integrator spends a week waiting for `envelope.singed`. The message lists every valid name."},{"code":"webhook_brand_unknown","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"`brand_id` names no brand of this organisation. ⚠️ 422 and not 404 because you asked for a WEBHOOK — the brand is a property of the body that does not fit. Send `null` (or omit it) to receive every envelope in the organisation."},{"code":"webhook_limit_reached","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"This organisation already holds 10 endpoints, which is the maximum: every endpoint multiplies every event into another outbound request. If you are registering one per brand, use ONE endpoint with `brand_id: null` and route on the `brand_id` every event already carries."},{"code":"api_key_owner_removed","status":403,"retryable":false,"backoff":null,"emitted":true,"guidance":"The person who created this key has left the organisation, and every envelope must name a real human sender. The key is not revoked and its reads still work; mint a new one from a current member and send with that."},{"code":"recipient_not_embedded","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"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."},{"code":"recipient_not_yet_turn","status":409,"retryable":true,"backoff":"Do not poll. Wait for the `recipient.completed` webhook for the position in front, then retry once.","emitted":true,"guidance":"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."},{"code":"recipient_cannot_sign","status":409,"retryable":false,"backoff":null,"emitted":true,"guidance":"The recipient has signed, approved, declined, or been superseded or delegated away. Every status that produces this has no outgoing edge, so a retry cannot help and a fresh URL would open onto the same nothing. Your `recipient.completed` or `recipient.declined` webhook already carries the real answer."},{"code":"envelope_not_sent","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"A URL can only be minted while the envelope is open for signing. A draft has not been sent to anybody — create it with `\"send\": true`, or send it first. A completed, declined, voided or expired envelope is finished with. ⚠️ A `vsk_test_` key always lands here: it may create a draft and may not send one, so its envelopes are never open."},{"code":"envelope_not_draft","status":409,"retryable":false,"backoff":null,"emitted":true,"guidance":"Only a draft can be sent, and this envelope has already left draft — through this API, through the sender’s own Send button, or by being voided. ⚠️ THE EXACT OPPOSITE OF `envelope_not_sent`, and the two are not interchangeable: that one means the envelope has NOT gone out. An envelope never returns to draft, so this is terminal — read it with `GET /api/v1/envelopes/{envelopeId}` to see where it got to. ⚠️ If you were retrying a request that timed out, send the SAME `Idempotency-Key` instead and you will be told what the first attempt did."},{"code":"brand_has_no_embed_origins","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"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."},{"code":"recipient_cannot_be_embedded","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"A recipient was declared `\"embedded\": true` on a role the template routes as `cc`. A copied-in reader is never asked to sign and is never issued a signing credential, so an embedded one could be reached by nothing at all — no email, and no URL. Either drop the flag, or give the role a routing type that signs, in the template editor."},{"code":"batch_interrupted","status":409,"retryable":true,"backoff":"Do not retry the batch. Retry the individual envelope this row names, with `POST /api/v1/envelopes/{envelopeId}/send` and its own `Idempotency-Key`. Immediately is fine; that endpoint refuses a second send by itself.","emitted":true,"guidance":"⚠️ THIS CODE APPEARS INSIDE A `207` BODY, ON THE ROW IT IS ABOUT, AND NEVER ON A STATUS LINE. It means our own request died partway through a bulk send and you are being shown the batch as it actually stands: the envelopes that went out are sent, and this one is a draft that never was. It already carries its recipients and its values, so nothing needs rebuilding — send it. ⚠️ RETRYING THE BATCH IS THE WRONG MOVE: under the same key you are replayed this same answer, and under a new key you get a second hundred envelopes."},{"code":"brand_unknown","status":422,"retryable":false,"backoff":null,"emitted":true,"guidance":"`brand_id` names no live brand of your organisation — it may belong to another account, or have been archived. Read the ids from Settings, or omit the field to send under the default. ⚠️ IT IS REFUSED RATHER THAN IGNORED because the branding an envelope went out under is frozen the moment it is sent: a wrong constant would put somebody else’s letterhead on every envelope you send, permanently, and nothing else would say so. (The product’s own send screen falls through to the default instead, because there the id came from a list a person was shown rather than from a program.)"}],"x-scopes":{"description":"What an API key may do. A key’s scopes are chosen when it is minted and are immutable afterwards — there is no endpoint and no screen that can widen a key, by design, so a key that needs more access is replaced rather than edited. ⚠️ A key with NO scopes holds EVERY capability, not none: keys issued before scopes existed carry an empty list and are full keys, and nothing narrows them retroactively. Each operation publishes the one scope it requires as `x-required-scope`; a key that does not hold it is refused `insufficient_scope` (403) with a `WWW-Authenticate` header naming the scope, per RFC 6750 §3.1.","values":[{"scope":"templates:read","description":"Read templates: the list, one template’s documents, roles, subjects, questions and fields, and the geometry of every field on it."},{"scope":"templates:write","description":"Author a template from an uploaded PDF. Field extraction runs on it, and what it produces is what every envelope made from it will ask for."},{"scope":"envelopes:read","description":"Read one envelope: its status, its recipients and their delivery state."},{"scope":"envelopes:write","description":"Create envelopes from a template, singly or in a batch, send them, and mint embedded signing URLs for their recipients. ⚠️ This is the scope that emails real people and spends billing allowance on a live key."},{"scope":"documents:read","description":"Read a document’s bytes: the pages of a document belonging to a template this key can already see, as PDF; and, separately, the executed contract of any envelope this key’s organisation owns — original or sealed — via `GET .../envelopes/{envelopeId}/documents`. ⚠️ NARROWER THAN `envelopes:read` on purpose: a key that may read an envelope’s status is not thereby a key that may fetch what it holds."},{"scope":"webhooks:read","description":"Read the registered webhook endpoints. Never their signing secrets."},{"scope":"webhooks:write","description":"Register, change, rotate the secret of and delete webhook endpoints. ⚠️ An endpoint is where envelope events — carrying recipient names and addresses — are POSTed, so this scope decides where a copy of them goes."}]},"x-webhooks":{"delivery":{"method":"POST","content_type":"application/json","timeout_seconds":10,"schema":{"$ref":"#/components/schemas/WebhookEvent"},"description":"Return any 2xx as soon as you have stored the event. ⚠️ DO THE WORK AFTERWARDS — we wait 10 seconds and then treat the delivery as failed, and a receiver that processes synchronously will start failing on the day its own dependencies get slower. Redirects are NOT followed: a 3xx is a failure, because following one would POST your data to a host you never registered."},"signature":{"header":"Vumasign-Signature","example":"t=1786809335, v1=<hex>, v0=<hex, only during rotation>","algorithm":"HMAC-SHA256","signed_material":"t + \".\" + <the raw request body, exactly as received>","timestamp_tolerance_seconds":300,"description":"Compute `HMAC-SHA256(secret, t + \".\" + rawBody)` and compare it to `v1` IN CONSTANT TIME.\n\n⚠️ RAW BODY MEANS THE BYTES AS RECEIVED. If your framework parses JSON and you re-serialise it before hashing, verification will fail on the first body containing a non-ASCII name or a different key order — and it will fail intermittently, which is worse than failing always.\n\n⚠️ COMPARE IN CONSTANT TIME. `a === b` on a hex string stops at the first differing character, so how long it takes measures how many leading characters an attacker guessed right — which turns forging a 256-bit MAC into 64 sequential 16-way guesses against an endpoint that answers promptly. Use `crypto.timingSafeEqual`, `hmac.compare_digest`, or your language’s equivalent.\n\n⚠️ CHECK THE TIMESTAMP IN BOTH DIRECTIONS, and check it FIRST. `now - t > 300` alone leaves a replay window with no far edge: the MAC covers `t`, so a captured delivery presented with a timestamp far in the future is internally consistent for ever. Reject when `abs(now - t)` exceeds the tolerance, before computing any HMAC — otherwise an unauthenticated caller can make your server hash a body of their choosing.\n\nWHY BOTH HALVES ARE SIGNED: signing the timestamp WITHOUT the body means a genuine signature is valid for any body sharing that timestamp and event name, so a captured delivery replays with different content (Dropbox Sign does this). Signing the body WITHOUT a timestamp means a captured delivery is valid for ever (DocuSign does this). Both are real products; both are why the signed material is `t + \".\" + body`.","rotation":"`v0` is present ONLY while a rotated secret is inside its grace window (24 hours). Accept a delivery if EITHER `v1` verifies under your current secret or `v0` verifies under your previous one. ⚠️ A verifier that ignores `v0` drops every event between the rotation and your deploy — which is exactly the outage the window exists to prevent."},"retry":{"attempts":7,"schedule_seconds":[0,60,900,2700,3600,28800,86400],"schedule_human":["immediately","+1 minutes","+15 minutes","+45 minutes","+1 hours","+8 hours","+24 hours"],"description":"Every non-2xx is retried, INCLUDING 4xx. A 404 from a receiver mid-deploy and a 401 from one whose secret rotation has not landed are both transient, and the ladder is bounded anyway. Each interval is measured from the previous attempt.\n\n⚠️ THESE ARE WHEN A DELIVERY BECOMES DUE, not when it will arrive. The queue is drained on an interval, so an attempt lands within one drain cycle of its due time.","auto_disable":{"after_seconds":604800,"description":"An endpoint with NO successful delivery for 7 days is deactivated: `active` goes false and `disabled_reason` says why, both readable from `GET /api/v1/webhooks`. The window is measured from the START of the current run of failures, not from a raw count — a threshold on the count alone disables a busy endpoint over a ten-minute outage and never disables a quiet one that has been dead for a month.\n\n⚠️ RECOVERY IS `PATCH {\"active\": true}`, WHICH ALSO RESETS THE WINDOW. There is deliberately no automatic reactivation, and BoldSign — whose ladder this otherwise copies — does advertise one. We cannot honestly offer it: recovery is only observable by sending, and having stopped sending we would have to probe your URL with either an unsigned request (teaching receivers to accept those) or a fabricated event (a body describing something that did not happen). Neither is acceptable in a system whose value is that its records are true.\n\n⚠️ AND NOBODY IS EMAILED WHEN THIS HAPPENS. There is no organisation-level notification channel in this product yet — every email it sends is to a recipient about an envelope. Poll your endpoint’s health, or watch for the absence of events. The gap is named rather than hidden."}},"ordering":"⚠️ ORDER IS NOT GUARANTEED AND YOU MUST NOT DEPEND ON IT. Deliveries are queued in the order events happen and drained in due-time order, so the common case is in order — but a `recipient.completed` that needed one retry arrives after the `envelope.completed` it preceded. Use `created_at`, and treat each event as independently meaningful.","deduplication":"Deduplicate on `id`. It is the EVENT’s identity: the same value at every endpoint subscribed to it and on every retry. `retry_count` is a convenience for logging and is 0 on a first delivery; it is not a substitute, because a delivery whose 200 we never received is retried with a higher count while your side has already processed it."},"security":[{"ApiKey":[]}],"paths":{"/api/v1/openapi.json":{"get":{"operationId":"getOpenApiDocument","summary":"This document.","description":"The machine-readable description of everything above, served to anybody who asks. ⚠️ NO CREDENTIAL, AND THAT IS THE POINT: an integrator evaluating this API, or an agent writing against it, should not have to hold a key to learn what the endpoints are. It discloses no customer data — it is the same bytes for every caller, which is why it is also the one path here that may be cached.","security":[],"responses":{"200":{"description":"The OpenAPI 3.1 document.","content":{"application/json":{"schema":{"type":"object","description":"An OpenAPI 3.1 document. Not schema’d further here: a spec that described its own shape in its own language would be a second, weaker copy of the OpenAPI meta-schema."}}}}}}},"/api/v1/templates":{"get":{"operationId":"listTemplates","summary":"List live templates, newest first.","description":"Walked by keyset cursor rather than by offset. ⚠️ OFFSET WOULD BE WRONG HERE, not merely unfashionable: this list is ordered newest first, so a template created between page 1 and page 2 shifts every row down — the caller receives one row twice and never sees another. The thing an integration does between two pages is upload a template.\n\nArchived templates are never returned, by either endpoint.","security":[{"ApiKey":[]}],"x-required-scope":"templates:read","parameters":[{"name":"limit","in":"query","required":false,"description":"⚠️ A VALUE OUTSIDE THE RANGE IS REFUSED, NOT CLAMPED. Clamping looks friendlier and produces a silent defect: a caller who asks for 1000 and receives 100 has a working integration that reads their whole account in ten times the requests they budgeted, and nothing anywhere says so.","schema":{"type":"integer","description":"How many templates to return.","minimum":1,"maximum":100,"default":25}},{"name":"cursor","in":"query","required":false,"description":"The `next_cursor` from the previous page. Omit it to start at the newest template. It is the last item’s `id` in clear — not an opaque blob, because encoding a uuid to signal opacity is a costume that makes every support conversation start with a decoding step.","schema":{"type":"string","format":"uuid","description":"A template id to resume after."}}],"responses":{"200":{"description":"A page of templates.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TemplateList","description":"The page."}}},"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 could not be read: a query parameter outside its range, a cursor that names nothing. The `message` names the parameter — there is no `param` field on the envelope yet, because no endpoint validates a body yet. Fix the request; retrying it unchanged will fail identically.","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."}}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}},"post":{"operationId":"createTemplate","summary":"Upload a PDF and get a template with its fields already placed.","description":"THE ENDPOINT THIS API EXISTS FOR. Send a document and a list of roles; get back a template whose signature boxes, date fields, ID-number combs and marital-status questions are already on the page, each carrying the `id` you need to address it. Nobody opens an editor.\n\n⚠️ **FIRST, CHECK THIS IS THE RESOURCE YOU WANT.** A template is a LIBRARY ENTRY — a kind of document you will send again, which stays in `GET /api/v1/templates` and on the Templates screen until it is archived. If what you are sending exists ONCE — an offer of employment to one candidate, a Letter of Authority — you want `POST /api/v1/envelopes/one-off` instead. It takes the same PDF and the SAME TEXT TAGS, does the same detection, sends the same envelope, and creates no template: `template_id` comes back `null`.\n\n⚠️ THIS FORK WAS NOT WRITTEN DOWN HERE AND IT COST A CUSTOMER A ROW PER SEND. The one-off endpoint explains itself perfectly and an integrator reading THIS page had no reason to go and find it — so a product owner sent one offer letter and watched it appear in his template library. The paragraph below about documents that differ per employee is what pointed them here, and it was right about the TAGS and silent about the RESOURCE.\n\n⚠️ TWO WAYS FIELDS GET PLACED, AND THE DOCUMENT CHOOSES. If your PDF carries TEXT TAGS, they win and detection does not run. Otherwise the page is measured.\n\n**Text tags** — write the field into the document where it goes, and it arrives placed and already assigned to the right signer:\n\n```\n<<sig:Main Member>>                      a signature for the role named\n<<text:Witness:Home address>>            a labelled text box\n<<title:Main Member>>                    the signer’s job title, prefilled\n<<datesigned:Main Member>>               the date they signed, stamped\n<<text:Witness:optional>>                a box they may leave empty\n<<text:Witness:validate(email)>>         checked as an email address\n<<sig:Main Member:dimension(60x15mm)>>   say the size, do not pad the tag\n<<text:Employee:key(employee_id)>>       the api name a value addresses\n```\n\nTypes: `sig`, `signature`, `initial`, `text`, `dateinput`, `check`, `checkbox`, `title`, `company`, `name`, `email`, `datesigned`. `<<sig:Main Member>>` names the role EXACTLY and case-sensitively; `<<sig:signerN>>` names it BY POSITION — `signer1` is the first entry in your `roles` array — for a generator that does not know your roster. SEVERAL DIRECTIVES may follow the role, in any order: `optional`, `validate(<type>)`, `dimension(WxH mm|pt)` and `key(<name>)` — which sets the field’s `data_key`, stored lower-cased, so address it in lower case when you prefill. The first segment that is none of them becomes the field’s label, which is why a field labelled literally “optional” cannot be expressed by a tag. What a tag still cannot set is a comb, a per-character input shape, a condition, a dropdown’s options or a `validate(regex)` pattern — a pattern would have to survive being typed into a document — so those belong to a field you edit afterwards or address by its `id`.\n\n⚠️ TAGS ARE WHAT YOU WANT WHEN EVERY DOCUMENT DIFFERS. A contract generated per employee — conditional clauses, a page count that varies — cannot reuse one template and has nothing detectable on it either. Tags travel with the text, so they land correctly however the document reflows.\n\n⚠️ BUT THAT SAYS NOTHING ABOUT **WHICH ENDPOINT**, and this paragraph used to be read as though it did. `POST /api/v1/envelopes/one-off` accepts the identical tags. If each generated contract goes to one person and is never sent again, send it THERE: every one you create here is a permanent library row, and a hundred offers of employment is a hundred of them. Create a template here when the document is a KIND you will send repeatedly — even if its text is merged per recipient.\n\n⚠️ A TAG THAT CANNOT BE PLACED REFUSES THE WHOLE REQUEST with 422 `invalid_request`, naming every bad tag and its page. A tag naming a role you did not declare is the common one. Nothing is created — silently dropping the tag would lose a signature field, and leaving it as text would print `<<sig:Main Member>>` on a document somebody signs.\n\n⚠️ **WHO A FIELD IS ABOUT** — `subject(dependant_2)`. Declare the people the document asks about in `subjects`, then address a field to one of them. A field addressed to somebody who is not on a given envelope is **hidden and not required, automatically**, with no condition to write:\n\n```\n<<text:Employee:subject(dependant_2):Surname>>\n```\n\nThe argument is the subject’s KEY, not its label — the key is stable and the label is prose somebody may rename. An undeclared key refuses the whole request, naming the ones the document does declare, exactly as an unknown role does.\n\n⚠️ AND THE SUBJECT MUST BE `conditional` FOR ANY OF IT TO MATTER. A `required` subject is on every envelope and can never be absent, which is the safe default; `conditional` is the one the caller or the signer decides about. A medical-aid application declaring `spouse` and `dependant_1..3` as conditional asks for none of them until somebody says that person exists — which is the whole point.\n\n⚠️ **WHERE THE FIELD LANDS**, which this reference did not say until a customer had to work it out from a screenshot. A tag is a POSITION, not a size: the field gets a default size for its type and is **centred on the tag’s own text** — so it straddles the line you typed on, reaching about half its height above the text and half below, and it lands in the same place whatever type it is.\n\n⚠️ SO WRITE THE TAG ON THE LINE **ABOVE** A RULE, not on the rule and not under it. Signature blocks are drawn with underscores, and there is nowhere on a row of underscores to put a tag without breaking the rule you drew:\n\n```\n<<sig:Employee>>            <<datesigned:Employee>>\n______________________      ______________________\nEmployee name               Date\n```\n\nBoth fields then sit across their own rule. A tag on the row BELOW a rule puts the field below it, beside whatever caption is there — which is a common way to end up with a date under the line instead of on it.\n\nSizes: a signature is 40×120pt, an initial 40×40, a date or text field 18pt tall, a checkbox 14×14, and the stamped types (`name`, `email`, `title`, `company`, `datesigned`) 15.2pt. The width grows to the tag’s own width when the tag is longer than the minimum. `dimension()` overrides both, and is centred the same way.\n\n⚠️ AND THE TAG STAYS VISIBLE unless you write it in white. Nothing here rewrites your PDF, so a tag in black ink prints on the sealed document. Extraction finds white text perfectly well.\n\n⚠️ AND THE DELIMITER IS `<< >>` BECAUSE YOUR DOCUMENT IS PROBABLY GENERATED. Every `{`-based templating engine destroys a `{{ }}` tag before we ever see the PDF — Mustache and Handlebars delete it SILENTLY, so the upload succeeds and reports no tags, and docxtemplater throws. `<< >>` is untouched by all three, so your merge fields (`{employee.full_name}`, `{#clause}…{/}`) and your tags coexist with nothing to reconfigure. A `{{type:Role}}` tag is refused by name rather than ignored, as is Dropbox Sign’s `[sig|req|signer1]`, so neither can be uploaded silently and printed on a signed document.\n\n⚠️ AND RENDER IT TO PDF BEFORE YOU SEND IT. `file` takes a PDF and nothing else — a `.docx` is refused by its bytes, whatever it is called — so a docxtemplater or Word pipeline has one more step than it looks like: write the tags into the document, render, then upload. The tags survive that render, which is the whole reason they are text.\n\n**Detection**, when there are no tags, is the same code the editor’s “Find fields” button runs, not a second implementation — so a template authored here and one authored by a person pressing that button have their fields in the same places, with the same masks and the same inferred types, because it is the same function. It infers position but not ownership, so every detected field goes to your first non-`cc` role, for you to reassign.\n\n⚠️ A DOCUMENT WITH NOTHING DETECTABLE IN IT IS NOT AN ERROR. A flat scan with no text layer yields no fields, and this still answers 201 with a template holding its roles, its document and no fields — which is the thing you then place fields on. `fields` being empty is how you tell.\n\nThe PDF travels base64-encoded inside the JSON body rather than as `multipart/form-data`, so one media type and one HTTP client serve every endpoint of this API. See `file` for what that costs and what it bounds.\n\nA `vsk_test_` key may author templates, deliberately: a template is a draft, nothing reaches a signer until an envelope is created AND sent, and iterating on where the boxes land is what a sandbox is for. Templates carry no sandbox/live distinction of their own.","security":[{"ApiKey":[]}],"x-required-scope":"templates:write","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, because an upload is the request most likely to lose its answer — the body is the largest this API accepts and the reply comes after the document has been parsed twice. Retrying with the same key returns the same template rather than authoring a second one from the same document.\n\n⚠️ AND IT IS ANSWERED CONSERVATIVELY IN ONE CASE. If a previous request under this key stopped partway and this API cannot establish whether it created a template, you are told so with `request_in_progress` and asked to look — it will not guess, because a wrong guess is a second template. Call `GET /api/v1/templates`; if it is not there, retry with a NEW key.","schema":{"type":"string","examples":["01960000-0000-4000-8000-00000000ffff"],"description":"The caller’s own request identifier."}}],"requestBody":{"required":true,"description":"The document, base64-encoded, and who signs it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTemplateRequest","description":"The document and its roles."}}}},"responses":{"201":{"description":"The template, in exactly the shape `GET /api/v1/templates/{templateId}` returns — same fields, same ids, same everything, because it is read back through the same function rather than assembled separately. `Idempotency-Replayed` says whether this request authored it (`false`) or is being shown an earlier one’s result (`true`); the status is 201 either way, deliberately, so a client branching on it behaves identically on a retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template","description":"The template, with every field’s id."}}},"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 could not be read: a query parameter outside its range, a cursor that names nothing. The `message` names the parameter — there is no `param` field on the envelope yet, because no endpoint validates a body yet. Fix the request; retrying it unchanged will fail identically.\n\n`idempotency_key_required` — Send `Idempotency-Key: <uuid>`. A distinct code from `invalid_request` because it is the one 400 whose remedy is a header rather than a body, and an integrator reading it in a log should not have to work that out.","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."}}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.\n\n`api_key_owner_removed` — The person who created this key has left the organisation, and every envelope must name a real human sender. The key is not revoked and its reads still work; mint a new one from a current member and send with that.","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."}}}},"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.\n\n`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."}}},"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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/templates/{templateId}":{"get":{"operationId":"getTemplate","summary":"One template, with every field address on it.","description":"THE ENDPOINT THAT SHOWS THE ADDRESSES. Read it once, by hand, before writing the code that sends anything: it is where you learn which `(subject, data_key)` pair each box on the paper answers to, and which boxes have no address at all.\n\n⚠️ AND IT SHOWS WHERE EVERY BOX IS. Each `Field` carries a `rect`, and `pages` carries the dimensions those rectangles are fractions of. Together with `GET /api/v1/documents/{documentId}/pages/{pageNumber}`, which serves the page itself, that is enough for a program that can SEE to match fields it could not match from labels alone — which is the case on any real form, where `checklist.17` is a field name and \"ID or passport number\" appears four times on one page.\n\n⚠️ FOUR SITUATIONS ANSWER WITH THE SAME 404: no such template anywhere; a template belonging to another organisation; one of yours that has been archived; and an id that is not a uuid. \"This exists but is not yours\" is itself the secret, and the handler could not distinguish them if it wanted to — the read runs inside a transaction already scoped to the key’s tenant.","security":[{"ApiKey":[]}],"x-required-scope":"templates:read","parameters":[{"name":"templateId","in":"path","required":true,"description":"The template’s `id`, as returned by the list endpoint.","schema":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-0000000007e1"],"description":"A template id."}}],"responses":{"200":{"description":"The template.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Template","description":"The template, in full."}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"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."}}},"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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/documents/{documentId}/pages/{pageNumber}":{"get":{"operationId":"getDocumentPage","summary":"One page of a document, as a PDF, to look at.","description":"THE PAGE ITSELF. `GET /api/v1/templates/{templateId}` tells you where every field is; this is the picture those coordinates are measured against. Fetch it, render it, and every `Field.rect` on this page lands on its box.\n\n⚠️ IT ANSWERS `application/pdf`, NOT AN IMAGE, AND THAT IS DELIBERATE. The page is served as a one-page PDF carrying the original page’s vectors, fonts, images and field boxes. Every model that accepts a screenshot accepts this too, and rasterises it at the resolution IT wants rather than one we picked — too low and the OCR-derived labels this feature exists to disambiguate are unreadable, too high and it is a render nobody asked for. You also keep the text layer, which a PNG would have thrown away.\n\nIt is one page rather than the whole document because the cost of the answer should not grow with the length of the paperwork: page 4 of a nine-page application and page 4 of a two-hundred-page one are the same response. There is deliberately no whole-document download.\n\n⚠️ THE PAGE IS NOT FILLABLE. Its field boxes DRAW exactly as they do in the original — the widget annotations come across — but the form itself does not, so this is a picture of the page and not a copy of the form. Values are supplied through `values` on `POST /api/v1/envelopes`.\n\n⚠️ FOUR SITUATIONS ANSWER WITH THE SAME 404: no such document anywhere; a document belonging to another organisation; an id that is not a uuid; and a `pageNumber` that is not a page number. A fifth is specific — \"this document has 6 pages\" — and only because it is reached after the document has been found under YOUR key, which is the point at which a refusal is allowed to say something.\n\nNever cached, by anything: `Cache-Control: private, no-store`. This is one organisation’s confidential paperwork selected by a request header, and a shared cache keyed on the URL would hand it to the next caller of the same path.","security":[{"ApiKey":[]}],"x-required-scope":"documents:read","parameters":[{"name":"documentId","in":"path","required":true,"description":"A `documents[].id` from `GET /api/v1/templates/{templateId}`. ⚠️ It is the DOCUMENT’s id, not the template’s — a template’s pack may hold several, and `Field.document_id` says which one a box is on.","schema":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-00000000d0c5"],"description":"A document id."}},{"name":"pageNumber","in":"path","required":true,"description":"1-indexed, WITHIN this document. The same number as `Field.page` and `Page.page`. ⚠️ There is one spelling of each page: `01`, `1.0` and ` 1` are refused rather than accepted as 1, so that one page does not answer to five URLs.","schema":{"type":"integer","minimum":1,"examples":[4],"description":"Which page of the document."}}],"responses":{"200":{"description":"The page, as a one-page PDF. `Content-Disposition: inline; filename=\"page-N.pdf\"` — the filename names the page and nothing about the customer.","content":{"application/pdf":{"schema":{"type":"string","format":"binary","description":"The PDF bytes. ⚠️ NOT JSON — this is the one operation in this API whose success body is not a resource. Its refusals still are: every non-2xx is the same `Error` envelope in `application/json`."}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"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."}}},"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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/envelopes":{"get":{"operationId":"listEnvelopes","summary":"List envelopes, newest first, optionally filtered by status.","description":"`GET /api/v1/envelopes/{envelopeId}` reads one envelope by an id you already hold; this is how you find out which ids exist. The webhook delivery ladder gives up after 7 attempts, so an integrator who dropped a delivery has no way to learn what was missed except by asking here.\n\nWalked by keyset cursor rather than by offset, for the same reason `GET /api/v1/templates` is: this list is ordered newest first, so an envelope created between page 1 and page 2 would shift every row down under an offset, and a caller would receive one row twice and never see another.\n\n⚠️ `cursor` IS OPAQUE. It is the last envelope’s `id` in clear, but that is an implementation detail rather than a promise — pass back exactly the `next_cursor` a previous page gave you and never construct one by hand. A well-formed uuid naming no envelope of yours (yours or another organisation’s — this endpoint cannot tell the two apart, and does not try) is refused rather than answered with an empty page, for the same reason an unknown `status` is below: an empty page reads as \"you have none\", which is the single hardest defect class to notice because it looks exactly like data.\n\nEACH ROW IS A SUMMARY, not the full `Envelope` `GET /api/v1/envelopes/{envelopeId}` returns — no recipients, no delivery state. Read one envelope in full once you know which one you want.","security":[{"ApiKey":[]}],"x-required-scope":"envelopes:read","parameters":[{"name":"limit","in":"query","required":false,"description":"⚠️ A VALUE OUTSIDE THE RANGE IS REFUSED, NOT CLAMPED, for the same reason `GET /api/v1/templates` refuses one: a caller who asks for 1000 and receives 100 has a working integration reading their whole account in ten times the requests they budgeted, and nothing anywhere says so.","schema":{"type":"integer","description":"How many envelopes to return.","minimum":1,"maximum":100,"default":25}},{"name":"cursor","in":"query","required":false,"description":"The `next_cursor` from the previous page. Omit it to start at the newest envelope. Opaque — see above.","schema":{"type":"string","format":"uuid","description":"An envelope id to resume after."}},{"name":"status","in":"query","required":false,"description":"⚠️ AN UNKNOWN VALUE IS REFUSED, NOT ANSWERED WITH AN EMPTY PAGE. A typo — `\"complete\"` for `\"completed\"` — would otherwise read as \"you have none\", which is data-shaped and is exactly the failure this refusal exists to avoid. Omit it to list every status.","schema":{"type":"string","enum":["draft","sent","partially_signed","completed","declined","voided","expired"],"description":"Restrict the list to one status."}}],"responses":{"200":{"description":"A page of envelopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvelopeList","description":"The page."}}},"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 could not be read: a query parameter outside its range, a cursor that names nothing. The `message` names the parameter — there is no `param` field on the envelope yet, because no endpoint validates a body yet. Fix the request; retrying it unchanged will fail identically.","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."}}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}},"post":{"operationId":"createEnvelope","summary":"Create an envelope from a template, and optionally send it.","description":"THE ENDPOINT THAT EMAILS REAL PEOPLE AND SPENDS REAL MONEY, and the reason `Idempotency-Key` is required rather than optional.\n\n⚠️ A REPLAY RETURNS THE SAME ENVELOPE AND CREATES NOTHING. Send the identical body under the identical key and you receive the first attempt’s answer, byte for byte, with `Idempotency-Replayed: true`. No second envelope, no second invitation, no second billed document. That is what makes a timeout safe to retry — and none of the three products this API was designed against offers it.\n\nA DIFFERENT BODY UNDER A USED KEY IS `idempotency_key_reused`, not a replay. A key identifies one request; answering a second one with the first one’s envelope is silent data loss wearing a 201.\n\nKEYS ARE REMEMBERED FOR 24 HOURS, per organisation and per environment (live and test never answer for one another). Past that the key is forgotten and the same string is a new request.\n\n⚠️ `values` IS A LIST OF TRIPLES AND ONE OF THEM MAY FILL SEVERAL BOXES. Read `GET /api/v1/templates/{templateId}` first: it is where the `(subject, data_key)` pairs come from.\n\nA REFUSAL CREATES NOTHING. Every 4xx below leaves no envelope behind — including a send refused after the draft was built, which is deleted again — and leaves the idempotency key free for a corrected retry.","security":[{"ApiKey":[]}],"x-required-scope":"envelopes:write","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 makes a second one and emails everybody twice.","schema":{"type":"string","examples":["01960000-0000-4000-8000-00000000ffff"],"description":"The caller’s own request identifier."}}],"requestBody":{"required":true,"description":"The template, the people, the values, and whether to send.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEnvelopeRequest","description":"The request."}}}},"responses":{"201":{"description":"The envelope, as it stands. `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, deliberately, so that a client branching on it behaves identically on a retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Envelope","description":"The envelope."}}},"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 could not be read: a query parameter outside its range, a cursor that names nothing. The `message` names the parameter — there is no `param` field on the envelope yet, because no endpoint validates a body yet. Fix the request; retrying it unchanged will fail identically.\n\n`idempotency_key_required` — Send `Idempotency-Key: <uuid>`. A distinct code from `invalid_request` because it is the one 400 whose remedy is a header rather than a body, and an integrator reading it in a log should not have to work that out.","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."}}}},"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."}}},"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."}}}},"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 a FREE plan is ever blocked, on the send after its fifth; a paying customer 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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.\n\n`test_key_cannot_send` — A `vsk_test_` key named 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 unsigned and watermarked, and everyone who receives one is told so before they can open it.\n\n`api_key_owner_removed` — The person who created this key has left the organisation, and every envelope must name a real human sender. The key is not revoked and its reads still work; mint a new one from a current member and send with that.","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."}}}},"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.\n\n`template_archived` — The template exists and has been retired. §7 asks for this code by name: a bare 404 tells an integrator whose template was archived yesterday nothing. It carries the same 404 status and it only ever fires for a template their own key could otherwise have read — discoverability without disclosure. Use a live template.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error","description":"The refusal."}}},"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."}}}},"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.\n\n`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."}}},"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."}}}},"422":{"description":"`template_unusable` — The template cannot produce an envelope at all. The message says which way.\n\n`template_has_no_fields` — The template has no fields, so the envelope would ask nobody to do anything. Place at least one field on it in the editor.\n\n`recipient_role_unknown` — A recipient names a role the template does not declare. Read the roles from `GET /api/v1/templates/{templateId}` — they are matched by NAME, exactly, because a positional address breaks when a sender reorders.\n\n`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.\n\n`value_subject_unknown` — A value names a subject the template does not declare. The subjects are in the template response; `null` is the subject for a field belonging to nobody in particular.\n\n`value_address_unknown` — The `(subject, key)` pair addresses no field of this template. ⚠️ This is the refusal that most often means the template has UNADDRESSED boxes rather than that you mistyped: `data_key` is null on any field the sender never gave an address to. Check the template response before blaming the value.\n\n`value_not_writable` — The address names real fields and none of them is one a caller may fill in — a `date_signed`, a `signer_name`, a signature. Those are written by the server or by the signer, and supplying them would be a forgery with extra steps.\n\n`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.\n\n`brand_unknown` — `brand_id` names no live brand of your organisation — it may belong to another account, or have been archived. Read the ids from Settings, or omit the field to send under the default. ⚠️ IT IS REFUSED RATHER THAN IGNORED because the branding an envelope went out under is frozen the moment it is sent: a wrong constant would put somebody else’s letterhead on every envelope you send, permanently, and nothing else would say so. (The product’s own send screen falls through to the default instead, because there the id came from a list a person was shown rather than from a program.)","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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/envelopes/one-off":{"post":{"operationId":"createOneOffEnvelope","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.\n\n⚠️ 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.\n\nLAYOUT 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.\n\nEVERY 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.\n\n⚠️ PDF ONLY, CHECKED BY THE BYTES. A `.docx` renamed `.pdf` is refused. Render Word, Google Docs or templating-engine output to PDF before uploading — text tags survive that render.\n\nA 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.\n\nNO 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.","security":[{"ApiKey":[]}],"x-required-scope":"envelopes:write","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."}}}},"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."}}},"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 could not be read: a query parameter outside its range, a cursor that names nothing. The `message` names the parameter — there is no `param` field on the envelope yet, because no endpoint validates a body yet. Fix the request; retrying it unchanged will fail identically.\n\n`idempotency_key_required` — Send `Idempotency-Key: <uuid>`. A distinct code from `invalid_request` because it is the one 400 whose remedy is a header rather than a body, and an integrator reading it in a log should not have to work that out.","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."}}}},"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."}}},"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."}}}},"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 a FREE plan is ever blocked, on the send after its fifth; a paying customer 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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.\n\n`api_key_owner_removed` — The person who created this key has left the organisation, and every envelope must name a real human sender. The key is not revoked and its reads still work; mint a new one from a current member and send with that.\n\n`test_key_cannot_send` — A `vsk_test_` key named 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 unsigned and watermarked, and everyone who receives one is told so before they can open it.","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."}}}},"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.\n\n`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."}}},"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."}}}},"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.\n\n`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.\n\n`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.\n\n`template_has_no_fields` — The template has no fields, so the envelope would ask nobody to do anything. Place at least one field on it in the editor.","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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/envelopes/batches":{"post":{"operationId":"createEnvelopeBatch","summary":"Create and send many envelopes from one template, in one request.","description":"ONE REQUEST, ONE `Idempotency-Key`, UP TO 100 ENVELOPES. What this saves is not our time but your error handling: a thousand loops of `POST /api/v1/envelopes` is a thousand keys to mint and persist, a thousand timeouts to resolve, and a thousand places to be halfway through.\n\n⚠️ **THE STATUS LINE IS THE ANSWER. YOU NEVER HAVE TO WALK THE BODY TO FIND OUT WHETHER IT WORKED.**\n\n- `201` — every row went out (or, with `send: false`, every draft exists). `failed` is 0.\n- `207` — at least one row did not. `failed` says how many and `results[i].error` says why. This is the only status that requires reading a body.\n- any `4xx` — **nothing was created at all.**\n\n⚠️ ONE BAD ROW REFUSES THE WHOLE BATCH, AND THAT IS DELIBERATE. Every refusal your own data can earn — an unknown role, an address matching no field, a value that cannot be drawn, a mailbox a test key may not write to — is decided in ONE transaction before anything is sent, so a hundred envelopes roll back together and your key is free for a corrected retry. A mistake in mapping code is systematic: if row 7 names a role the template does not have, rows 8 to 99 probably do too, and sending 93 binding documents to prove it is the expensive way to find out. Refusals name the row: `rows[7].recipients[2].role`.\n\nPAST THAT POINT IT IS PER ROW, because an email handed to a provider cannot be recalled. A row whose send is refused KEEPS ITS DRAFT, with its recipients and values already on it, and its id is in the answer — so the remedy is `POST /api/v1/envelopes/{envelopeId}/send` on the ones that failed rather than rebuilding the ones that did not.\n\n⚠️ A REPLAY RETURNS THE SAME BATCH AND CREATES NOTHING, including a replay of a `207` — which returns the same `207`, failed rows and all, rather than retrying them. If a replay could DO something, two replays could give two different answers and a retry after a timeout would tell you nothing.\n\n⚠️ IT IS SYNCHRONOUS. There is no job to poll, because the answer is the response. That is what the 100-row bound buys, and it is why the bound is not larger.\n\n⚠️ **IT COSTS N AGAINST THE RATE LIMIT, NOT 1** — one unit per row, because one row is one envelope and an envelope is what costs us. A batch that does not fit in what the minute has left is refused WHOLE with `rate_limited`, never partly, and is charged 1 rather than N for being refused. `RateLimit-Remaining` tells you how many rows would fit now.","security":[{"ApiKey":[]}],"x-required-scope":"envelopes:write","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"description":"Any string that identifies this request; at most 255 characters. ⚠️ REQUIRED, and this is the endpoint it matters most on: without it a timeout is unresolvable and a retry creates a second hundred envelopes and emails everybody in them twice.","schema":{"type":"string","examples":["01960000-0000-4000-8000-00000000ffff"],"description":"The caller’s own request identifier."}}],"requestBody":{"required":true,"description":"The template, the rows, and whether to send.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEnvelopeBatchRequest","description":"The request."}}}},"responses":{"201":{"description":"Every row succeeded. `failed` is 0 and every `results[i].error` is null. `Idempotency-Replayed` says whether this request created the batch (`false`) or is being shown an earlier one’s result (`true`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvelopeBatch","description":"The batch."}}},"headers":{"RateLimit-Limit":{"description":"How many requests this key may make per minute: 600 on any paid plan and 60 on the free plan, the same in both environments. Per key, not per organisation — a second key has its own allowance. ⚠️ READ IT RATHER THAN ASSUMING IT: it is the number the database applied to this request, so it is right even when this description is out of date.","schema":{"type":"integer","description":"Requests permitted in the current minute."}},"RateLimit-Remaining":{"description":"How many of them are left, this one already counted. Zero on the request that is refused, and zero on every further request of that minute — ⚠️ a refused request still counts.","schema":{"type":"integer","description":"Requests remaining in the current minute."}},"RateLimit-Reset":{"description":"Seconds until the current minute closes and the allowance returns. At most 60, which is what makes the published exponential backoff converge in about six doublings rather than eleven.","schema":{"type":"integer","description":"Seconds until the window resets."}}}},"207":{"description":"⚠️ SOME ROWS DID NOT GO OUT. The envelopes that did are sent and cannot be unsent; the ones that did not are drafts you now own, each named in `results[i].envelope.id` with its reason in `results[i].error`. `failed` is the count. The commonest cause is `send_allowance_exhausted` partway through — the plan’s allowance is consumed one unit per envelope, so a batch larger than what is left sends what fits.\n\nThe other cause is `batch_interrupted`, which means our own request died partway and you are being shown the batch as it actually stands. In both cases the remedy is the same: send the named drafts with `POST /api/v1/envelopes/{envelopeId}/send`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvelopeBatch","description":"The batch, including the rows that failed."}}},"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 could not be read: a query parameter outside its range, a cursor that names nothing. The `message` names the parameter — there is no `param` field on the envelope yet, because no endpoint validates a body yet. Fix the request; retrying it unchanged will fail identically.\n\n`idempotency_key_required` — Send `Idempotency-Key: <uuid>`. A distinct code from `invalid_request` because it is the one 400 whose remedy is a header rather than a body, and an integrator reading it in a log should not have to work that out.","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."}}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.\n\n`test_key_cannot_send` — A `vsk_test_` key named 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 unsigned and watermarked, and everyone who receives one is told so before they can open it.\n\n`api_key_owner_removed` — The person who created this key has left the organisation, and every envelope must name a real human sender. The key is not revoked and its reads still work; mint a new one from a current member and send with that.","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."}}}},"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.\n\n`template_archived` — The template exists and has been retired. §7 asks for this code by name: a bare 404 tells an integrator whose template was archived yesterday nothing. It carries the same 404 status and it only ever fires for a template their own key could otherwise have read — discoverability without disclosure. Use a live template.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error","description":"The refusal."}}},"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."}}}},"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.\n\n`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."}}},"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."}}}},"422":{"description":"`template_unusable` — The template cannot produce an envelope at all. The message says which way.\n\n`recipient_role_unknown` — A recipient names a role the template does not declare. Read the roles from `GET /api/v1/templates/{templateId}` — they are matched by NAME, exactly, because a positional address breaks when a sender reorders.\n\n`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.\n\n`value_subject_unknown` — A value names a subject the template does not declare. The subjects are in the template response; `null` is the subject for a field belonging to nobody in particular.\n\n`value_address_unknown` — The `(subject, key)` pair addresses no field of this template. ⚠️ This is the refusal that most often means the template has UNADDRESSED boxes rather than that you mistyped: `data_key` is null on any field the sender never gave an address to. Check the template response before blaming the value.\n\n`value_not_writable` — The address names real fields and none of them is one a caller may fill in — a `date_signed`, a `signer_name`, a signature. Those are written by the server or by the signer, and supplying them would be a forgery with extra steps.\n\n`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.\n\n`brand_unknown` — `brand_id` names no live brand of your organisation — it may belong to another account, or have been archived. Read the ids from Settings, or omit the field to send under the default. ⚠️ IT IS REFUSED RATHER THAN IGNORED because the branding an envelope went out under is frozen the moment it is sent: a wrong constant would put somebody else’s letterhead on every envelope you send, permanently, and nothing else would say so. (The product’s own send screen falls through to the default instead, because there the id came from a list a person was shown rather than from a program.)\n\n`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.\n\n`recipient_cannot_be_embedded` — A recipient was declared `\"embedded\": true` on a role the template routes as `cc`. A copied-in reader is never asked to sign and is never issued a signing credential, so an embedded one could be reached by nothing at all — no email, and no URL. Either drop the flag, or give the role a routing type that signs, in the template editor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error","description":"The refusal."}}},"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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/envelopes/{envelopeId}":{"get":{"operationId":"getEnvelope","summary":"One envelope, as it stands right now.","description":"⚠️ **THIS IS WHAT EVERY WEBHOOK BODY’S `uri` POINTS AT, AND WHY THAT FIELD EXISTS.** A webhook payload is a snapshot of a moment; this is the resource. Anything you are about to do because of an event — release goods, bill somebody, advance a workflow — should be decided from a read here rather than from the body you were posted, because the body cannot have changed since it was written and the envelope can.\n\nIt is a safe GET: it writes nothing, mints nothing and takes no `Idempotency-Key`. Poll it if you must, but the webhook is the signal and this is the confirmation.\n\n⚠️ `invitation_delivered` IS ANSWERED HERE FROM THE EVIDENCE, not left null. A recipient the provider has reported delivery for reads `true` weeks later, and a recipient nobody was ever meant to email — a `cc`, an embedded signer, a position a sequential envelope is still holding back — reads `null`. Those two are different facts and this endpoint keeps them different.\n\n⚠️ AND THIS IS THE ENDPOINT TO RE-READ IT FROM, BECAUSE THE TWO ENDPOINTS MEAN DIFFERENT THINGS BY `true` AND THAT IS DELIBERATE. Here, `true` means THE PROVIDER REPORTED A DELIVERY, on the provider’s clock; an invitation the provider has merely accepted and not yet reported on reads `false`. `POST .../send` answers `true` for that same recipient — it is built from the answers the provider gave while the send was running, and acceptance is the only thing anybody knows at that moment. Its own description says so, and says it is the one place `true` means acceptance. So a recipient can read `true` from the send and `false` here seconds later, and neither answer is wrong: deciding anything real from the send response is deciding from a snapshot taken before the fact existed. Re-read here.\n\n⚠️ FOUR SITUATIONS ANSWER WITH THE SAME 404: no such envelope anywhere; one belonging to another organisation; a draft that has since been deleted; and an id that is not a uuid. \"It exists but is not yours\" is itself the secret, and the handler could not tell the first two apart if it wanted to — the read runs inside a transaction already scoped to the key’s tenant.\n\nA `vsk_test_` key may read. Reading emails nobody.","security":[{"ApiKey":[]}],"x-required-scope":"envelopes:read","parameters":[{"name":"envelopeId","in":"path","required":true,"description":"The envelope’s `id`, as returned by `POST /api/v1/envelopes` or carried as `envelope_id` on every webhook event.","schema":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-0000000000e5"],"description":"An envelope id."}}],"responses":{"200":{"description":"The envelope, with its recipients in routing order.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Envelope","description":"The envelope."}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"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."}}},"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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/envelopes/{envelopeId}/documents":{"get":{"operationId":"listEnvelopeDocuments","summary":"Get an envelope’s document, and its certificate once sealed.","description":"THE BYTES `GET /api/v1/envelopes/{envelopeId}` COULD NOT REACH. That endpoint answers with status and recipients; this is where the contract itself lives — the one route an integrator who embedded signing and received `envelope.completed` needs and, until now, did not have.\n\n⚠️ `url` IS SHORT-LIVED AND MUST BE FETCHED, NOT STORED. It is minted fresh on every call — a few minutes of validity, never the same value twice — so keep the envelope id and the `position`, and come back to this endpoint for a new `url` whenever you next need the bytes.\n\n⚠️ `sealed: false` MEANS \"THIS IS THE DOCUMENT AS ORIGINALLY SENT\" — READ `status` TO KNOW WHAT HAPPENS NEXT. Sealing runs asynchronously, so for a `draft`, `sent` or `partially_signed` envelope `false` usually means the certificate has not landed yet and a later read will flip it. For a `voided`, `declined` or `expired` envelope it means never — none of those statuses ever completes, and polling this endpoint waits forever for a seal that is not coming. The original is still returned rather than an empty list either way, honestly marked unsealed.\n\n`position` is 1-based, matching what a sender is shown — \"Document 2 of 3\" — and ordinarily there is exactly one.\n\n⚠️ `documents:read`, NOT `envelopes:read`. A key that may read an envelope's status is not thereby a key that may fetch what it holds; wiring a status dashboard needs only the first.\n\n⚠️ A `vsk_test_` KEY MAY READ THE STATUS OF ANY ENVELOPE IN ITS ORGANISATION BUT MAY ONLY DOWNLOAD THE DOCUMENTS OF ONE IT CREATED — `test_key_cannot_read_documents` otherwise. That argument (`GET /api/v1/envelopes/{envelopeId}`'s own rule) is about `status` and `recipients`, and it does not transfer to the bytes of a real, signed contract.\n\n⚠️ FOUR SITUATIONS ANSWER WITH THE SAME 404, the disclosure rule this namespace uses everywhere: no such envelope anywhere; one belonging to another organisation; a deleted draft; and an id that is not a uuid. \"It exists but is not yours\" is itself the secret.","security":[{"ApiKey":[]}],"x-required-scope":"documents:read","parameters":[{"name":"envelopeId","in":"path","required":true,"description":"The envelope’s `id`, as returned by `POST /api/v1/envelopes`.","schema":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-0000000000e5"],"description":"An envelope id."}}],"responses":{"200":{"description":"The envelope’s status and documents, in position order.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvelopeDocumentsResponse","description":"The status and documents."}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.\n\n`test_key_cannot_read_documents` — A `vsk_test_` key asked `GET /api/v1/envelopes/{id}/documents` for an envelope that is not a sandbox envelope. A test key may read the STATUS of any envelope in its organisation — that emails nobody and spends no allowance — but the documents endpoint hands over the bytes of a signed contract, and a sandbox is meant to be rehearsable without ever touching something real. Fetch this envelope’s documents with a live key, or fetch the documents of an envelope a test key created.","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."}}}},"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."}}},"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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/envelopes/{envelopeId}/void":{"post":{"operationId":"voidEnvelope","summary":"Withdraw a sent envelope.","description":"STOPS AN ENVELOPE THAT IS OUT FOR SIGNATURE. Every outstanding signing link is revoked, the envelope moves to `voided`, and the reason is kept on the record and in the audit trail.\n\n⚠️ IT DOES NOT DELETE ANYTHING. Signatures already collected stay exactly where they are and the evidence chain is untouched — voiding is a statement about what happens NEXT, not an erasure of what happened. A signer who follows an old link is told the envelope was withdrawn rather than being shown a document they can no longer act on.\n\n**200, not 201.** Nothing was created; the envelope existed before this call.\n\n## Which envelopes can be voided\n\nONLY `sent` AND `partially_signed` — an envelope that is out for signature. Anything else is `envelope_not_sent`:\n\n- a **draft** has asked nobody to do anything, so there is nothing to withdraw. Simply never send it.\n- a **completed** envelope is executed. Voiding it would claim a signed agreement was withdrawn after the fact, which is not true and not this endpoint’s to say.\n- an envelope that is already `voided`, `declined` or `expired` has stopped once already.\n\n## The idempotency\n\n⚠️ `Idempotency-Key` IS REQUIRED, and the reason is sharper here than for most endpoints. Without one, a timeout is unresolvable in the worst way: you retry, the envelope IS already voided, and you are refused — so a successful void and a failed one look identical from the outside. With a key the retry replays the original 200 and the envelope it names.\n\nTHE REASON IS NOT PART OF THE KEY’S FINGERPRINT. Two voids of the same envelope under one key with different reasons are the same act; the first reason is the one recorded. A DIFFERENT envelope under a used key is `idempotency_key_reused`.","security":[{"ApiKey":[]}],"x-required-scope":"envelopes:write","parameters":[{"name":"envelopeId","in":"path","required":true,"description":"The envelope to withdraw.","schema":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-0000000000e5"],"description":"An envelope id."}},{"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 — see the note above on why a void without one cannot be retried safely.","schema":{"type":"string","examples":["01960000-0000-4000-8000-00000000ffff"],"description":"The caller’s own request identifier."}}],"requestBody":{"required":true,"description":"Why this envelope is being withdrawn.","content":{"application/json":{"schema":{"type":"object","description":"Why this envelope is being withdrawn.","properties":{"reason":{"type":"string","maxLength":500,"examples":["Superseded by a corrected offer"],"description":"WHY, IN WORDS SOMEBODY WILL READ MONTHS LATER. Required, and kept on the envelope record and in the audit event — it is the answer to “why is this contract withdrawn”, asked by somebody who was not there. At most 500 characters."}},"required":["reason"]}}}},"responses":{"200":{"description":"The envelope, now `voided`, with the reason it carries. Its recipients are returned as they stand — a recipient who had already signed still reads `signed`, because they did.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Envelope","description":"The envelope, as it now stands."}}},"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 could not be read: a query parameter outside its range, a cursor that names nothing. The `message` names the parameter — there is no `param` field on the envelope yet, because no endpoint validates a body yet. Fix the request; retrying it unchanged will fail identically.\n\n`idempotency_key_required` — Send `Idempotency-Key: <uuid>`. A distinct code from `invalid_request` because it is the one 400 whose remedy is a header rather than a body, and an integrator reading it in a log should not have to work that out.","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."}}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"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."}}},"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."}}}},"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.\n\n`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."}}},"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."}}}},"422":{"description":"`envelope_not_sent` — A URL can only be minted while the envelope is open for signing. A draft has not been sent to anybody — create it with `\"send\": true`, or send it first. A completed, declined, voided or expired envelope is finished with. ⚠️ A `vsk_test_` key always lands here: it may create a draft and may not send one, so its envelopes are never open.","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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/envelopes/{envelopeId}/send":{"post":{"operationId":"sendEnvelope","summary":"Send a draft envelope to its recipients.","description":"THE SECOND HALF OF THE CREATED-VERSUS-SENT SPLIT. `POST /api/v1/envelopes` with `\"send\": false` makes a draft and emails nobody; this is how that draft goes out. Two acts an integrator can separate — build the envelope when a form is submitted, send it when a human approves — which is the arrangement a one-shot create cannot express without either sending too early or holding a request open.\n\n⚠️ A SUB-RESOURCE AND NOT `PATCH {\"status\": \"sent\"}`. Sending emails people, consumes billing allowance and writes an audit event; making that look like editing a property would make an irreversible, billable act look reversible.\n\n**200, not 201.** Nothing was created — the envelope existed before this call. A client branching on 201 to mean \"store this new id\" would be wrong here every time.\n\n## The idempotency, which is the point\n\n⚠️ `Idempotency-Key` IS REQUIRED. None of DocuSign, Dropbox Sign or BoldSign offers one on the endpoint that costs real money; this is that endpoint, and an optional guarantee is one nobody sends until they have already been burned.\n\nA REPLAY MEANS \"you already sent this, and here is what happened\" — the first attempt’s answer, byte for byte, with `Idempotency-Replayed: true`. Not a second send, not a resend, not a refusal. That is what makes a timeout safe to retry.\n\nA DIFFERENT ENVELOPE UNDER A USED KEY IS `idempotency_key_reused`, never a replay — including a key you already used to CREATE something. A key identifies one request.\n\nKEYS ARE REMEMBERED FOR 24 HOURS, per organisation and per environment. Past that the same string is a new request — and a new request to send an envelope that has already gone out is `envelope_not_draft`, not a second send. Two mechanisms, and the second one is the database: `draft → sent` is a locked, one-way transition, so a duplicate send is impossible whatever the key says. The key decides what the second caller is TOLD.\n\n## What it refuses\n\nA REFUSAL SENDS NOTHING AND CHANGES NOTHING. The envelope is left exactly as it was — unlike a refused `\"send\": true` on the create endpoint, which deletes the draft it had just built — and the idempotency key is freed, so a caller who upgrades their plan or fixes their roster retries with the same key.\n\n⚠️ A `vsk_test_` KEY IS REFUSED. It may create drafts and may not send one: there is no environment column on an envelope yet, so a test send would email real people and consume real allowance.","security":[{"ApiKey":[]}],"x-required-scope":"envelopes:write","parameters":[{"name":"envelopeId","in":"path","required":true,"description":"The draft envelope’s `id`, as returned when it was created.","schema":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-0000000000e5"],"description":"An envelope id."}},{"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 went out, and retrying either sends it twice or tells you it is not a draft without saying who sent it.","schema":{"type":"string","examples":["01960000-0000-4000-8000-00000000ffff"],"description":"The caller’s own request identifier."}}],"responses":{"200":{"description":"The envelope, now `sent`. `invitation_delivered` on each recipient is what the email provider said about that invitation — `null` where none was attempted, which on a `sequential` envelope is every position after the first.\n\n⚠️ HERE, AND ONLY HERE, `true` MEANS \"THE PROVIDER ACCEPTED IT\" RATHER THAN \"THE PROVIDER DELIVERED IT\". This response is built from the answers the provider gave while the send was running, and at that moment nobody — us or them — knows whether the message will arrive. Delivery is reported afterwards, and `GET /api/v1/envelopes/{id}` is where it shows up. If the distinction matters to you, re-read there rather than trusting this snapshot.\n\n⚠️ A recipient still reading `pending` after a successful send is NORMAL on a sequential envelope, not a failure.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Envelope","description":"The envelope, as it now stands."}}},"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 could not be read: a query parameter outside its range, a cursor that names nothing. The `message` names the parameter — there is no `param` field on the envelope yet, because no endpoint validates a body yet. Fix the request; retrying it unchanged will fail identically.\n\n`idempotency_key_required` — Send `Idempotency-Key: <uuid>`. A distinct code from `invalid_request` because it is the one 400 whose remedy is a header rather than a body, and an integrator reading it in a log should not have to work that out.","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."}}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.\n\n`test_key_cannot_send` — A `vsk_test_` key named 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 unsigned and watermarked, and everyone who receives one is told so before they can open it.\n\n`api_key_owner_removed` — The person who created this key has left the organisation, and every envelope must name a real human sender. The key is not revoked and its reads still work; mint a new one from a current member and send with that.","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."}}}},"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."}}},"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."}}}},"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.\n\n`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.\n\n`envelope_not_draft` — Only a draft can be sent, and this envelope has already left draft — through this API, through the sender’s own Send button, or by being voided. ⚠️ THE EXACT OPPOSITE OF `envelope_not_sent`, and the two are not interchangeable: that one means the envelope has NOT gone out. An envelope never returns to draft, so this is terminal — read it with `GET /api/v1/envelopes/{envelopeId}` to see where it got to. ⚠️ If you were retrying a request that timed out, send the SAME `Idempotency-Key` instead and you will be told what the first attempt did.","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."}}}},"422":{"description":"`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.\n\n`template_has_no_fields` — The template has no fields, so the envelope would ask nobody to do anything. Place at least one field on it in the editor.\n\n`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.","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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/envelopes/{envelopeId}/recipients/{recipientId}/signing-url":{"post":{"operationId":"createSigningUrl","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.\n\n⚠️ TEN MINUTES, AND SINGLE USE. The first GET spends it. A second GET renders a page that posts `signing_url_invalid` to your host page and signs 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.\n\n⚠️ THE URL AND THE SESSION HAVE DIFFERENT LIVES. `expires_at` is when the URL stops being redeemable; a signer who redeems at minute nine gets a full hour to read and sign. Reload the iframe on this timer only if nobody ever opened it.\n\nFOUR 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).\n\n## Framing\n\nThe 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. Every other path in this product keeps `X-Frame-Options: DENY`.\n\n## Talking to your page\n\nThe iframe posts `{ action, ...payload }` to each registered origin, never to `*`. Actions: `ready`, `signed`, `declined`, `delegated`, and `signing_url_invalid` (which carries `can_remint` — branch on that, not on `reason`, or a signer who finishes in another tab turns your retry into an infinite loop).\n\n⚠️ `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.\n\n⚠️ **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.\n\nNO `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.","security":[{"ApiKey":[]}],"x-required-scope":"envelopes:write","parameters":[{"name":"envelopeId","in":"path","required":true,"description":"The envelope, as returned by `POST /api/v1/envelopes`.","schema":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-0000000000e5"],"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-00000000005e"],"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."}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"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."}}},"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."}}}},"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.\n\n`recipient_cannot_sign` — The recipient has signed, approved, declined, or been superseded or delegated away. Every status that produces this has no outgoing edge, so a retry cannot help and a fresh URL would open onto the same nothing. Your `recipient.completed` or `recipient.declined` webhook already carries the real answer.","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."}}}},"422":{"description":"`envelope_not_sent` — A URL can only be minted while the envelope is open for signing. A draft has not been sent to anybody — create it with `\"send\": true`, or send it first. A completed, declined, voided or expired envelope is finished with. ⚠️ A `vsk_test_` key always lands here: it may create a draft and may not send one, so its envelopes are never open.\n\n`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.\n\n`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."}}},"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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/webhooks":{"get":{"operationId":"listWebhooks","summary":"Every registered endpoint, with its health.","description":"Newest first. ⚠️ NO `secret` ON ANY OF THEM — a secret is returned by the registration and by a rotation, and by nothing else, ever.\n\n`active`, `consecutive_failures`, `last_success_at` and `disabled_reason` are here so that \"why did my endpoint stop receiving events\" is a GET rather than a support ticket.","security":[{"ApiKey":[]}],"x-required-scope":"webhooks:read","responses":{"200":{"description":"The endpoints.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointList","description":"The list."}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}},"post":{"operationId":"createWebhook","summary":"Register an endpoint and mint its signing secret.","description":"THE ENDPOINT THAT MAKES MULTI-TENANT ONBOARDING PROGRAMMATIC. BoldSign has no webhook management API at all — theirs is configured in a dashboard, by a human — which means a customer who resells to their own tenants cannot onboard one without somebody clicking. This is the same resource with the same six methods every other noun gets.\n\n⚠️ THE RESPONSE CARRIES `secret` AND NOTHING ELSE EVER WILL. Store it now. There is no \"show me again\": if you lose it, rotate.\n\n⚠️ NO `Idempotency-Key`, UNLIKE `POST /api/v1/envelopes`, and the difference is what a retry costs. A duplicated send emails a stranger twice and bills twice, and neither can be taken back; a duplicated registration emails nobody, bills nothing, is visible in a list of at most 10 and is one DELETE away.\n\nSee `x-webhooks` at the root of this document for the body we will POST, the signature, and the retry ladder.","security":[{"ApiKey":[]}],"x-required-scope":"webhooks:write","requestBody":{"required":true,"description":"Where to deliver, what to deliver, and optionally which brand only.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookRequest","description":"The registration."}}}},"responses":{"201":{"description":"The endpoint, and the signing secret — for the only time.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointWithSecret","description":"The endpoint and its secret."}}},"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 could not be read: a query parameter outside its range, a cursor that names nothing. The `message` names the parameter — there is no `param` field on the envelope yet, because no endpoint validates a body yet. Fix the request; retrying it unchanged will fail identically.","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."}}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"422":{"description":"`webhook_url_invalid` — The `url` is not one we will POST to: it is not `https://`, it carries credentials, or it names a loopback or private address. Deliveries are made from inside our network, so a private address would make this API a request-forgery tool. Fix the URL; retrying it unchanged fails identically.\n\n`webhook_events_invalid` — `events` was empty, or named a type outside the published vocabulary. ⚠️ An unknown name is REFUSED rather than accepted and silently never delivered — that is how an integrator spends a week waiting for `envelope.singed`. The message lists every valid name.\n\n`webhook_brand_unknown` — `brand_id` names no brand of this organisation. ⚠️ 422 and not 404 because you asked for a WEBHOOK — the brand is a property of the body that does not fit. Send `null` (or omit it) to receive every envelope in the organisation.\n\n`webhook_limit_reached` — This organisation already holds 10 endpoints, which is the maximum: every endpoint multiplies every event into another outbound request. If you are registering one per brand, use ONE endpoint with `brand_id: null` and route on the `brand_id` every event already carries.","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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/webhooks/{webhookId}":{"get":{"operationId":"getWebhook","summary":"One endpoint, with its health.","description":"⚠️ THREE SITUATIONS ANSWER WITH THE SAME 404: no such endpoint; one belonging to another organisation; an id that is not a uuid. \"This exists but is not yours\" is itself the secret, and the handler could not distinguish them if it wanted to — the read runs inside a transaction already scoped to the key’s tenant.","security":[{"ApiKey":[]}],"x-required-scope":"webhooks:read","parameters":[{"name":"webhookId","in":"path","required":true,"description":"The endpoint’s `id`, as returned by the list.","schema":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-000000000e5d"],"description":"An endpoint id."}}],"responses":{"200":{"description":"The endpoint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint","description":"The endpoint."}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"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."}}},"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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}},"patch":{"operationId":"updateWebhook","summary":"Change the URL, the subscription, the brand filter or the active flag.","description":"A PARTIAL BODY, and one path for every property this resource will ever have. BoldSign minted one endpoint per property — `addTags`, `changeAccessCode`, `extendExpiry`, eight ways to edit a document — and has 87 paths and a casing typo they can never fix.\n\n⚠️ `\"active\": false` STOPS DELIVERY AND KEEPS THE QUEUE. This is the operation for a maintenance window: queued deliveries wait, and their retry ladders do not advance while they wait. `\"active\": true` resumes them and resets the health window, which is how an auto-disabled endpoint is brought back.\n\n⚠️ OMISSION IS MEANINGFUL HERE AND NOWHERE ELSE IN THIS API. Sending `\"brand_id\": null` REMOVES the filter; omitting `brand_id` leaves it alone. Every other request in this document spells absence as `null`.\n\nThe secret is not settable — see `/rotate-secret`.","security":[{"ApiKey":[]}],"x-required-scope":"webhooks:write","parameters":[{"name":"webhookId","in":"path","required":true,"description":"The endpoint’s `id`.","schema":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-000000000e5d"],"description":"An endpoint id."}}],"requestBody":{"required":true,"description":"The properties to change. Anything omitted is left as it was.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchWebhookRequest","description":"The change."}}}},"responses":{"200":{"description":"The endpoint as it now stands.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint","description":"The endpoint."}}},"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 could not be read: a query parameter outside its range, a cursor that names nothing. The `message` names the parameter — there is no `param` field on the envelope yet, because no endpoint validates a body yet. Fix the request; retrying it unchanged will fail identically.","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."}}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"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."}}},"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."}}}},"422":{"description":"`webhook_url_invalid` — The `url` is not one we will POST to: it is not `https://`, it carries credentials, or it names a loopback or private address. Deliveries are made from inside our network, so a private address would make this API a request-forgery tool. Fix the URL; retrying it unchanged fails identically.\n\n`webhook_events_invalid` — `events` was empty, or named a type outside the published vocabulary. ⚠️ An unknown name is REFUSED rather than accepted and silently never delivered — that is how an integrator spends a week waiting for `envelope.singed`. The message lists every valid name.\n\n`webhook_brand_unknown` — `brand_id` names no brand of this organisation. ⚠️ 422 and not 404 because you asked for a WEBHOOK — the brand is a property of the body that does not fit. Send `null` (or omit it) to receive every envelope in the organisation.","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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}},"delete":{"operationId":"deleteWebhook","summary":"Remove an endpoint. ⚠️ Queued deliveries go with it.","description":"⚠️ **EVERY DELIVERY STILL QUEUED FOR THIS ENDPOINT IS DISCARDED.** That is a decision, stated here because a customer who expected the queue to drain deserves to have been told rather than to find out.\n\nWhy: the secret that would sign a retry lives on this record and dies with it, so a surviving delivery could only be sent unsigned — which no correct receiver should accept. And deleting an endpoint is a withdrawal of consent to be POSTed to: the usual reasons are a decommissioned URL or a leaked secret, and continuing to send to a hostname that may since have been reassigned is exactly what you asked us to stop doing.\n\n**If you want the queue kept, use `PATCH {\"active\": false}` instead.** It stops delivery immediately, holds the backlog, and resumes it when you re-activate.\n\n⚠️ A SECOND DELETE IS 404, NOT 204. The act is idempotent — the end state is identical — but a client that deleted the wrong id and re-ran its script deserves to learn that the second call named nothing.","security":[{"ApiKey":[]}],"x-required-scope":"webhooks:write","parameters":[{"name":"webhookId","in":"path","required":true,"description":"The endpoint’s `id`.","schema":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-000000000e5d"],"description":"An endpoint id."}}],"responses":{"204":{"description":"Deleted, along with anything queued for it. No body: the resource is gone, so there is nothing true to describe.","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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"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."}}},"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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}},"/api/v1/webhooks/{webhookId}/rotate-secret":{"post":{"operationId":"rotateWebhookSecret","summary":"Mint a new signing secret, keeping the old one alive briefly.","description":"THE SECOND AND LAST TIME A SECRET IS RETURNED.\n\n⚠️ THE OLD SECRET KEEPS WORKING FOR 24 HOURS, and during that window every delivery carries BOTH signatures — `v1` under the new secret and `v0` under the old. That is what makes rotation a deploy you can take your time over rather than an outage: without it, every delivery between our rotation and your release would be unverifiable, and a receiver doing the correct thing would drop real events for the whole gap.\n\n**Deploy the new secret, then do nothing** — `v0` expires on its own.\n\n⚠️ IF THE OLD SECRET LEAKED, ROTATE TWICE. `previous_secret` holds whatever the current secret held a moment ago, so a second rotation displaces the leaked value immediately. It is the only way to end the window early, and nobody would guess it.\n\n⚠️ A SUB-RESOURCE RATHER THAN A PATCH, because this is an act with consequences rather than a property assignment: it invalidates what you have deployed, starts a clock, and returns a credential once. There is no `Idempotency-Key` here and a retry rotates twice — but the RESPONSE is the secret, so \"I do not know which state I am in\" is not reachable: the live secret is the last one you successfully received.\n\n200 rather than 201: nothing was created, and the endpoint keeps its id.","security":[{"ApiKey":[]}],"x-required-scope":"webhooks:write","parameters":[{"name":"webhookId","in":"path","required":true,"description":"The endpoint’s `id`.","schema":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-000000000e5d"],"description":"An endpoint id."}}],"responses":{"200":{"description":"The endpoint, and the new secret — for the only time. The response also carries `Vumasign-Previous-Secret-Expires-In`, in seconds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointWithSecret","description":"The endpoint and its new secret."}}},"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."}}},"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."}}}},"403":{"description":"`forbidden` — The key is valid and we cannot establish what the organisation’s plan includes, so nothing is granted. Nothing about the credential needs to change. No plan excludes the API — a test key works on every one of them — so this is not a refusal an ordinary account can meet; it means ask us. It is deliberately MORE specific than 401 because the caller has already proved they hold the key.\n\n`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.","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."}}}},"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."}}},"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."}}}},"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 answer we give, including this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. `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, always the same value as `RateLimit-Reset`, and never more than 60. `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."}}},"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."}}}}}}}},"components":{"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."}},"schemas":{"Error":{"type":"object","description":"The body of every refusal, at every status, from every endpoint. 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.","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","idempotency_key_required","idempotency_key_reused","request_in_progress","template_archived","template_unusable","template_has_no_fields","recipient_role_unknown","recipient_role_missing","value_subject_unknown","value_address_unknown","value_not_writable","cannot_be_drawn","send_allowance_exhausted","test_key_cannot_send","test_key_cannot_read_documents","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"]},"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"]},"CreateTemplateRequest":{"type":"object","description":"One PDF, and who signs it.","properties":{"file":{"type":"string","examples":["JVBERi0xLjcKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c+PgplbmRvYmoK"],"description":"⚠️ THE WHOLE PDF, BASE64-ENCODED, AND THE EXAMPLE ABOVE IS A TRUNCATED STUB — replace it with your own document’s bytes or the request is refused as unreadable. Standard alphabet only: no `data:` prefix, no whitespace, no line wrapping, and not the URL-safe `-`/`_` variant. Base64 is about a third larger than the file it carries, and the whole request body is bounded at 8388608 bytes — so roughly 6291456 bytes of PDF, whatever its page count."},"roles":{"type":"array","description":"Who signs it, in order, at least one and at most six. ⚠️ THE ORDER IS LOAD-BEARING: it is a role’s position in the recipient list and its colour in the editor, and — when `signing_mode` is `sequential` — the order people are actually asked. Every field detected in the document is allocated to the first role that is not a `cc`, because a page cannot say which of three signers writes in a box; reassign them afterwards, or address them individually with the `id` each one comes back with.","items":{"type":"string","examples":["Employee"],"description":"One role’s name."}},"filename":{"type":"string","examples":["employment-contract.pdf"],"description":"Optional. Used only to NAME the template when `name` is absent, exactly as the upload box suggests a name from the file you dropped on it. It never reaches storage — the object key is the content hash."},"name":{"type":"string","examples":["Employment contract"],"description":"Optional. What to call the template. Absent or blank falls back to `filename`, and then to “Untitled document”. ⚠️ A name that is SENT and cannot be stored is refused, while a name that is not sent is not — a template’s name is editable afterwards, and refusing an upload over one would be a wall in front of the thing this endpoint exists to make free."},"routing_types":{"type":"array","description":"Optional. One entry per role, in the same order. ⚠️ IF YOU SEND IT AT ALL IT MUST HAVE EXACTLY ONE ENTRY PER ROLE — a short list is refused rather than padded, because from an API caller a short list is far more likely to be an off-by-one than an intention. Omit it entirely and every role signs. A `cc` is sent a finished copy and is never asked to fill anything in, which is why detected fields skip past one.","items":{"type":"string","enum":["sign","approve","cc"],"examples":["sign"],"description":"What this role is asked to do."}},"signing_mode":{"type":"string","enum":["parallel","sequential"],"examples":["parallel"],"description":"Optional, default `parallel` — everybody is asked at once. `sequential` asks them in the order `roles` is written, each invitation going out only when the one before it is finished."}},"required":["file","roles"]},"TemplateSummary":{"type":"object","description":"A template as it appears in a list. The detail response is a strict SUPERSET of this, so anything read here can be read there.","properties":{"id":{"type":"string","format":"uuid","description":"The template’s id. A bare uuid, no prefix."},"name":{"type":"string","description":"What the sender called it."},"created_at":{"type":"string","format":"date-time","description":"ISO 8601, UTC. Also the order this list is in — newest first."}},"required":["id","name","created_at"]},"Template":{"type":"object","description":"One template in full: its documents, its roles, its subjects, its questions and every field placement. This is the response an integrator reads once, by hand, to learn the ADDRESSES their values must be written to.","properties":{"id":{"type":"string","format":"uuid","description":"The template’s id. A bare uuid, no prefix."},"name":{"type":"string","description":"What the sender called it."},"created_at":{"type":"string","format":"date-time","description":"ISO 8601, UTC. Also the order this list is in — newest first."},"documents":{"type":"array","description":"The pack, in the order it is stacked.","items":{"$ref":"#/components/schemas/Document","description":"One document of the pack."}},"pages":{"type":"array","description":"⚠️ THE DENOMINATOR FOR EVERY `Field.rect`, and a client doing field matching cannot skip it: a rectangle of fractions is dimensionless without the page it is a fraction of. Join on `(document_id, page)`, the same pair `fields` is joined by.\n\nEvery page of every document of the pack, in the pack’s order and then by page number — the order a signer scrolls.","items":{"$ref":"#/components/schemas/Page","description":"One page: its size in points and its rotation."}},"roles":{"type":"array","description":"The signing roles, in routing order.","items":{"$ref":"#/components/schemas/Role","description":"One role."}},"subjects":{"type":"array","description":"The people whose data this template collects.","items":{"$ref":"#/components/schemas/Subject","description":"One subject."}},"questions":{"type":"array","description":"The tickbox questions and how many answers each takes.","items":{"$ref":"#/components/schemas/Question","description":"One question."}},"fields":{"type":"array","description":"⚠️ A FLAT LIST, NOT A MAP KEYED BY ADDRESS. Several fields sharing one `(subject, data_key)` pair is NORMAL — real forms ask for an ID number on the application and again on the declaration, and initials in the footer of all nine pages. A value supplied for an address means it for every box that asks.","items":{"$ref":"#/components/schemas/Field","description":"One field placement."}}},"required":["id","name","created_at","documents","pages","roles","subjects","questions","fields"]},"TemplateList":{"type":"object","description":"A page of templates, newest first, walked by keyset cursor.","properties":{"data":{"type":"array","description":"This page’s templates.","items":{"$ref":"#/components/schemas/TemplateSummary","description":"One template, in summary."}},"has_more":{"type":"boolean","description":"⚠️ THE ANSWER, rather than something to infer. A caller inferring \"more\" from a full page loops one extra time on every list whose size is an exact multiple of `limit`."},"next_cursor":{"type":["string","null"],"description":"Pass this as `cursor` for the next page. Null exactly when `has_more` is false — the two are computed from one expression, so a true with a null cursor cannot happen."}},"required":["data","has_more","next_cursor"]},"Document":{"type":"object","description":"One document of a template’s pack. Present so that a field’s `page` means something: the pack renders as one continuous column, so page 1 occurs once per document. Listed in the order the pack is stacked, which is the order a signer scrolls.","properties":{"id":{"type":"string","format":"uuid","description":"The document’s id. ⚠️ ALSO THE `documentId` OF `GET /api/v1/documents/{documentId}/pages/{pageNumber}`, which is where you fetch the page itself to look at."},"page_count":{"type":"integer","description":"How many pages this document contributes to the pack."}},"required":["id","page_count"]},"Page":{"type":"object","description":"One page of one of the template’s documents: how big it is and which way up it is. ⚠️ EVERY `Field.rect` IS A FRACTION OF THIS — a rectangle without its page is dimensionless, so a client that ignores this array cannot tell a portrait page from a landscape one and cannot check its own render against ours.","properties":{"document_id":{"type":"string","format":"uuid","description":"Which document of the pack. Matches `Document.id` and `Field.document_id`."},"page":{"type":"integer","description":"1-indexed, WITHIN `document_id` and not within the pack. Matches `Field.page`, and is the `pageNumber` of `GET /api/v1/documents/{documentId}/pages/{pageNumber}`."},"width":{"type":"number","description":"The visible width in PDF points (1/72 inch). ⚠️ ALREADY ROTATED — this is the width of the page as it is drawn and as you will see it. A4 portrait is 595.28; the same page at `rotation: 90` reports 841.89 here.","examples":[595.28]},"height":{"type":"number","description":"The visible height in PDF points. Already rotated, like `width`.","examples":[841.89]},"rotation":{"type":"integer","examples":[0],"description":"One of 0, 90, 180 or 270. ⚠️ INFORMATION, NOT AN INSTRUCTION. `width`, `height` and every `Field.rect` on this page are ALREADY expressed against the rotated page. A client that rotates the geometry again because this says 90 places every field wrongly, and the result looks entirely plausible.\n\nIt is published for the two things it is genuinely needed for: telling a page that is landscape because it was rotated from one that was authored landscape, and checking that whatever produced your image honoured the rotation at all — if your image’s aspect ratio disagrees with `width`/`height`, your renderer is what is wrong."}},"required":["document_id","page","width","height","rotation"]},"Rect":{"type":"object","description":"⚠️ WHERE THE BOX IS. **Fractions of the VISIBLE page. Origin TOP-LEFT. Y increases DOWNWARD. `x`/`y` are the box’s TOP-LEFT corner.**\n\nTo place it on an image of the page you rendered at any size:\n`left = x * imageWidth`, `top = y * imageHeight`, `width = w * imageWidth`, `height = h * imageHeight`. That arithmetic is correct at every resolution, which is the reason these are fractions and not points.\n\n⚠️ \"VISIBLE\" MEANS AFTER ROTATION. A page with `rotation: 90` is measured as it is drawn and as it is seen, not as it is stored — so these fractions are already right for the image in front of you and must not be rotated again. Join `Page` on `(document_id, page)` for the dimensions they are fractions of.\n\n⚠️ TOP-LEFT IS NOT PDF’S CONVENTION. PDF measures from the bottom-left upward. This API does not, because what you are matching against is an image, and every image and every vision model is top-left origin. If your fields come out mirrored vertically, this is the paragraph you needed.","properties":{"x":{"type":"number","description":"The LEFT edge, as a fraction of the visible page width. 0 is the left edge of the page, 1 the right.","examples":[0.1024]},"y":{"type":"number","description":"⚠️ The TOP edge, as a fraction of the visible page height, measured DOWNWARD. 0 is the top of the page, 1 the bottom.","examples":[0.2153]},"w":{"type":"number","description":"Width, as a fraction of the visible page width. Always positive.","examples":[0.3201]},"h":{"type":"number","description":"Height, as a fraction of the visible page height. Always positive.","examples":[0.0261]}},"required":["x","y","w","h"]},"Role":{"type":"object","description":"A signing role, addressed by NAME and never by position. A positional address breaks the moment a sender reorders a template; the array arrives in routing order, so nothing is lost by omitting the index.","properties":{"name":{"type":"string","description":"What the sender called this role. Matches `Field.role`."},"routing_type":{"type":"string","description":"What this role does with the document.","enum":["sign","approve","cc"]}},"required":["name","routing_type"]},"Subject":{"type":"object","description":"A subject: WHOSE data a field holds, as opposed to who fills it in. Half of every field address — `first_name` under the subject `spouse` is a different datum from `first_name` under the subject `main_member`.","properties":{"key":{"type":"string","description":"The machine half of the address — letters, digits and underscores only, so it can never contain a dot. Matches `Field.subject`."},"label":{"type":"string","description":"What the sender called this person. This is what an integrator matches against their own record of the same human."}},"required":["key","label"]},"Question":{"type":"object","description":"⚠️ A QUESTION, WHICH IS WHAT A SET OF TICKBOXES MEANS. A caller supplying a value for \"Race\" has to know the question takes exactly one answer, and no individual tickbox can tell them: `required` is per option, so a \"required\" Race question would mean every race must be ticked. The bounds here are the answer.","properties":{"id":{"type":"string","format":"uuid","description":"⚠️ A JOIN KEY WITHIN THIS RESPONSE, NOT AN ADDRESS. Nothing in the API accepts it as input. It exists because a question has no natural key — two questions on one form legitimately called \"Other\" are a form, not a mistake — so joining fields to questions by name would merge them."},"name":{"type":"string","description":"What a signer reads above the boxes: \"Race\", \"Marital status\"."},"min_selected":{"type":"integer","description":"0 makes the question optional. `>= 1` is what \"required\" means here."},"max_selected":{"type":"integer","description":"1 makes the question exclusive. ⚠️ It is NOT always 1: \"tick any that apply, at least one\" is `(1, n)`. Never 0 — a question that takes no answers is not a question."}},"required":["id","name","min_selected","max_selected"]},"Field":{"type":"object","description":"One placement on the paper. ⚠️ ONE ARRAY WITH A `type` DISCRIMINATOR, not one array per type: DocuSign has 37 typed tab arrays, so adding a type is a breaking change there and a generic client must enumerate 37 keys.","properties":{"id":{"type":"string","format":"uuid","description":"⚠️ THE HANDLE FOR EXACTLY ONE FIELD, AND NOT A SECOND ADDRESS. `subject` + `data_key` is the address and it names a SET of fields on purpose — one `main_member.id_number` is meant to fill the box on the application, the box on the declaration and the initials in nine footers. Write values to the ADDRESS by default. Use this id when you need to name ONE of several boxes the address cannot tell apart, or to keep a durable reference to a particular box. The two can never name the same target, so there is no precedence to learn: an id is one field, an address is a set. ⚠️ NOTHING ACCEPTS IT AS INPUT TODAY — `values` on `POST /api/v1/envelopes` is addressed by `subject` and `key`, and there is no id-keyed form. ⚠️ IT IS STABLE across ordinary editing — the sender may move, retype, relabel and readdress a box, or delete its neighbours, and this value does not change — so it is safe to store. ⚠️ IT NAMES A TEMPLATE FIELD: sending a template COPIES its fields onto the envelope, and those copies are their own rows with their own ids."},"type":{"type":"string","description":"What kind of box this is. This vocabulary is `FIELD_TYPES` verbatim and the database CHECK constraint’s, not a translation of either.","enum":["signature","initial","text","date","checkbox","dropdown","attachment","date_signed","signer_name","signer_email","signer_title","signer_company"]},"label":{"type":["string","null"],"description":"What is printed beside the box. Null for a box the sender never named."},"required":{"type":"boolean","description":"⚠️ ALWAYS FALSE ON A GROUPED OPTION — requiredness lives on the question, so read `min_selected` there instead. This flag is the answer for independent boxes only."},"role":{"type":"string","description":"WHO fills this in. Matches `Role.name`."},"document_id":{"type":"string","format":"uuid","description":"Which document of the pack this box is on. Matches `Document.id`."},"page":{"type":"integer","description":"1-indexed, WITHIN `document_id` and not within the pack."},"subject":{"type":["string","null"],"description":"WHOSE datum this is — a `Subject.key` — or null for the signer’s own. Half of the address."},"data_key":{"type":["string","null"],"description":"⚠️ THE OTHER HALF OF THE ADDRESS, AND NULL IS THE MOST USEFUL THING THIS ENDPOINT SAYS. Null means this box has no address at all and an integration CANNOT write to it. Find those here, against your own data model, rather than by watching a send leave them blank. ⚠️ It may contain a dot and any Unicode letter — South Africa has eleven official languages and a Sesotho label is not a malformed address — which is why this is never composed with `subject` into one dotted string."},"question_id":{"type":["string","null"],"description":"The `Question.id` this box is an option of, or null for an independent one."},"options":{"type":["array","null"],"items":{"type":"string","description":"One option, exactly as a signer sees it and exactly as it is sealed."},"description":"WHAT A `dropdown` OFFERS, in the order the signer reads them. Null on every other type. An option is ONE STRING — what is shown, what you write back, and what is sealed onto the document; there is no separate value and label, so the string on the certificate cannot disagree with the string in the database. ⚠️ A value you write to a dropdown MUST be one of these exactly: anything else is refused. ⚠️ Null on a dropdown created before options existed — such a field cannot be answered at all, by you or by a signer, and has to be given a list in the editor."},"rect":{"$ref":"#/components/schemas/Rect","description":"⚠️ WHERE THE BOX IS ON THE PAGE, AND THE REASON THIS ENDPOINT IS USABLE BY SOMETHING THAT CAN SEE. `label` says what is printed beside the box and `data_key` says what may be written into it; on a real form neither is enough. A nine-page medical application carries 284 fields named `checklist.17` and `fullwidth.42`, with OCR-derived labels, where \"ID or passport number\" appears four times on one page and only the POSITION says which dependant it belongs to.\n\nFetch the page it is on from `GET /api/v1/documents/{document_id}/pages/{page}`, render it, and these four fractions land on the box. ⚠️ Read `Rect` before using them: origin top-left, Y downward, measured against the page AFTER rotation."}},"required":["id","type","label","required","role","document_id","page","subject","data_key","question_id","options","rect"]},"CreateEnvelopeRequest":{"type":"object","description":"What to make the envelope from, who it is for, and whether to send it.","properties":{"template_id":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-0000000007e1"],"description":"The template to instantiate. Layout is authored there, never here (§3)."},"recipients":{"type":"array","description":"⚠️ THE ORDER OF THIS ARRAY IS THE ROUTING ORDER. It becomes `order_index`, which is what a template with sequential signing gates on — so reordering it is a different request, and an idempotency key will not replay across the two.","items":{"$ref":"#/components/schemas/RecipientInput","description":"One recipient."}},"values":{"type":"array","description":"Optional. Values to prefill, as triples. Omitted means none. ⚠️ A signature, a `date_signed`, a `signer_name` and a `signer_email` may not be prefilled — they are acts, or they are written by this system.","items":{"$ref":"#/components/schemas/ValueInput","description":"One value at one address."}},"send":{"type":"boolean","description":"Optional, default `false`. False creates a draft and emails nobody; true sends it, consuming the plan’s allowance and delivering an invitation to everyone whose turn it is."},"expires_at":{"type":["string","null"],"format":"date-time","description":"Optional, default `null` — no deadline. When this envelope stops being signable, as an ISO 8601 instant. ⚠️ AN ABSOLUTE TIME RATHER THAN A DURATION, so a retried request carrying the same `Idempotency-Key` asks for the same deadline the first attempt did. At least an hour out and at most 365 days out; anything else is `invalid_request`. ⚠️ IT CANNOT BE CHANGED AFTERWARDS — there is no endpoint that extends a deadline yet, and an envelope that reaches one while partly signed can only be voided and started again, discarding the signatures already collected. It is enforced: every signing link is capped by it, no link can be reissued past it, and the envelope transitions to `expired` and emits `envelope.expired` once it passes."},"brand_id":{"type":["string","null"],"format":"uuid","description":"Optional, default `null` — the organisation’s default brand. Which brand the envelope goes out under: the letterhead, the colours and the sending name a signer sees. ⚠️ THIS IS THE FIELD FOR SENDING ON BEHALF OF MANY CUSTOMER BRANDS FROM ONE ACCOUNT — change it between calls and the same integration sends Customer A’s paperwork under Customer A’s brand and Customer B’s under Customer B’s.\n\n⚠️ AN ID THAT NAMES NO LIVE BRAND OF YOUR ORGANISATION IS REFUSED WITH `brand_unknown`, NOT IGNORED — including one that has been archived. The branding an envelope went out under is frozen the moment it is sent and cannot be corrected afterwards, so a wrong constant in your configuration would otherwise put somebody else’s letterhead on every envelope you ever send and nothing would say so.\n\nIt is recorded on the envelope, so a draft created with `send: false` still goes out under this brand when it is sent later."}},"required":["template_id","recipients"]},"RecipientInput":{"type":"object","description":"One person, and the template role they fill.","required":["role","name","email"],"properties":{"role":{"type":"string","examples":["Tenant"],"description":"A `name` from the template’s `roles`. ⚠️ NOT AN ORDINAL: §3 refuses BoldSign’s `roleIndex`, which breaks the moment somebody reorders a template. An unknown name is `recipient_role_unknown` and names itself."},"name":{"type":"string","examples":["Thandi Mokoena"],"description":"What the invitation and the certificate will call them."},"email":{"type":"string","format":"email","examples":["thandi@example.test"],"description":"Where the invitation goes. ⚠️ STILL REQUIRED WHEN `embedded` IS TRUE, even though nothing is sent to it: the address is on the certificate of completion, in the audit chain, and in any `signer_email` field on the document. Embedded signing says your application authenticated this person; it does not say they have no identity. ⚠️ THE SHAPE IS CHECKED AND ONLY THE SHAPE: one `@`, a dotted domain, at most 254 characters. A malformed address is `invalid_request` naming the offending index — refused before anything is created, because a sent envelope cannot have its recipients corrected. Nothing here can tell whether the mailbox exists; that arrives later, as a `recipient.invite_failed` event."},"job_title":{"type":["string","null"],"examples":["Group Executive"],"description":"Optional, default `null`. The job title you assert this person holds, at most 200 characters. ⚠️ IT IS SEEDED INTO A `signer_title` FIELD AT SEND AND THE SIGNER MAY THEN CORRECT IT — they are the authority on their own title, and stamping it would mean an executive whose title you got slightly wrong must sign a document that misstates their role or decline it. Your assertion is not lost: the seeded value is recorded with `source: \"prefill\"` and a correction with `source: \"signer\"`.\n\n⚠️ IT APPEARS NOWHERE IF THE DOCUMENT ASKS FOR IT NOWHERE. The template defines what is collected and what is shown, so a title supplied for a document that places no title field is on neither the pages nor the certificate of completion. ⚠️ AND IT IS NOT ECHOED BACK: it is read once, at send, and the certificate reports the FIELD VALUE rather than this. Blank is refused — send `null`."},"company":{"type":["string","null"],"examples":["Vumasign (Pty) Ltd"],"description":"Optional, default `null`. The employer you assert this person has, at most 200 characters. Seeded into a `signer_company` field at send, editable by the signer, absent from the certificate unless the document asked, and not echoed back — all four for the same reasons as `job_title`. ⚠️ THIS REPLACES THE ADDRESS ROUTE FOR THE COMMON CASE: `values` with the address `company` still works and still takes precedence, but you no longer have to describe the same person twice."},"embedded":{"type":"boolean","description":"Optional, default `false`. ⚠️ TRUE MEANS THIS PERSON IS NEVER EMAILED — you reach them by minting a URL with `POST /api/v1/envelopes/{envelopeId}/recipients/{recipientId}/signing-url` and framing it. An explicit mode rather than DocuSign’s inference from `clientUserId` being non-null, and it is returned on every recipient we describe, which theirs is not.\n\n⚠️ REFUSED IN TWO SITUATIONS, BOTH OF WHICH WOULD LEAVE THE PERSON UNREACHABLE: a role the template routes as `cc` (never issued a signing credential by any path), and an organisation whose default brand has no embedding origins registered."},"external_ref":{"type":["string","null"],"description":"Optional, default `null`. Your own identifier for this human, at most 255 characters. Stored, echoed back, and used by us for nothing at all — it is deliberately separate from `embedded` because they answer different questions and this one is optional."}}},"ValueInput":{"type":"object","description":"One value, at one address. See the note above about triples.","properties":{"subject":{"type":["string","null"],"examples":["tenant"],"description":"A `key` from the template’s `subjects` — WHOSE datum this is — or `null` for a field belonging to nobody in particular. Required as a property: `null` and \"I did not say\" must not be two spellings of one thing."},"key":{"type":"string","examples":["full_name"],"description":"The field’s `data_key`, exactly as the template endpoint reports it."},"value":{"type":"string","examples":["Thandi Mokoena"],"description":"What to write. It lands in EVERY field at this address, which is the point. An address that matches nothing is refused rather than dropped."}},"required":["subject","key","value"]},"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."},"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","created_at","sent_at","expires_at","recipients"]},"EnvelopeRecipient":{"type":"object","description":"One recipient of an envelope, as it stands.","properties":{"id":{"type":"string","format":"uuid","description":"This recipient. Published where a template ROLE’s id deliberately is not, because a recipient has no natural key — one person may hold two roles and two people may hold one."},"role":{"type":"string","description":"The template role they were given."},"name":{"type":"string","description":"As stored, after normalisation."},"email":{"type":"string","format":"email","description":"As stored, after normalisation."},"routing_type":{"type":"string","enum":["sign","approve","cc"],"description":"⚠️ FROM THE TEMPLATE, NEVER FROM THE REQUEST. Whether a role signs, approves or is copied in is part of the layout (§3), and a caller who could override it could send a document whose signature boxes nobody will ever be asked to fill."},"status":{"type":"string","description":"`recipients.status` verbatim. ⚠️ `pending` AFTER A SUCCESSFUL SEND IS NORMAL: a sequential template invites only the first position and holds the rest back until it empties."},"invitation_delivered":{"type":["boolean","null"],"description":"Whether the email provider reports the invitation as DELIVERED. `null` when none was attempted — a draft, a `cc`, an embedded recipient, or a position held back.\n\n⚠️ 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.\n\n⚠️ 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.\n\n⚠️ `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, verbatim, or `null`. 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"]},"EnvelopeSummary":{"type":"object","description":"One envelope as it appears in a list: enough to recognise it and decide whether to read it in full. `GET /api/v1/envelopes/{envelopeId}` for its recipients and their delivery state.","properties":{"id":{"type":"string","format":"uuid","description":"The envelope’s id. A bare uuid."},"title":{"type":"string","description":"What the signers see naming the document. Defaults to the template’s name."},"status":{"type":"string","enum":["draft","sent","partially_signed","completed","declined","voided","expired"],"description":"One of the statuses `?status=` filters on, below."},"template_id":{"type":["string","null"],"format":"uuid","description":"The template this was made from, or `null` for a one-off created by `POST /api/v1/envelopes/one-off`, which carries the document itself and never had one."},"created_at":{"type":"string","format":"date-time","description":"ISO 8601, UTC. Also the order this list is in -- newest first."},"sent_at":{"type":["string","null"],"format":"date-time","description":"ISO 8601, UTC. Null while it is still a draft."}},"required":["id","title","status","template_id","created_at","sent_at"]},"EnvelopeList":{"type":"object","description":"A page of envelopes, newest first, walked by keyset cursor.","properties":{"data":{"type":"array","description":"This page’s envelopes.","items":{"$ref":"#/components/schemas/EnvelopeSummary","description":"One envelope, in summary."}},"has_more":{"type":"boolean","description":"⚠️ THE ANSWER, rather than something to infer. A caller inferring \"more\" from a full page loops one extra time on every organisation whose envelope count is an exact multiple of `limit`."},"next_cursor":{"type":["string","null"],"description":"Pass this as `cursor` for the next page. Null exactly when `has_more` is false -- the two are computed from one expression, so a true with a null cursor cannot happen. ⚠️ OPAQUE: it is the last envelope’s `id`, but that is an implementation detail rather than a promise -- pass it back exactly as received and never construct one by hand."}},"required":["data","has_more","next_cursor"]},"EnvelopeDocument":{"type":"object","description":"One document belonging to an envelope: the executed artefact once the seal has landed, the original otherwise.","properties":{"position":{"type":"integer","description":"1-based, matching what a sender is shown — \"Document 2 of 3\". Never 0."},"sealed":{"type":"boolean","description":"Whether `url` names the executed artefact or the document originally sent. ⚠️ `false` ON ITS OWN DOES NOT MEAN \"POLL AGAIN\" — read it alongside this response’s `status`. For a `draft`, `sent` or `partially_signed` envelope, `false` usually does mean the seal has not landed yet: sealing runs asynchronously after completion, so read again later. For a `voided`, `declined` or `expired` envelope, `false` means never — none of those statuses ever completes, and a caller that keeps polling here waits forever for something that is not coming."},"byte_size":{"type":"integer","description":"The size of the file at `url`, in bytes."},"url":{"type":"string","description":"⚠️ SHORT-LIVED. FETCH IT, DO NOT STORE IT. This is a one-time path, valid for a few minutes, minted fresh on every read of this endpoint — never a stable identifier and never the same value twice. Keep the envelope id, and the position, and come back here for a new one when you need the bytes again."}},"required":["position","sealed","byte_size","url"]},"EnvelopeDocumentsResponse":{"type":"object","description":"The documents attached to one envelope, in position order.","properties":{"status":{"type":"string","description":"The envelope’s status, exactly as `GET /api/v1/envelopes/{envelopeId}` reports it. ⚠️ READ THIS BEFORE ACTING ON `sealed: false` BELOW — it is the only way to tell \"the seal has not landed yet\" (`draft`, `sent`, `partially_signed`) from \"it never will\" (`voided`, `declined`, `expired`) apart, and the two calls for different behaviour: poll again, or stop."},"documents":{"type":"array","description":"Ordinarily one entry. More than one only for an envelope built from a multi-document template or one-off pack.","items":{"$ref":"#/components/schemas/EnvelopeDocument","description":"One document."}}},"required":["status","documents"]},"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 PDFs, 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. ⚠️ PDF ONLY, CHECKED BY THE BYTES AND NOT THE FILENAME — render Word, Google Docs or templating-engine output to PDF first. Text tags survive that render. 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."}},"required":["title","documents","recipients"]},"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."},"name":{"type":"string","examples":["A. Dlamini"],"description":"The person. Printed on the Certificate of Completion."},"email":{"type":"string","format":"email","examples":["a.dlamini@example.co.za"],"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 so a webhook or an envelope read can be matched to your record without a side table. Never read by this system."},"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"]},"OneOffDocument":{"type":"object","description":"One PDF, base64-encoded.","properties":{"content":{"type":"string","examples":["JVBERi0xLjcKJc..."],"description":"The PDF’s bytes, base64-encoded with the standard alphabet — no `data:` prefix, no whitespace, no URL-safe `-` or `_`. ⚠️ AT MOST 6 MB OF PDF, because the whole JSON body is capped at 8 MiB and base64 costs a third on top. A larger file is refused by that bound, not by the 20 MB the upload screen allows — send it through the dashboard, or split it."}},"required":["content"]},"CreateEnvelopeBatchRequest":{"type":"object","description":"One template, many recipient sets, one answer.","properties":{"template_id":{"type":"string","format":"uuid","examples":["01960000-0000-4000-8000-0000000007e1"],"description":"The template every row instantiates. ⚠️ BATCH-LEVEL, AND ONE TEMPLATE IS THE FEATURE — a request that could name a different template per row would be `POST /api/v1/envelopes` in a loop with the loop moved inside our process."},"rows":{"type":"array","description":"The recipient sets, one envelope each, in order. ⚠️ AT MOST 100 ROWS AND AT MOST 1000 RECIPIENTS IN TOTAL ACROSS THEM; either bound is `invalid_request` and nothing is created. `results[i]` in the response is what happened to `rows[i]`, and every result also carries its own `index`.","items":{"$ref":"#/components/schemas/EnvelopeBatchRow","description":"One row: the people for one copy of the template."}},"send":{"type":"boolean","description":"Optional, default `false`, and it applies to the whole batch. False creates the drafts and emails nobody; true sends every row, consuming one unit of the plan’s allowance per envelope."},"expires_at":{"type":["string","null"],"format":"date-time","description":"Optional, default `null`. One deadline for every envelope in the batch. Same rules as on `POST /api/v1/envelopes`: an absolute ISO 8601 instant, at least an hour and at most 365 days out."},"brand_id":{"type":["string","null"],"format":"uuid","description":"Optional, default `null` — the organisation’s default brand. Which brand the envelope goes out under: the letterhead, the colours and the sending name a signer sees. ⚠️ THIS IS THE FIELD FOR SENDING ON BEHALF OF MANY CUSTOMER BRANDS FROM ONE ACCOUNT — change it between calls and the same integration sends Customer A’s paperwork under Customer A’s brand and Customer B’s under Customer B’s.\n\n⚠️ AN ID THAT NAMES NO LIVE BRAND OF YOUR ORGANISATION IS REFUSED WITH `brand_unknown`, NOT IGNORED — including one that has been archived. The branding an envelope went out under is frozen the moment it is sent and cannot be corrected afterwards, so a wrong constant in your configuration would otherwise put somebody else’s letterhead on every envelope you ever send and nothing would say so.\n\nIt is recorded on the envelope, so a draft created with `send: false` still goes out under this brand when it is sent later.\n\n⚠️ PER BATCH, NOT PER ROW. One request sends under one brand — which is the reseller case exactly, since a batch already names one template and two customers do not share one. Two brands means two batches. A row-level override remains additive if that ever changes."}},"required":["template_id","rows"]},"EnvelopeBatchRow":{"type":"object","description":"One envelope’s worth of people and values. ⚠️ IT IS THE SAME `recipients` AND `values` AS `POST /api/v1/envelopes` — same rules, same refusals, same error codes — so an integrator who can send one envelope can send a hundred without learning a second vocabulary.","properties":{"recipients":{"type":"array","description":"⚠️ THE ORDER OF THIS ARRAY IS THE ROUTING ORDER, per row. At most 100.","items":{"$ref":"#/components/schemas/RecipientInput","description":"One recipient."}},"values":{"type":"array","description":"Optional. Values to prefill for this row, as triples. At most 500.","items":{"$ref":"#/components/schemas/ValueInput","description":"One value at one address."}}},"required":["recipients"]},"EnvelopeBatch":{"type":"object","description":"A bulk send, and what became of every row of it.","properties":{"batch_id":{"type":"string","format":"uuid","description":"This request’s own identifier, recorded on every envelope it created. There is no endpoint that takes it back — the answer is in your hand, and a caller who lost it replays their `Idempotency-Key`."},"template_id":{"type":"string","format":"uuid","description":"The template every row was made from."},"count":{"type":"integer","description":"How many envelopes exist. Always the number of rows in the request: every row produces an envelope, or the whole request is refused."},"sent":{"type":"integer","description":"How many went out. Zero when `send` was false."},"failed":{"type":"integer","description":"⚠️ THE ONE FIELD THAT ANSWERS \"DID IT WORK\", AND IT IS AN INTEGER RATHER THAN A WALK. Zero on a `201` and non-zero on a `207`, always — so the status line and this number never disagree, and a client that reads neither the body nor this field still gets the truth from the status."},"results":{"type":"array","description":"One entry per row, in the order the request listed them.","items":{"$ref":"#/components/schemas/EnvelopeBatchResult","description":"What happened to one row."}}},"required":["batch_id","template_id","count","sent","failed","results"]},"EnvelopeBatchResult":{"type":"object","description":"What happened to one row of the batch.","properties":{"index":{"type":"integer","description":"This row’s position in the request’s `rows` array. Published even though the results are in order, because a client matching by array position is one `filter` away from telling the wrong person they have a contract waiting."},"envelope":{"$ref":"#/components/schemas/Envelope","description":"The envelope this row became, in the state it is actually in. ⚠️ PRESENT EVEN WHEN THE ROW FAILED: a failed row keeps its draft, with its recipients and values already on it, and `POST /api/v1/envelopes/{id}/send` is how you finish it once the cause is fixed."},"error":{"type":["object","null"],"description":"Why this row was not sent, or `null` when it was. ⚠️ THE SAME `{ code, message }` AS EVERY REFUSAL IN THIS API, with `code` from the same closed enum — so an existing switch statement works unchanged on a row. It is deliberately NOT a top-level `error` key: a 207 is not a failed request, and \"a response is a failure if and only if it has an `error` key\" stays true.","properties":{"code":{"type":"string","description":"A member of `x-error-codes`."},"message":{"type":"string","description":"Prose for a developer. Never parse it; branch on `code`."}},"required":["code","message"]}},"required":["index","envelope","error"]},"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 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. Ten 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 at minute nine gets a full session 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"]},"WebhookEndpoint":{"type":"object","description":"A registered endpoint: where to deliver, what to deliver, and how healthy it is. ⚠️ CARRIES NO `secret`. The secret is returned exactly twice in this API — by the registration and by a rotation — and is never readable afterwards.","properties":{"id":{"type":"string","format":"uuid","description":"The endpoint’s id. A bare uuid."},"url":{"type":"string","description":"Where deliveries are POSTed. `https://` only, no credentials, and not a private or loopback address — deliveries are made from inside our network."},"subscribed_events":{"type":"array","description":"What this endpoint hears about. Never empty. ⚠️ An unknown name is refused at registration rather than accepted and never delivered.","items":{"type":"string","description":"One event type.","enum":["envelope.sent","envelope.completed","envelope.declined","envelope.voided","envelope.expired","recipient.completed"]}},"brand_id":{"type":["string","null"],"format":"uuid","description":"Deliver only envelopes carrying this brand; null for every envelope in the organisation.\n\n⚠️ **THIS IS ROUTING, NOT ISOLATION.** Any key on this organisation can read every envelope in it whatever brand it carries — there is no brand predicate in any access rule anywhere. Filtering decides which events are POSTed to which URL; it does not, and cannot, stop a consumer learning about another brand by asking. Do not build a permission boundary out of it."},"active":{"type":"boolean","description":"Whether we are delivering. False either because you deactivated it or because it was auto-disabled after failing for the whole health window — `disabled_reason` says which, in prose."},"consecutive_failures":{"type":"integer","description":"⚠️ DELIVERIES THAT EXHAUSTED THE WHOLE LADDER SINCE THE LAST SUCCESS, not individual attempts. One unreachable host over one event counts once here, not seven times."},"last_success_at":{"type":["string","null"],"format":"date-time","description":"The last 2xx we received. Null if there has never been one."},"last_failure_at":{"type":["string","null"],"format":"date-time","description":"The last delivery that used up its ladder."},"disabled_at":{"type":["string","null"],"format":"date-time","description":"When delivery stopped. Null while `active`."},"disabled_reason":{"type":["string","null"],"description":"Why, in prose for a person. Null while `active`. ⚠️ This is the field that answers \"why did my endpoint stop receiving events\" without a support ticket."},"created_at":{"type":"string","format":"date-time","description":"ISO 8601, UTC."}},"required":["id","url","subscribed_events","brand_id","active","consecutive_failures","last_success_at","last_failure_at","disabled_at","disabled_reason","created_at"]},"WebhookEndpointWithSecret":{"type":"object","description":"The endpoint, plus the one-and-only-time signing secret. ⚠️ STORE `secret` NOW. It is not readable from any other endpoint and there is no recovery path; if you lose it, rotate.","properties":{"id":{"type":"string","format":"uuid","description":"The endpoint’s id. A bare uuid."},"url":{"type":"string","description":"Where deliveries are POSTed. `https://` only, no credentials, and not a private or loopback address — deliveries are made from inside our network."},"subscribed_events":{"type":"array","description":"What this endpoint hears about. Never empty. ⚠️ An unknown name is refused at registration rather than accepted and never delivered.","items":{"type":"string","description":"One event type.","enum":["envelope.sent","envelope.completed","envelope.declined","envelope.voided","envelope.expired","recipient.completed"]}},"brand_id":{"type":["string","null"],"format":"uuid","description":"Deliver only envelopes carrying this brand; null for every envelope in the organisation.\n\n⚠️ **THIS IS ROUTING, NOT ISOLATION.** Any key on this organisation can read every envelope in it whatever brand it carries — there is no brand predicate in any access rule anywhere. Filtering decides which events are POSTed to which URL; it does not, and cannot, stop a consumer learning about another brand by asking. Do not build a permission boundary out of it."},"active":{"type":"boolean","description":"Whether we are delivering. False either because you deactivated it or because it was auto-disabled after failing for the whole health window — `disabled_reason` says which, in prose."},"consecutive_failures":{"type":"integer","description":"⚠️ DELIVERIES THAT EXHAUSTED THE WHOLE LADDER SINCE THE LAST SUCCESS, not individual attempts. One unreachable host over one event counts once here, not seven times."},"last_success_at":{"type":["string","null"],"format":"date-time","description":"The last 2xx we received. Null if there has never been one."},"last_failure_at":{"type":["string","null"],"format":"date-time","description":"The last delivery that used up its ladder."},"disabled_at":{"type":["string","null"],"format":"date-time","description":"When delivery stopped. Null while `active`."},"disabled_reason":{"type":["string","null"],"description":"Why, in prose for a person. Null while `active`. ⚠️ This is the field that answers \"why did my endpoint stop receiving events\" without a support ticket."},"created_at":{"type":"string","format":"date-time","description":"ISO 8601, UTC."},"secret":{"type":"string","description":"⚠️ SHOWN ONCE, HERE. This is the key the `Vumasign-Signature` header is computed under. Treat it as a credential: it is not in any log of ours and it must not be in one of yours."}},"required":["id","url","subscribed_events","brand_id","active","consecutive_failures","last_success_at","last_failure_at","disabled_at","disabled_reason","created_at","secret"]},"WebhookEndpointList":{"type":"object","description":"Every endpoint of this organisation, newest first. ⚠️ No cursor and no `has_more`: the list is capped at 10, so there is only ever one page. If that cap ever rises past a page, pagination is added additively.","properties":{"data":{"type":"array","description":"The endpoints.","items":{"$ref":"#/components/schemas/WebhookEndpoint","description":"One endpoint."}}},"required":["data"]},"CreateWebhookRequest":{"type":"object","description":"Where to deliver, what to deliver, and optionally which brand only.","properties":{"url":{"type":"string","examples":["https://api.example.test/hooks/vumasign"],"description":"An `https://` URL we will POST to. It must be reachable from the public internet and must not carry credentials in the URL — the signature is how a delivery proves it came from us."},"events":{"type":"array","description":"At least one event type. ⚠️ An empty array is refused rather than read as \"everything\": an endpoint subscribed to nothing is a support ticket, and this API already has one place where an empty list means \"all\" (an API key’s scopes) — two opposite readings of an empty array is how somebody eventually gets one of them wrong. Duplicates are collapsed.","items":{"type":"string","description":"One event type.","enum":["envelope.sent","envelope.completed","envelope.declined","envelope.voided","envelope.expired","recipient.completed"]}},"brand_id":{"type":["string","null"],"format":"uuid","examples":[null],"description":"Optional, default `null`. Filter deliveries to one brand. ⚠️ ROUTING, NOT ISOLATION — see the property of the same name on the endpoint."}},"required":["url","events"]},"PatchWebhookRequest":{"type":"object","description":"The properties to change. Send only those; anything omitted is left exactly as it was. ⚠️ An unknown property is REFUSED rather than ignored — a PATCH that answered 200 having changed nothing is the worst possible answer to a typo.","properties":{"url":{"type":"string","examples":["https://api.example.test/hooks/vumasign"],"description":"A new delivery URL, on the same terms as registration."},"events":{"type":"array","description":"Replaces the whole subscription list. Not merged with it.","items":{"type":"string","description":"One event type.","enum":["envelope.sent","envelope.completed","envelope.declined","envelope.voided","envelope.expired","recipient.completed"]}},"brand_id":{"type":["string","null"],"format":"uuid","description":"`null` removes the filter. Omitting the property leaves it unchanged."},"active":{"type":"boolean","description":"`false` stops delivery and **KEEPS THE QUEUE** — this is the operation to use for maintenance, not DELETE. `true` resumes it AND resets the health window, which is how an auto-disabled endpoint is brought back."}},"required":[]},"WebhookEvent":{"type":"object","description":"The body of one webhook delivery. ⚠️ Signed — see `x-webhooks` at the root of this document for the header, the material that is hashed, and the retry ladder. Verify before you trust it.","properties":{"id":{"type":"string","format":"uuid","description":"⚠️ THE EVENT, NOT THE DELIVERY. The same value at every endpoint subscribed to it and on every retry, which makes it the right thing to deduplicate on. `retry_count` is a convenience; this is the mechanism."},"type":{"type":"string","description":"What happened. ⚠️ AN EVENT, NOT AN OBSERVED STATE — DocuSign's own migration note concedes state observation was the wrong design (\"a workflow triggered from `status: sent` should now be triggered from `event: envelope-sent`\"), and we start where they ended up.","enum":["envelope.sent","envelope.completed","envelope.declined","envelope.voided","envelope.expired","recipient.completed"]},"created_at":{"type":"string","format":"date-time","description":"When the event HAPPENED, ISO 8601 UTC — not when this attempt was made. A redelivery three hours later carries the original instant, because the body describes a moment rather than the present."},"retry_count":{"type":"integer","description":"⚠️ 0 ON THE FIRST DELIVERY. It counts RETRIES. This is the one field not frozen at enqueue — everything else is byte-identical across attempts, so a receiver comparing two bodies sees exactly one difference."},"envelope_id":{"type":["string","null"],"format":"uuid","description":"The envelope this is about. Null only for future org-level events."},"recipient_id":{"type":["string","null"],"format":"uuid","description":"Set on `recipient.completed`. Null on envelope events."},"brand_id":{"type":["string","null"],"format":"uuid","description":"The brand this envelope was sent under, or null. ⚠️ CARRIED WHETHER OR NOT YOUR ENDPOINT FILTERS ON BRAND, so that one endpoint plus a switch in your own code is a complete integration — you should not have to register N endpoints to route on N brands."},"uri":{"type":["string","null"],"description":"⚠️ WHERE TO RE-READ AUTHORITATIVE STATE INSTEAD OF TRUSTING THIS PAYLOAD: `/api/v1/envelopes/{envelope_id}`. This body describes a moment that has already passed — it was frozen when the event happened and is byte-identical on a redelivery three hours later — so anything you do because of it (release goods, bill somebody, advance a workflow) belongs behind a GET to this path.\n\nA PATH, NOT AN ABSOLUTE URL. Resolve it against the origin that posted to you. An absolute URL would bake one deployment’s hostname into stored, signed evidence that outlives it.\n\nNull only on an event carrying no `envelope_id`, which is a future organisation-level event and none exists yet. (It was null on every delivery until the read endpoint shipped, deliberately — a URI answering 404 would have been worse than none.)"}},"required":["id","type","created_at","retry_count","envelope_id","recipient_id","brand_id","uri"]}}}}