Embedded builders and the compatibility shim
The document and template builders inside your product: the embed script, every message in both directions, the origin rules, and the message protocol.
Updated
On this page
The builders let your users prepare documents and templates without leaving your product: upload files, place fields, set recipients, and send. This page is the full contract between your page and the iframe, using the Signclad embed script and its message protocol.
The shape of it
your page (your origin) iframe (app.signclad.com)
┌───────────────────────────────┐ ┌────────────────────────────────┐
│ <script src=".../embed.js"> │ │ Signclad embedded page │
│ new SigncladEmbed({url, …}) │──── src ───▶│ ?signclad_embedded_iframe=1 │
│ │ │ │
│ window.addEventListener │◀── SC-* ────│ window.parent.postMessage(…) │
│ ("message", …) │ │ │
│ iframe.contentWindow │──── SC- ───▶│ window.addEventListener │
│ .postMessage(…) │ Embedder │ ("message", …) │
└───────────────────────────────┘ └────────────────────────────────┘
Your host page never calls the Signclad API. It has one URL — a signing link, an embedded signing link, or an embedded edit link — and everything after that happens between the page and the script over postMessage. Your server mints the URL; your browser code just frames it.
Getting a URL
Create the document as a draft. The response carries a document-level embedded_edit_url:
{
"name": "Onboarding packet",
"draft": true,
"embedded_signing": true,
"text_tags": true,
"api_application_id": "5c1d9e7f-2a3b-4c5d-8e9f-0a1b2c3d4e5f",
"files": [{ "name": "packet.docx", "file_url": "https://files.example.com/packet.docx" }],
"recipients": [
{ "id": "signer-1", "name": "Jane Doe", "email": "[email protected]", "send_email": false }
]
}
{ "id": "8f14e45f-…", "status": "Created",
"embedded_edit_url": "https://app.signclad.com/embed/document/Kp7wT2nR9vXc…" }
The token is sixty-four opaque characters and it is the last path segment, not a query parameter. Documents are at /embed/document/<token> and templates at /embed/template/<token> — both singular.
Templates work the same way:
POST /document_templates
{ "name": "Onboarding agreement", "draft": true,
"files": [...], "placeholders": [{ "id": "borrower", "name": "Borrower" }] }
When your user sends from inside the builder, the document sends. You do not call send yourself.
Views
The builder opens narrowed to one job. view arrives in SC-document.setId and is one of:
| View | Shows |
|---|---|
document_builder |
Everything: files, recipients, fields, settings. |
template_builder |
The template equivalent. |
edit_files |
File management only — add, remove, reorder, replace. |
edit_recipients |
Recipients and signing order only. |
recipient_side |
The signer view, for embedded signing. |
Narrow views keep your own product’s flow in charge. If your app already knows the recipients, open the file or field view and your user never sees a recipient form.
The template builder’s Sending tab
The template builder’s right-hand panel has two tabs. Roles & Fields is the roles on the template and the fields on the page. Sending is what every document made from the template starts with — subject, message, expiry, reminders, the redirect after signing, whether a signer may decline or reassign, and the signing language. Setting them once on the template is the point: a use-template flow that asks for a subject line every time is a template that saved nobody anything.
If your application writes its own invitation email, hide the tab with showSendingDefaults: false:
new SigncladEmbed({
url: embeddedEditUrl,
containerId: 'builder',
showSendingDefaults: false,
}).open();
With the tab hidden, the builder leaves those settings exactly as the API has them rather than saving back what it never showed — whatever you set on the template through the API stays set. The option appends sending_defaults=0 to the iframe URL, so a host that frames the URL itself can append that flag directly.
Bootstrapping, step by step
- The embed script appends
signclad_embedded_iframe=1to the URL (plusstart=andtype=where the builder needs them) and loads it in an iframe. It starts a 20 second timer; ifloadhas not fired by then it raiseserrorwitherrorCode: "request_timed_out". - The page exchanges the token in its own URL for a session, holds the bearer token in memory and
sessionStorage, and sendsAuthorization: Bearer …on every later call. No cookies — third-party cookies are partitioned or blocked in every current browser, and a cookie-based session would fail exactly where embedding matters. - The page fetches its own context — the view name, your branding, the allowed parent origins, whether the compatibility aliases are on, and the next edit URL. This request is between the page and Signclad; it is not something your host calls, though you will see it in the network panel.
- The page posts
SC-document.setId. Nothing before this point is visible to your host.
On a reload inside the iframe, step 2 reuses the session from sessionStorage rather than re-exchanging: an embedded link is single use, and a second exchange answers 410 link_already_used.
Messages: iframe → your page
Every message is window.parent.postMessage(payload, targetOrigin) where the payload is {type, …} and targetOrigin is your origin — never "*".
type |
Payload | When |
|---|---|---|
SC-document.setId |
{id, apiAppStyles, newEditUrl, view} |
Once, as soon as the document is known. |
SC-document.documentLoaded |
{id} |
The document is rendered and interactive. |
SC-document.completed |
{id, redirect, url} |
The signer finished, or the builder’s send completed. redirect is a boolean; url is where to go when it is true. |
SC-document.declined |
{id, declineReason, url} |
The signer declined. |
SC-document.closed |
{id} |
The user asked to close the embedded view. |
SC-document.error |
{id, errorCode, errorMessage} |
Anything you should know about. errorCode is stable and machine-readable; errorMessage is for humans. |
SC-document.clickedContinueBtn |
{id, url} |
The user pressed the primary continue action. |
SC-Embedder.addLoader |
{} |
Show your loading overlay. |
SC-Embedder.removeLoader |
{} |
Hide it. |
window.addEventListener('message', (event) => {
if (event.origin !== 'https://app.signclad.com') return;
switch (event.data.type) {
case 'SC-document.setId': onReady(event.data); break;
case 'SC-document.documentLoaded': hideSkeleton(); break;
case 'SC-document.completed': onSent(event.data); break;
case 'SC-document.declined': onDeclined(event.data); break;
case 'SC-document.closed': closeModal(); break;
case 'SC-document.error': onError(event.data); break;
case 'SC-Embedder.addLoader': showOverlay(); break;
case 'SC-Embedder.removeLoader': hideOverlay(); break;
}
});
Treat these as hints for your own UI, not as the record. A browser message is not durable: the tab can close before it fires. Webhooks — document_sent, document_completed, template_created — are the source of truth.
setId in detail
{
type: "SC-document.setId",
id: "9c8f2b1a-…", // the document (or template) id
view: "document_builder",
apiAppStyles: {
primary_color: "#1B4DFF",
button_text_color: "#FFFFFF",
link_text_color: "#1B4DFF",
buttons_border_radius: 6,
logo_url: "https://…" // short-lived signed URL, may be null
},
newEditUrl: "https://app.signclad.com/embed/document/…" // edit views only
}
apiAppStyles is your API application’s branding, verbatim. Your page does not inject CSS across the origin boundary; you configure an API application and the iframe styles itself from it.
newEditUrl is how edit links renew
An embedded edit URL is single use. The page mints the next one and hands it to you inside setId; the embed script stores it as a property you read:
const next = embed.newEditUrl; // a property, not a call
There is no host-side regeneration call in the browser. If you lose the value, mint another server-side:
POST /documents/{id}/embedded_edit_url
POST /document_templates/{id}/embedded_edit_url
Both return { "embedded_edit_url", "expires_at" }, and both mint a fresh single-use URL that retires the previous one. The document route only works while the document is still a draft: once it has been sent there is nothing left to build, and you get a 409 saying which state it is in.
Messages: your page → iframe
type |
Payload | Meaning |
|---|---|---|
SC-Embedder-SetAttr |
All the embed options | Your configuration, sent once the iframe is listening. |
SC-Embedder.resizing |
{width, height} |
Your container changed size. |
SC-Embedder.clickAway |
{} |
The user clicked outside the modal. |
SC-document.completeSelfSigning |
{} |
Finish a self-signing flow programmatically. |
SetAttr flags
SC-Embedder-SetAttr carries the whole configuration in one message:
| Group | Keys |
|---|---|
| Booleans | allowDecline, allowClose, allowDownload, showHeader, allowRedirect, iframeRedirect, allowShareLink, showSendButton, allowAddContacts, allowCC, allowRemoveContacts, showProgressBar |
| Context | isEmbedded, isEmbeddedModal, isLargeDevice |
| Values | signatureDefaultName, primaryColor, redirectionUrl, declineRedirectionUrl, requestingRedirectUrl |
Note the name. SC-Embedder-SetAttr uses a hyphen where every other message uses a dot. That is what the vendored script sends, so it is what Signclad accepts. Do not “fix” it in your own code either.
Origin rules
These are not optional on either side.
- The page learns your origin from the browser.
event.originis stamped by the browser on every message it delivers and cannot be forged by the sender, so the first message your embed script sends identifies your page. That origin is pinned for the rest of the session, and anything arriving later from a different one is ignored. - The page always passes a specific
targetOriginon every outbound message, never"*". Outbound payloads carry a fresh builder URL, which is a live credential. - Your listener must check too. Compare
event.originagainst the Signclad origin before you readevent.data. The snippet above is the minimum.
There is nothing to allowlist. Earlier versions asked you to register every origin that might frame a builder on your API application; a missing or stale entry produced a builder that looked and saved perfectly normally while Send, Close and your own options silently went nowhere, which is a bad trade for a list that never restricted framing in the first place.
Because nothing goes out until your page speaks first, an embed script that never sends its opening message will see no events at all. The bundled script sends it on iframe load, so this only bites a hand-rolled host.
Loading state
A builder loads a PDF renderer and the document’s pages. Give the user something immediately: the preparing state should be visible within 500 ms and the iframe interactive within three seconds. Render your own skeleton until SC-document.documentLoaded arrives rather than showing an empty rectangle, and honour SC-Embedder.addLoader / removeLoader for the moments the page asks for your overlay.
Give the iframe real height. Below roughly 700 pixels the builder is unpleasant to use.