Embedded signing
Mint a short-lived, single-signer session and render the signing view inside your own page — iframe or mobile webview, your branding, postMessage events, no API key in the browser.
An embed session is a short-lived permit for one signer to sign one
document inside your page. Your backend exchanges a signing request for
it; your frontend drops the returned URL into an <iframe> or a mobile
webview. The frame shows the document, that signer's fields and the signing
action — and nothing else.
What it is not: not a login, not a portal, not a way to browse your workspace, and not a long-lived link. There is no account creation in the frame, no navigation to another document, and no way to widen the permit — a leaked session URL is still one signer, one document, minutes.
Embedded signing is an Enterprise feature (embedded_signing_enabled).
Minting answers 402 enterprise-required on every other plan. Reading and
revoking a session are never gated — see Lifetime and
revocation.
The three endpoints
| Endpoint | What it does |
|---|---|
POST /v1/embedded/sign-sessions | Mints a session for one signing request and one ancestor origin. Returns embed_url. |
GET /v1/embedded/sign-sessions/{id} | Status of the permit: active / expired / revoked, plus when the frame was first and last seen. |
DELETE /v1/embedded/sign-sessions/{id} | Revokes it. Idempotent. |
All three need a key with the embedded scope (a full key satisfies it — see
Key scopes), and all three count against the
same 60/minute bucket as the rest of /v1.
Not to be confused with POST /v1/embedded/sessions — no hyphen — the
retired guest-placement endpoint. It still answers
410 embedded_sessions_retired and is unrelated — see the retired
endpoint at the bottom.
Before you start: register the origins
Embedding is a permission, not a default. Every origin that may frame a session is registered on the API key itself, in Settings → API, on the key's card ("Allowed embed origins"). A key with an empty list cannot mint a session at all.
An origin is https://host or https://host:port — scheme, host, optional
port, nothing else:
| Accepted | Refused |
|---|---|
https://portal.example.com | http://portal.example.com (never http) |
https://portal.example.com:8443 | https://portal.example.com/ (a path — even a bare slash) |
https://PORTAL.Example.com → stored lower-case | https://*.example.com (no wildcards, ever) |
https://portal.example.com:443 → stored without the port | https://user@portal.example.com (no credentials) |
Matching is exact after canonicalisation. https://portal.example.com does
not cover https://www.portal.example.com, and a subdomain wildcard is not a
thing: list each host you actually frame from, up to 20 per key. Staging and
production are different origins — register both, or use one key per
environment.
Mint a session
POST https://api.wesign.now/v1/embedded/sign-sessions
Authorization: Bearer wsk_live_…
Content-Type: application/json{
"signing_request_id": "3f1c9e2a-…", // required — a signer from POST /v1/signing-requests, instantiate or confirm
"origin": "https://portal.example.com", // required — must be registered on this key
"ttl_seconds": 300, // optional — 60…900, default 300
"locale": "de", // optional — en | de | fr | it | es | nl
"theme": { // optional — see Theming
"accent": "#1a73e8",
"font": "Inter, system-ui, sans-serif",
"logo_url": "https://cdn.example.com/firm-logo.svg",
"radius": "md"
}
}HTTP/1.1 201 Created
{
"id": "b7c2…",
"embed_url": "https://acme.letssign.now/de/embed/sign/b7c2…",
"expires_at": "2026-09-10T14:35:12.004Z",
"signing_request_id": "3f1c9e2a-…"
}Mint one session per signer, per frame render. Sessions are cheap, and a
short one that you re-mint on demand is the point: if the case worker leaves
the tab open over lunch, mint a new one when they come back rather than asking
for a longer ttl_seconds.
ttl_seconds outside 60…900 is refused, not clamped — an integrator who
asks for an hour finds out here rather than from a frame that dies mid-signature.
Errors
| Status | Code | When |
|---|---|---|
| 401 | invalid_key | Missing, malformed, unknown or revoked key. |
| 402 | enterprise-required | The workspace is not on Enterprise. |
| 400 | invalid_request | Body validation — bad uuid, ttl_seconds out of 60…900, unknown locale, bad theme field. |
| 400 | invalid_origin | origin is not an https origin (path, query, credentials, wildcard, http). |
| 400 | origin_not_allowed | Well-formed, but not registered on this key. The message lists what is registered. |
| 404 | not_found | No such signing request — or it belongs to another workspace. Deliberately indistinguishable. |
| 409 | request_not_signable | The signing request is not pending or viewed — already signed / declined / withdrawn / expired, or still queued (a later signer of a sequential send, or a recipient of a staged instance) — or it is past its own expires_at. |
| 429 | rate_limited | 60 requests per minute per key. Honour Retry-After. |
| 503 | embedding_unavailable | This deployment has not applied migration 0155 yet. Transient by definition; retry or contact support. |
| 500 | db_failed | Our fault. Retry. |
Embedding a signer created from a template
A signer created by
POST /v1/templates/{id}/instantiate is a
signing request like any other, so it embeds the same way. Two calls:
-
Instantiate with
"send_emails": false, so we don't email the signer a link they will never use:POST https://api.wesign.now/v1/templates/{id}/instantiate Authorization: Bearer wsk_live_… Content-Type: application/json { "recipients": [{ "slot": 1, "email": "anna@example.ch", "name": "Anna Muster" }], "field_values": { "client_name": "Anna Muster" }, "metadata": { "case_id": "ZT-2026-0142" }, "send_emails": false }The
200names each recipient's signing request:{ "document_id": "8a1e4f9a-…", "metadata": { "case_id": "ZT-2026-0142" }, "recipients": [ { "slot": 1, "email": "anna@example.ch", "signing_request_id": "3f1c9e2a-…", "signing_url": "https://acme.letssign.now/en/sign/…", "emailed": false // … plus the SMS read-back and reference } ] // … template_version, signing_mode, field_values_echoed, warnings } -
Mint a session for that
signing_request_id, exactly as above, and frame theembed_url.
What decides whether a template signer can be framed yet:
- Only a
pendingorviewedrequest can be minted for. In asequentialsend, slot 1 ispendingat once and the later slots arequeued(409 request_not_signable) until the signer before them signs. Mint the next session when that signer'ssigning_request.signedarrives — and note that the hand-over emails the next signer even withsend_emails: false. - A
review: trueinstance is staged — every recipient isqueueduntilPOST /v1/documents/{id}/confirm. Pass"send_emails": falseon the confirm (or on the instantiate; the confirm keeps it), then mint. - Automatic reminders still email a signer who has not signed, by default 7 and 14 days after the send. Switch them off in Settings → Signing if every signer of yours signs in the frame.
sms_gate: "before_view"cannot be signed in a frame yet — see below.- Instantiate and minting both need Enterprise, and the key must have the
fullscope for the instantiate — anembeddedkey can only call/v1/embedded/*.
The embed URL
embed_url is always the workspace's own signing host — the workspace's
branded subdomain — plus the locale and the session id:
https://<workspace slug>.<root>/<locale>/embed/sign/<session id>There are two live roots (letssign.now and wesign.now) and a workspace
belongs to one of them, so a workspace slugged acme frames from
https://acme.letssign.now or https://acme.wesign.now. Use the
embed_url you were handed — never rebuild it — and name both hosts in
your CSP so a brand move does not break your page (see below).
Treat the URL like a short-lived bearer token: anyone who has it, within its
few minutes, can open that one signing view. Don't log it where the signer's
counterparty can read it, don't email it, and DELETE the session if it went
somewhere it shouldn't.
Render the frame
<iframe
src="https://acme.letssign.now/de/embed/sign/b7c2…"
title="Sign the mandate"
width="100%"
height="820"
style="border:0"
></iframe>- Give it height. v1 has no resize message. The frame scrolls internally; give it a tall box (≥ 700 px, or a full-height modal on mobile) rather than expecting it to auto-fit.
- Don't sandbox it unless you must. The signing UI needs scripts, forms and
its own origin. If your policy requires the attribute, the minimum that works
is
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"— and note thatallow-scripts+allow-same-originon a cross-origin frame is not a meaningful restriction anyway. The real boundary isframe-ancestors, which we enforce for you. - One frame at a time. A session is bound to one signer; rendering the same URL twice on one page is supported but pointless.
There is a complete, copy-pasteable host page at
/embed-example.html — a single file, restrictive CSP,
that frames a session and logs every v1 message.
Content-Security-Policy
What you publish
The only directive you must relax is frame-src, naming the workspace's
signing host on both roots:
Content-Security-Policy: frame-src https://acme.letssign.now https://acme.wesign.now;That is genuinely all. A cross-origin iframe does not inherit your page's
CSP: everything the signing UI loads — the PDF, the pdf.js worker, its CMaps,
standard fonts and wasm, the typefaces, the field overlays — is fetched by the
framed document against our origin and our headers, so it never touches
your script-src, font-src, img-src or connect-src. If your policy has
no frame-src, browsers fall back to child-src and then default-src; add
frame-src explicitly rather than widening either of those.
If you also filter outbound network traffic (a proxy allow-list, not CSP),
here is the complete list of what leaves the frame. Everything the signer needs
is same-origin on the signing host: the document
(/api/sign/…/pdf), the pdf.js worker and its /pdfjs/ CMaps, standard fonts
and wasm, the typefaces under /_next/static/, the sign / decline / SMS
endpoints, and our error reporter, which is tunnelled through /monitoring
rather than sent to a third party. Exactly two requests can leave our origin,
and neither is required to sign:
https://unpkg.com— only in the fallback where a deployed pdf.js worker version drifts from the bundled one. Blocking it leaves signing working.theme.logo_url, if you set one — your image, from wherever you host it. Blocking it just means your logo does not appear.
We run no analytics and no third-party tags inside the frame: the product analytics and the schema.org markup the rest of our site carries are suppressed on embed paths, so a signer in your product is not measured in our funnel and our entity is not published inside your document.
What we send
On embed paths only, the response carries:
Content-Security-Policy: frame-ancestors https://portal.example.com— the session's registered origin, verbatim, and nothing else. A different site that gets hold of the URL cannot frame it.
If the session id is malformed or unknown, or the lookup fails at all (the
database is unreachable, the deployment is mid-migration), we send
frame-ancestors 'none' and fail closed: one reload for a legitimate
integrator beats a leaked URL framable anywhere. A session that exists but
has expired or been revoked keeps its own origin in the header, so your page
renders the frame's "this link is no longer valid" state instead of a silently
blank box.
Embed paths deliberately do not carry the app's global
X-Frame-Options: SAMEORIGIN. Browsers ignore X-Frame-Options when a CSP
frame-ancestors is present, but we don't rely on that: the header is
excluded from these paths rather than contradicted.
Belt and braces, for a client that ignores CSP: if the request carries an
Origin or Referer header naming an origin that is neither the session's nor
our own, the frame renders a short "blocked" notice instead of the document —
and posts no message at all, because whoever is framing is not the host we were
told to talk to.
postMessage events
The frame posts to window.parent with targetOrigin set to the session's
registered origin — never '*'. Messages are a UX signal only:
The webhook is the source of truth. A message can be lost —
the viewer closes the tab, the browser kills the frame, the network drops
mid-post — and no message is retried. Advance your UI on wesign.signed;
record the case as signed on signing_request.signed /
document.completed. The two must never contradict each other, and if they
appear to, the webhook is right.
Schema v1
{
"v": 1, // schema version — branch on this
"type": "wesign.signed",
"session_id": "b7c2…",
"signing_request_id": "3f1c9e2a-…", // the id your webhook carries too
"at": "2026-09-10T14:31:02.881Z", // ISO-8601 UTC
"detail": "…" // optional, ≤ 200 chars, absent when empty
}type | Meaning |
|---|---|
wesign.ready | The frame mounted and the signing view is usable. Hide your spinner. |
wesign.viewed | The signer has the document in front of them. A browser signal only; the server-side signing_request.viewed webhook is the record. |
wesign.signed | This signer signed. Advance your case; confirm with the webhook. |
wesign.declined | The signer declined. No detail: the reason they typed is theirs and never crosses into your page. Your backend gets it as reason on the signing_request.declined webhook (first 1000 characters), in full in auditEvents[type=declined].meta.reason on GET /v1/signing-requests/{id}, and in the sender's notification email. |
wesign.expired | The permit ran out or the signing request is no longer signable. detail is expired or request_not_signable. Mint a new session to try again. |
wesign.error | The frame could not be used, or signing failed. detail is always a short, stable code — not_found, revoked, key_revoked, unavailable, withdrawn, queued when the permit or the request itself is the problem, sms_required for a signer with sms_gate: "before_view" (see below); sign_failed, sealing_in_progress, pades_failed, seal_persist_failed or http_<status> when a submitted signature failed. sealing_in_progress means the frame kept retrying for 30 seconds while a signature on the same document was still being sealed; the signer can press Sign again. Never a raw server message: detail crosses into your page, so we only ever hand over a value we control. Show your own retry affordance. |
Order is not guaranteed beyond wesign.ready arriving first, and every type
other than ready may legitimately never arrive. Ignore any message you don't
recognise instead of erroring — that is how v2 will be introduced, and a
future v: 2 message must not break your page.
Host-side listener
const FRAME_ORIGIN = 'https://acme.letssign.now' // the origin of embed_url
window.addEventListener('message', (event) => {
// 1. Always check the sender. Anyone can postMessage to your window.
if (event.origin !== FRAME_ORIGIN) return
const msg = event.data
// 2. Validate the shape before trusting a single field.
if (!msg || msg.v !== 1 || typeof msg.type !== 'string') return
if (!msg.type.startsWith('wesign.')) return
// 3. Bind it to the session you actually rendered.
if (msg.session_id !== currentSessionId) return
switch (msg.type) {
case 'wesign.ready':
hideSpinner()
break
case 'wesign.signed':
// Optimistic UI only — the webhook confirms it server-side.
showSignedState(msg.signing_request_id)
break
case 'wesign.declined':
showDeclinedState()
break
case 'wesign.expired':
offerRetry() // ask your backend for a fresh session
break
case 'wesign.error':
showProblem(msg.detail)
break
}
})Never postMessage back into the frame expecting it to act on it: the channel
is one-way by design, and the frame ignores inbound messages.
Theming and locale
The frame carries no vendor chrome — no wordmark, no navigation, no footer, no feedback button. What you pass at mint time is applied as CSS variables on the embed root:
| Field | Shape | Applied as |
|---|---|---|
accent | #rrggbb (lower-cased; #rgb, rgba() and colour names are refused) | the accent on buttons, focus rings and the active field |
font | a CSS font-family list, ≤ 60 chars, letters/digits/spaces/commas/quotes/hyphens only | the UI typeface (the document keeps its own fonts) |
logo_url | https URL, ≤ 512 chars | an <img> at the top of the frame, loaded with referrerPolicy="no-referrer" |
radius | none | sm | md | lg | the corner radius of controls |
A value we cannot use fails the mint with invalid_request rather than being
silently reinterpreted; a field you omit simply keeps our default. Self-host
the logo you pass — we do not proxy, resize or cache it.
locale accepts en, de, fr, it, es, nl and sets the frame's
language: labels, buttons, the consent statement, the SMS step-up and every
error state. The PDF itself is the file you sent — we never translate its
content. Omit locale and the signing request's own is used, falling back to
English. The audit trail stays English whatever the signer sees, by design:
one legal record, one language.
Lifetime and revocation
A session dies in four ways, and every one of them fails the frame closed:
- Expiry —
expires_at, 60…900 seconds after minting (default 300). DELETE— immediate, idempotent, and the safety valve if a URL leaked.- The API key is revoked — every session minted with it stops resolving.
- The signing request stops being signable — signed, declined, withdrawn or expired.
Within its lifetime the session is reusable: a reload inside the frame works,
and we record first_seen_at / last_seen_at so you can tell "never opened"
from "opened and abandoned".
GET https://api.wesign.now/v1/embedded/sign-sessions/{id}
Authorization: Bearer wsk_live_…{
"id": "b7c2…",
"status": "active", // active | expired | revoked
"expires_at": "2026-09-10T14:35:12.004Z",
"first_seen_at": "2026-09-10T14:30:41.220Z",
"last_seen_at": "2026-09-10T14:31:02.910Z",
"signing_request_id": "3f1c9e2a-…"
}DELETE https://api.wesign.now/v1/embedded/sign-sessions/{id}
Authorization: Bearer wsk_live_…
→ 200 { "ok": true }GET and DELETE are workspace-scoped through the key: another tenant's
session id answers 404 not_found, never 403 (which would confirm it
exists). Neither is Enterprise-gated — if a plan lapses with sessions still
live, you can always see them and pull them.
What happens after signing
The frame shows the signer a confirmation in place and emits wesign.signed.
By design it does not turn into a download surface: no sealed PDF is handed
out inside your page, and nothing ever navigates out of the frame.
Collect the result the way every other integration does:
- Wait for
signing_request.signed, anddocument.completedonce every signer is done (webhooks). - Pull the sealed PDF from
signed_pdf_url(GET /v1/documents/{id}/signed) and the audit trail fromGET /v1/documents/{id}/audit-trail, both with your key, server-side. - Show it in your own UI, under your own auth. The signer separately receives the completion email linking to the sealed document, exactly as on the hosted flow — embedding changes nothing about who gets told what.
For a multi-signer document, one signer finishing in your frame does not complete the document; mint a separate session per signer, or let the others use their emailed links.
Accessibility
A signature is a legal act, so the frame is the same accessible surface as the hosted page — nothing is stripped for embedding:
- Keyboard. The fields, the consent checkbox and the sign action are real
focusable controls —
<input>,<button>,<input type="checkbox">— reached withTaband activated withEnter/Space. Nothing in the signing path requires a pointer. - Signing without a pointer. The signature control has two tabs: Draw (a canvas, pointer or touch) and Type (a plain text input rendered in a script face). Type is the keyboard and screen-reader path and produces an equally valid signature. Don't hide it.
- Screen readers. Every field is announced with its label and whether it is already filled; the consent statement is real text, not an image.
- Give the frame a
title. Assistive technology announces the frame by it.title="Sign the mandate"beatstitle="iframe". - Don't trap focus outside it. If you render the frame in a modal, let focus move into it; a focus trap that excludes the frame makes signing impossible with a keyboard.
Mobile webviews
The frame works in an iOS WKWebView and an Android WebView:
- Load
embed_urldirectly in the webview, or frame it in your own page inside the webview —frame-ancestorsapplies either way, so if you frame it, the page doing the framing must be served from the registered origin. - On-screen keyboard. The layout reflows; make the webview resize with the
keyboard (
android:windowSoftInputMode="adjustResize", and don't pin the content height on iOS) or the field being typed into can end up under it. - Touch drawing works: the signature canvas uses pointer events and disables touch-scrolling over itself, so a finger draws instead of panning.
- Third-party cookies. SMS step-up (
require_sms_verification) does not depend on them — the verified state is recorded on the signing request server-side, so a webview or browser that blocks third-party cookies still gets a working AES step-up, with no re-verification loop.
SMS before viewing is not available in a frame yet. A signing request
created with sms_gate: "before_view" cannot be signed embedded: until its
code is verified the frame shows the "session not available" card and posts
wesign.error with detail: "sms_required". Send such signers the hosted
signing link, or create them with sms_gate: "before_sign" (the default),
which works in the frame — the code is asked at the Sign press. See
SMS verification.
Security model
- No API key in the browser, ever. Only your backend calls the mint endpoint; the browser only ever holds a session URL.
- One tenant, one document, one signer, one origin, minutes. There is nothing to widen: the session cannot be pointed at another document, another signer or another workspace.
- Embedding is granted, not assumed. An origin that isn't on the key can't
be minted for, and even if a URL escapes,
frame-ancestorsstops another site from framing it. - The legal content is untouched. Consent text, the SMS second factor, PAdES sealing, the timestamp and the audit trail are identical to the hosted page. Embedding changes the chrome around a signature, never the signature.
The retired endpoint
POST /v1/embedded/sessions (the 2026 guest-placement flow) was retired on
2026-07-31 and still answers a stable 410:
HTTP/1.1 410 Gone
{
"error": "Embedded sessions have been retired. …",
"code": "embedded_sessions_retired"
}It minted a no-login guest link to a hosted placement editor in a full
browser tab — never an iframe, no postMessage, no origin allow-list — and no
session was ever minted in production.
GET/DELETE /v1/embedded/sessions/{id} still read and revoke rows from that
era. The endpoints above (sign-sessions, hyphenated) are the replacement and
share nothing with it but a word.
