How to integrate e-signatures into a Node.js application

Matt Kim
Matt Kim
Cover Image for How to integrate e-signatures into a Node.js application

Adding e-signatures to a Node.js application comes down to four moving parts: a reusable document template, a signature request, a way to know when signing finishes, and the signed PDF itself. With the official formable-node SDK, each of those is one or two function calls, and you can have a working integration running in an afternoon.

This guide walks through the whole thing with real, copy-pasteable code: an Express backend that creates signature requests, an iframe embed for in-app signing, a webhook handler with HMAC verification, and the final download of the signed document with its audit trail.

What you'll build:

  • A Formable client initialized once and reused across your app
  • A template created from a PDF or DOCX with signer roles
  • Two signing flows: email delivery or embedded in your own UI
  • A webhook handler that verifies signatures and reacts to document_completed
  • A download path for the signed PDF with its audit trail

Table of Contents

Why use an e-signature API instead of building it yourself?

Building signing in-house means owning PDF manipulation, tamper-evident audit logs, key management, and legal admissibility under ESIGN and UETA. Teams consistently underestimate that scope. An e-signature API gives you a legally compliant flow, a hosted signing UI, and an audit trail with timestamps, actors, and IP addresses appended to every signed PDF, for a predictable per-document cost.

For a deeper look at the integration patterns (hosted redirect, embedded iframe, API-only) and the legal background, see our guide on how to implement e-signing onto your site. This post assumes you've picked the API route and focuses on the Node.js implementation. Working in Python instead? There's a Python version of this guide too.


Step 1: install and initialize the SDK

You'll need a Formable account and an API key from Settings. The SDK has zero runtime dependencies, ships full TypeScript types, and runs on Node.js 18+.

npm install formable-node

Create one client and reuse it everywhere. The API key is a bearer token, so it belongs in an environment variable on your server, never in browser code.

// lib/formable.js
import Formable from "formable-node";

export const formable = new Formable({
  apiKey: process.env.FORMABLE_API_KEY,
});

Step 2: create a template from your document

A template is your document plus the fields signers fill in: signatures, dates, text, checkboxes. Upload the file once, then reuse the template for every signature request.

import { readFile } from "node:fs/promises";
import { formable } from "./lib/formable.js";

const file = await readFile("./agreement.pdf");

const { templateId, editTemplateAccess } = await formable.templates.create({
  file,
  filename: "agreement.pdf",
  signerRoles: [
    { name: "Client", order: 0 },
    { name: "Witness", order: 1 },
  ],
});

console.log(templateId);
console.log(editTemplateAccess.editUrl);

Open the editUrl in a browser to place at least one required signature field and assign it a signer role like Client. Edit URLs expire after a day; mint a fresh one anytime:

const { editUrl } = await formable.templates.createEditUrl(templateId);

In a typical integration you upload the template once (or let your customers upload theirs through your product), store the templateId in your database, and reuse it for every request from then on.


Step 3: choose a signing flow

Formable supports two delivery models, and both start from the same template.

Non-embeddedEmbedded
DeliveryFormable emails each signer a linkYou mint a signing URL and render it in an iframe
UIFormable hosted pageInside your product
SDK callssignatureRequests.createsignatureRequests.createEmbedded + createSigningUrl
Best forEmail-based signing outside your appIn-app signing flows

If email delivery is enough, you're one call away from done. Formable creates the request and emails every signer a signing link:

const request = await formable.signatureRequests.create({
  templateId,
  signers: [
    { email: "jane@example.com", name: "Jane Doe", role: "Client" },
    { email: "bob@example.com", name: "Bob Smith", role: "Witness" },
  ],
  testMode: true,
});

console.log(request.signatureRequestId); // save for tracking and download

If you want signing to happen inside your product, keep reading.


Step 4: embed signing in your Node.js app

The embedded signing flow has two server-side steps: create an embedded signature request, then mint a short-lived signing URL for each signer. Signing URLs expire one hour after creation, so generate them right before the signer needs one.

