Design an e-signature workflow like DocuSign
A sender uploads a PDF, places signature and data fields for each signer, and sends it; signers open an emailed link, prove who they are, are guided through their required fields in the right order, and sign; everyone gets a sealed final PDF with a certificate of completion. The hard parts are the envelope and routing model, mapping fields between PDF points and screen pixels, a guided signing flow that works on a phone, and keeping the legally binding artifact and the audit trail on the server.
The question
Design an e-signature product like DocuSign. A sender uploads a contract as a PDF, says who needs to sign and in what order, drops signature, date and text fields onto the pages for each person, and sends it.
Each signer gets an email, opens the document in the browser, fills their fields, and signs. When the last person signs, everyone receives the completed PDF and a certificate that proves what happened. Walk me through the data model, the sender's editor, the signing experience on desktop and phone, routing and reminders, embedding, security, and accessibility.
Real-life examples: DocuSign eSignature, Adobe Acrobat Sign, Dropbox Sign, Zoho Sign, Leegality and Digio (Aadhaar eSign in India)
Requirements and what makes e-signing hard
An e-signature product replaces printing, signing with a pen, scanning, and emailing back. A sender (an HR team, a landlord) has a PDF that others must sign. The product wraps that PDF in an envelope: the documents, the people who must act on them, the order they act in, and the fields each person must fill.
DocuSign, Adobe Acrobat Sign, Dropbox Sign and Zoho Sign all work this way. In India, products such as Leegality and Digio also offer Aadhaar eSign, where the signer confirms with a one-time code (or a fingerprint) tied to their Aadhaar number, India's national ID.
Most of the people who touch an envelope never log in. A tenant gets an email, taps a link on their phone, signs in two minutes, and leaves. That one fact shapes a lot of this design: the signing page must be fast, work on a small screen, and treat the link itself as a key.
Several nearby pieces have their own guides, linked at the end of this page, and we do not repeat them. Rendering PDF pages with PDF.js, text layers, zoom and virtualized pages is the pdf-viewer guide. Drawing a signature with smooth strokes, and the short legal note on ESIGN and eIDAS, is the signature-pad guide.
Schema-driven fields are form-builder, uploading the file is file-uploader, sign in and one-time codes are auth-flow and otp-input, and the in-app bell with its unread list is notifications-center. Showing our page inside a partner's site is embeddable-widget. This guide is about the multi-party workflow around the PDF.
Questions to ask
| Question | What we assume | What it changes |
|---|---|---|
| Who are the users? | Senders log in; signers usually do not have an account | Two apps: a logged-in editor and a token-based signing page |
| How many signers per envelope? | Typically 1 to 5, up to about 50 for board consents | Routing order, parallel groups, and a status per recipient |
| How big are the documents? | 1 to 30 pages typical, up to a few hundred pages and 25 MB (an estimate) | Page virtualization and lazy page images on the signing page |
| Which devices? | Senders on desktop; a large share of signers on phones (check your own data) | A field-by-field mobile flow, not a pinch-zoom desktop page |
| Which identity checks? | Email link by default; SMS code, ID check or Aadhaar eSign as add-ons | A pluggable gate before the document opens |
| Can other apps embed signing? | Yes, a partner can show signing inside their own app | A recipient view URL, iframe rules, and completion events |
| What is the legal artifact? | A server-sealed PDF plus a certificate of completion | The client never produces the final document |
Functional requirements
- Senders upload one or more PDFs, add recipients with a role (signer, approver, or CC) and a routing order, and place fields per recipient: signature, initials, date signed, text, checkbox, and dropdown.
- Senders can auto-place fields from anchor text in the document, and save an envelope as a reusable template.
- Signers open an emailed link, pass the identity check the sender chose, consent to sign electronically, and are guided through their required fields.
- Signers adopt a signature (typed, drawn, or uploaded), can save and come back, and can decline with a reason.
- When a recipient finishes, the next one in the routing order is notified. Reminders and expiry run on a schedule. Senders can void or correct an envelope.
- On completion, every party gets the sealed PDF and a certificate of completion with the audit trail.
- Senders see a dashboard of envelope statuses that updates live.
Non-functional requirements
- Integrity: once signed, any change to the document must be detectable. The final file and the audit trail come from the server, never from browser code.
- Exact placement: a field placed at 150% zoom on a desktop must land on the same spot of the paper as on a phone at 50%.
- Fast first page for signers: our target is the first page visible in under 2 seconds on a mid-range phone over 4G (75th percentile LCP), because many signers decide in the first seconds whether to sign now or later.
- Accessibility: WCAG 2.2 AA for the signing flow, including signers who use a keyboard or a screen reader, even when the PDF itself is not accessible.
- Scale (estimates): 1 million envelopes a day means about 12 envelope creations per second on average and maybe 5 times that at peak, plus several page views per recipient.
What makes it hard
At first glance this is a PDF viewer with a few input boxes. Four properties make it more, and each drives a later chapter.
- A workflow between strangers. The envelope moves from person to person over days, and decline, void and expiry can arrive at any time. That is a state machine, not a form.
- Fields live on paper, screens live in pixels. Every field is stored in PDF units and drawn in zoomed, rotated CSS pixels, with the y axis pointing the other way.
- The output is evidence. The completed PDF may be shown in court years later, so it must be tamper-evident and come with a record of who did what, when, and from where.
- The link is the key. For a signer without an account, the emailed link is the credential. If it leaks into analytics, a referrer header or a forwarded email, someone else can open the contract.
Two more pressures sit on top: partners run our signing page inside their own iframes, and many PDFs are scans with no text or tags, yet a screen reader user still needs a way to sign.
Check your understanding · Exercise 1
Which answers to scope questions change the frontend design the most? Select all that apply.
The envelope model: documents, recipients, routing, fields, statuses
The industry has settled on a shape, and DocuSign's API names are a good reference. An envelope holds one or more documents and a list of recipients. Each recipient has a role and a routing order. Each signing recipient owns a set of fields (DocuSign calls them tabs, such as signHere, initialHere, dateSigned, text, checkbox and list) placed on specific pages of specific documents.
Roles and routing order
Our roles: a signer must fill their fields and sign. An approver reviews and clicks approve or decline but has no signature field. A CC (carbon copy) never acts: they receive a copy when routing reaches their order, so a CC placed last gets the completed document. DocuSign documents more types (certified delivery, in-person signer, witness, notary, agent, editor), but these three cover most envelopes.
The routing order is a small integer per recipient. Lower numbers go first. Recipients who share a number act in parallel, and the next number starts only when everyone in the current group has finished. DocuSign's routingOrder works the same way.
So a sales contract might be: the customer's two directors at order 1 (in parallel, either can go first), our finance approver at order 2, our CEO at order 3, and legal as CC at order 4.
Who owns the status?
Statuses are where naive designs fail. The envelope has a status, each recipient has a status, and they depend on each other. If the CEO declines, the whole envelope is declined, and if the sender voids, every pending recipient must stop. If the expiry job and a signer's Finish land in the same second, only one of them may win.
Decision How do we store and change envelope and recipient statuses?
What we pick: Pick explicit state machines on the server. The envelope stores its status (so lists are cheap), but it only changes by applying an event, inside the same transaction that changes the recipient. The client reads statuses and requests actions such as sign, decline or void. The state-machines lesson covers the pattern; here the important part is that the server is the only place that runs it.
status; it asks for an event and shows the result.Our envelope statuses: draft (being edited), sent (out for signature), completed, declined, voided, and expired. Recipient statuses are finer: created, sent (email queued to them), delivered (they opened the link), viewed (the document rendered for them), signed (or approved), declined, and completed for CCs who received the copy.
DocuSign's documented statuses are close but not identical. Its recipient delivered already means the recipient viewed the documents, so it has no separate viewed; we keep the two apart because "opened the link but the PDF never loaded" is a real support case. It also has no expired envelope status: an expired envelope shows as voided, with a void reason that says it expired.
| Event | Allowed when envelope is | Effect |
|---|---|---|
Send | draft and every signer has a signature field | Envelope sent; recipients at the lowest order become sent |
RecipientSigned | sent and that recipient is in the active group | Recipient signed; if the group is done, the next group becomes sent (CCs in it get their copy and become completed); if none left, envelope completed |
RecipientDeclined | sent | Recipient declined, envelope declined, all pending recipients stop |
Void | sent | Envelope voided with a reason; pending links stop working |
Expire | sent and the expiry date has passed | Envelope expired; a scheduler fires it, never a browser |
Check your understanding · Exercise 2
Recipients A and B have routing order 1, C has order 2, and D is a CC at order 3. A has signed. Which statements are true? Select all that apply.
The sender's editor: placing fields on paper from a screen
The sender sees the PDF pages (rendered as in the pdf-viewer guide), a palette of field types on the left, and the recipient list with one colour each. They pick a recipient, drag a Signature field onto page 2, resize it, and move on. The hard question hides inside that drag: where exactly did the field land?
Three coordinate systems
- PDF user space. Units are points, 1/72 of an inch, so a US Letter page is 612 by 792 and A4 is about 595 by 842. The origin is normally the bottom-left corner and y grows upward. A page can also carry a
/Rotatevalue of 0, 90, 180 or 270, which tells viewers to show it turned clockwise by that many degrees. - The page boxes. The visible page is its
CropBox(by default theMediaBox), and that box usually starts at 0, 0 but does not have to. PDF.js passes it to the viewport as the view box, so the conversions below handle an offset box too. - CSS pixels. A CSS pixel is 1/96 of an inch, so at 100% zoom one point is 96/72 = 1.333 CSS pixels and a Letter page is 816 by 1056. PDF.js's viewer uses the same factor. The origin is the top-left and y grows downward. Zoom multiplies everything, and rotation swaps width and height.
- Device pixels. On a phone with
devicePixelRatio3, the canvas backing store is 3 times bigger than its CSS size so text stays sharp. This affects rendering only. Pointer events and layout are in CSS pixels, so field math never needs the device pixel ratio.
top = (pageHeight - y - h) * scale, not just a multiply.Decision In which units do we store a field's position?
What we pick: Store PDF points on the unrotated page, with the page number and document id. It is the only unit the server can stamp with no extra context. DocuSign's API makes a similar choice: its fixed xPosition and yPosition are in points too, but measured down from the top-left corner. Our API keeps the PDF's own bottom-left origin so the stamping code never flips.
PDF.js already knows the transform. page.getViewport({ scale, rotation }) returns a viewport whose convertToPdfPoint and convertToViewportRectangle handle scale, rotation and the y flip together. We create it with scale = zoom * 96 / 72 so its output is CSS pixels, and with the page's own rotation plus any rotation the user added.
import type { PageViewport } from "pdfjs-dist";
// PDF user space: points, origin bottom-left, unrotated page
export type PdfRect = { x: number; y: number; w: number; h: number };
export function cssToPdf(vp: PageViewport, left: number, top: number, width: number, height: number): PdfRect {
const [x1, y1] = vp.convertToPdfPoint(left, top);
const [x2, y2] = vp.convertToPdfPoint(left + width, top + height);
return { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
}
export function pdfToCss(vp: PageViewport, r: PdfRect) {
const [a, b, c, d] = vp.convertToViewportRectangle([r.x, r.y, r.x + r.w, r.y + r.h]);
return { left: Math.min(a, c), top: Math.min(b, d), width: Math.abs(c - a), height: Math.abs(d - b) };
}
// Snap in points, so the grid is the same at every zoom
export const snap = (v: number, step = 4) => Math.round(v / step) * step;
A worked example on a Letter page. The sender places a signature box at x: 72, y: 100 (one inch from the left, 100 points up from the bottom), 180 by 36 points. At 100% zoom the box draws at left: 96px, top: (792 - 100 - 36) * 1.333 = 874.7px, 240 by 48 CSS pixels, and at 50% everything halves.
If the page has /Rotate 90, the viewer shows it landscape, and the same box draws at left: 133.3px, top: 96px, 48 by 240: tall and thin, because it turned with the page. The stored numbers never change.
Alice's signature: CSS left 96, top 874.7, 240 by 48 pixels. Saved as x 72, y 100, 180 by 36 points.
Drag, snap, resize
Dragging uses pointer events with setPointerCapture, so the drag continues if the pointer leaves the page. During the drag only a CSS transform changes; we convert to points on pointerup. Snapping happens in points: a 4 point grid, plus alignment guides when an edge comes within 3 points of another field's edge.
Arrow keys nudge a selected field by one grid step, and by 5 steps with Shift, for keyboard users. Touch users may have no keyboard, so WCAG 2.2's Dragging Movements criterion (2.5.7) also wants a single-pointer way: tap a field type in the palette, then tap the spot on the page.
Anchor text: let the document place its own fields
An HR team sending 500 offer letters a month will not drag fields by hand. Their template already says "Employee signature: /sn1/". With anchor tagging (DocuSign calls it auto-place), a field definition says anchorString: "/sn1/" plus anchorXOffset, anchorYOffset and anchorUnits. The server finds every place that string appears in every document and puts a field there.
Authors often colour the anchor text white so it is invisible on paper. Adobe Acrobat Sign has a similar feature called text tags, written in double braces inside the document.
Anchor search runs on the server, because it needs positioned text from every page and API senders who never open our editor need it too. The editor shows auto-placed fields like any other, marked with their source, and flags an anchor that matched nothing. DocuSign, for example, returns an error for an unmatched anchor unless anchorIgnoreIfNotPresent is set.
Templates and validation before send
A template is an envelope with placeholder roles ("Tenant", "Landlord") instead of people. Sending from a template fills in names and emails and copies the fields. Fields belong to the role, so a template with 40 fields becomes a ready envelope in one step.
Before the Send button works, the editor checks: every signer has at least one signature field, every field lies inside its page, required dropdowns have options, routing orders have no gaps, and no two recipients' fields overlap. Errors show as a list at the top, each linking to its field, and focus moves to the list (the input-validation and focus-management lessons cover the pattern). The server runs the same checks on send, because API senders skip our editor entirely.
Check your understanding · Exercise 3
A field is stored as { page: 1, x: 72, y: 100, w: 180, h: 36 } in PDF points. Which statements are true? Select all that apply.
The signer: from an email link to Finish
The signer's journey has five steps, and each one can lose them: open the link, prove who they are, agree to sign electronically, fill the fields, and finish. Our job is to make each step short and obvious.
Opening the link
The email contains a link like https://sign.example.com/s/k7Qx... with a long random token (at least 128 bits). The token identifies one recipient on one envelope. The signing app sends it once to POST /signing-sessions, and the server checks it and the recipient's status and returns a short-lived session in an HttpOnly cookie.
The app then removes the token from the address bar with history.replaceState, so it does not end up in screenshots, browser history sync or analytics page views. The security chapter covers the rest of the link's protection.
Proving who they are
By default, opening the link proves only that the person can read that email inbox. That is enough for many agreements, and senders can add stronger checks per recipient.
Decision Which identity check gates the document?
What we pick: Make the gate pluggable per recipient: the sender picks the check, the server stores it on the recipient, and the signing app renders the matching gate component before it fetches any page of the document. No document bytes reach the browser until the gate passes. Default to email possession, and suggest SMS for anything with money in it.
Aadhaar eSign deserves a closer look, because the shape is unusual. As India's Controller of Certifying Authorities (CCA) describes it, a licensed eSign Service Provider authenticates the signer through an e-KYC service such as Aadhaar's (OTP or biometric). It creates a key pair in a hardware security module, gets a short-validity certificate for it, signs only the hash of the document, and destroys the key after that one use.
The document itself never goes to the provider. For our frontend it is a redirect to the provider and a waiting state. Details vary by provider, so treat this as an outline.
Consent to sign electronically
In the US, section 101(c) of the ESIGN Act (15 U.S.C. 7001(c)) covers information that a law says must be given to a consumer in writing. An electronic record satisfies that rule only if the consumer affirmatively consented after a clear statement of, among other things, their right to paper copies, their right to withdraw consent, and the hardware and software needed. The consent must also be given electronically in a way that reasonably shows the consumer can access the records.
Products show this as an Electronic Record and Signature Disclosure (DocuSign's name for it) with a checkbox before the first field, on the same signing page, which also shows the signer can open it. We store which version of the disclosure text was shown and when the box was ticked, as an audit event. Whether a given agreement needs it is a legal question for the sender; the product just makes it easy to do right.
Guided navigation: Start, Next, Finish
A 12-page lease might have 9 fields for this signer spread over pages 1, 4, 11 and 12. So the signing page has one big button: Start, which scrolls to the first required field and focuses it. After that it becomes Next, which goes to the next empty required field, and a counter says "3 of 9 required fields done". When nothing required is left, it becomes Finish.
type Field = { id: string; page: number; x: number; y: number; h: number; required: boolean };
export function readingOrder(fields: Field[]): Field[] {
return [...fields].sort((a, b) =>
a.page - b.page ||
// top edge in points; higher y is nearer the top of the page
Math.round((b.y + b.h) / 6) - Math.round((a.y + a.h) / 6) ||
a.x - b.x,
);
}
export function nextRequired(fields: Field[], values: Record<string, unknown>, afterId?: string) {
const ordered = readingOrder(fields).filter((f) => f.required && values[f.id] == null);
if (!afterId) return ordered[0];
const all = readingOrder(fields);
const from = all.findIndex((f) => f.id === afterId);
return ordered.find((f) => all.indexOf(f) > from) ?? ordered[0];
}
Rounding the top edge to 6 point bands treats fields that sit on the same line as one row, even if one is a point higher, so Next goes left to right along a line instead of jumping around.
Adopting a signature
The first time the signer taps a signature field, a dialog asks them to adopt a signature: type their name and pick a script font, draw it (see the signature-pad guide), or upload an image. After that, every signature and initials field fills with one tap. For a typed signature we send the text and the font name, and the server renders it with fonts we control.
Signing on a phone
A Letter page on a 390 pixel wide phone is shown at under 50% zoom, so 10 point body text is about 6 CSS pixels tall and a checkbox is a few pixels across. Pinch-zoom alone works, but signers get lost. A pattern that works better: show the page at fit-width for context, and when Next moves to a field, zoom and scroll so that field is centred and comfortably large.
The input itself opens in a bottom sheet (a panel that slides up from the bottom edge) with a native input, so the right keyboard and autofill appear. Signature adoption opens full screen, often in landscape, because a drawn signature needs room. WCAG 2.2's minimum target size (success criterion 2.5.8) asks for at least 24 by 24 CSS pixels or enough spacing, and we aim for 44.
Saving progress and declining
Field values autosave to the server about a second after the last change, as draft values for this recipient. They are not a signature: the signature happens only on Finish, which sends a final confirm with all values. "Finish later" just ends the session; the same link resumes with values restored.
Declining opens a dialog asking for a reason, which many senders require and which is shown to them. The dialog says what happens next, because decline is final.
Check your understanding · Exercise 4
A signer opens the link on a phone. Which design choices are sound? Select all that apply.
Finishing: the final document and why the browser never builds it
When the signer taps Finish, they expect a done screen right away. Behind it, the server turns an original PDF plus a set of field values into a document that a court, an auditor or the other party can trust, and the frontend is designed around that job.
The Finish request carries every field value. The server re-checks the session, that the recipient is in the active routing group, that the envelope is still sent, each value against its field type, and that every required field is filled; a date signed field gets the server's clock, not the browser's. If the sender voided the envelope while the signer was typing, the answer is 409 Conflict with the new status, and the client shows "This envelope was voided by the sender" instead of a success page.
Decision Who produces the final, completed PDF?
What we pick: The server stamps, flattens and seals. The browser sends values and intent; it never produces the binding artifact.
How the seal works, in short
A PDF digital signature covers a byte range: every byte of the file except a reserved slot where the signature itself goes. The server hashes those bytes (typically with SHA-256), signs the hash with a private key kept in a hardware security module (HSM, a device that never lets the key out), and writes the signature and the certificate chain into the slot. A PDF reader such as Acrobat repeats the hash, and if one byte changed, it reports the signature as invalid.
PAdES (ETSI EN 319 142) is the European standard profile for these signatures. Its baseline levels go from B-B (the signature alone) to B-T (plus a trusted timestamp), B-LT (plus the revocation data needed to check it later) and B-LTA (plus archive timestamps, so it stays checkable for years).
Which level of signature a contract needs is a legal question. The EU's eIDAS regulation defines three levels: simple, advanced, and qualified (SES, AES and QES). All three can be valid evidence, but only a qualified electronic signature has the equivalent legal effect of a handwritten one by law. In the US, ESIGN and the state laws based on UETA accept a broad range of electronic signatures, and India's IT Act 2000 recognises digital signatures and, through its rules, eSign.
Many platforms seal the envelope with their own certificate and keep the per-person evidence in the audit trail; qualified and Aadhaar eSign flows add a certificate per signer. None of this is legal advice. The design point is that sealing is a server step with a pluggable signer.
The certificate of completion
The certificate is a few pages appended to (or sent with) the final PDF. Ours lists the envelope id and each document with its page count and SHA-256 hash at send. For every recipient it lists name, email, role, routing order, identity check used, consent to electronic records with time, the adopted signature image and type, and timestamps with IP address for sent, delivered, viewed, signed or declined.
The hash of the final sealed file cannot sit inside that same file, so it goes into the audit log and into any separately sent copy of the certificate. DocuSign's certificate of completion records much the same facts (envelope id, recipient events with timestamps and IP addresses, signature adoption, authentication and disclosure consent), which tells us what customers and their lawyers expect to see.
What the frontend does while the server works
- Shows the done screen as soon as Finish succeeds, without waiting for the seal.
- For the last signer, offers "Download" only when the sealed file is ready (a live event, or polling for up to a minute).
- Never offers a client-rendered "preview of the final PDF" as a download, because people would keep and forward that unsealed copy.
- Disables Finish after the first click and sends an
Idempotency-Key, so a double tap or a retry on a flaky network does not sign twice.
Check your understanding · Exercise 5
Why is the final PDF produced and sealed on the server rather than in the signer's browser? Select all that apply.
Routing, reminders, the audit trail, and a live dashboard
An envelope can take minutes or weeks. This chapter is about that time: who acts next, who gets nudged, what gets recorded, and how the sender sees it live.
Ana and Ben share routing order 1, so both were emailed at once.
Routing, reminders, expiry, reassigning
- Next group. When the last recipient of the active routing group signs, the same transaction activates the next group and queues their emails. The email is written to an outbox table in that same transaction and sent by a worker after the commit, so a crash cannot send an email for a step that never happened.
- Reminders. A scheduler checks pending recipients: a first reminder 3 days after their email went out and then every 2 days (our defaults; senders can change them). The sender can also tap Remind now, rate-limited to one per recipient per hour.
- Expiry. Each envelope has an expiry date, 30 days by default in our design. A scheduled job fires the
Expireevent. A signer who opens the link afterwards sees a clear page: "This document expired on 14 March. Ask the sender to send it again." For comparison, DocuSign's documented default is 120 days, which an account admin can change. - Reassign and correct. If allowed, a signer can hand their part to a colleague, which creates a new recipient at the same routing order and records who reassigned to whom. Correcting a recipient's email reissues their link and kills the old one.
- Void. The sender voids with a reason. Every pending link stops working at once, and recipients who already signed get a notice.
The audit trail: who records what
The audit trail is the list of everything that happened to the envelope, with time, actor and IP address. Most events happen on the server and the server records them itself: sent, link opened (the session exchange), identity check passed, consent given, field values saved, signed, declined, voided, completed. But some events only the browser can see. The server knows it sent page images; only the browser knows page 7 was actually shown on screen.
Decision How are view events, which only the browser sees, recorded?
What we pick: Let the client report, and let the server stamp and label. The certificate shows server-observed events as facts and client-reported viewing as such. Send these events with navigator.sendBeacon so a closing tab still delivers them, and batch them, because a 40-page contract produces 40 view events.
source label are added on the server, and the certificate shows client-reported views as exactly that.Immutability is a server property, but it shapes the API: there is no update or delete endpoint for audit events, ever. The table is append-only, each row stores the hash of the previous row (a simple hash chain, so a removed or edited row breaks every hash after it), and the final chain hash goes into the certificate. The insurance-wizard guide uses the same append-only, server-stamped log for its answer history.
The sender's dashboard, live
Senders live on a list of envelopes grouped as Action required, Waiting for others, Completed, and Declined or voided. A sales rep who just sent a contract keeps that tab open and wants to see "Viewed" and then "Signed" appear without refreshing. Events are rare (a few per envelope per day) and flow one way, from server to browser.
So we open one Server-Sent Events (SSE) stream per signed-in sender (GET /events/stream), or reuse the app's WebSocket if it already has one (the websockets lesson compares them). Each message is small: { envelopeId, version, status, recipients: [{ id, status }] }. The client patches its query cache only if the message's version is newer than what it holds, so an out-of-order message cannot move an envelope backwards.
On reconnect, the client refetches the visible page of the list, because messages sent while it was offline are gone. For partner systems, the same events go out as signed webhooks; DocuSign Connect, for example, can sign each message with an HMAC (the webhooks lesson covers retries and signatures).
Sender actions are not optimistic when they are destructive. Void waits for the server, because it can fail (the last signer may have just finished, making the envelope completed). Remind can be optimistic: we show "Reminder sent" at once and roll back on a 429. The optimistic-updates lesson covers the rollback pattern.
Check your understanding · Exercise 6
The sender clicks Void at the same moment the last signer clicks Finish. Which statements are true? Select all that apply.
Embedded signing and security
Many signatures never start from our email. A property management app wants the tenant to sign the lease inside its own onboarding flow; an HR system wants the offer letter signed on its own page. That is embedded signing: our signing page runs inside, or redirected from, someone else's app.
The recipient view URL
DocuSign's model is a good template. The partner's backend creates the envelope and marks the recipient as embedded by setting a clientUserId, which means "don't email this person, my app has already identified them". When the user is ready, the partner's backend calls createRecipientView with that same clientUserId and a returnUrl to send the user back to.
The answer is a recipient view: a signing URL that, per DocuSign's docs, works once and expires after five minutes. The partner's frontend either redirects the whole page to it or shows it in an iframe. When signing ends, the user lands on the returnUrl with an event query parameter such as signing_complete, decline, cancel or ttl_expired.
postMessage only starts a check. The last three arrows are the proof: a server-to-server read of the envelope status.For the iframe case, DocuSign's newer Focused View has the partner's backend list allowed frameAncestors and messageOrigins in the view request, and the partner page loads a small script that mounts the iframe and emits ready and sessionEnd events. Our design follows the same rules:
- Framing is opt-in per session. The signing page sends
Content-Security-Policy: frame-ancestors 'none'by default, and only for an embedded session does it sendframe-ancestors https://app.partner.com, taken from the view request. This blocks clickjacking, where an attacker frames the page and tricks a click on Finish (the csp and clickjacking-and-iframes lessons go deeper). - Messages go to one origin. On finish, the iframe calls
parent.postMessage({ type: "signing_complete", envelopeId }, "https://app.partner.com"), never with"*", and the partner's listener checksevent.originbefore trusting it. - Events are hints, not proof. A
returnUrlquery parameter or apostMessagecan be faked by anyone with dev tools. The partner must confirm the status through the API or a signed webhook before shipping the goods or starting the job. - Cookies inside a frame are third-party. Safari and Firefox block or partition third-party cookies by default, so an iframe session that depends on a normal cookie breaks. We keep the session either in a
Partitionedcookie (CHIPS) or in memory, bootstrapped from the one-time URL. The embeddable-widget guide covers the same constraint.
const SIGN_ORIGIN = "https://sign.example.com";
window.addEventListener("message", async (event: MessageEvent) => {
if (event.origin !== SIGN_ORIGIN) return;
const msg = event.data as { type?: string; envelopeId?: string };
if (msg.type !== "signing_complete" || !msg.envelopeId) return;
showSpinner("Confirming your signature...");
// Our own backend asks the e-signature API for the real status
const res = await fetch(`/api/leases/${msg.envelopeId}/status`);
const { status } = (await res.json()) as { status: string };
if (status === "completed" || status === "signed") goToNextStep();
else showError("We could not confirm the signature yet. Please wait a moment.");
});
Signing links are bearer tokens
Whoever holds the emailed link is treated as the recipient, at least until an identity check. So we design the link like a password reset link, and the auth-tokens lesson applies directly.
- Random and opaque: 128 bits or more from a secure random source, stored on the server only as a hash, so a database leak does not leak working links. No email address, name, or envelope title in the URL.
- Bound and scoped: one token, one recipient, one envelope. It dies when the envelope completes, is voided or expires, and when the sender corrects that recipient.
- GET is harmless: corporate email security scanners often open links before the human does. Opening the URL must not mark the recipient as delivered or use up a one-time token. The page's own JavaScript does the
POSTexchange, which scanners generally do not run. - Out of logs and analytics: signing pages send
Referrer-Policy: no-referrerand load no third-party analytics script, and our own page view events record the route pattern (/s/:token), never the real path. - Rate-limited checks: five wrong one-time codes lock the recipient for 15 minutes and notify the sender (our numbers).
Document bytes follow the same rule. Page images and the PDF are served from session-checked endpoints with Cache-Control: private, no-store, never from a public CDN path. On the sender side, access to envelopes follows the account's roles and shared folders; the permission-based-ui guide covers how the UI reflects that.
Check your understanding · Exercise 7
A partner embeds our signing page in an iframe. Which practices are correct? Select all that apply.
Architecture, data model and API
The sender is logged in on a desktop, editing for minutes inside a big app (folders, templates, settings, team). The signer is anonymous, often on a phone and a slow network, and done in two minutes. One bundle for both would make signers download the editor and the dashboard just to sign.
So we build two frontends on one backend. The sender app is a client-rendered single-page app behind login. The signing app is a small, separately deployed app on its own origin (sign.example.com), server-rendered for a fast first paint, with its own strict headers: frame-ancestors, no third-party scripts, Referrer-Policy: no-referrer. A separate origin also means a script bug in the signing app cannot read the sender app's storage or its host-only cookies.
| Part | Lives in | Responsibility |
|---|---|---|
| Field editor | Sender app | Pages, palette, drag and snap in points, validation before send |
| Envelope list | Sender app | Statuses by group, filters, live updates from the event stream |
| Page renderer | Both | PDF.js in a worker for the editor; pre-rendered page images for signers |
| Identity gate | Signing app | Renders the check chosen for this recipient; nothing loads before it passes |
| Guided signer | Signing app | Field order, Start and Next, bottom sheet inputs, autosave, Finish |
| Event reporter | Signing app | Batches page views and sends them with sendBeacon |
| Envelope service | Server | State machines, routing, version checks, outbox for emails |
| Document service | Server | Stores originals, anchor search, page images, stamping and sealing |
One choice in that table needs a word: signers get page images, not the PDF. The server renders each page once to WebP at two widths, and the signing app shows them with img tags and loading="lazy", with the field overlay on top. That avoids shipping PDF.js (several hundred KB) to a phone, renders the same on every device, and makes the first page an ordinary image that can be the LCP element.
The cost is that an image has no text. So each page also carries the text the server extracted from it, visually hidden, for screen readers and find in page, and the signer can always download the original PDF to read in their own reader. The sender's editor keeps PDF.js, because the sender needs the real text layer for placing fields next to words.
Data model and API
The client keeps three kinds of state. Server data (envelopes, recipients, fields, statuses) lives in a query cache keyed by envelope id and version. Editor state that has not been saved (the selected field, a drag in progress, the zoom) lives in component state. The signer's typed values live in a form store that autosaves; the form-state lesson covers dirty tracking and validation.
type EnvelopeStatus = "draft" | "sent" | "completed" | "declined" | "voided" | "expired";
type RecipientStatus = "created" | "sent" | "delivered" | "viewed" | "signed" | "declined" | "completed";
type Envelope = {
id: string;
version: number; // bumps on every applied event
status: EnvelopeStatus;
documents: { id: string; name: string; pages: { width: number; height: number; rotate: 0 | 90 | 180 | 270 }[] }[];
recipients: Recipient[];
expiresAt: string;
};
type Recipient = {
id: string;
name: string;
email: string;
role: "signer" | "approver" | "cc";
routingOrder: number;
auth: "email" | "sms" | "id-check" | "aadhaar-esign";
status: RecipientStatus;
};
type Field = {
id: string;
recipientId: string;
documentId: string;
page: number;
rect: { x: number; y: number; w: number; h: number }; // PDF points, unrotated page
kind: "signature" | "initials" | "date-signed" | "text" | "checkbox" | "dropdown";
required: boolean;
options?: string[];
anchor?: { text: string; dx: number; dy: number };
};
| Call | Who | Shape |
|---|---|---|
POST /envelopes | Sender | Documents, recipients, fields; returns a draft envelope |
PATCH /envelopes/:id | Sender | Field and recipient edits with If-Match: version |
POST /envelopes/:id/send | Sender | Runs validation; 422 with a list of { fieldId, problem } |
POST /envelopes/:id/void | Sender | { reason }; 409 if no longer sent |
GET /events/stream | Sender | SSE: { envelopeId, version, status, recipients } |
POST /signing-sessions | Signer | { token } exchanged for a session cookie and the recipient's view |
PUT /signing/values | Signer | Autosave: { fieldId: value } drafts |
POST /signing/finish | Signer | All values and an Idempotency-Key; 409 if the status no longer allows it |
POST /signing/decline | Signer | { reason } |
POST /signing/events | Signer | Batched viewed events, sent with sendBeacon |
Errors use one format everywhere ({ code, message, details }, as in the api-contracts-and-errors lesson), and the signing app maps a short list of codes to whole-page states: LINK_EXPIRED, ENVELOPE_VOIDED, ALREADY_SIGNED, NOT_YOUR_TURN, AUTH_LOCKED. Each gets plain words and a next step, because a signer who sees "Error 409" will just close the tab.
Check your understanding · Exercise 8
Which choices in this data model and API are well reasoned? Select all that apply.
Accessibility, performance, and the edges
Most PDFs that people send for signature are not accessible. Many are scans with no text at all; others have text but no tags, so a screen reader reads columns in the wrong order or not at all. We cannot fix every customer's PDF. We can make the signing accessible, because the fields are ours, not the PDF's.
Signing without seeing the page
- Every field is a real form control in the DOM, positioned over the page image: a
buttonfor signature and initials,inputfor text and dates, a checkboxinput, aselectfor dropdowns. Nothing is drawn only on a canvas. - Each control has a label built from the sender's field name and its place: "Tenant signature, page 4, required". Without a sender-given name, we use the nearest text on the page.
- Tab order follows the reading order from the signer chapter, not the DOM order of pages, and Start and Next move focus, not just the scroll position. The keyboard-navigation and focus-management lessons cover the details.
- A list view toggle shows all of the signer's fields as a plain, linear form with the same labels, next to a link to the page each one sits on. For a screen reader user, this is the main way to sign.
- Progress ("3 of 9 required fields done") lives in a polite live region, and errors are linked to their fields (the aria-patterns and screen-readers lessons).
- Signature adoption offers typed signatures, so nobody has to draw. WCAG's Understanding notes for 2.5.1 (Pointer Gestures) point out that a drawn signature may be inherently path-based, but agreeing or confirming identity need not be, and the typed option gives that path.
- Before Finish, a review step lists what will be signed with a way back, which helps meet WCAG 3.3.4 Error Prevention for legal and financial submissions.
Performance budgets for the signing page
Our budget for the signing app: under 80 KB of compressed JavaScript for the first page, and a first page image about 1,000 CSS pixels wide in WebP, around 60 to 120 KB for a text page (both our estimates). The LCP target is under 2 seconds at the 75th percentile, stricter than the 2.5 second Core Web Vitals good threshold. The server-rendered shell includes the envelope title, the recipient's name and the first page's img with fetchpriority="high", so the first useful thing appears before any client code runs.
Later pages load lazily as they approach the viewport. For a 200-page envelope, the field list loads first so Next can jump to page 180 and fetch that page on demand.
The editor has different costs: PDF.js parsing a 30 MB file can take seconds, so it runs in its worker, pages render only near the viewport (the pdf-viewer guide covers this), and anchor search results arrive from the server instead of being computed from every page in the browser.
Edges worth naming
- Two tabs, same signer: autosave is last write wins per field, and a
BroadcastChannelmessage tells the other tab to reload its values. - Signer finishes offline: Finish needs the network. We keep the values locally and show "Not sent yet" with a retry, using the same idempotency key.
- The sender replaces a document after some people signed: not allowed. Correcting the document voids and re-sends, because signatures apply to exact bytes.
- Right-to-left and other scripts: typed signature fonts must cover the signer's script, or we fall back to a drawn signature. Dates are stamped in the envelope's chosen format and time zone, shown in the certificate in UTC as well.
Check your understanding · Exercise 9
A blind signer opens a scanned, untagged PDF to sign. Which parts of our design let them complete it? Select all that apply.
Running it in the interview
In 45 minutes you cannot cover all of this. Show that you see the whole workflow, then go deep where the interviewer leans. A plan that works:
- Minutes 0 to 5: ask who signs (accounts or not), devices, identity needs, embedding, and what the legal output is. Say early that the server produces the final document.
- Minutes 5 to 12: draw the envelope model and the status machine. Name routing order and parallel groups.
- Minutes 12 to 25: pick one deep dive. If the role is UI-heavy, do coordinates and the editor; otherwise do the guided signing flow on a phone.
- Minutes 25 to 35: architecture with two apps, the API, the event stream, and the Finish sequence with its 409.
- Minutes 35 to 45: security of links and embedding, audit trail, accessibility, then questions.
Say the reasons out loud. "I store positions in PDF points because the server stamps in points" earns more than a correct diagram without the why.
Expect follow-ups such as: how do you handle a signer who forwards the email (identity checks and reassign), and what if two signers in a parallel group finish at once (the server applies them one after the other, both signatures stand, and whichever commits second completes the group). Others: how do you prove someone saw page 7 (client-reported view events, stamped and labelled by the server), and what changes for India (Aadhaar eSign through a licensed provider, as a pluggable identity and signing step).
Summary
- Model an envelope as documents, recipients with a role and a routing order, and fields per recipient. Same routing order means parallel; the next group starts when the current one is done.
- Envelope and recipient statuses change only through server-side state machine events, applied one at a time per envelope. The client requests actions and mirrors the result.
- Store field positions in PDF points on the unrotated page, and convert to CSS pixels with the page viewport for every zoom and rotation. Device pixel ratio never enters field math.
- Signers need a separate, light signing app: token exchanged for a session, a pluggable identity gate, consent, Start and Next through required fields, a bottom sheet on phones, autosave, and decline with a reason.
- The browser places and collects; the server stamps, flattens, seals, and writes the certificate of completion. The final PDF is never built in the browser.
- The audit trail is append-only. The server records what it observes, and view events reported by the client are stamped and labelled by the server.
- Embedded signing uses a short-lived recipient view URL, per-session
frame-ancestors,postMessageto one origin, and a server-side status check before trusting completion. - Signing links are bearer tokens: random, hashed at rest, bound to one recipient, harmless on GET, removed from the URL after exchange, and kept out of logs and analytics.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Docusign: Auto-place (anchor tagging) tabs How anchor strings place fields wherever a piece of text appears.
- Docusign: Envelope status codes A production set of envelope statuses to compare with ours.
- Docusign: Fixed (absolute) positioning of tabs Tab positions in points, measured from the top-left corner of the page.
- Docusign: Embedded signing clientUserId, the recipient view, and signing URLs that expire after five minutes.
- Docusign code examples for Node.js Embedded signing, focused view with frame ancestors, and anchor tabs in working code.
- PDF.js The PDF renderer and its viewport API for converting between PDF and screen coordinates.
- 15 U.S. Code 7001 (ESIGN Act), General rule of validity Section (c) lists the consumer consent requirements.
- eIDAS Regulation (EU) No 910/2014 Simple, advanced and qualified electronic signatures in the EU.
- Controller of Certifying Authorities: eSign How Aadhaar-based eSign works in India, from the regulator.
- MDN: CSP frame-ancestors Controlling who may embed the signing page.
- MDN: Window.postMessage() Target origins and origin checks for iframe messages.
- Web Content Accessibility Guidelines (WCAG) 2.2 Target size, dragging alternatives, labels, and error prevention for legal submissions.
Concepts behind it
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.
- Clickjacking and iframesIframes power embeds, payment fields, and widgets, and also let an attacker hide your page under a fake one to steal a click: control both directions with framing headers, the sandbox attribute, and checked postMessage.
- WebhooksAn HTTP request that another company's server sends to yours the moment something happens, signed so you can trust it and retried until you answer: the first hop of many updates that end on a user's screen.
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.