Guides
Embedding the signing page
The ceremony is a white-labeled signing page built to be embedded. Your
server mints an embed session for one recipient, your page
or app opens the returned URL in an iframe or a WebView, and the ceremony
reports ready, completed, rejected
and error on the channel you chose when you minted it.
Everything the recipient sees and signs is the ordinary ceremony: consent,
verification, the certificate.
1. Allow your origins
In the dashboard, Settings → Workspace → Embedding, list the origins whose
pages may frame your signing page: a scheme, a host and an optional port,
like https://app.example.com. One per line. No wildcards, no
paths. http://localhost:5173 is accepted for development.
Browsers enforce this list through the signing page's
frame-ancestors policy, so a page on an unlisted origin never
loads the ceremony at all. The list is empty by default, which means
embedding is off.
2. Mint a session on every render
Server side, with your API key, once the envelope is sent. The session works once, for ten minutes. Mint it in the request that renders the page; never store or cache the URL.
Pages render twice more often than you think (a development strict mode,
a retry after a dropped response). Send an Idempotency-Key
on the call that creates the envelope, keyed on your own record
(an order id), so a second render cannot make a second document; the API
honours the key for 24 hours. Minting a second session is harmless.
curl -s -X POST https://app-staging.signenvoy.com/v1/documents/doc_…/recipients/{recipient_id}/embed-session \
-H "Authorization: Bearer $SENV_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event_delivery": "message"}'
{
"recipient_id": "…",
"embed_url": "https://app-staging.signenvoy.com/s/…?event_delivery=message",
"expires_at": "2026-09-21T16:54:52Z",
"event_delivery": "message"
}
The body is optional; event_delivery defaults to
message. Section 4 explains the three values. The URL carries
the value you chose, and the ceremony reads it from there; it is not a
secret and nothing is stored.
409 invalid-state unless the envelope is in flight and the
recipient has not signed or declined; 404 not-found for a
recipient outside the envelope. A test-mode key mints sessions for
test-mode envelopes only.
Step 1 is checked at the mint. A message session from a
workspace with an empty origins list can be shown by no page of yours,
so a live-mode key gets 409 embed-origins-missing and
nothing is minted, and a test-mode key gets the session plus a
warnings entry with that code (and a Warning
header) so you learn on the first call rather than from a blank frame.
Both carry a settings_url for the Embedding page. Only an
empty list is caught: a wrong entry still fails in the browser, and the
dashboard shows a notice while the list is empty and a framed session
was minted in the last seven days. redirect and
none sessions need no origin and are never refused.
Opening the URL issues that recipient's signing link and loads the
ceremony in place. This rotates the link, exactly like the
signing-link mint: a link SignEnvoy emailed earlier stops working. When you
embed, send with "delivery": "none" so there is no emailed link
to invalidate. With "none", recipients must use
auth_level: "link": a one-time code needs SignEnvoy to deliver
the link, and the send refuses the combination (422).
The envelope's timeline records the issue as link_reissued with
channel: "embed", and the certificate says the link the signer
used was "delivered by the sender".
3. Put it in your page
With the React component:
import { SignEnvoyCeremony } from "@signenvoy/embed-react";
<SignEnvoyCeremony
embedUrl={session.embed_url}
onCompleted={(envelopeId) => markSigned(envelopeId)}
onRejected={() => showDeclined()}
onError={(slug) => slug === "embed-session-expired" && refresh()}
/>
Without React:
import { mountCeremony } from "@signenvoy/embed-react/core";
const mounted = mountCeremony(envelope.querySelector("#sign"), {
embedUrl: session.embed_url,
onEvent: (e) => console.log(e.event, e),
});
// later: mounted.unmount()
Or an iframe by hand. The ceremony posts messages to your page's origin
only, shaped { source: "signenvoy-ceremony", event, ... }; check
event.origin against the API host and event.source
against the frame before you trust one. The package does both.
4. How events reach you
event_delivery on the mint names the channel, because your
server knows what will open the URL and the ceremony should not guess.
| value | use it for | what the ceremony does |
|---|---|---|
message (default) | an iframe in your web page; a WebView with an injected script | In a frame, posts to your page's origin as above. Loaded top-level (a WebView), posts the same payload to its own window, where a script you injected can listen and forward to native code. |
redirect | a WebView, when you would rather intercept a URL than inject a script | On completed, rejected or error only, navigates to signenvoy-message://<event>?…. Your navigation handler cancels it and reads it. Nothing reaches the network; a desktop browser ignores it. Progress events are not sent. |
none | you rely on webhooks alone | Nothing. |
The payload is the same on every channel: the event name plus
envelopeId on ready and completed,
role on completed,
slug on error. envelopeId is the
document's doc_… id, the one GET /v1/documents/{id}
and the webhooks use. Whichever channel you use,
webhooks remain the record. Anything that arrives through
the signer's own device is a cue for your interface, not proof.
iOS (WKWebView)
// mint with event_delivery: "redirect", then load embed_url top-level
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let url = action.request.url, url.scheme == "signenvoy-message" {
let event = url.host ?? "" // "completed" | "rejected" | "error"
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
handleCeremony(event, items) // e.g. pop the signing screen
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
Android (WebView)
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url
if (url.scheme == "signenvoy-message") {
handleCeremony(url.host ?: "", url.getQueryParameter("envelopeId"), url.getQueryParameter("slug"))
return true // cancel the navigation
}
return false
}
React Native (react-native-webview)
import { parseCeremonyRedirect } from "@signenvoy/embed-react/core";
<WebView
source={{ uri: session.embed_url }}
onShouldStartLoadWithRequest={(req) => {
const event = parseCeremonyRedirect(req.url);
if (!event) return true; // an ordinary navigation
if (event.event === "completed") markSigned(event.envelopeId);
if (event.event === "error" && event.slug === "embed-session-expired") refresh();
return false; // cancel it
}}
/>
Prefer message in a WebView? Inject a script at document start
that listens on window, accepts a message only when its origin
is the API host and its source is the window itself
(isCeremonySelfMessage in the package does this check), and
forwards it through your platform's bridge.
A frame on an origin that is listed but could not be identified (for example, two or more listed origins and a host page that suppresses its referrer) posts nothing and writes one warning to the browser console naming the framing origin. List exactly the origins that embed, and check the console before the webhooks.
5. The events
| event | when | carries |
|---|---|---|
ready | the ceremony loaded and the recipient can act | envelopeId |
consented | the recipient accepted electronic signing | |
field_completed | one field was completed | fieldId |
completed | the recipient finished: signed, approved when role is approver, or marked as viewed when it is viewer | envelopeId, role |
rejected | the recipient declined | |
error | the ceremony could not proceed | slug, from the error reference |
redirect_requested | the envelope's redirect_url is about to be followed | url |
completed means the recipient signed or, for an approver
(role: "approver"), approved: an approval is not a signature.
For a viewer (role: "viewer") it means they marked the
document as viewed, which is neither.
The sealed PDF follows a moment later. Use the document.sealed webhook, or poll the
envelope, before you offer a download. The events are a convenience for
your page; webhooks remain the record.
If the envelope has a redirect_url, the ceremony ends with a
destination confirmation ("the sender asks to send you to
host") and moves only when the signer clicks,
posting redirect_requested first; nothing navigates on its
own, and an embedded frame never navigates its parent. The URL must be
https://; an http:// value is refused when the
envelope is created. An application on plain localhost
should omit it, or use a tunnel's https origin, while developing.
6. When the frame says "session ended"
A reused URL, a URL older than ten minutes, or an envelope that changed
state between the mint and the open renders the recipient a plain
"this signing session has ended" page and reports error with
slug: "embed-session-expired" on your channel. Mint a fresh
session and render again. See embed-session-expired.
A page on an origin you have not listed gets nothing: the browser refuses to show the frame, and no event is posted. The attempt still spends the session (the browser applies the frame policy to the response it received), so after fixing the origins list, mint again. Check the list first when a frame stays blank.
7. Abandoned signings
On a "delivery": "email" envelope, the automatic reminder
never replaces a link an embed session issued: you have taken over
delivery for that signer. On a "none" envelope SignEnvoy
emails nobody unless you ask. To recover a signer who left your page
without finishing, set reminder_cadence_days on the envelope or
call send again: SignEnvoy then emails the signer a fresh link, which
replaces the embedded one and is recorded with channel: "email".
See Signing links.