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

# Embedded signing

> The host page contract: what to register, what to mint, and what not to trust.

export const signingUrlTtl = "10 minutes";

export const signingSessionTtl = "1 hour";

This is the page to read before writing the iframe; the machine-readable half is the
[API reference](/api-reference/create-signing-url).

## ⚠️ Read this first: nothing in a browser is a completion signal

**A `postMessage` from our iframe does not mean a document was signed, and there
is no `return_url` on this API because a redirect does not mean it either.**

`signed` is a browser event. The signer can close the tab a millisecond before
it fires, lose connectivity, have JavaScript blocked by an extension — and any
script running on your own page can call `postMessage` with the same body, so it
is not even evidence that it came from us unless you check `event.origin`.

**The webhook is authoritative.** `recipient.completed` and `envelope.completed`
are delivered from our servers to yours, signed and retried, and depend on
nothing staying open. So:

| Use a `postMessage` to | Use a webhook to |
| - | - |
| close your modal | record that a document was signed |
| show a spinner or a thank-you | release goods, or grant access |
| navigate the user onwards | bill anybody |
| re-mint a dead URL | reconcile your own records |

DocuSign appends `?event=signing_complete` to their return URL and our own study
records the consequence: *"every first integration mistakes it for the truth"*.
This one is written down so that it is a decision rather than a discovery.

***

## The shape of an integration

### 1. Register where you will embed — once, per brand

Settings → Brands → **Embedding origins**, one per line.

Origins must be **bare**: scheme, host, optional port. No trailing slash, no
path, no wildcard, and `https://` only.

```
https://app.example.com          ✓
https://app.example.com:8443     ✓
https://app.example.com/         ✗  refused: a trailing slash
https://app.example.com/sign     ✗  refused: a path
http://app.example.com           ✗  refused: not https
https://*.example.com            ✗  refused: a wildcard
```

⚠️ **This is refused at registration rather than at render, and the reason is
measured.** Against Chromium, a `frame-ancestors` directive with a trailing
slash still matches — so a browser tolerates it and teaches you the value is
fine — while one carrying a path silently stops matching, and the only symptom
is an iframe that never loads. Neither produces an error you could see. The
moment you are still holding what you typed is the only useful moment to refuse
it.

Origins live on the **brand**, not on the API key. One key acts for every brand;
each brand is one of your customers; each embeds on their own domain.

⚠️ **They are a clickjacking defence for the signer, not an access boundary for
you.** Any key on the organisation that holds `envelopes:write` can mint a
signing URL for any envelope of any brand.

### 2. Declare the recipient embedded, at creation

```http theme={null}
POST /api/v1/envelopes
Authorization: Bearer vsk_live_…
Idempotency-Key: 8f14e45f-…

{ "template_id": "9f2a…",
  "recipients": [
    { "role": "Member", "name": "Ada Lovelace", "email": "ada@example.com",
      "embedded": true, "external_ref": "user_1001" }
  ],
  "send": true }
```

* `embedded: true` is an **explicit mode**, not an inference from some other
  field being non-null, and it comes back on every recipient this API describes.
* `external_ref` is your own identifier for that human. We store it, echo it,
  and use it for nothing.
* ⚠️ **An embedded recipient is never emailed.** That is the point. It is also
  why `embedded: true` is refused in two situations that would leave them
  unreachable: a role the template routes as `cc` (`recipient_cannot_be_embedded`),
  and a brand with no embedding origins registered (`brand_has_no_embed_origins`).
  That brand is the one the envelope goes out under — the `brand_id` you sent, or
  the organisation's default when you sent none — so registering origins against
  the default while sending under another brand is refused.
* The **email address is still required**, even though nothing is sent to it. It
  is on the certificate of completion and in the audit chain. Embedded signing
  says *your* application authenticated this person; it does not say they have no
  identity.

`embedded` cannot be changed afterwards. It decides how a human is reached, and
an envelope that has gone out has already reached them.

### 3. Mint a URL when the signer arrives — not before

```http theme={null}
POST /api/v1/envelopes/{envelope_id}/recipients/{recipient_id}/signing-url
Authorization: Bearer vsk_live_…

→ 201
{ "url": "https://app.vumasign.com/e/…",
  "expires_at": "2026-08-16T18:40:00Z" }
```

