cf.completefrontend
My progress
SYSTEM DESIGN · #140

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

QuestionWhat we assumeWhat it changes
Who are the users?Senders log in; signers usually do not have an accountTwo 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 consentsRouting 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-onsA pluggable gate before the document opens
Can other apps embed signing?Yes, a partner can show signing inside their own appA recipient view URL, iframe rules, and completion events
What is the legal artifact?A server-sealed PDF plus a certificate of completionThe 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Data model sketch. An Envelope with status, version and expiry has many Documents, many Recipients and many AuditEvents. Each Document lists its pages with size and rotation. Each Recipient has a role and a routing order and owns many Fields. Each Field sits on one page of one document, with a rectangle in PDF points. A note gives typical sizes: one envelope, 1 to 50 recipients, 0 to 200 fields.Envelopestatus, version, expiryDocumentpages: size, rotateRecipientrole, routingOrderAuditEventappend-onlyFieldpage + rect in pointsownssits on a page1 envelope1 to 50 recipients0 to 200 fields
Fields belong to a recipient but point at a page of a document. That double link is why one envelope can mix three PDFs and five signers without confusion.

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?

A status string anyone can set

Each endpoint writes status = "completed" or "declined" when it thinks it should.

The client shows whatever string comes back.

Pros

  • Fast to build.
  • Easy to read in the database.

Cons

  • Illegal moves happen: a voided envelope gets a signature, an expired one gets completed.
  • Two writers race and the last write wins.
  • The audit trail and the status disagree.

Derive the envelope status from recipients

Store only recipient statuses.

Compute the envelope status on read: all signed means completed, any declined means declined.

Pros

  • One source of truth for recipients.
  • No sync bugs between the two levels.

Cons

  • Void and expiry are envelope-level events with no recipient to hang them on.
  • Every list query recomputes, which hurts a dashboard of 10,000 envelopes.

Explicit state machines on the server, driven by events

The envelope and each recipient have a state machine with allowed transitions.

Every change is an event (RecipientSigned, Void) applied in one transaction that locks the envelope row, so events on one envelope run one at a time.

The client mirrors the state and asks for transitions; it never sets a status.

Pros

  • Illegal moves are rejected in one place.
  • Each event is also an audit entry.
  • Races resolve cleanly: an event that is no longer allowed gets a 409 with the current status.

Cons

  • More design up front.
  • Needs care to keep the stored envelope status in step with recipient events in the same transaction.

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.

State machine of an envelope. Draft moves to sent on Send, only if every signer has a signature field. From sent, four end states: completed when the last group signs, declined when any recipient declines, voided when the sender voids, and expired when the expiry date passes. Inside sent, each RecipientSigned event activates the next routing group.draftsentcompletedlast group signsdeclinedanyone declinesvoidedsender voidsexpiredpast expiresAtSendRecipientSigned:next group gets sent
Every arrow is an event the server applies in one transaction. A browser never writes 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.

EventAllowed when envelope isEffect
Senddraft and every signer has a signature fieldEnvelope sent; recipients at the lowest order become sent
RecipientSignedsent and that recipient is in the active groupRecipient 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
RecipientDeclinedsentRecipient declined, envelope declined, all pending recipients stop
VoidsentEnvelope voided with a reason; pending links stop working
Expiresent and the expiry date has passedEnvelope 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 /Rotate value 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 the MediaBox), 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 devicePixelRatio 3, 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.
Two drawings of the same US Letter page. Left, PDF user space in points: origin at the bottom-left, x to the right, y upward; a signature box at x 72, y 100. Right, CSS pixels at 100% zoom: origin at the top-left, x to the right, y downward; the same box at left 96, top 875. An arrow between them says the PDF.js viewport multiplies by 1.333 and flips y.PDF page, pointsscreen, CSS pixelsx 72, y 100 ptxy(0, 0) bottom-leftleft 96, top 875xy(0, 0) top-leftPDF.js viewportx 1.333, flip y
Same spot on paper, two sets of numbers. The y axis points the other way, so the conversion is top = (pageHeight - y - h) * scale, not just a multiply.

Decision In which units do we store a field's position?

CSS pixels at the sender's zoom

Save left and top from the drag, as the browser reports them.

Pros

  • No conversion at all in the editor.

Cons

  • Meaningless on any other zoom, screen or device.
  • The server cannot stamp the PDF without knowing the sender's zoom and rotation.

Percent of the rendered page

Save x = 0.12, y = 0.83 of the visible page box.