Here's a minimal Express backend exposing both steps to your frontend:

// server.js
import express from "express";
import { formable } from "./lib/formable.js";

const app = express();
app.use(express.json());

app.post("/api/signature-requests", async (req, res) => {
  const { templateId, signer } = req.body;

  const request = await formable.signatureRequests.createEmbedded({
    templateId,
    signers: [{ email: signer.email, name: signer.name, role: "Client" }],
    testMode: true,
  });

  // Persist signatureRequestId and each signer's recipientSignatureId
  res.json({
    signatureRequestId: request.signatureRequestId,
    recipientSignatureId: request.signers[0].recipientSignatureId,
  });
});

app.get("/api/signing-url", async (req, res) => {
  const { signingUrl, expiresAt } = await formable.signatureRequests.createSigningUrl(
    req.query.recipientSignatureId
  );
  res.json({ signingUrl, expiresAt });
});

app.listen(3000);

On the client, fetch the URL from your own backend and render it in an iframe. Formable posts a message to the parent window when signing finishes, which you can use to update your UI immediately:

// SigningFrame.jsx
function SigningFrame({ recipientSignatureId, onComplete }) {
  const [signingUrl, setSigningUrl] = useState(null);

  useEffect(() => {
    fetch(`/api/signing-url?recipientSignatureId=${recipientSignatureId}`)
      .then((res) => res.json())
      .then((data) => setSigningUrl(data.signingUrl));
  }, [recipientSignatureId]);

  useEffect(() => {
    const onMessage = (event) => {
      if (event.origin !== "https://app.formabledocs.com") return;
      if (event.data?.type === "onSigningComplete") onComplete();
    };
    window.addEventListener("message", onMessage);
    return () => window.removeEventListener("message", onMessage);
  }, [onComplete]);

  if (!signingUrl) return <p>Loading…</p>;

  return (
    <iframe
      src={signingUrl}
      width="100%"
      height="800"
      allow="fullscreen"
      style={{ border: "none" }}
      title="Sign document"
    />
  );
}

Two rules keep this secure. First, the browser only ever sees the short-lived signing URL, never your API key. Second, treat onSigningComplete as a UX signal for closing the iframe, and confirm actual completion with a webhook before downloading anything.

Pro tip: if the template has non-signature fields like company name or effective date, prefill them at request creation with the fields option:

const request = await formable.signatureRequests.createEmbedded({
  templateId,
  signers: [{ email: "jane@example.com", name: "Jane Doe", role: "Client" }],
  fields: [
    { fieldId: "field_company_name", value: "Acme Corporation" },
    { fieldId: "field_effective_date", value: "2026-02-01" },
  ],
  testMode: true,
});

Step 5: handle webhooks reliably

Polling works, but webhooks are the recommended source of truth for signing progress. Register an endpoint in Settings and store the signing secret it shows you once as an environment variable.

The signing events you'll receive:

EventWhen it fires
document_viewedA signer opened the document
document_signedA signer finished their part
document_completedAll signers done and the signed PDF is ready

Every delivery is signed with HMAC-SHA256 over the raw request body, sent in the Content-Sha256 header. Verify it before trusting the event. The important details: use express.raw on the webhook route so you compute the HMAC over the exact bytes received, base64-decode the secret before using it as the key, and compare with a constant-time function.

// webhook.js
import express from "express";
import crypto from "node:crypto";
import { formable } from "./lib/formable.js";

const app = express();

app.use("/webhooks/formable", express.raw({ type: "application/json" }));

const isValidSignature = (body, received) => {
  if (!received) return false;
  const secret = Buffer.from(process.env.FORMABLE_WEBHOOK_SECRET, "base64");
  const expected = crypto.createHmac("sha256", secret).update(body).digest("base64");
  return (
    expected.length === received.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))
  );
};