**It lasts {signingUrlTtl}, or less if the envelope's own deadline is sooner.
Single use.** The first GET spends it.

⚠️ **`url` is a bearer credential for a legal act.** Anyone holding it can open
the document as that signer. Put it in an `iframe src` and nowhere else — not a
log, not an analytics event, not a database column, not a support ticket. There
is no endpoint that reads it back: only a digest is stored.

⚠️ **`expires_at` is the life of the URL, not of the signing session.** A signer
who opens it just before it expires gets {signingSessionTtl} from that moment to
read and sign (again, less if the envelope's deadline is sooner). Reload the
iframe on this timer only if nobody ever opened it.

Every refusal this call can answer, each with its own `code` so you can branch on
it. The table is generated from the API reference, so it is the whole list:

| Code | HTTP | Retry? | What it means |
| - | - | - | - |
| `unauthenticated` | 401 | no | 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. |
| `forbidden` | 403 | no | The key is valid and the organisation’s plan does not grant what this operation does. Nothing about the credential needs to change, and it is deliberately MORE specific than 401 because the caller has already proved they hold the key. TWO SITUATIONS PRODUCE IT. (1) THE PLAN EXCLUDES THE OPERATION: `POST /api/v1/templates` on the Free plan, which does not include authoring templates. A template that already exists stays readable at `GET /api/v1/templates`; authoring one needs a plan that includes templates. (2) WE CANNOT ESTABLISH WHAT THE PLAN INCLUDES, on any operation, so nothing is granted. That one is ours rather than yours, an ordinary account does not meet it, and it means ask us. |
| `insufficient_scope` | 403 | no | This key is scoped and does not hold the scope this operation requires — `x-required-scope` on the operation names it, and the `WWW-Authenticate` header on the refusal repeats it (RFC 6750 §3.1). ⚠️ Retrying cannot help and neither can editing the key: scopes are fixed when a key is minted and no endpoint or screen can widen them. Issue a new key with the access it needs. A key created before scopes existed carries an empty scope list, which means EVERY capability, so this refusal cannot reach an integration that was already working. |
| `test_key_cannot_mint` | 403 | no | A `vsk_test_` key asked `POST /api/v1/envelopes/{envelopeId}/recipients/{recipientId}/signing-url` for an envelope that is not a sandbox envelope. A minted URL opens that signer’s session on the document, and on a live envelope that document is binding, so a test key may mint only on an envelope a test key created. Nothing was minted and nothing was recorded. Mint this one with a live key, or rehearse on an envelope created and sent with your test key. Retrying with the same key fails identically. |
| `not_found` | 404 | no | 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. |
| `recipient_not_yet_turn` | 409 | Do not poll. Wait for the `recipient.completed` webhook for the position in front, then retry once. | A sequential envelope is holding this recipient back until everybody at an earlier position has finished. Nothing about the request is wrong and the identical call will succeed later, which is why it is a 409 rather than a 422 — the same answer Dropbox Sign gives. |
| `recipient_cannot_sign` | 409 | no | The recipient has signed, approved, declined, or been superseded or delegated away — or another member of their signing group signed first (`group_resolved`). Every status that produces this has no outgoing edge, so a retry cannot help and a fresh URL would open onto the same nothing. A signature or an approval is announced by the `recipient.completed` webhook, and a decline that ends the envelope by `envelope.declined` — a member of a signing group who declines does not end it while another member can still sign. ⚠️ A `group_resolved` recipient gets no `recipient.completed` of their own: the one that fired names the member who signed. A recipient who delegated or was superseded has a replacement: read it from `GET /api/v1/envelopes/{envelopeId}`. |
| `envelope_not_sent` | 422 | no | This operation needs an envelope that is out for signing, and this one is not: it is still a draft, or it is finished (completed, declined, voided or expired). Two operations answer it. Minting a signing URL: a draft has not been sent to anybody — create it with `"send": true`, or send it first. Voiding: a draft has nothing to withdraw, because nobody has been asked to sign it, and a finished envelope cannot be withdrawn. The `message` says which state it is in. ⚠️ A `vsk_test_` key CAN reach an open envelope: it may send one when everybody it would email is a member of your organisation or a confirmed test recipient, and an `"embedded": true` recipient is never emailed, so is never checked. A test key meets this code for the same reasons a live key does — most often, a draft that was never sent — except that when minting, on an envelope a test key did not create, it is refused first with `test_key_cannot_mint`. |
| `recipient_not_embedded` | 422 | no | This recipient was created without `"embedded": true`, so they have been emailed a link and no URL can be minted for them. ⚠️ THE REMEDY IS NOT ON THIS ENDPOINT: embedding is declared when the envelope is created, because it decides how a human is reached, and an envelope that has gone out has already reached them. Create the next one with the flag set. |
| `brand_has_no_embed_origins` | 422 | no | 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. |
| `rate_limited` | 429 | Exponential from 1s, doubling, with jitter. Honour `Retry-After` when present. | This key has spent its minute. The limit is per KEY and per minute, and it follows your PLAN rather than the kind of key — 600 requests a minute on every paid plan, 60 on the free plan, and the sandbox gets the same number as production so that an integration which passes in rehearsal passes live. Every successful authenticated response, and this one, carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down before it is made to. TWO OPERATIONS ALSO ANSWER IT FOR A REASON OF THEIR OWN. `POST /api/v1/envelopes/batches` costs one request per row and is refused WHOLE when its rows do not fit what is left of the key’s minute — `RateLimit-Remaining` says how many would. `GET /api/v1/envelopes/{envelopeId}/documents/{position}/draft` has its own per-envelope and per-organisation ceilings on renders each minute, and the `message` says which one refused; there `Retry-After` is that ceiling’s, while the `RateLimit-*` headers still describe the key. `Retry-After` on this refusal is at most 60 and is read from the stored window rather than computed, so two of our instances refusing the same key quote the same instant. ⚠️ A REFUSED REQUEST STILL COUNTS: hammering a limit you have already exceeded pushes the counter higher rather than holding it. Honour `Retry-After`. If one key genuinely needs more throughput, issue a second one in Settings — the ceiling is a fairness and blast-radius control, not a commercial meter. |
| `internal_error` | 500 | Exponential from 1s, at most three attempts, then stop and alert a human. | 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.) |

