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.
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.
- 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
- 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
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}`),
});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.
// 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.
// 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.
// 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.comRedirect (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.
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;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.
// 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;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.