app.post("/webhooks/formable", async (req, res) => {
  if (!isValidSignature(req.body, req.header("Content-Sha256"))) {
    return res.status(401).send("Invalid signature");
  }

  const payload = JSON.parse(req.body.toString("utf8"));

  if (payload.event.event_type === "document_completed") {
    const signatureRequestId = payload.signing.signature_request_id;
    const { signedEnvelopePresignedUrl } =
      await formable.signatureRequests.getSignedEnvelope(signatureRequestId);
    const pdf = await fetch(signedEnvelopePresignedUrl);
    // store the PDF, mark the record complete, notify your user
  }

  res.sendStatus(200);
});

Don't download on document_signed. It fires when an individual signer finishes, before the completed file is available. Wait for document_completed.

For local development, expose your handler with a tunneling tool like ngrok, or fall back to polling:

const request = await formable.signatureRequests.get(signatureRequestId);

if (request.status === "Completed") {
  // safe to download
}

// Reconcile in bulk after downtime
const recent = await formable.signatureRequests.list({
  updatedSince: new Date(Date.now() - 24 * 60 * 60 * 1000),
});

Step 6: download the signed PDF

Once the request is complete, fetch a presigned URL for the signed PDF, called the signed envelope. The link is short-lived, so download promptly and store the file in your own object storage.

const { signedEnvelopePresignedUrl } =
  await formable.signatureRequests.getSignedEnvelope(signatureRequestId);

The signed PDF includes an audit trail with created, sent, signed, and completed events, each with timestamps, actors, and IP addresses. That audit trail is what makes the signature defensible under ESIGN and UETA, so store the PDF unmodified.


Error handling and test mode

Every non-2xx response throws a FormableError carrying the HTTP status, server message, and parsed body. The one you'll hit most during integration is 409 from getSignedEnvelope, which means the document isn't signed yet:

import { FormableError } from "formable-node";

try {
  await formable.signatureRequests.getSignedEnvelope(signatureRequestId);
} catch (error) {
  if (error instanceof FormableError && error.status === 409) {
    // not signed yet — wait for the document_completed webhook
    return;
  }
  throw error;
}

Other statuses to handle: 400 for invalid input like a fieldId that doesn't exist on the template, 401 for a missing or invalid API key, and 404 for an unknown templateId or signatureRequestId.

While integrating, pass testMode: true on every signature request. Test documents are watermarked, not legally binding, and don't count toward billing. Drop the flag when you go live.


Key takeaways

PointDetails
Four SDK calls end to endtemplates.create, signatureRequests.createEmbedded, createSigningUrl, getSignedEnvelope take you from a raw PDF to a signed document.
Keep keys server-sideThe browser only ever sees a short-lived signing URL; the API key stays in your backend environment.
Webhooks are the source of truthVerify the Content-Sha256 HMAC over the raw body, act on document_completed, and treat iframe postMessages as UX signals only.
Download promptlyThe signed envelope URL is a short-lived presigned link; store the PDF with its audit trail in your own storage.
Test mode firsttestMode: true gives you watermarked, non-billable documents for the whole integration phase.

FAQ

How do I add e-signatures to a Node.js application?

Install the formable-node SDK, create a template from your PDF or DOCX, then create a signature request. Formable can email signers directly, or you can generate a short-lived signing URL and embed it in an iframe in your own UI. A webhook tells you when signing completes so you can download the signed PDF.

Do I need a frontend framework to embed signing?

No. The signing URL works in any iframe, whether you render it with React, plain HTML, or a server-rendered template. Your backend mints the URL with one SDK call and hands it to the page.

How do I know when a document has been signed?

Register a webhook endpoint and handle the document_completed event, which fires once the signed PDF is ready. You can also poll the signature request until its status is Completed.

Can I test the integration without sending legally binding documents?

Yes. Set testMode to true when creating signature requests. Test documents are watermarked, not legally binding, and don't count toward billing.

Does the signed PDF include an audit trail?

Yes. Every signed document includes an appended audit trail with created, sent, signed, and completed events, including timestamps, actors, and IP addresses, which supports enforceability under ESIGN and UETA.

Formable
© 2026 Formable Inc. All rights reserved