There is **no `Idempotency-Key`** on this call: the response *is* the credential,
so "I do not know which state I am in" is unreachable, and a duplicate emails
nobody and bills nothing. ⚠️ Redeeming a *second* URL does revoke the session a
first one began.

### 4. Frame it

```html theme={null}
<iframe src="{url}" allow="clipboard-write" style="width:100%;height:800px;border:0"></iframe>
```

The page is served with

```
Content-Security-Policy: frame-ancestors https://app.example.com
```

naming the origins registered against the brand this envelope was sent under,
and with **no `X-Frame-Options`**. A URL we cannot verify — unreadable, altered,
or **expired**, whether that is the {signingUrlTtl} of an unopened URL or the
{signingSessionTtl} of the session it began — gets `X-Frame-Options: DENY`
instead, so the browser refuses to frame it and nothing inside it runs. The
ordinary emailed signing page keeps `X-Frame-Options: DENY` unconditionally.

### 5. Listen

```js theme={null}
// What you minted for, and the frame you put it in. `signingUrl` is the `url`
// from the 201 above, so the origin is whatever host actually served it.
const iframe = document.getElementById('vumasign-signing')
const expected = { envelope_id: envelopeId, recipient_id: recipientId }
const trustedOrigin = new URL(signingUrl).origin

window.addEventListener('message', (event) => {
  // ⚠️ FIRST. A message listener hears from every frame on the page.
  // Compare against the origin of the url you minted, not a hardcoded host.
  if (event.origin !== trustedOrigin) return
  // ⚠️ THEN WHICH FRAME. Another Vumasign iframe on the page has the same origin.
  if (event.source !== iframe.contentWindow) return
  // ⚠️ AND WHICH SIGNER. Every message carries both ids; ignore one that is not
  // about the envelope and recipient this frame was minted for.
  const { action, envelope_id, recipient_id } = event.data
  if (envelope_id !== expected.envelope_id || recipient_id !== expected.recipient_id) return

  if (action === 'ready')    hideSpinner()
  if (action === 'signed')   closeModal()          // NOT "it is signed"
  if (action === 'declined') closeModal()
  if (action === 'delegated') closeModal()         // NOT signed — somebody else has it now
  if (action === 'signing_url_invalid') {
    // ⚠️ Branch on can_remint, NOT on reason.
    if (event.data.can_remint) reloadIframeWithAFreshUrl()
    else showSomethingElse()
  }
})
```