Pros

  • Independent of zoom.
  • Easy to draw with CSS.

Cons

  • Depends on rotation: a percent of a landscape view is not the same spot on the portrait page.
  • Sizes in percent make a signature box a different physical size on Letter and A4.

PDF points on the unrotated page

Convert every pointer position to PDF user space with the page's viewport, then store { page, x, y, w, h } in points.

Convert back to CSS for drawing at whatever zoom and rotation the viewer uses.

Pros

  • One truth for the editor, the signer view, the mobile view and the stamping server.
  • Physical sizes: a 180 by 36 point box is 2.5 by 0.5 inches on any paper.

Cons

  • Every draw and drag goes through a conversion, which must handle the origin flip and rotation correctly.

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.

ts
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;
Converting a dragged box to points and back, with the page viewport doing the math

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.

Field
ZoomRotate
Move

Alice's signature: CSS left 96, top 874.7, 240 by 48 pixels. Saved as x 72, y 100, 180 by 36 points.

Field placement lab. A US Letter page at 100% zoom, rotated 0 degrees, 816 by 1056 CSS pixels. Alice's signature: CSS left 96, top 874.7, 240 by 48 pixels. Saved as x 72, y 100, 180 by 36 points.Alice, signaturezoom 100%, rotate 0°CSS px (what you drag)left 96, top 874.7240 x 48PDF pt (what we save)x 72, y 100180 x 36scale = zoom x 96/72= 1.333Alice plain, Bob hatched{ "recipient": "alice", "kind": "signature", "page": 1, "rect": { "x": 72, "y": 100, "w": 180, "h": 36 } }
Drag a field or use the buttons (12 points per step, snapped to a 4 point grid), then change zoom and rotation. The CSS numbers change every time; the saved points change only when you actually move the field.

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.

Wireframe of the signing page at sign.example.com. A header says Review and sign, 3 of 9 required done, with a Decline button. The main area shows page 4 of 12 as an image with lines of text, a highlighted Sign here field and a Date signed field. On the right, a Your fields list: page 1 Initials done, page 4 Signature next, page 4 Date filled by the server, page 11 Text empty. At the bottom, a large button reads Next: Signature.sign.example.com: Lease, 12 pagesReview and sign: 3 of 9 required doneDeclinepage 4 of 12Sign hereDate signedYour fieldsp1 Initials: donep4 Signature: nextp4 Date: autop11 Text: emptyNext: Signaturemoves focus, not just scroll
The page image is only the backdrop. The fields, the list and the Next button are real controls, so the flow works with a keyboard, a screen reader, or a thumb.

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.

Sequence diagram of opening a signing link. The signer taps the emailed link. The signing app posts the token to create a session. If the link expired or the envelope was voided, the API answers 410 and the app shows an explanation page. Otherwise the API sets a session cookie and says an SMS code is required. The app strips the token from the URL and shows the code gate. The signer enters the code, the app posts it, and the API returns the fields and page image URLs. The app shows page 1 and a Start button.SignerSigning appEnvelope APItap emailed linkPOST session {token}410 if expired or voidedcookie + gate: smsstrip token, ask codeenter 6-digit codePOST verify {code}fields + page URLspage 1 + Start
Nothing about the document reaches the browser until the gate passes. The red reply is the most common failure: an old link from last month's email.

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?

Email possession only

Whoever opens the unique link is treated as the recipient.

Pros

  • Zero friction.
  • Fine for low-risk forms such as an NDA or a school permission slip.

Cons

  • A forwarded email or a shared inbox lets someone else sign.

A one-time code by SMS or a second email

Before the document opens, we send a 6-digit code and ask for it (the otp-input guide covers the field).

Pros

  • A second factor that most people can complete.
  • Cheap.

Cons

  • SMS can be intercepted or SIM-swapped.
  • Adds 20 to 60 seconds and some drop-off (an estimate).

ID verification or knowledge-based questions

Photo of a government ID plus a selfie, checked by a vendor; or questions drawn from credit records (common in the US).

Pros

  • Much stronger link between the signature and a real person.

Cons

  • Costly per check, slow, and fails for people with thin records.
  • Knowledge questions are weak today because many answers have leaked.

A national eSign scheme, such as Aadhaar eSign in India

The signer is redirected to a licensed eSign Service Provider and authenticates with an Aadhaar OTP or fingerprint.

Pros

  • Recognised under India's IT Act 2000 as an electronic signature.

Cons

  • Only for people with Aadhaar; a redirect away from our page and back.

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.

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.

