⚠️ Read this first: nothing in a browser is a completion signal
ApostMessage 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, andhttps:// only.
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: trueis an explicit mode, not an inference from some other field being non-null, and it comes back on every recipient this API describes.external_refis 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: trueis refused in two situations that would leave them unreachable: a role the template routes ascc(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 — thebrand_idyou 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
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
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
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://localhostas 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.