The origin says a message came from us, not from which of our frames: with two
signers embedded on one page, both iframes share that origin, so `event.source`
is what ties a message to this iframe and the ids are what tie it to the signer
you minted for. Every action carries `envelope_id` and `recipient_id` — see the
table below — so the id check never has to be skipped. When you re-mint for a
replacement after `delegated`, update `expected` to the new recipient id.

Every message this product will ever post is `{ action, ...payload }` — one
envelope, defined before there was a second surface to disagree with it.
BoldSign has three shapes across three surfaces (`params.data` as a bare string,
`params.data.action`, `params.data.status`) because nobody wrote one down first.

| `action` | Payload | Means |
| - | - | - |
| `ready` | `envelope_id`, `recipient_id` | The signing surface is on screen. |
| `signed` | `envelope_id`, `recipient_id` | They pressed Finish. **Not a completion signal.** |
| `declined` | `envelope_id`, `recipient_id` | They declined. Not "they closed the iframe" — we cannot observe that. |
| `delegated` | `envelope_id`, `recipient_id` | "This is not mine to sign — it is hers." This recipient is retired and a replacement holds their position. |
| `signing_url_invalid` | `reason`, `can_remint`, `envelope_id`, `recipient_id` | The URL did not get anybody in. |

⚠️ **`delegated` is not `signed`.** The session is over for this recipient; the
envelope is not finished. And unlike `signed` — which `recipient.completed`
follows as soon as the delivery queue next drains, typically within a minute —
**there may be no webhook behind this one for a long time**: a sequential
replacement is not invited until its turn comes, and an embedded replacement is
never emailed at all, because you mint their URL yourself. Re-read the roster
with `GET /api/v1/envelopes/{envelopeId}` and mint against the new recipient id
when you want them in your page.

`signing_url_invalid` reasons:

| `reason` | `can_remint` | Cause |
| - | - | - |
| `used` | `true` | Somebody already opened this URL — a double render, a back-button, a link-prefetching proxy — within its 10 minutes. |
| `expired` | `true` | Declared, so a `switch` over `reason` stays total, but not delivered today: an expired URL or session is refused a frame, so nothing of ours runs to post it. |
| `no_longer_signable` | `false` | The recipient or the envelope can no longer be acted on: the recipient signed, approved, declined, delegated, was superseded or was resolved by another member of their signing group, or the envelope is completed, declined, voided or expired. |
| `not_embeddable` | `false` | The brand’s embedding origins were removed. |

⚠️ **Branch on `can_remint`.** A host that re-mints on every refusal builds an
infinite loop against this API the first time a signer finishes in another tab.

Messages are posted to each registered origin and never to `*`, so a page we did
not sanction learns nothing — including the envelope and recipient ids.

⚠️ **Some failures post nothing at all.** A URL we cannot read — truncated or
altered on its way into the `src` — names no origin to post to. An **expired**
URL, or a reload once the session's {signingSessionTtl} is up, is refused a frame
(`X-Frame-Options: DENY`, above), so no script of ours runs to post anything, and
`expired` does not arrive.
A session that runs out while the page stays open posts nothing either: the
signer's next action is refused inside the frame. So treat a `ready` that has not
arrived after a reasonable timeout as "mint a new URL, once" — and if the second
URL also produces no `ready`, stop and show something else rather than looping.

***

## What we deliberately did not build

* **`return_url`.** It is a browser redirect and would be mistaken for the
  truth. The postMessage envelope does the job a return URL was being asked to do
  — moving your interface — without navigating anywhere.
* **A "read my minted URLs" endpoint.** A credential readable twice is one stored
  somewhere readable. What is readable after the fact is the audit chain, which
  records that a URL was minted, by which key, for whom and until when — and never
  the URL.
* **`http://localhost` as a registrable origin.** A permanently open plaintext
  origin in production is a hole nobody remembers to close. Terminate TLS locally,
  or use a tunnel.
* **Embedded sending and embedded template authoring.** One embedded surface,
  one message envelope, and the envelope is defined so the second one cannot
  disagree with it.
