E-signature · by Docusign, Inc.
Build your own DocuSign.
Upload a PDF, drop a signature box, email it, get it back signed with an audit trail. DocuSign meters that by the envelope and charges per user on top.
What you're paying now
$2,700 per year
Business Pro for 5 users: $45 × 5 × 12 on annual billing, capped at 100 envelopes per user per year.
People who built it
–
Counting…
Build it in any tool
Pick where you work. The agent fetches everything it needs.
Claude Code. Run this in a terminal from an empty folder. Claude Code fetches the spec and starts building.
claude "Build my own DocuSign. Fetch https://buildyourown.software/like/docusign/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
01
What it does
DocuSign lets you send a document to one or more people, have them sign it in a browser, and get a completed PDF back with a certificate that records who signed, when, and from where. That record is what makes the signature hold up.
The rest of the product is volume and enterprise: templates, bulk send, web forms, payments during signing, identity verification, CLM (contract lifecycle management), and integrations into Salesforce and the like.
Electronic signatures are legally valid in the US under the ESIGN Act and in the EU under eIDAS when you can show intent, consent, and a tamper-evident record. None of that requires DocuSign. It requires a PDF library, an email, and a log.
02
What it costs
List prices from the vendor's own pricing page. The fine print is where the money goes.
Personal
$11
per month billed annually ($132/yr), single user
- –5 envelopes per month
- –Reusable templates
- –Basic fields
Five sends a month. Send a sixth and you're upgrading.
Standard
$30
per user / month billed annually ($360/user/yr)
- –100 envelopes per user per year
- –Shared templates
- –Custom branding
- –Comments
Business Pro
$45
per user / month billed annually ($540/user/yr)
- –100 envelopes per user per year
- –Web forms
- –Payments during signing
- –Bulk send
- –Formulas and conditional fields
Enhanced plans
Custom
50+ users, contact sales
- –Custom envelope allowances
- –SSO
- –Org admin
- –24/7 support
The fine print
- Envelopes are the real meter. 100 per user per year is about two a week; go over and you buy more or move up a tier.
- Every person who needs to send needs a seat. Signers are free, senders are not.
- Annual billing is the advertised price. Monthly billing costs more per month.
- Identity verification, SMS delivery, and notarization are add-ons charged per use.
- Your signed documents live in DocuSign. Getting them all out at once requires the API or a support ticket.
Prices checked September 10, 2026 at ecom.docusign.com. They change. Check before you quote them.
03
What you actually use
The headline features, and an honest call on whether a typical team needs each one.
8 of 12 headline features are worth building for a typical user. The rest is what you're paying for.
- Build it
Upload a document and place fields
Drag signature, initials, date, text, and checkbox fields onto PDF pages for each signer.
Why: This is the sender's whole experience. A PDF viewer with absolutely positioned boxes.
- Build it
Send to signers by email
Each signer gets a unique link. Optional signing order.
Why: A tokenized link per signer and one email. Sequential order is a status field.
- Build it
Sign in the browser
Draw, type, or upload a signature; fill fields; agree to e-sign consent.
Why: Canvas for drawing, a cursive font for typing. The consent checkbox is what makes it legal.
- Build it
Completed PDF
Signatures and field values burned into the PDF, sent to everyone.
Why: pdf-lib draws images and text at coordinates. This is the deliverable.
- Build it
Certificate of completion and audit trail
Who signed, when, IP address, email, document hash, every event.
Why: This is what makes the signature defensible. Append it as the last page and store the log.
- Build it
Reminders and expiration
Nudge signers who haven't signed; void after a deadline.
Why: A daily job in the same process and two columns.
- Build it
Templates
Save a document with its fields to reuse with new signers.
Why: If you send the same NDA weekly, this is most of the value. It's the same table with a flag.
- Build it
Status dashboard
See what's out, who's signed, what's overdue.
Why: One list view.
- Maybe
Bulk send
Send one template to hundreds of recipients from a CSV.
Why: Useful for HR or annual renewals. It's a loop over the send function; add it when you need it.
- Skip
Web forms and payments during signing
Public forms that generate documents; collect a card while signing.
Why: Two different products bolted on. Use Stripe Checkout separately if you need to charge.
- Skip
Identity verification and notarization
ID scans, knowledge-based auth, remote online notary.
Why: Regulated and expensive. If your documents need this, keep paying for it.
- Skip
CLM, Salesforce, and enterprise admin
Contract lifecycle management, CRM-triggered sends, SSO.
Why: Not a small-team problem. A webhook on completion covers the integration case.
04
How it works under the hood
The data model, the flows that matter, and the integrations you need. This is what the build prompt is based on.
Data model
Document (envelope)
id · owner_id · title · original_path · signed_path · status (draft | sent | completed | voided | expired) · signing_order (parallel | sequential) · message · expires_at · completed_at · sha256_original · sha256_signed
Store PDFs on local disk under ./data/files, never in the database. The app mints its own 10-minute signed download URLs.
Signer
id · document_id · name · email · order · status (pending | viewed | signed | declined) · token · signed_at · ip · user_agent · consent_at · signature_image_path
token is a long random secret; the signing link is /sign/<token>.
Field
id · document_id · signer_id · type (signature | initials | date | text | checkbox) · page · x · y · width · height · required · value · label
Coordinates in PDF points from the bottom-left, matching pdf-lib.
Audit event
id · document_id · signer_id (nullable) · type (created | sent | viewed | consented | field_filled | signed | declined | reminded | completed | voided | downloaded) · at · ip · user_agent · meta (json)
Append-only. SQLite BEFORE UPDATE and BEFORE DELETE triggers that RAISE(ABORT).
Template
id · owner_id · title · pdf_path · roles (json: [{ name, order }]) · fields (json, keyed by role)
Key flows
Prepare and send
- 1.Owner uploads a PDF. Server writes it to ./data/files, computes SHA-256, renders page thumbnails with pdf.js.
- 2.Owner adds signers (name, email, order) and drags fields onto pages for each signer.
- 3.On send: status → sent, a token per signer, audit 'sent'.
- 4.Email each signer in the first order group with their link. Later groups wait.
Sign
- 1.Signer opens /sign/<token>. Audit 'viewed' with IP and user agent.
- 2.Show the PDF with only their fields highlighted. Require the e-sign consent checkbox before any field is active.
- 3.Signer draws, types, or uploads a signature once; it's applied to every signature field. Fill remaining fields.
- 4.On submit: validate required fields, save values and the signature PNG to disk, audit 'signed', status → signed. Push the change to the owner's dashboard over SSE.
- 5.If sequential and more signers remain, email the next group. If everyone has signed, run completion.
Complete
- 1.Load the original PDF with pdf-lib. Draw each signature image and field value at its coordinates.
- 2.Append a certificate page: document title, original hash, each signer's name, email, IP, timestamps, and the full audit log.
- 3.Save to ./data/files, compute SHA-256 of the signed PDF, store both hashes, status → completed.
- 4.Email the signed PDF to the owner and every signer (to ./data/outbox when no SMTP_URL is set). Fire the completion webhook if configured.
Remind, expire, void
- 1.Daily croner job inside the app process, guarded by a job_locks row: for sent documents, email pending signers every N days (owner setting), audit 'reminded'.
- 2.If expires_at has passed, status → expired, notify the owner.
- 3.Owner can void a sent document at any time; signing links stop working, audit 'voided'.
Integrations
- pdf-librequiredDraw signatures and text into the PDF, append the certificate page.
- pdf.js (pdfjs-dist)requiredRender pages in the browser for field placement and signing.
- SQLite on local diskrequiredDocuments, signers, fields, audit log, and job locks via better-sqlite3 and Drizzle.
- Local disk for filesrequiredOriginal and signed PDFs, signature images under ./data/files, served through the app's own signed URLs.
- SMTP relay (optional; outbox in dev)optionalSigning invitations, reminders, completed copies through nodemailer. Without SMTP_URL, mail goes to ./data/outbox and the owner copies the signing link by hand.
- Self-issued sessionsrequiredOwner sign-in with signed cookies; first-run admin, invite links for teammates. Signers never need an account.
- croner (in-process)requiredReminders and expiration.
- Outbound webhookoptionalNotify your CRM or Slack on completion.
- StripeoptionalIf you must collect payment alongside a signature, do it as a separate Checkout step.
05
The build prompt
The full spec the one-line command points at. Web apps here all follow the same one-box setup: one process, SQLite, local disk, no hosted services. Paste it directly if your tool can't fetch URLs.
# Build an e-signature app (replacing DocuSign eSignature)
You are building a self-hosted e-signature app for a small team. It replaces DocuSign for sending PDFs to be signed, collecting signatures in the browser, and producing a completed PDF with a certificate of completion and a tamper-evident audit trail. Build it end to end. The audit trail and the final PDF must be correct; everything else can be plain.
## Stack
- One Node 22 process running Next.js (App Router) with TypeScript and Tailwind. No serverless functions.
- SQLite through `better-sqlite3` and Drizzle, WAL mode, file at `./data/app.db`. Migrations are checked in and run on startup. Keep the schema portable so it can move to Postgres later, but don't build for that now.
- Files on local disk under `./data/files/<table>/<id>/`: original PDFs, signed PDFs, signature PNGs. The app serves them through an authenticated route with signed URLs it mints itself. No bucket.
- `pdf-lib` for writing into PDFs; `pdfjs-dist` for rendering pages in the browser.
- `croner` inside the app process for reminders and expiration. A `job_locks` table stops two instances from running the same job.
- Sessions the app issues itself as signed httpOnly cookies. On first run the app creates the admin owner from `ADMIN_EMAIL` and `ADMIN_PASSWORD`, or prompts for them in the browser if unset. Teammates join through invite links. Signers never create accounts.
- Server-sent events from an in-memory event bus for live status on the dashboard and the signer's done page.
- Email through `nodemailer` with `SMTP_URL`. Without it, every message is written to `./data/outbox/*.eml` and printed to the log. An SMTP relay is the one outside service this tool keeps, because mail sent straight from a VPS lands in spam. On a laptop, copy the signing link from the document page and the whole flow works with no mail at all.
- UTC in the database, local time in the UI, `date-fns-tz` for every conversion.
Full profile: https://buildyourown.software/deploy.md
## Running it
- Laptop: `pnpm install && pnpm dev`. No env vars needed; every setting has a default that works locally.
- VPS: 1 CPU, 1 GB RAM, one A record, then `docker compose up -d`. The compose file has `app`, `caddy` (gets its own TLS certificate), and `backup`. `./data` is a named volume.
- Backups: `pnpm backup` runs `sqlite3 .backup` into `./data/backups/<date>.db`, copies `./data/files` alongside it, and prunes to the last 14. The `backup` service runs it nightly. Restoring is copying the files back.
- Updates: `git pull && docker compose up -d --build`. Migrations run on boot.
## Data model
SQLite types only: `text`, `integer`, `real`. All tables: `id` text (uuid), `created_at`, `updated_at`. Every timestamp is an ISO 8601 UTC string like `2026-09-10T14:03:00.000Z`, so string comparison sorts correctly and `expires_at < now` works as a plain `<`. JSON columns are `text` holding JSON; parse in the app, never in SQL. Booleans are `integer` 0/1. Nothing here assumes `timestamptz` or `jsonb`.
- `owners`: `email` (unique), `name`, `company_name`, `password_hash`, `role` (`admin` | `member`), `reminder_every_days` (integer, default 3), `webhook_url` (nullable), `webhook_secret`.
- `sessions`: `owner_id`, `expires_at`. `invites`: `email`, `token` (unique), `invited_by`, `expires_at`, `accepted_at` (nullable).
- `documents`: `owner_id`, `title`, `message` (text to signers), `original_path` (relative to `./data/files`), `signed_path` (nullable), `original_sha256`, `signed_sha256` (nullable), `page_count` (integer), `page_sizes` (json text: `[{ width, height }]` in PDF points), `status` (`draft` | `sent` | `completed` | `voided` | `expired`), `signing_order` (`parallel` | `sequential`), `expires_at` (nullable), `sent_at`, `completed_at`, `template_id` (nullable).
- `signers`: `document_id`, `name`, `email`, `order` (integer, 1-based), `status` (`pending` | `viewed` | `signed` | `declined`), `token` (32 random bytes base64url, unique), `signature_image_path` (nullable), `signature_kind` (`drawn` | `typed` | `uploaded`), `consent_at`, `signed_at`, `declined_reason`, `last_ip`, `last_user_agent`.
- `fields`: `document_id`, `signer_id`, `type` (`signature` | `initials` | `date_signed` | `text` | `checkbox`), `page` (integer, 1-based), `x`, `y`, `width`, `height` (real, PDF points, origin bottom-left, matching pdf-lib), `required` (integer 0/1), `label`, `value` (nullable text), `font_size` (real, default 11).
- `audit_events`: `document_id`, `signer_id` (nullable), `type` (`created` | `uploaded` | `sent` | `viewed` | `consented` | `field_filled` | `signed` | `declined` | `reminded` | `completed` | `voided` | `expired` | `downloaded`), `at`, `ip`, `user_agent`, `meta` (json text). Append-only: no update or delete endpoints, plus two SQLite triggers in the migration: `CREATE TRIGGER audit_events_no_update BEFORE UPDATE ON audit_events BEGIN SELECT RAISE(ABORT, 'audit_events is append-only'); END;` and the same for `BEFORE DELETE`.
- `templates`: `owner_id`, `title`, `pdf_path`, `page_count`, `page_sizes`, `roles` (json text `[{ name, order }]`), `fields` (json text, same shape as `fields` but with `role` instead of `signer_id`).
- `job_locks`: `name` (primary key), `locked_by`, `locked_until`, `last_run_at`.
## Screens
### Owner (authenticated)
1. **First run and sign-in**: if `owners` is empty, `/` shows a one-time form that creates the admin (or uses `ADMIN_EMAIL`/`ADMIN_PASSWORD` when set and skips the form). `/login` is email and password. Settings has "Invite a teammate", which emails a `/join/[token]` link good for 7 days.
2. **Dashboard (`/app`)**: tabs for Action needed (sent, with any signer pending), Completed, Drafts, Voided/Expired. Each row: title, signers with status dots, sent date, expires date. Actions: remind, void, download signed PDF, view audit log. Subscribes to `/api/events` (SSE) so status dots update when a signer views or signs without a refresh.
3. **New document (`/app/new`)**: upload a PDF (max 25 MB). Server writes it to `./data/files/documents/<id>/original.pdf`, computes SHA-256, reads page count and sizes with pdf-lib, and creates a draft.
4. **Prepare (`/app/documents/[id]/prepare`)**: three-pane editor. Left: signers list (add name, email, drag to reorder, choose parallel or sequential). Center: pages rendered with pdf.js, scrollable. Right: field palette. Click a field type then click on a page to place it, assigned to the selected signer. Fields are draggable and resizable. Store coordinates converted to PDF points with the bottom-left origin. Show a "Send" button that validates every signer has at least one field and every signature field is assigned.
5. **Send modal**: subject, message, expiration (none, 7, 14, 30 days). On send: status → sent, generate tokens, write `sent` audit, email the first order group.
6. **Document detail (`/app/documents/[id]`)**: status, signers, full audit log table, download original and signed PDF, remind, void. Each pending signer's row has a "Copy signing link" button so the owner can hand the link over directly when no SMTP relay is set up.
7. **Templates (`/app/templates`)**: create from a PDF with the same prepare editor but with roles instead of signers; "Use template" asks for a name and email per role and creates a prepared draft.
8. **Settings**: name, company, reminder cadence, webhook URL, invite a teammate.
### Signer (public, no auth)
9. **Signing page (`/sign/[token]`)**:
- Reject with a clear page if the token is unknown, the document is voided or expired, or this signer already signed or declined.
- Log a `viewed` audit event with IP and user agent on load; set signer status to `viewed`.
- Show the document title, who sent it, and the message. Show the PDF pages with only this signer's fields overlaid, in page order, with a "next field" button.
- Before any field is interactive, require a checkbox: "I agree to use electronic records and signatures" linking to a short consent page. Record `consented` with timestamp.
- Signature capture modal: tabs for Draw (canvas, pointer events, works on touch), Type (name rendered in a cursive font bundled with the app, rendered to PNG), Upload (PNG/JPG). Once captured, the same image is applied to every signature field; initials fields get a separate capture.
- `date_signed` fields auto-fill with today's date in the signer's locale at submit time.
- "Finish" validates required fields, writes the signature PNG(s) to `./data/files/signers/<id>/`, saves field values, writes `field_filled` events (one per field, in `meta`) and a `signed` event, sets signer status to `signed`. Then run the "advance" logic below.
- Also offer "Decline to sign" with a reason; records `declined`, emails the owner, and stops the document (status stays `sent` with a declined signer; owner can void).
10. **Done page**: confirmation, and once the document completes (pushed over SSE), a link to download the completed PDF (signed URL, logs `downloaded`).
## Advance and completion logic (server, `lib/documents/advance.ts`)
After any signer signs:
1. If `signing_order = sequential`, find the lowest `order` with any pending signer. If it's greater than the one that just signed, email that group and stop.
2. If any signer is still pending, stop.
3. Otherwise complete:
- Load the original PDF with pdf-lib.
- For every field: `signature`/`initials` → `drawImage` of the PNG scaled to fit the box preserving aspect ratio, bottom-left aligned to (x, y). `text`/`date_signed` → `drawText` with Helvetica at `font_size`, clipped to width. `checkbox` → draw a check mark if true.
- Append a certificate page (Letter or A4 matching page 1): title "Certificate of Completion", document title, document id, original SHA-256, sent date, completed date, then a table per signer with name, email, IP, consent time, signed time, signature kind; then the full audit log (event, who, when, IP), continuing onto more pages if needed.
- Save, compute SHA-256, write to `./data/files/documents/<id>/signed.pdf`, set `status = completed`, `completed_at`, write `completed` audit, publish `document.completed` on the event bus.
- Email the completed PDF (attachment if under 10 MB, otherwise a signed link) to the owner and every signer.
- If `webhook_url` is set, POST `{ event: "document.completed", document_id, title, signers, signed_sha256, download_url }` with an HMAC-SHA256 signature header using `webhook_secret`. Retry 3 times with backoff. This is optional and the only outbound call besides mail.
## Jobs (`lib/jobs/daily.ts`, croner in the app process)
- One job, `daily`, scheduled at 08:00 UTC and also run once on boot if `last_run_at` is more than 24 hours old. Before running, take the `job_locks` row for `daily`: set `locked_by` to this process id and `locked_until` to now + 10 minutes in one `UPDATE ... WHERE locked_until IS NULL OR locked_until < now`. If the update touches zero rows, another instance has it; skip. Clear the lock and set `last_run_at` when done. A crashed run leaves a lock that expires on its own.
- Reminders: for each `sent` document with pending signers and `reminder_every_days` elapsed since the last `sent` or `reminded` event for that signer, email a reminder and write `reminded`. Commit per document so a kill mid-run never re-reminds a signer already handled.
- Expiration: for each `sent` document with `expires_at < now`, set `expired`, write `expired`, email the owner.
## Security and integrity
- Signing tokens are unguessable (32 random bytes) and single-purpose. Never expose signer tokens to other signers or in owner-facing URLs.
- `./data/files` is never served statically. All downloads go through `/files/[token]`, where the token is an HMAC-SHA256 of path and expiry using `APP_SECRET` (generated into `./data/secret` on first boot if unset). Links expire in 10 minutes and log a `downloaded` event.
- Rate-limit `/sign/[token]`, `/login`, and the signature upload endpoint with an in-memory counter.
- Every state change writes an audit event in the same SQLite transaction.
- Store `x-forwarded-for` (first IP, set by Caddy) and user agent on every signer action.
- Never mutate a completed document. Re-sending creates a new document.
## Non-goals
No bulk send, web forms, payments, identity verification, notarization, in-person signing, SMS delivery, Salesforce/CRM integration, or SSO. No hosted services beyond an optional SMTP relay and the optional completion webhook target. Keep the schema ready for `team_id` on owners.
## Acceptance criteria
1. Uploading a 3-page PDF creates a draft with `page_count = 3`, correct `page_sizes`, and a matching `original_sha256`.
2. A field placed at the visual center of page 2 in the editor is stored as PDF points within 2 pt of `(width/2 - w/2, height/2 - h/2)` for that page.
3. Sending a sequential document with signers A (order 1) and B (order 2) emails only A. After A signs, B is emailed. A parallel document emails both immediately.
4. Opening a signing link writes a `viewed` event with the request IP and user agent.
5. Fields are disabled until consent is checked; checking it writes `consented`.
6. Submitting with a required text field empty returns a validation error and writes nothing.
7. A drawn signature submitted from a touch device (Playwright mobile emulation) produces a non-empty PNG under `./data/files/signers/<id>/` referenced by the signer.
8. After the last signer signs, the completed PDF has `page_count + 1` (or more) pages, contains the signature image at the field's coordinates, and its SHA-256 equals `signed_sha256`.
9. The certificate page text includes every signer's name, email, IP, and the `signed` timestamp, and the audit table lists every event in order.
10. A voided document's signing links return the rejection page and write no `viewed` event.
11. The daily job sends a reminder to a signer 3 days after `sent` and does not send again the next day; a document past `expires_at` becomes `expired` and the owner is emailed.
12. `UPDATE` or `DELETE` on `audit_events` fails at the database level.
13. Completion webhook is delivered with a valid HMAC signature and retried on a 500.
14. Creating a template from a PDF and using it with two named signers produces a draft with all fields assigned to the right signer.
15. A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
16. `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
17. Killing the process mid-job and restarting it doesn't double-run or lose the job.
18. `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
19. Every outbound email in dev shows up in `./data/outbox`.
## Deliverables
- The app with migrations, the SQLite audit triggers, and a seed script creating one owner, one template, one sent document with two signers, and one completed document.
- A `scripts/verify-pdf.ts` that takes a signed PDF and a document id and checks the hash and the certificate text.
- `docker-compose.yml` (`app`, `caddy`, `backup`), a `Caddyfile` that takes the domain from `DOMAIN`, and `scripts/backup.sh`.
- README: running on a laptop with no env vars, running on a VPS with compose, backup and restore, setting `SMTP_URL` for a relay and where the outbox lives without one, and the optional completion webhook.
Build in this order: first-run admin and sessions, upload and hashing, the prepare editor with correct coordinate conversion, sending and the signing page, completion with pdf-lib, the certificate page, the croner job with its lock, templates, then compose and backup. Open the produced PDFs and check them visually after the completion step.06
Where to build it
Any of these will take the prompt. Use the one you already pay for.
- Claude CodeAgentic coding in your terminal
Best fit. The PDF coordinate work and the audit trail need an agent that will write tests and open the output PDF to check it.
- ReplitBuild and host in the browser
Fine for building and trying it. SQLite and ./data/files work in the workspace. When it works, move the folder to a VPS and run docker compose.
- LovablePrompt-to-app with Supabase built in
Skip, or UI only for the dashboard and signer screens. It's built around Supabase, and this app runs on SQLite and local disk with pdf-lib in the one Node process.
- ChatGPT / CodexPlan, then build with Codex
Have ChatGPT adjust the field types and certificate wording to your jurisdiction, then build with Codex.
- StripePayments, if you need them
Only if you take payment with the signature. Do it as a separate step, not inside the signing page.
07
Test it
Walk the checklist by hand once, then let the agent write the automated tests.
Manual checklist
- Upload a 3-page PDF and confirm all pages render with correct aspect ratio.
- Place a signature field on page 2 near the bottom and confirm it lands in the same spot in the final PDF.
- Send to two signers in sequential order with no SMTP_URL set and confirm only the first signer's .eml appears in ./data/outbox until the first signs.
- Open a signing link and confirm no field is interactive until the consent box is checked.
- Draw a signature on a phone and confirm it looks right in the completed PDF.
- Type a signature and confirm the cursive rendering is applied to every signature field.
- Leave a required field empty and confirm submission is blocked with a clear message.
- Complete a document and confirm the certificate page lists every signer with IP and timestamps.
- Confirm the SHA-256 of the downloaded signed PDF matches the value stored on the document.
- Open a signing link after the document was voided and confirm it's rejected.
- Set a 1-day expiration, advance the clock, and confirm the document expires and the owner is notified.
- Decline as a signer and confirm the owner is emailed and the audit log records it.
- Create a template from a completed document and send it to a new signer.
- Run `docker compose up -d` on a fresh VPS and open the domain over HTTPS.
Generate automated tests
Paste this into the same tool that built the app. It writes tests against the acceptance criteria from the build prompt.
Write automated tests for the e-signature app in this repo, using the acceptance criteria below as the spec. Use Vitest for unit and integration tests, and Playwright (including a mobile emulation project) for signer flows. Each test file gets its own on-disk temp SQLite database (`mkdtemp` a directory, set `DATA_DIR` to it, run the migrations, delete it after), so the real triggers, `job_locks`, `./data/files`, and `./data/outbox` are exercised. Mock only the two allowed outside services: the nodemailer transport (when `SMTP_URL` is set) and the webhook target. Use `pdf-lib` and `pdf-parse` (or `pdfjs-dist`) in tests to inspect produced PDFs.
## Acceptance criteria to cover
1. Uploading `fixtures/three-pages.pdf` creates a draft with `page_count = 3`, correct `page_sizes`, and `original_sha256` equal to the file's hash.
2. A field placed at the visual center of page 2 is stored within 2 pt of the expected bottom-left PDF coordinates. Test the conversion function directly with a Letter page and an A4 landscape page.
3. Sequential send emails only the order-1 signer; after they sign, the order-2 signer is emailed. Parallel send emails all signers at once.
4. Opening `/sign/[token]` writes a `viewed` event with the IP from `x-forwarded-for` and the user agent.
5. Consent must be checked before fields are enabled; checking it writes `consented`.
6. Submitting with a required field empty returns 422 and writes no `signed` event and no field values.
7. A drawn signature on Playwright's mobile project results in a non-empty PNG under `DATA_DIR/files/signers/<id>/` referenced by the signer.
8. After the final signer signs, the signed PDF has at least `page_count + 1` pages, contains an image XObject on page 2 near the field's coordinates, and its SHA-256 matches `signed_sha256`.
9. The certificate page text contains every signer's name, email, IP, and `signed` timestamp, and the audit table lists events in chronological order.
10. A voided document's signing link renders the rejection page and writes no `viewed` event.
11. Daily job: call `runDaily()` directly with a frozen clock. A reminder is sent 3 days after `sent`, not again the next day; a document past `expires_at` becomes `expired` and the owner is emailed once.
12. `UPDATE` and `DELETE` on `audit_events` throw at the database level (the SQLite `RAISE(ABORT)` triggers).
13. The completion webhook body carries a valid HMAC-SHA256 header and is retried when the endpoint returns 500 then 200.
14. Using a template with two roles produces a draft with fields assigned to the correct signers.
15. Starting the app with an empty `DATA_DIR` and no env vars serves the first-run form at `/`, and submitting it creates the admin owner and a session cookie.
16. `docker compose config` parses and lists `app`, `caddy`, and `backup`; the Caddyfile references `DOMAIN`. (The real HTTPS check is manual.)
17. Job lock: with the `daily` lock row held by another process id and `locked_until` in the future, `runDaily()` returns without sending anything. With `locked_until` in the past, it takes the lock, runs, and clears it. Killing the run after the first document's reminder commits and calling `runDaily()` again reminds only the remaining documents.
18. Run `scripts/backup.sh` against a seeded database, delete `DATA_DIR/app.db`, restore the newest backup, and confirm every row count and every file under `DATA_DIR/files` matches.
19. With `SMTP_URL` unset, sending a document writes one `.eml` per signer into `DATA_DIR/outbox/` containing the signing link, and nothing is sent through nodemailer.
20. A signing token for a signer who already signed shows "already signed" and cannot re-submit.
## Layout
- `tests/unit/`: coordinate conversion, HMAC signing, signed download URL minting and expiry, certificate text builder, reminder-due logic (criteria 2, 11 logic, 13 signature).
- `tests/integration/`: first run, upload, send, sign, advance, complete, daily job, job lock, audit triggers, webhook, outbox, backup (criteria 1, 3, 4, 6, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20).
- `tests/e2e/`: Playwright desktop and mobile projects for 5 and 7, plus a full happy path from first-run form to download.
## Rules
- Name tests after criteria: `test("AC8: completed PDF hash matches signed_sha256")`.
- Put fixture PDFs in `fixtures/` (generate them with pdf-lib in a setup script if they don't exist: a 3-page Letter PDF and an A4 landscape PDF).
- Freeze time with fake timers for the daily job tests; never wait on the real croner schedule.
- Add `pnpm test` and a GitHub Actions workflow. No services needed: SQLite and the outbox are on disk, so the workflow is checkout, `pnpm install`, `pnpm test`.
- Run everything. Fix the app where it is wrong and the tests where they are wrong. Report per-criterion pass/fail and every change made.DocuSign is a trademark of Docusign, Inc.. This page is independent research and is not affiliated with or endorsed by Docusign, Inc.. Last updated 2026-09-10.