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

EndpointWhat it does
POST /v1/embedded/sign-sessionsMints 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:

AcceptedRefused
https://portal.example.comhttp://portal.example.com (never http)
https://portal.example.com:8443https://portal.example.com/ (a path — even a bare slash)
https://PORTAL.Example.com → stored lower-casehttps://*.example.com (no wildcards, ever)
https://portal.example.com:443 → stored without the porthttps://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

StatusCodeWhen
401invalid_keyMissing, malformed, unknown or revoked key.
402enterprise-requiredThe workspace is not on Enterprise.
400invalid_requestBody validation — bad uuid, ttl_seconds out of 60…900, unknown locale, bad theme field.
400invalid_originorigin is not an https origin (path, query, credentials, wildcard, http).
400origin_not_allowedWell-formed, but not registered on this key. The message lists what is registered.
404not_foundNo such signing request — or it belongs to another workspace. Deliberately indistinguishable.
409request_not_signableThe 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.
429rate_limited60 requests per minute per key. Honour Retry-After.
503embedding_unavailableThis deployment has not applied migration 0155 yet. Transient by definition; retry or contact support.
500db_failedOur 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:

  1. 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 200 names 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
    }
  2. Mint a session for that signing_request_id, exactly as above, and frame the embed_url.

What decides whether a template signer can be framed yet:

  • Only a pending or viewed request can be minted for. In a sequential send, slot 1 is pending at once and the later slots are queued (409 request_not_signable) until the signer before them signs. Mint the next session when that signer's signing_request.signed arrives — and note that the hand-over emails the next signer even with send_emails: false.
  • A review: true instance is staged — every recipient is queued until POST /v1/documents/{id}/confirm. Pass "send_emails": false on 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 full scope for the instantiate — an embedded key 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 that allow-scripts + allow-same-origin on a cross-origin frame is not a meaningful restriction anyway. The real boundary is frame-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
}
typeMeaning
wesign.readyThe frame mounted and the signing view is usable. Hide your spinner.
wesign.viewedThe signer has the document in front of them. A browser signal only; the server-side signing_request.viewed webhook is the record.
wesign.signedThis signer signed. Advance your case; confirm with the webhook.
wesign.declinedThe 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.expiredThe 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.errorThe 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:

FieldShapeApplied as
accent#rrggbb (lower-cased; #rgb, rgba() and colour names are refused)the accent on buttons, focus rings and the active field
fonta CSS font-family list, ≤ 60 chars, letters/digits/spaces/commas/quotes/hyphens onlythe UI typeface (the document keeps its own fonts)
logo_urlhttps URL, ≤ 512 charsan <img> at the top of the frame, loaded with referrerPolicy="no-referrer"
radiusnone | sm | md | lgthe 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:

  1. Expiry — expires_at, 60…900 seconds after minting (default 300).
  2. DELETE — immediate, idempotent, and the safety valve if a URL leaked.
  3. The API key is revoked — every session minted with it stops resolving.
  4. 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:

  1. Wait for signing_request.signed, and document.completed once every signer is done (webhooks).
  2. Pull the sealed PDF from signed_pdf_url (GET /v1/documents/{id}/signed) and the audit trail from GET /v1/documents/{id}/audit-trail, both with your key, server-side.
  3. 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 with Tab and activated with Enter/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" beats title="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_url directly in the webview, or frame it in your own page inside the webview — frame-ancestors applies 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-ancestors stops 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.