Spondeo
Embedded signing

Stripe-grade signing customization. Two layers, one signed boundary.

Drop the signing surface into your app with three API calls. The embedding site controls theme, layout, copy. Your server controls the things that have legal weight: brand identity, consent disclosure, embed-domain allowlist. The split is enforced by the JWT we mint at the boundary — the embedder can't lie about who's attesting consent.


The model

SDK = visual. JWT = policy.

Customization is split by trust: anything the embedder can change client-side rides as a base64url JSON blob in the URL (the cfg query param). Anything that has to be attested by you (the merchant) rides as claims inside the short-lived signing JWT.

@spondeo/embed
SDK side
  • appearance.theme (light/dark/flat/minimal)
  • appearance.variables (colors, fonts, radius, ...)
  • layout (chrome show/hide, density, topBar mode)
  • copy (button labels, consent gate heading, ...)
  • behavior (exitOnComplete, autoAdvance, ...)
  • locale
No re-mint needed. Change between renders.
POST /v1/.../signing_url
JWT side
  • display.branding (logo, brand_name, font_src, hide badge)
  • display.layout (server-locked policies)
  • display.consent (standard / custom text / pre-collected)
  • workspace.features.embedOrigins → frame-ancestors
Mint a new URL to change. Embedder can't override.

Layer 1 · SDK

Appearance, layout, copy, behavior.

Anything the embedding site is allowed to change. Lives in the @spondeo/embed call. No re-mint.

Appearance

Pick a theme preset, override CSS variables, choose a font. Nineteen variables in total — nine colors (primary, background, surface, text, textMuted, border, danger, success, warning), four typography knobs, four shape/spacing knobs, and three button-geometry vars for padding + min-width.

Spondeo.embed({
  url: signingUrl,
  container: '#sign-here',
  appearance: {
    theme: 'light',           // light | dark | flat | minimal
    variables: {
      colorPrimary: '#5b21b6',
      colorBackground: '#fafafa',
      borderRadius: '12px',
      fontFamily: 'Inter, system-ui, sans-serif',
      buttonPaddingX: '20px',
      buttonPaddingY: '12px',
      buttonMinWidth: '180px',
    },
  },
});

Layout

Show/hide every piece of chrome — top bar, toolbar, zoom controls, page navigation, decline button, terminal screen, sender info. Tighten density for embedded use. Hide the top bar entirely when your own UI already provides context.

Spondeo.embed({
  url: signingUrl,
  container: '#sign-here',
  layout: {
    topBar: 'compact',         // 'show' | 'compact' | 'hide'
    toolbar: 'hide',
    declineButton: 'hide',     // server policy can force this back on
    terminalScreen: 'hide',    // unmount on complete, your code handles success
    density: 'compact',
  },
});

Copy & locale

Eighteen string overrides for every label on the signer surface. Replace 'Finish signing' with 'Submit application' or whatever fits your funnel. Locale defaults shipped: English. More on the roadmap.

Spondeo.embed({
  url: signingUrl,
  container: '#sign-here',
  locale: 'en',
  copy: {
    finishButton: 'Submit application',
    consentTitle: 'One last step',
    completedTitle: 'Application received',
    completedBody: 'We\'ll email you within one business day.',
  },
});

Behavior

Suppress the built-in 'thank you' card and let your app handle the success state. Auto-advance to the next required field as signers fill in. All optional.

Spondeo.embed({
  url: signingUrl,
  container: '#sign-here',
  behavior: {
    exitOnComplete: true,           // your onComplete handles the next step
    exitOnDecline: true,
    autoAdvanceToNextField: true,
  },
  onComplete: (e) => router.push(`/signed/${e.envelope_id}`),
});

Layer 2 · JWT

Branding, consent, embed allowlist.

Anything that affects audit posture or unlocks a paid feature. Set on the signing-URL mint. Server-attested, signed into the JWT.

Branding

Replace the sender's email with your brand name. Drop in a logo. Suppress the Spondeo badge. Requires the Branding add-on on your workspace in live mode — test mode is free to prototype. Logos host on Spondeo (upload via the workspace settings page) or your own CDN, allowlisted via SPONDEO_BRANDING_ASSET_HOSTS.

Live-mode workspaces without the Branding add-on get HTTP 403 feature_not_available with code custom_branding_required.
// On your server, when minting the signing URL:
const { signing_url } = await spondeo.envelopes.signingUrls.create({
  envelope_id: envelope.id,
  recipient_id: envelope.recipients[0].id,
  ttl_seconds: 600,
  display: {
    branding: {
      brand_name: 'Acme Capital',
      logo: { url: 'https://cdn.acme.com/logo.svg', height: 28 },
      font_src: 'https://fonts.acme.com/Acme.woff2',
      hide_spondeo_badge: true,
    },
  },
});

Consent

Standard mode shows our default ESIGN disclosure and captures an explicit click. Custom mode swaps the disclosure language to yours (you bear the compliance burden); the signer still clicks. Pre-collected mode skips the gate entirely for cases where you captured consent in your own flow — requires a signed compliance addendum on your workspace, because the audit cert flags those envelopes as merchant-attested rather than Spondeo-witnessed.

