Add E-Signatures to SvelteKit 5 Applications via REST API & Webhooks (2026)
A production full-stack tutorial for integrating legally binding electronic signatures into SvelteKit 5 using Svelte 5 Runes ($state, $derived, $effect), progressive Form Actions in +page.server.ts, and timing-safe HMAC-SHA256 webhook verification in +server.ts.
Quick Answer (TL;DR)
Full-Stack SvelteKit 5 Pattern • Zero-Bundle-Bloat E-Signatures
To integrate e-signatures in SvelteKit 5, store SIGNBEE_API_KEY in $env/static/private and create a +page.server.ts Form Action that calls POST https://signb.ee/api/v1/send with CommonMark markdown or a base64 PDF. In your +page.svelte component, use Svelte 5 Runes ($state and $derived) to build reactive contract templates and progressive use:enhance submission states. Finally, expose a +server.ts webhook endpoint that verifies X-Signbee-Signature via crypto.timingSafeEqual to record the final SHA-256 certificate in your database.
1. Why SvelteKit 5 Developers Reject Legacy E-Signature SDKs
SvelteKit 5 and Svelte 5 Runes represent a triumph of compiler-first web engineering: fine-grained signal reactivity, sub-20KB client bundles, progressive enhancement via Form Actions, and effortless deployment across Node.js, Bun, Vercel Edge, and Cloudflare Workers. Yet when a SvelteKit team needs to add B2B contract signing, vendor onboarding agreements, or customer quote approvals to their application, integrating legacy e-signature providers feels like stepping back to 2014.
Traditional SDKs like docusign-esign ship over 42MB of legacy CommonJS classes, rely on Node-specific C++ or fs bindings that crash Vite SSR builds on edge runtimes, and require convoluted RSA private-key JWT impersonation flows just to send a two-page agreement. Even worse, as we documented in our investigation into DocuSign API pricing and production seat requirements, legacy platforms force SaaS startups into $480+/month annual contracts with strict envelope caps.
Signbee was engineered specifically for modern full-stack TypeScript frameworks, ranking at the top of our guide to the best e-signature APIs for developers in 2026. Instead of wrestling with heavyweight SDKs or coordinate-tagged PDF templates, your SvelteKit server action makes a single standard fetch("https://signb.ee/api/v1/send") request containing dynamic CommonMark markdown or a base64 PDF. Signbee compiles the markdown into a paginated A4 PDF, records Party A's cryptographic signature, emails Party B a mobile-responsive signing link, and fires an HMAC-signed webhook to your +server.ts endpoint the moment the document is sealed with a SHA-256 certificate.
2. Architectural Blueprint: SvelteKit 5 + Signbee Lifecycle
Before writing code, let's examine how SvelteKit 5's routing and environment boundaries map to a secure, tamper-evident electronic signature lifecycle:
| SvelteKit 5 Layer | File / Module | Key Primitives Used | Security & Execution Responsibility |
|---|---|---|---|
| 1. Environment Secrets | $env/static/private | SIGNBEE_API_KEY, SIGNBEE_WEBHOOK_SECRET | Compile-time guard preventing client bundle leakage |
| 2. Server Form Action | src/routes/contracts/+page.server.ts | Actions, fail(), event.fetch | Validates payload, injects Bearer token, calls POST /api/v1/send |
| 3. Reactive Client UI | src/routes/contracts/+page.svelte | $state, $derived.by, $effect, use:enhance | Synthesizes live CommonMark preview & tracks signing status |
| 4. Webhook Receiver | src/routes/api/webhooks/signbee/+server.ts | RequestHandler, crypto.timingSafeEqual | Verifies X-Signbee-Signature & stores SHA-256 hash in DB |
3. Server-Side Contract Dispatch in +page.server.ts
Never call an e-signature API directly from browser JavaScript. Doing so exposes your private API key in the browser's Network inspector and allows malicious actors to forge contracts under your organizational identity. In SvelteKit 5, we isolate all Signbee communication inside src/routes/contracts/+page.server.ts using $env/static/private.
First, add your credentials to your local .env file (you can generate a key in 30 seconds on our free e-signature API tier):
- SIGNBEE_API_KEY="sb_live_9f8e7d6c5b4a3f2e1d0c..."
- SIGNBEE_WEBHOOK_SECRET="whsec_c4a8b2e9d1f0a3c5e7b9..."
- PUBLIC_APP_URL="https://app.yourdomain.com"
Now implement the load function and sendContract Form Action in src/routes/contracts/+page.server.ts. Notice how we compute a deterministic X-Idempotency-Key so accidental double-clicks or network retries never dispatch duplicate signing emails:
import { fail } from "@sveltejs/kit";
import { SIGNBEE_API_KEY } from "$env/static/private";
import { PUBLIC_APP_URL } from "$env/static/public";
import crypto from "node:crypto";
import type { Actions, PageServerLoad } from "./$types";
export interface SignbeeSendResponse {
id: string;
status: "PENDING" | "SIGNED" | "EXPIRED";
title: string;
recipient_email: string;
verify_url: string;
expires_at: string;
}
export const load: PageServerLoad = async ({ locals }) => {
return {
senderName: locals.user?.name ?? "Alex Rivera",
senderEmail: locals.user?.email ?? "alex@acmecloud.io",
};
};
export const actions: Actions = {
sendContract: async ({ request, fetch, locals }) => {
const formData = await request.formData();
const title = String(formData.get("title") ?? "").trim();
const recipientName = String(formData.get("recipient_name") ?? "").trim();
const recipientEmail = String(formData.get("recipient_email") ?? "").trim();
const markdown = String(formData.get("markdown") ?? "").trim();
const expiresInDays = Number(formData.get("expires_in_days") ?? 14);
if (!title || !recipientName || !recipientEmail.includes("@") || markdown.length < 40) {
return fail(400, {
error: "Please provide a valid title, counterparty email, and complete contract body.",
values: { title, recipientName, recipientEmail },
});
}
const senderName = locals.user?.name ?? "Alex Rivera";
const senderEmail = locals.user?.email ?? "alex@acmecloud.io";
// Generate deterministic idempotency key from recipient + markdown digest
const idempotencyKey = crypto
.createHash("sha256")
.update(`${recipientEmail}:${title}:${markdown}`)
.digest("hex")
.slice(0, 24);
const response = await fetch("https://signb.ee/api/v1/send", {
method: "POST",
headers: {
Authorization: `Bearer ${SIGNBEE_API_KEY}`,
"Content-Type": "application/json",
"X-Idempotency-Key": `sveltekit-${idempotencyKey}`,
},
body: JSON.stringify({
title,
markdown,
sender_name: senderName,
sender_email: senderEmail,
recipient_name: recipientName,
recipient_email: recipientEmail,
expires_in_days: expiresInDays,
webhook_url: `${PUBLIC_APP_URL}/api/webhooks/signbee`,
}),
});
if (!response.ok) {
const errText = await response.text();
return fail(response.status, {
error: `Signbee API error (${response.status}): ${errText}`,
values: { title, recipientName, recipientEmail },
});
}
const receipt = (await response.json()) as SignbeeSendResponse;
return {
success: true,
envelope: receipt,
};
},
};4. Reactive Contract Builder UI with Svelte 5 Runes ($state, $derived, $effect)
In Svelte 4, coordinating a dynamic multi-item quote table, live CommonMark contract generation, form submission loading states, and real-time status polling required juggling $: reactive labels and manual onDestroy cleanup hooks. In Svelte 5, Runes ($props, $state, $derived.by, and $effect) make this remarkably clean and explicit.
Below is the complete src/routes/contracts/+page.svelte component. As the user edits client details or pricing tiers, $derived.by() recomputes the exact CommonMark markdown contract (including a formatted Markdown pricing table that Signbee compiles into an A4 PDF table). Once dispatched via use:enhance, an $effect() rune automatically polls the envelope status every 5 seconds and cleans up its interval timer the moment the status reaches SIGNED:
<script lang="ts">
import { enhance } from "$app/forms";
import type { PageData, ActionData } from "./$types";
let { data, form }: { data: PageData; form: ActionData } = $props();
// 1. Reactive contract state using Svelte 5 $state() rune
let recipientName = $state("Elena Rostova");
let recipientEmail = $state("elena@vanguardlogistics.io");
let companyName = $state("Vanguard Logistics Ltd");
let monthlyRetainer = $state(4500);
let onboardingFee = $state(2000);
let isSubmitting = $state(false);
let liveStatus = $state<"IDLE" | "PENDING" | "SIGNED">("IDLE");
let sha256Hash = $state<string | null>(null);
// 2. Derived total & CommonMark document via $derived and $derived.by
let totalFirstMonth = $derived(monthlyRetainer + onboardingFee);
let contractTitle = $derived(`Master SaaS Subscription Agreement — ${companyName}`);
let generatedMarkdown = $derived.by(() => {
const today = new Date().toISOString().split("T")[0];
return [
`# ${contractTitle}`,
"",
`This Master Subscription Agreement ("Agreement") is entered into on **${today}** between **Acme Cloud Inc.** ("Provider", represented by ${data.senderName}) and **${companyName}** ("Customer", represented by ${recipientName}).`,
"",
"### 1. Commercial Schedule & Fees",
"",
"| Line Item | Billing Cadence | Amount (USD) |",
"|---|---|---|",
`| Enterprise Platform Subscription | Monthly Recurring | $${monthlyRetainer.toLocaleString()} |`,
`| Dedicated Architecture Onboarding | One-Time Setup | $${onboardingFee.toLocaleString()} |`,
`| **Total Initial Commitment** | **Due on Execution** | **$${totalFirstMonth.toLocaleString()}** |`,
"",
"### 2. Term, Security & Cryptographic Execution",
"Customer authorizes Provider to provision dedicated tenant infrastructure upon execution. Both parties agree that electronic execution sealed via Signbee SHA-256 cryptographic audit trail constitutes an original binding instrument under ESIGN and eIDAS.",
].join("\n");
});
// 3. Reactive $effect() rune to poll signing completion once dispatched
$effect(() => {
const docId = form?.envelope?.id;
if (!docId || liveStatus === "SIGNED") return;
liveStatus = "PENDING";
const interval = setInterval(async () => {
const res = await fetch(`/api/contracts/${docId}/status`);
if (res.ok) {
const payload = await res.json();
if (payload.status === "SIGNED") {
liveStatus = "SIGNED";
sha256Hash = payload.sha256_hash;
clearInterval(interval);
}
}
}, 5000);
return () => clearInterval(interval);
});
</script>
<div class="max-w-3xl mx-auto p-6 bg-zinc-900 text-zinc-100 rounded-2xl border border-zinc-800">
<h2 class="text-xl font-bold mb-4">{contractTitle}</h2>
<form
method="POST"
action="?/sendContract"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
};
}}
class="space-y-4"
>
<input type="hidden" name="title" value={contractTitle} />
<input type="hidden" name="markdown" value={generatedMarkdown} />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label class="block text-xs font-mono text-zinc-400">
Counterparty Signer Name
<input
name="recipient_name"
bind:value={recipientName}
class="mt-1 w-full rounded-lg bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm text-zinc-100"
/>
</label>
<label class="block text-xs font-mono text-zinc-400">
Counterparty Email
<input
name="recipient_email"
type="email"
bind:value={recipientEmail}
class="mt-1 w-full rounded-lg bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm text-zinc-100"
/>
</label>
</div>
<button
type="submit"
disabled={isSubmitting}
class="px-5 py-2.5 rounded-lg bg-amber-500 text-zinc-950 font-semibold text-sm hover:bg-amber-400 disabled:opacity-50"
>
{isSubmitting ? "Compiling A4 PDF & Dispatching..." : `Dispatch Agreement ($${totalFirstMonth.toLocaleString()})`}
</button>
</form>
{#if form?.envelope}
<div class="mt-6 p-4 rounded-xl bg-zinc-950 border border-emerald-500/30 text-xs font-mono space-y-1">
<p class="text-emerald-400 font-semibold">Envelope Dispatched: {form.envelope.id}</p>
<p class="text-zinc-300">Live Status: {liveStatus}</p>
<p class="text-zinc-400">Public Audit URL: {form.envelope.verify_url}</p>
{#if sha256Hash}
<p class="text-amber-400">Sealed SHA-256: {sha256Hash}</p>
{/if}
</div>
{/if}
</div>5. Timing-Safe Webhook Verification in src/routes/api/webhooks/signbee/+server.ts
While client-side polling via $effect() is great for immediate UI feedback when the sender keeps their browser tab open, your authoritative database update must always happen via a server-to-server webhook. When Party B completes Signbee's signing ceremony on their phone or laptop, Signbee appends the A4 Signing Certificate, computes the SHA-256 hash of the final PDF bytes, and issues an HTTP POST request to your webhook_url.
To prevent spoofed webhook payloads from marking unpaid contracts as SIGNED, your SvelteKit +server.ts endpoint must read the raw request body via await request.text() and verify the X-Signbee-Signature header using constant-time byte comparison:
import { json, error } from "@sveltejs/kit";
import { SIGNBEE_WEBHOOK_SECRET } from "$env/static/private";
import crypto from "node:crypto";
import type { RequestHandler } from "./$types";
interface SignbeeWebhookEvent {
event: "document.signed" | "document.viewed" | "document.expired";
document_id: string;
title: string;
recipient_name: string;
recipient_email: string;
signed_at: string;
sha256_hash: string;
verify_url: string;
download_url: string;
}
export const POST: RequestHandler = async ({ request }) => {
const signature = request.headers.get("x-signbee-signature");
if (!signature) {
throw error(401, "Missing X-Signbee-Signature header");
}
// Always read raw text before JSON parsing so HMAC matches exact wire bytes
const rawBody = await request.text();
const expectedHex = crypto
.createHmac("sha256", SIGNBEE_WEBHOOK_SECRET)
.update(rawBody, "utf8")
.digest("hex");
const sigBuffer = Buffer.from(signature, "utf8");
const expectedBuffer = Buffer.from(expectedHex, "utf8");
if (
sigBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(sigBuffer, expectedBuffer)
) {
throw error(401, "Invalid webhook HMAC signature");
}
const payload = JSON.parse(rawBody) as SignbeeWebhookEvent;
if (payload.event === "document.signed") {
// Update your Drizzle / Prisma PostgreSQL contract record
console.info(
`[Signbee Webhook] Document ${payload.document_id} signed by ${payload.recipient_email}. SHA-256: ${payload.sha256_hash}`
);
}
return json({ received: true, document_id: payload.document_id });
};6. Edge Runtime Compatibility: Web Crypto API & Drizzle ORM Persistence
Many SvelteKit 5 teams deploy to V8 isolate edge environments such as Cloudflare Pages (@sveltejs/adapter-cloudflare) or Vercel Edge Functions, where Node's node:crypto module may require compatibility flags. Fortunately, the standard W3C Web Crypto API (crypto.subtle) is available globally in every modern edge runtime and performs constant-time HMAC-SHA256 verification natively via crypto.subtle.verify().
Below is an edge-native helper function that verifies X-Signbee-Signature using crypto.subtle and persists the immutable audit trail into PostgreSQL or Cloudflare D1 via Drizzle ORM:
import { eq } from "drizzle-orm";
import { db } from "$lib/server/db";
import { contractsTable } from "$lib/server/db/schema";
function hexToUint8Array(hex: string): Uint8Array {
if (hex.length % 2 !== 0) return new Uint8Array(0);
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
}
return bytes;
}
export async function verifySignbeeWebhookEdge(
rawBody: string,
signatureHex: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
const signatureBytes = hexToUint8Array(signatureHex);
if (signatureBytes.length === 0) return false;
return crypto.subtle.verify(
"HMAC",
key,
signatureBytes,
encoder.encode(rawBody)
);
}
export async function recordSignedCertificate(event: {
document_id: string;
sha256_hash: string;
verify_url: string;
signed_at: string;
}) {
await db
.update(contractsTable)
.set({
status: "SIGNED",
sha256Hash: event.sha256_hash,
verifyUrl: event.verify_url,
signedAt: new Date(event.signed_at),
})
.where(eq(contractsTable.signbeeDocumentId, event.document_id));
}Because crypto.subtle.verify() compares the candidate HMAC bytes against the computed digest inside the underlying C++/Rust Web Crypto implementation in constant time, it provides the exact same side-channel protection as node:crypto.timingSafeEqual while running in sub-millisecond cold starts across 300+ global edge locations.
7. Performance & Cost Benchmark: Signbee REST vs. Legacy Node SDKs in SvelteKit
Whether you deploy your SvelteKit 5 application to a persistent Node.js server, Vercel Serverless, or Cloudflare Pages, choosing an API-first e-signature engine directly impacts cold-start latency, bundle footprint, and unit economics. For a broader market view, see our 2026 e-signature API pricing comparison, affordable e-signature API integration guide, and open-source e-signature API comparison.
| Technical Metric | Signbee REST + SvelteKit 5 Fetch | docusign-esign Node SDK | dropbox-sign Node SDK |
|---|---|---|---|
| Server Bundle Dependency Size | 0 KB (Native Web Fetch) | 42.6 MB (node_modules) | 18.4 MB (node_modules) |
| Cloudflare Workers / Edge Support | 100% Native Compatibility | Fails (Requires Node CJS Polyfills) | Partial (Axios Adapter Issues) |
| Auth Handshake Overhead | Single Header (Bearer sb_...) | RSA JWT Grant + Token Exchange | Basic Auth / OAuth 2.0 Flow |
| Dynamic Markdown Tables & Clauses | Built-In CommonMark PDF Engine | Requires External PDF Generator | Requires Pre-Built Template |
| Median Form Action Response Time | 235ms | 1,980ms | 1,340ms |
Furthermore, if you are building a multi-tenant B2B SaaS product in SvelteKit where your customers send agreements to their own end clients, you can pair this exact +page.server.ts pattern with our white-label e-signature API workflows to maintain seamless brand continuity across the entire signing experience.
7. Frequently Asked Questions: SvelteKit 5 E-Signature Integration
How do I keep my Signbee API key secure inside a SvelteKit 5 application?
In SvelteKit 5, store SIGNBEE_API_KEY and SIGNBEE_WEBHOOK_SECRET in your server environment and import them strictly via $env/static/private (or $env/dynamic/private for containerized runtime injection) inside +page.server.ts and +server.ts files. SvelteKit's Vite compiler enforces a build-time boundary that fails compilation immediately if any module imported into a client-side +page.svelte component attempts to read $env/static/private, guaranteeing your e-signature Bearer token is never leaked in browser JavaScript bundles.
Why use native fetch with Signbee instead of heavy legacy e-signature SDKs in SvelteKit?
Legacy Node.js SDKs such as docusign-esign pull in over 40MB of CommonJS transitive dependencies, break on Cloudflare Workers and Vercel Edge runtimes, and require multi-step OAuth 2.0 JWT grant flows. Signbee exposes a clean, zero-SDK REST endpoint (POST https://signb.ee/api/v1/send) that works natively with SvelteKit's built-in Web Fetch API across Node.js, Bun, Cloudflare Pages, and Deno Deploy while adding 0 KB to your server or client bundle.
How do Svelte 5 Runes ($state, $derived, $effect) improve contract drafting and signing status UIs?
Svelte 5 Runes provide fine-grained signal reactivity without virtual DOM diffing overhead. In a contract builder UI, you can bind line items and customer metadata to $state(), automatically recompute the live CommonMark markdown contract and total contract value via $derived.by(), and use $effect() to subscribe to Server-Sent Events or poll signing progress when an envelope status is PENDING—automatically tearing down the timer the instant the status transitions to SIGNED.
How do I verify Signbee webhook signatures in a SvelteKit +server.ts route?
In your src/routes/api/webhooks/signbee/+server.ts POST handler, read the raw unparsed request body using await request.text() rather than await request.json() so whitespace and key ordering remain intact. Compute an HMAC-SHA256 digest of the raw body using SIGNBEE_WEBHOOK_SECRET, convert both the computed hex digest and the incoming X-Signbee-Signature header into fixed-length Buffers, and compare them with Node's crypto.timingSafeEqual() to prevent timing side-channel attacks.
Can I deploy a SvelteKit 5 + Signbee integration to Cloudflare Pages or Vercel Edge?
Yes. Because Signbee's REST API uses standard JSON over HTTPS with Bearer token authentication, your +page.server.ts Form Actions run identically on @sveltejs/adapter-node, @sveltejs/adapter-vercel, and @sveltejs/adapter-cloudflare. When running webhook verification on Cloudflare Workers or Edge runtimes without Node's node:crypto module, you can use the standard Web Crypto API (crypto.subtle.importKey and crypto.subtle.verify) to validate X-Signbee-Signature with zero external packages.
How does Signbee handle contract styling when dispatching from a SvelteKit Form Action?
When your SvelteKit Form Action sends a CommonMark markdown string in the markdown field of POST /api/v1/send, Signbee's server-side typography engine compiles headings, clauses, bullet lists, and Markdown pricing tables into a paginated A4 PDF document. Party A is digitally bound upon dispatch, Party B receives a mobile-friendly signing link via email, and the executed PDF is sealed with an A4 SHA-256 Audit Certificate and public verification URL at https://signb.ee/verify/[id].
Ship E-Signatures in Your SvelteKit 5 App Today
Zero client bundle bloat, native Web Fetch compatibility, and cryptographic SHA-256 audit trails in one POST request.