Templates
Create reusable PDF or HTML templates, define signer roles, add fillable fields, and render previews before documents are sent.
Use this guide to deploy the platform, create accounts, operate the dashboard, and integrate with the API, SDK, webhooks, and signer flow.
cp .env.example .env pnpm install pnpm db:generate pnpm db:migrate:deploy pnpm db:seed pnpm build pnpm dev
Postgres stores tenants, users, templates, documents, submissions, signed document records, audit events, metadata, webhook deliveries, and idempotency keys. Redis powers queues. MinIO or S3 stores PDFs and signatures. SMTP sends signer email.
Run API, worker, web, and signer as separate processes or containers. Use real secrets, TLS, persistent object storage, managed Postgres backups, Redis persistence where needed, and monitored SMTP delivery.
Create reusable PDF or HTML templates, define signer roles, add fillable fields, and render previews before documents are sent.
Drag and drop signature, text, date, checkbox, initial, and attachment fields directly onto your PDF template with visual placement, resizing, and per-role colour coding.
Create documents from templates, assign submitters, send signing links, track statuses, void submissions, and resend reminders.
Choose parallel, sequential, or custom group signing. In custom mode, assign signers to numbered groups - groups execute sequentially while signers within a group sign in parallel. Conditional fields show or hide based on other field values.
Signers open a secure signing URL, review the document, complete required fields, draw a signature, sign, or decline with a reason.
Upload a signed PDF or provide a SHA-256 hash to verify whether a document is valid, superseded, invalidated, tampered, or unknown.
Invite team members by email with role-based access (Owner, Admin, Member). Manage roles, revoke invitations, and remove members from the dashboard.
View, filter, and download the complete audit trail for your tenant. Export as CSV or JSON, filter by submission, date range, or event type.
Create one-time-visible API keys for server integrations, list existing keys, and revoke keys that should no longer have access.
Subscribe to document, submission, signing, metadata, and invalidation events. Failed deliveries can be retried by the worker.
Send a template to hundreds of recipients in one operation. Upload a CSV or add recipients manually. Track batch progress, success/failure counts, and per-recipient errors from the dashboard.
Set your organisation's brand name, accent colour, and logo. Customise email headers and footers with HTML. All signing request, reminder, and invitation emails use your branding automatically.
Monitor production health with real-time stats for job queues (render, email, webhook, PDF), storage counts, mail delivery, webhook success rates, recent failures, and activity breakdowns.
Owners and Admins can invite new team members from Dashboard → Team. Each invitation is sent via email and expires after 7 days.
Full access. Can invite any role, promote or demote anyone, and remove members including other owners.
Can invite Members and Admins, manage roles for non-owners, access observability, and manage all resources.
Can create and manage templates, documents, and submissions. Cannot invite or manage team members.
GET /v1/team/members List team members GET /v1/team/invitations List pending invitations POST /v1/team/invitations Invite a member (email, name, role) POST /v1/team/invitations/:token/accept Accept invitation (name, password) DELETE /v1/team/invitations/:id Revoke a pending invitation PATCH /v1/team/members/:id/role Change a member's role DELETE /v1/team/members/:id Remove a member
Navigate to Templates → Edit Fields to open the drag-and-drop field editor. Click a field type in the toolbar, then click on the document canvas to place it.
Fields can be configured to appear only when another field has a specific value. Set the “Conditional On” property to reference another field and specify the trigger value.
Fields use normalised coordinates (0–1) for x, y, width, and height so placement scales to any PDF render size. Each field is colour-coded by its assigned signer role.
Use this setup when creating each eSign template for the DCarbon embedded agreement flow.
Add exactly one signer role:
signer
Use lowercase signer, because the DCarbon webapp creates embed sessions with this role.
signer: {
role: "signer"
}After creating the template, open the template editor and add at least:
SIGNATURE Role: signer Required: true
The signature field is the critical one. Without it, the signer can complete the flow but the final document may not visibly show a signature where you expect.
TEXT Label: Full Name Role: signer Required: true
DATE Label: Signed Date Role: signer Required: true
TEXT Label: Address Role: signer Required: false or true
The DCarbon webapp currently sends these variables into eSign:
signerName signerEmail signerAddress agreementType signedDate
For an HTML template, you can render those values directly:
<p>Name: {{signerName}}</p>
<p>Email: {{signerEmail}}</p>
<p>Address: {{signerAddress}}</p>
<p>Agreement Type: {{agreementType}}</p>
<p>Date: {{signedDate}}</p>Use this variable schema in the template form:
{
"signerName": { "type": "string" },
"signerEmail": { "type": "string" },
"signerAddress": { "type": "string" },
"agreementType": { "type": "string" },
"signedDate": { "type": "string" }
}signer.{{signerName}}, {{signerEmail}}, {{signerAddress}}, and {{signedDate}} when using an HTML template.SIGNATURE field near the signature line.DATE and TEXT fields if the signer should fill or confirm those values on the signing screen.ESIGN_TEMPLATE_ID_COMMERCIAL_OWNER=<Commercial Owner template id> ESIGN_TEMPLATE_ID_COMMERCIAL_OPERATOR=<Commercial Operator template id> ESIGN_TEMPLATE_ID_RESIDENTIAL=<Residential template id> ESIGN_TEMPLATE_ID_PARTNER=<Partner template id>
If you create a PDF template, {{variables}} will not automatically render into the PDF body. For variable substitution inside the document body, use an HTML template. For PDF templates, rely on placed fields like SIGNATURE, TEXT, and DATE.
JSON variables are not signing fields. Variables are data injected into the document or session; fields are the actual places on the PDF where the signer signs, dates, checks, or types.
Because the DCarbon app already sends signer name, email, address, agreement type, and signed date, the signer does not need to type those again unless the PDF needs visible fillable text fields for them. The required field is usually SIGNATURE.
When creating a document, choose one of three signing order modes:
All signers receive their invitation immediately and can sign in any order.
Each signer is assigned to their own group. The next signer can only sign after the previous one completes.
Assign signers to numbered groups. Groups execute in order (0 first, then 1, etc.). Within a group, signers can sign in parallel.
The signing order is enforced at sign time. If a signer in group 2 attempts to sign before all group 1 signers have completed, the API returns a 409 Conflict error.
Navigate to Dashboard → Audit Trail to view, filter, and export the complete activity log for your tenant.
GET /v1/audit-trail/download?format=csv&from=2026-01-01&to=2026-06-01 GET /v1/audit-trail/download?format=json&submissionId=UUID
Admins and Owners can access Dashboard → Observability for real-time production health monitoring.
Monitor render, email, webhook, and PDF finalise queues with counts for waiting, active, delayed, completed, and failed jobs.
View counts for templates, documents, and signed documents in your tenant.
Track pending signers, viewed-in-24h counts, and current email queue depth.
24h and 7d success rates, delivery counts, and recent failure details with HTTP status codes and retry counts.
GET /v1/observability/queues Queue stats (render, email, webhook, pdf) GET /v1/observability/storage Storage counts GET /v1/observability/webhooks Webhook delivery stats and failures GET /v1/observability/mail Mail queue and signer status GET /v1/observability/activity Event breakdown by type (7d)
Create an API key from Dashboard - API Keys. Send requests with Authorization: Bearer YOUR_API_KEY. For retriable writes, include an Idempotency-Key header.
import { ESignClient } from "@esign/sdk-node";
const client = new ESignClient({
apiKey: process.env.ESIGN_API_KEY!,
baseUrl: "http://localhost:3000",
});const template = await client.templates.create({
name: "Mutual NDA",
sourceType: "HTML",
roles: [{ name: "Signer", displayOrder: 1 }],
htmlSource: "<h1>{{companyName}} NDA</h1>",
fields: [
{
role: "Signer",
type: "SIGNATURE",
page: 1,
x: 0.12,
y: 0.72,
width: 0.32,
height: 0.08
}
]
});const document = await client.documents.create({
templateId: template.id,
name: "NDA for Example Co",
metadata: { department: "Legal" },
initialVersion: {
variables: { companyName: "Example Co" },
submitters: [
{ name: "Jane Doe", email: "jane@example.com", role: "Signer", signingGroup: 0 },
{ name: "John Smith", email: "john@example.com", role: "Approver", signingGroup: 1 }
],
signingOrder: "parallel",
signingOrderConfig: { mode: "CUSTOM" }
}
});await fetch("http://localhost:3000/v1/team/invitations", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_KEY"
},
body: JSON.stringify({
email: "newmember@example.com",
name: "New Member",
role: "ADMIN"
})
});const response = await fetch("http://localhost:3000/v1/signed-documents/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sha256: "SIGNED_DOCUMENT_SHA256" })
});
const result = await response.json();An interactive OpenAPI (Swagger) reference is published at {API_URL}/documentation — for example https://esign-api.dcarbon.solutions/documentation. It lists the integration endpoints (embedded signing sessions, templates, documents, submissions, signed-document download, and webhooks) with request and response schemas and live examples.
Typical embedded-signing flow: call POST /v1/embed/sessions to create a session, redirect the signer to the returned embedUrl, then poll GET /v1/embed/submissions/{id}/resultuntil signedDocument is returned with a downloadUrl.
Two complementary ways stop a retried or re-initiated signing from creating duplicate documents on POST /v1/embed/sessions (and POST /v1/documents). With neither, every call creates a new document.
Send the same Idempotency-Key header when safely retrying an identical create request. The original response is replayed instead of creating a new document, until the key expires — controlled by IDEMPOTENCY_TTL_HOURS on the API service (default 24h). The caller must resend the same key.
fetch("https://api.yourdomain.com/v1/embed/sessions", {
method: "POST",
headers: {
"Authorization": "Bearer esk_live_...",
"Content-Type": "application/json",
"Idempotency-Key": "a-stable-uuid-you-generate"
},
body: JSON.stringify({ templateId, signer })
});Pass a stable reference (e.g. a facilityId or orderId) on POST /v1/embed/sessions. Repeat calls with the same templateId + signer email + reference return the existing open submission (with a fresh embed link) instead of a duplicate. There is no TTL — validity is gated by signing status: once that submission is completed, voided, or declined, the next call starts a fresh document. The key is derived server-side, so the client only needs to send a consistent reference.
body: JSON.stringify({
templateId,
signer: { name, email },
reference: "facility_abc123" // same value on every retry for this intent
})Embed the signing experience directly into your application. Users sign documents inline without leaving your page, and you get the signed document back via API.
Call the API to create an embed session. You get back a token and embed URL.
Load the widget using the JS SDK or an iframe. The user signs right on your page.
Receive a callback when signing completes. Fetch the signed PDF via API.
// Server-side: create document + get embed token in one call
const session = await fetch("http://localhost:3000/v1/embed/sessions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_KEY"
},
body: JSON.stringify({
templateId: "TEMPLATE_UUID",
name: "Contract for Jane",
variables: { companyName: "Acme Inc" },
signer: { name: "Jane Doe", email: "jane@example.com", role: "signer" },
metadata: { orderId: "ORD-123" }
})
}).then(r => r.json());
// session = { token, embedUrl, documentId, submissionId, submitterId }// If you already created a document and have a submitter ID:
const result = await fetch("http://localhost:3000/v1/embed/tokens", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_KEY"
},
body: JSON.stringify({ submitterId: "SUBMITTER_UUID" })
}).then(r => r.json());
// result = { token, embedUrl, submitterId }<div id="signing-widget" style="height: 700px;"></div>
<script src="https://cdn.yourdomain.com/esign-embed.js"></script>
<script>
const widget = ESign.createWidget({
container: "#signing-widget",
token: "TOKEN_FROM_SERVER",
signerUrl: "http://localhost:3002",
onReady: (data) => {
console.log("Ready to sign:", data.documentName);
},
onSigned: async (data) => {
console.log("Signed!", data.submissionId);
// Fetch the signed PDF from your server
// Your server calls: GET /v1/embed/submissions/:id/result
widget.destroy();
loadNextDocument(); // Move to next document in your flow
},
onDeclined: (data) => {
console.log("Declined:", data.reason);
widget.destroy();
},
onError: (data) => {
console.error("Error:", data.message);
}
});
</script><iframe
id="esign-frame"
src="http://localhost:3002/embed/sign/TOKEN"
style="width: 100%; height: 700px; border: none;"
></iframe>
<script>
window.addEventListener("message", (event) => {
if (event.data?.source !== "esign-embed") return;
if (event.data.event === "esign:signed") {
// Signing complete — fetch signed doc via your backend
fetchSignedDocument(event.data.submissionId);
}
});
</script>// Server-side: poll until the signed PDF is ready
const result = await fetch(
"http://localhost:3000/v1/embed/submissions/SUBMISSION_ID/result",
{ headers: { "Authorization": "Bearer YOUR_API_KEY" } }
).then(r => r.json());
if (result.signedDocument) {
// Download the signed PDF using the presigned URL
const pdf = await fetch(result.signedDocument.downloadUrl);
// Save to your own storage
}| Event | Data | When |
|---|---|---|
| esign:ready | submitterId, documentName | Widget loaded and ready |
| esign:signed | submitterId, submissionId, documentId, documentName | User completed signing |
| esign:declined | submitterId, submissionId, reason | User declined to sign |
| esign:error | message | An error occurred |
POST /v1/embed/sessions Create document + embed token in one call POST /v1/embed/tokens Get embed token for existing submitter GET /v1/embed/submissions/:id/result Get submission result + signed PDF URL
Webhooks can be configured for document creation, version supersession, submission lifecycle events, submitter signing, signed document invalidation, and metadata changes.
{
"id": "evt_DELIVERY_ID",
"type": "submission.completed",
"createdAt": "2026-06-03T00:00:00.000Z",
"data": {
"tenantId": "TENANT_ID",
"submissionId": "SUBMISSION_ID"
}
}Verify webhook signatures with the SDK helper before trusting payloads. Failed deliveries are queued and retried by the worker.
This guide walks through deploying the full ESign platform on a Netcup root server using Easypanel as the control panel. Easypanel handles Docker builds, SSL certificates, reverse proxying, and service networking automatically.
Netcup RS 1000 or higher — at least 2 GB RAM (4 GB recommended), 40 GB disk, Ubuntu 22.04+
SSH into your Netcup server and run the one-line installer:
curl -sSL https://get.easypanel.io | sh
Once installed, open http://YOUR_SERVER_IP:3000 in your browser to create your admin account.
Create three A records pointing to your Netcup server IP:
| Record | Type | Value |
|---|---|---|
| api.yourdomain.com | A | YOUR_SERVER_IP |
| app.yourdomain.com | A | YOUR_SERVER_IP |
| sign.yourdomain.com | A | YOUR_SERVER_IP |
esignpostgres16esignredis7minio/minio:latestserver /data --console-address :9001MINIO_ROOT_USER and MINIO_ROOT_PASSWORD/dataesign-documentsS3_PUBLIC_ENDPOINT on the API service.Run these locally and save the output — you will need them for every app service:
openssl rand -hex 32 # → JWT_SECRET openssl rand -hex 32 # → API_KEY_PEPPER openssl rand -base64 24 # → POSTGRES_PASSWORD (if not already set) openssl rand -base64 24 # → REDIS_PASSWORD (if not already set)
Each app service uses GitHub as source, with a Dockerfile from the docker/ folder. Services communicate internally via Easypanel’s Docker network using hostnames like esign-postgres, esign-redis, esign-minio.
| Service | Dockerfile | Domain | Port |
|---|---|---|---|
| api | docker/Dockerfile.api | api.yourdomain.com | 3000 |
| worker | docker/Dockerfile.worker | none (background jobs) | — |
| web | docker/Dockerfile.web | app.yourdomain.com | 3001 |
| signer | docker/Dockerfile.signer | sign.yourdomain.com | 3002 |
Enable HTTPS on every domain — Easypanel provisions SSL certificates via Let’s Encrypt automatically.
Add these to both the api and worker services:
NODE_ENV=production DATABASE_URL=postgresql://postgres:YOUR_PG_PASSWORD@esign-postgres:5432/esign REDIS_URL=redis://:YOUR_REDIS_PASSWORD@esign-redis:6379 S3_ENDPOINT=http://esign-minio:9000 S3_PUBLIC_ENDPOINT=https://s3.yourdomain.com S3_ACCESS_KEY=your_minio_user S3_SECRET_KEY=your_minio_password S3_BUCKET=esign-documents JWT_SECRET=your_jwt_secret JWT_ISSUER=esign-platform API_KEY_PEPPER=your_api_key_pepper API_URL=https://api.yourdomain.com WEB_URL=https://app.yourdomain.com SIGNER_URL=https://sign.yourdomain.com SMTP_HOST=smtp.resend.com SMTP_PORT=587 SMTP_SECURE=true SMTP_USER=resend SMTP_PASS=re_your_api_key SMTP_FROM=noreply@yourdomain.com LOG_LEVEL=warn
Add NEXT_PUBLIC_API_URL=https://api.yourdomain.com as both an environment variable and a build argument (Next.js bakes public env vars into the client bundle at build time).
NODE_ENV=production NEXT_PUBLIC_API_URL=https://api.yourdomain.com
After the API service deploys, open its Terminal in Easypanel and run:
npx prisma migrate deploy --schema=packages/db/prisma/schema.prisma
Or sign up at https://app.yourdomain.com/signup to create the first admin account.
The platform sends emails when documents need signing. Use a real SMTP provider:
| Provider | Free tier | SMTP host |
|---|---|---|
| Resend | 3,000 emails/mo | smtp.resend.com |
| Postmark | 100 emails/mo | smtp.postmarkapp.com |
| Brevo | 300 emails/day | smtp-relay.brevo.com |
Easypanel Project: esign ├── postgres (Postgres 16, internal :5432) ├── redis (Redis 7, internal :6379) ├── minio (MinIO, internal :9000) ├── api (Fastify → api.yourdomain.com) ├── worker (BullMQ background jobs, no public domain) ├── web (Next.js → app.yourdomain.com) └── signer (Next.js → sign.yourdomain.com) All services communicate via Easypanel's internal Docker network. Only api, web, and signer are publicly exposed with automatic HTTPS via Let's Encrypt.
| Issue | Fix |
|---|---|
| API returns 500 | Check logs — usually a missing env var. Verify DATABASE_URL and REDIS_URL |
| Web shows “fetch failed” | NEXT_PUBLIC_API_URL must match the API’s public domain exactly (include https://) |
| Emails not sending | Check worker logs. Verify SMTP credentials. Test with Resend’s free tier first |
| MinIO bucket error | Open MinIO console (port 9001) and create the esign-documents bucket manually |
| Build fails | Dockerfile path must be docker/Dockerfile.api (not ./Dockerfile) |
| CORS errors | API allows WEB_URL and SIGNER_URL origins — they must match your domains exactly |
master branch → click Rebuild (or enable auto-deploy)Admin email: admin@dcarbon.local
Admin password: admin123
Tenant slug: dcarbon
Local API: http://localhost:3000
Local web: http://localhost:3001
Local signer: http://localhost:3002