ts
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];
}
Fields in reading order: by page, then top to bottom (y is flipped in PDF space), then left to right

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.

Sequence diagram of finishing. The signer taps Finish. The signing app posts all values with an idempotency key. If the sender voided meanwhile, the API answers 409 and the app shows a voided page. Otherwise the API queues a stamp and seal job, answers 200 signed, and the app shows the done screen. Later the seal worker returns the sealed PDF and its hash, the API sends a completed event, and the app enables Download.SignerSigning appEnvelope APISeal workertap Finishfinish + keyor 409: voidedstamp + seal job200 signeddone screensealed PDF, hashSSE: completedDownload on
The done screen does not wait for the seal. The 409 path matters because voids and finishes really do collide, and the signer deserves a clear page, not a spinner.

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?

The browser stamps and uploads it

The signing page uses a library such as pdf-lib to draw values into the PDF, then uploads the result.

Pros

  • No PDF work on the server.

Cons

  • The user controls the code and the bytes; nothing stops an edited document.
  • Every browser and font setup produces a slightly different file.
  • The last signer's device does work for everyone.

The server stamps it, no seal

The server draws values onto the stored original and saves a new PDF.

Pros

  • Trusted inputs and one rendering path.

Cons

  • Anyone can edit the downloaded file later and nobody can tell which copy is the real one.

The server stamps, flattens, and seals it

Stamp values into page content, remove the interactive form fields (flattening), append the certificate of completion, then apply a PDF digital signature and a trusted timestamp.

Pros

  • Any later change breaks the signature and PDF readers say so.
  • One file carries the document and its evidence.

Cons

  • Needs a certificate, key protection in a hardware security module, and a job queue.
  • Takes seconds, so completion is asynchronous.

What we pick: The server stamps, flattens and seals. The browser sends values and intent; it never produces the binding artifact.

The server-only pipeline that produces the final PDF, in six steps. The original PDF with its hash taken at send. Stamp values, using server fonts and the server clock. Flatten, so no live form fields remain. Append the certificate of completion with events, IPs and hashes. Sign with a PAdES signature and trusted timestamp, the key in a hardware security module. The sealed PDF, whose hash is stored in the audit log, since a file cannot contain its own hash. A note says the browser sends values, never bytes.Server onlyoriginal.pdfhash at sendstamp valuesserver fonts, clockflattenno live form fieldscertificateevents, IPs, hashessign + timePAdES, key in HSMsealed.pdfhash in audit logthe browser sends values, never bytes
Each step takes trusted input only: the original from storage, values the server already validated, and a key the browser never sees.

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.

Act as
SenderTime

Ana and Ben share routing order 1, so both were emailed at once.

Routing lab, day 0. Envelope sent. Ana, order 1, signer, sent; Ben, order 1, signer, sent; Chloe, order 2, approver, created; Dev, order 3, signer, created; Eve, order 4, cc, created. Audit trail: d0 sent to Ana, Ben.Envelope, day 0sent1Anasignersent1Bensignersent2Chloeapprovercreated3Devsignercreated4EvecccreatedAudit traild0 sent to Ana, Ben
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 Expire event. 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?

Don't record them

Only server-side events go into the trail.

Pros

  • Everything in the trail is server-observed.

Cons

  • "I never saw clause 14" is a common dispute, and we have no evidence either way.

The client writes its own events with its own timestamps

The browser builds the full event list and uploads it.

Pros

  • Rich detail.

Cons

  • Client clocks and client data can be faked; it mixes trusted and untrusted facts in one list.

The client reports, the server stamps and labels

The browser sends small viewed events (page 7 visible for at least a second, found with IntersectionObserver).

The server adds its own time and the request IP, and stores them with source: "client".

Pros

  • Useful evidence of viewing, clearly marked as reported by the signer's device.
  • Time and IP still come from the server.

Cons

  • Ad blockers or a closed tab can drop some events, so absence proves nothing.

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.

Where audit events come from. In the browser, a view tracker built on IntersectionObserver notices page 7 was visible for a second, and a sendBeacon batch sends view events when the page hides or every 10 seconds. On the server, the Envelope API stamps each with server time, the IP address and source client. Server-observed events, such as sent, opened, signed, consent, declined, voided and completed, go straight in. Both flow into an append-only audit log.Browserview trackerIntersectionObserversendBeacon batchon hide or every 10 sServerEnvelope APIstamps time, IP, sourcesent, opened, signedconsent, declinedvoided, completedAudit logappend-onlyserver facts and client reports, labelled, in one chain
The browser only says what it saw. Time, IP, and the 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.