The audit certificate distinguishes recipient.consented (we witnessed the click) from recipient.consent_pre_collected (merchant attests). The closing paragraph of the cert calls this out explicitly when any recipient used pre-collected mode.
// Custom disclosure — common case
display: {
  consent: {
    mode: 'custom',
    text: 'By signing below, I consent to use electronic signatures with Acme Capital under the terms of acme.com/esign.',
  },
}

// Pre-collected — only with the workspace addendum
display: {
  consent: {
    mode: 'pre_collected',
    collected_at: '2026-05-14T18:00:00Z',
    method: 'checkbox at /checkout step 3',
  },
}

Iframe-ancestor allowlist

Lock down who can iframe your signing surfaces. Set the list once in workspace settings; every signing URL minted from that workspace ships a per-request Content-Security-Policy: frame-ancestors header. Empty = legacy permissive default for backward compat.

The allowlist rides in the signing JWT as the `ea` claim; the Edge middleware reads it and emits the CSP per request, so a leaked URL still can't be iframed from a domain you didn't authorize.
// Workspace settings (UI at /settings/workspace):
//   Iframe embedding allowlist
//   https://acme.com
//   https://app.acme.com
//
// Signing URLs minted by this workspace will emit:
//   Content-Security-Policy: frame-ancestors https://acme.com https://app.acme.com

Redirect (Stripe-Checkout-style)

When the signer page is loaded directly (no iframe), set success_url and cancel_url to navigate the user back to your app after the terminal state. Ignored in iframe mode — the SDK's onComplete/onDecline events handle navigation there, since cross-frame parent-redirect would be a security surprise.

The signer page checks window.parent !== window to detect iframe context. Same JWT works for both embedded and redirect flows — the URLs are simply ignored in embedded mode.
display: {
  redirects: {
    success_url: 'https://acme.com/loan/signed?id=loan_abc',
    cancel_url: 'https://acme.com/loan/declined?id=loan_abc',
  },
}

// Then send the user to /sign/<jwt> directly — no iframe:
window.location.href = signing_url;

Presentation

Inline, modal, full-width, redirect.

Four ways to put the signing surface in front of a user. Pick by how much chrome you want around it.

Four presentation modes

Inline (default) renders into your container, sized by you. Full-width is inline with width: '100vw', height: '100vh'. Modal builds a fullscreen overlay with backdrop + close button + Escape-to-close — no need to bring your own modal library. Redirect mode is server-side: navigate to /sign/<jwt> directly with display.redirects set.

Modal mode handles Escape, backdrop-click, and body-scroll lock automatically. Call handle.unmount() to close programmatically.
// Inline (default) — your container, your sizing.
Spondeo.embed({ url, container: '#sign-here' });

// Full-width — same iframe, page-stretched.
Spondeo.embed({ url, container: '#sign-here', width: '100vw', height: '100vh' });

// Modal — SDK builds the overlay. No container needed.
const handle = Spondeo.embed({
  url,
  mode: 'modal',
  onComplete: (e) => {
    handle.unmount();         // close the modal
    router.push('/success');
  },
});

// Redirect — navigate the user to /sign/<jwt> directly. JWT carries
// success_url + cancel_url; signer page handles navigation.
window.location.href = signing_url;

Complete example

End-to-end, both layers.

Three calls: upload, mint, mount. The mint call carries the server-attested branding + consent. The mount call carries the visual customization. The signer page loads with both applied.

// 1. Upload your PDF (one time, returns doc_…).
const document = await spondeo.documents.create({ file });

// 2. Create the envelope and mint a signing URL with display policy.
const envelope = await spondeo.envelopes.create({
  subject: 'Mutual NDA',
  document_id: document.id,
  recipients: [{ name: 'Jane Roe', email: '[email protected]' }],
  send: true,
});

const { signing_url } = await spondeo.envelopes.signingUrls.create({
  envelope_id: envelope.id,
  recipient_id: envelope.recipients[0].id,
  ttl_seconds: 600,
  display: {
    branding: {
      brand_name: 'Acme Capital',
      logo: { url: 'https://cdn.acme.com/logo.svg', height: 28 },
      hide_spondeo_badge: true,
    },
    consent: {
      mode: 'custom',
      text: 'By signing, I agree to Acme Capital\'s electronic-signature disclosure.',
    },
  },
});

// 3. Mount it. `appearance` / `layout` / `copy` are SDK-side — change
//    them on every render without re-minting.
import { Spondeo } from '@spondeo/embed';

Spondeo.embed({
  url: signing_url,
  container: '#sign-here',
  appearance: {
    theme: 'light',
    variables: { colorPrimary: '#5b21b6', borderRadius: '10px' },
  },
  layout: { topBar: 'compact', toolbar: 'hide' },
  copy: { finishButton: 'Submit application' },
  behavior: { exitOnComplete: true },
  onComplete: (e) => router.push(`/signed/${e.envelope_id}`),
  onDecline: (e) => router.push('/loan/declined'),
});

See it in 30 seconds.

The /try demo lets you toggle every knob live against a real envelope. Theme, palette, brand picker, custom consent text — all of it ships with the API.