FreeSign

Guide

Embed FreeSign signing on your site

Banks, insurers, and internal portals often need a signing step inside their own UI — without sending the contract PDF to a third-party vault. FreeSign’s pure-embed v1 integration loads a framable ceremony at /embed, ships a dependency-free SDK at /freesign-embed.js, and moves the PDF between your page and the iframe with a versioned postMessage contract. The server path is unchanged: hashes and evidence only, never document bytes.

What stays the same

Architecture at a glance

Your site hosts the PDF and the integration shell. FreeSign hosts the ceremony UI and the signing API. The only bridge is postMessage with freesign: "1" (full tables in /llms.txt under “Embedded signing” and in the repo’s docs/EMBED-PROTOCOL.md).

  1. Parent loads /freesign-embed.js and calls new FreeSignSigner({...}).
  2. SDK inserts an <iframe src="/embed"> (use origin: "https://free-sign.com" in production, or your dev host when testing locally).
  3. Iframe emits ready; SDK sends load with the PDF (recommended max 25 MiB).
  4. Signer completes email, OTP/passkey, and consent inside the frame.
  5. Iframe emits signed; your onSigned callback receives signedPdf as Uint8Array.

Step-by-step

1. Load the SDK

<div id="signing-host"></div>
<script src="https://free-sign.com/freesign-embed.js"></script>

The script is standalone (no bundler). It sets iframe.allow for WebAuthn delegation when the browser supports passkeys in embedded frames.

2. Mount FreeSignSigner

const signer = new FreeSignSigner({
  container: "#signing-host",
  pdf: pdfBytes,              // ArrayBuffer or Uint8Array from your app
  filename: "contract.pdf",
  signer: { email: "you@example.com", name: "Ada Lovelace" }, // optional prefill
  language: "pl",             // optional; defaults to the signer's browser language
  branding: {                 // optional; recolour + logo (cosmetic; via the SDK it is sent only over postMessage, never to the server)
    bg: "#eef4fb", text: "#0b1f3a", accent: "#1d4ed8",
    brandName: "ACME Corp",
    logo: "data:image/svg+xml;base64,…",
  },
  origin: "https://free-sign.com",
  onSigned({ signedPdf, envelopeId, evidenceJson, verificationUrl, documents, failed, total }) {
    // single document: use the flat fields. batch (pass `pdfs`): iterate
    // documents[] (succeeded) and inspect failed[]/total — see below.
    // persist signedPdf on your origin
  },
  onError({ code, message }) {
    console.error(code, message);
  },
});

onSigned and onError are required. Optional hooks: onReady, onPdfLoaded, onOtpSent, onCancelled, onNotice (non-terminal advisories such as an over-cap batch trimmed to the limit, or a dismissed passkey falling back to OTP — the ceremony keeps going).

language is optional and controls the iframe ceremony copy only. Use a primary language code such as pl or a browser-style tag such as pl-PL. If omitted, FreeSign uses the signer’s browser language and falls back to English; unsupported values also fall back to English.

branding is optional and purely cosmetic — it never changes hashes, evidence, certificates, or verification. Pass hex colours for bg/text/accent, a short brandName, and a logo as a data: image URI or a same-origin path (external image origins are out of scope in v1). With the SDK, branding travels only over the load postMessage and never reaches FreeSign servers. You can alternatively set the colour/name subset as query params on the iframe, e.g. /embed?bg=%23eef4fb&accent=%231d4ed8&brandName=ACME, which applies the theme at first paint — but those params are part of the GET URL and are therefore visible to the server (the logo is never read from the URL). Invalid values are dropped and fall back to the default theme.

Multi-document batch signing

To sign several PDFs in one ceremony, pass pdfs instead of pdf. One emailed OTP code (or one passkey prompt per document) unlocks the whole batch, and the iframe shows a clickable document list with a per-document ✓/✗ status.

const signer = new FreeSignSigner({
  container: "#signing-host",
  pdfs: [
    { pdf: bytesA, filename: "nda.pdf" },
    { pdf: bytesB, filename: "msa.pdf" },
  ],                          // up to BATCH_MAX_DOCS (default 10); each ≤25 MiB
  onSigned({ documents, failed, total }) {
    // documents[] = succeeded, failed[] = [{ filename, error }], total = batch size
    for (const doc of documents) persist(doc.signedPdf, doc);
    if (failed.length) console.warn(`${failed.length} of ${total} documents failed`, failed);
  },
  onError({ code, message }) { console.error(code, message); },
});

For a multi-document batch, onSigned receives documents (an array of the succeeded documents, each with signedPdf, filename, envelopeId, evidenceJson, verificationUrl, …), plus failed ([{ filename, error }], empty on a clean batch) and total (the batch size). For a single document the legacy flat fields (signedPdf, envelopeId, …) are still present at the top level, so existing one-PDF integrations keep working unchanged. A partial batch still fires onSigned with the successes; only a fully failed batch withholds it (you get per-document onError events instead).

3. Protocol and origin pinning

Every message includes freesign: "1". The iframe pins your page on the first parent load and replies only to that origin thereafter (signed PDF is never broadcast with targetOrigin: "*"). The SDK filters inbound events to event.source === iframe.contentWindow and your configured origin.

Stable error codes include pdf_too_large, pdf_invalid, envelope_failed, and ceremony_failed. User dismissals emit cancelled with otp_dismissed or passkey_dismissed.

4. Ceremony inside the iframe

The embedded surface reuses the same seal pipeline as the home page (embed.js + shared ceremony-core.js). Turnstile, when enabled on the deployment, gates OTP delivery the same way. Self-verify runs before the iframe posts signed back to you.

5. After signing

Store signedPdf in your backend or DMS. Use verificationUrl for a human-readable receipt link. Call signer.reset() to run another document in the same iframe, or signer.destroy() to tear down.

Security headers (for your security review)

Out of scope in v1

No integration API keys, server webhooks, or envelope resume via URL parameters inside the iframe. Agents automating signing should use REST/MCP on hashes, not this embed path — see headless automation and /free-sign-agent/SKILL.md.

Try it interactively

The parent-page demo loads a real iframe, streams a local PDF through the SDK, and logs every postMessage event. Use it to validate origin pinning and the onSigned callback before wiring your own backend.

Open the embedded signing demo →