Sequence diagram of embedded signing. The partner page asks its own API to start signing. The partner API asks the e-signature API for a recipient view and receives a one-time URL, which it passes to the page. The page mounts the signing iframe. When the signer finishes, the iframe posts a complete message to the page. The page asks the partner API to check status, which gets the envelope from the e-signature API and learns it is completed.Partner pagePartner APIeSign APISign iframestart signingrecipient viewone-time URLURLmount iframepostMessage donecheck statusGET envelopecompleted
The 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 send frame-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 checks event.origin before trusting it.
  • Events are hints, not proof. A returnUrl query parameter or a postMessage can 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 Partitioned cookie (CHIPS) or in memory, bootstrapped from the one-time URL. The embeddable-widget guide covers the same constraint.
ts
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.");
});
The partner's side of the iframe: trust nothing until the server confirms

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 POST exchange, which scanners generally do not run.
  • Out of logs and analytics: signing pages send Referrer-Policy: no-referrer and 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.

Component architecture. The sender app, a logged-in single-page app, has FieldEditor, EnvelopeList, a PDF.js worker (labelled PDF worker), a query cache, an API client and an SSE client. The signing app, on its own origin, has IdentityGate, GuidedSigner, a form store, page images and an event reporter. On the server: a seal worker, the Envelope API and the document service. The API client calls the Envelope API over REST, the Envelope API pushes events to the SSE client, the event reporter and Finish post to the Envelope API, and the document service serves page images to the signing app.Sender app (SPA)FieldEditorEnvelopeListPDF workerQuery cacheAPI clientSSE clientSigning app (own origin)IdentityGateGuidedSignerForm storePage imagesEvent reporterServerSeal workerEnvelope APIDocument svcRESTSSEfinish, eventspages
Two apps, because their users differ: a sender who edits for minutes on a desktop, and a signer who signs in two minutes on a phone. They share types and one envelope service, not a bundle.
PartLives inResponsibility
Field editorSender appPages, palette, drag and snap in points, validation before send
Envelope listSender appStatuses by group, filters, live updates from the event stream
Page rendererBothPDF.js in a worker for the editor; pre-rendered page images for signers
Identity gateSigning appRenders the check chosen for this recipient; nothing loads before it passes
Guided signerSigning appField order, Start and Next, bottom sheet inputs, autosave, Finish
Event reporterSigning appBatches page views and sends them with sendBeacon
Envelope serviceServerState machines, routing, version checks, outbox for emails
Document serviceServerStores 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 flow for the signing screen. The envelope database feeds the Envelope API, which sends the recipient view to the server-rendered signing shell, which hydrates the query cache, which feeds the field overlay. In parallel the document service sends WebP page images to page img elements under the overlay. Typed values flow from the field overlay into the form store, and the form store sends PUT values and finish back to the Envelope API.Envelope DBEnvelope APIstate machinesDocument svcWebP per pageSSR shellHTML + JSONpage imglazy, privateQuery cacheenvelope, fieldsForm storedraft valuesField overlaycontrols over imgrecipient viewpage.webphydrateundertyped valuesPUT values
Reads come down two lanes, fields as JSON and pages as images, and meet in the overlay. Writes go back one way: the form store is the only thing that talks to the API about values.

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.

ts
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 };
};
The shapes both apps share
CallWhoShape
POST /envelopesSenderDocuments, recipients, fields; returns a draft envelope
PATCH /envelopes/:idSenderField and recipient edits with If-Match: version
POST /envelopes/:id/sendSenderRuns validation; 422 with a list of { fieldId, problem }
POST /envelopes/:id/voidSender{ reason }; 409 if no longer sent
GET /events/streamSenderSSE: { envelopeId, version, status, recipients }
POST /signing-sessionsSigner{ token } exchanged for a session cookie and the recipient's view
PUT /signing/valuesSignerAutosave: { fieldId: value } drafts
POST /signing/finishSignerAll values and an Idempotency-Key; 409 if the status no longer allows it
POST /signing/declineSigner{ reason }
POST /signing/eventsSignerBatched 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 button for signature and initials, input for text and dates, a checkbox input, a select for 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 BroadcastChannel message 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:

  1. 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.
  2. Minutes 5 to 12: draw the envelope model and the status machine. Name routing order and parallel groups.
  3. 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.
  4. Minutes 25 to 35: architecture with two apps, the API, the event stream, and the Finish sequence with its 409.
  5. 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, postMessage to 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next