Skip to main content
This is the page to read before writing the iframe; the machine-readable half is the API reference.

⚠️ 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: 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.
⚠️ 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

  • 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

It lasts 10 minutes, 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 1 hour 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: 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

The page is served with
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 10 minutes of an unopened URL or the 1 hour 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

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. ⚠️ 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: ⚠️ 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 1 hour 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.