All-in-One Privacy-First PDF Toolkit
A browser-first PDF toolkit that edits, converts, e-signs, and OCRs documents privately, with a public REST API.
18,845 lines236,223 words26 sectionsgenerated in 2h 34mAug 19, 2026
PDFWorks — Product Requirements & Build Specification #
An all-in-one, privacy-first PDF toolkit: browser-native editing, server-side OCR and Office conversion, ESIGN/eIDAS-compliant e-signature, and a first-class public REST API.
Version 1.0 · Status: Final · Audience: the engineering team or AI coding agent that will build this product from an empty repository.
Overview #
PDF work today is scattered across iLovePDF, Smallpdf, and Sejda. Users juggle three sites, hit a paywall halfway through a task, and upload contracts, medical records, and financial statements to servers they have never evaluated. Teams need signing, forms, and redaction sitting next to the basic edits. Developers need an API. No single product combines the full editing surface with genuine privacy and a real developer platform.
PDFWorks does. Its central architectural commitment is that most tools never upload your file. Merging, splitting, compressing, watermarking, Bates numbering, encrypting, editing, annotating, form-filling, and — critically — redaction all run inside the browser on a WebAssembly engine. The bytes stay on the device. Only the four things that genuinely cannot run locally go to a server: OCR, Office and HTML conversion, signature requests sent to other people, and anything invoked through the public API, which has no browser to run in. Those uploads are encrypted with a per-job key, processed in a network-isolated sandbox, and destroyed within 24 hours. Every tool tells the user which mode it is in before they act, and that indicator is enforced by a build-time check rather than by good intentions.
The same WebAssembly artifact runs in the browser and on the server, so an operation performed in a tab and the same operation performed through the REST API produce byte-identical output. There is one engine, not two.
This document specifies that product completely: 23 sections covering the data model, the processing engine, every tool, the e-signature system and its tamper-evident audit trail, accounts and teams, billing and quotas, the public API and webhooks, the frontend and design system, security and privacy, testing, infrastructure, the marketing site, and a milestone-by-milestone build plan. It is written to be executed cold, without clarifying questions. Every concern has exactly one owning section; every other section references it by number.
How to read this: Section 1 lists the decisions you may want to change before you start, each with a working default so you can begin immediately. Section 22 is the build order. Section 23.6 is the direct instruction set for the executing agent. Everything between is the specification.
Table of Contents #
- Before You Start: Customization Decisions
- Product Overview, Vision & Success Metrics
- Technology Stack & System Architecture
- Conventions & Engineering Standards
- Data Model & Database Schema
- The Processing Engine: Client-Side and Server-Side
- Tool Specifications A: Page Operations, Compression, Image Conversion
- Tool Specifications B: Editing, Annotation, Forms
- Tool Specifications C: Redaction, Watermarks & Numbering, Document Security, OCR, Office & HTML Conversion
- E-Signature System
- Accounts, Guest Access, Teams & Workspaces
- Plans, Billing, Quotas & Entitlements
- Batch Processing & Job Orchestration
- Public REST API & Webhooks
- Frontend Application Architecture
- Design System, Accessibility & Internationalization
- Security, Privacy & Compliance
- Observability, Performance & Reliability
- Testing & Quality Assurance
- Infrastructure, Deployment & Operations
- Marketing Site, SEO Strategy & Product Copy
- Milestones & Execution Plan
- Appendices
1. Before You Start: Customization Decisions #
This section exists so the executor can start building immediately without asking anyone anything. Every value in this document that a real deployment would need to change is listed below with a working default, the reasoning behind that default, and the exact place to change it. If the executor changes nothing in this section, the product builds, runs, and is fully functional under placeholder identity. Nothing downstream in this document depends on any of these values being correct on day one; they are all overridable through configuration, not hard-coded into logic.
1.1 Customization Decisions Table #
| Decision | Working default | Rationale | What to change, and where |
|---|---|---|---|
| Product name | PDFWorks |
A neutral, descriptive working name that does not collide with the established competitors named in Section 2.3 and reads clearly in a toolbar at 14px. | Global find-and-replace of the literal string PDFWorks across the repository (marketing copy, package.json name fields, the <title> templates in apps/web, transactional email templates in packages/contracts's email schemas, the OpenAPI info.title, and the SDK package name @pdfworks/sdk). There is no single "brand config" file — the name appears in source because it appears in generated code (OpenAPI, SDK) as well as prose. |
| Placeholder domains | pdfworks.io (marketing) · app.pdfworks.io (web app) · api.pdfworks.io (public API) · docs.pdfworks.io (documentation) · sign.pdfworks.io (e-signature signer portal) · status.pdfworks.io (status page) |
One apex plus five subdomains keeps the isolated, cross-origin-isolated app origin (app.pdfworks.io, see Section 3.8) cleanly separated from the non-isolated marketing origin, from the public API origin (which has its own CORS and rate-limit posture, Section 14), and from the signer portal (which must remain usable by a signer with no account, on a memorable link). These are placeholders and must not ship in production; every occurrence is a literal string, not a resolved DNS lookup. |
This is a literal find-and-replace, not a search: work through this exact list, in order, replacing every occurrence of (1) pdfworks.io, (2) app.pdfworks.io, (3) api.pdfworks.io, (4) docs.pdfworks.io, (5) sign.pdfworks.io, (6) status.pdfworks.io with the operator's real domains. Occurrences live in: apps/web environment defaults, apps/api CORS allow-list, the Stripe Checkout/Billing Portal success_url/cancel_url templates (Section 3.8, Section 12), DNS and TLS certificate configuration in infra/, the CSP connect-src/frame-src directives (Section 17.6), the OpenAPI servers block in packages/contracts, and the SDK's default baseUrl in packages/sdk-js. The infrastructure domain map in Section 20.7 is generated from these same six literal strings — replace them here first, or the DNS layer and the application layer disagree about what the product's domains are. |
| Cloud provider and region | AWS, us-east-1 primary |
AWS has first-class S3, KMS, and ECS/Fargate support that the server-runtime design (envelope encryption with KMS-wrapped data keys, gVisor-sandboxed worker containers, Section 3.9) assumes without modification. us-east-1 is the lowest-latency region to the largest anticipated initial user base and has the deepest managed-service feature parity. |
infra/terraform/variables.tf (aws_region, environment), and the infra/helm values files per environment (Section 20.2). Moving provider (e.g., to GCP) requires re-implementing the KMS envelope-encryption calls in the storage adapter (packages/db's storage module) against Cloud KMS and swapping the ECS worker definitions for GKE; the encryption model in Section 3.9 does not change. |
| S3-compatible object store | Amazon S3 | Matches the chosen cloud provider, has native per-object server-side encryption context support that pairs cleanly with the application-managed envelope encryption in Section 3.9, and has the broadest tooling support for lifecycle rules used by the retention janitor (Section 6). | infra/terraform/s3.tf for bucket definitions and lifecycle rules; S3_ENDPOINT, S3_REGION, S3_BUCKET_DOCUMENTS, S3_BUCKET_ENVELOPES environment variables (catalogued in Section 23.2). Switching to Cloudflare R2 requires only pointing the AWS SDK v3 S3 client at R2's S3-compatible endpoint and disabling S3-specific lifecycle features not supported by R2, replacing them with the janitor queue's own sweep (Section 13.6). |
| Email sending domain | mail.pdfworks.io, sent via Resend |
A dedicated subdomain protects the apex domain's sender reputation and lets SPF/DKIM/DMARC be scoped narrowly. Resend is chosen for its first-class React Email integration, matching the version table (Section 3.2). | DNS records (SPF, DKIM, DMARC) documented in infra/README.md (a file the executor creates in their own deployment repository); RESEND_API_KEY and EMAIL_FROM_ADDRESS environment variables (Section 23.2); the from field in every React Email template under apps/api/src/email/templates. |
| Stripe account and price IDs | A dedicated Stripe account per environment (test mode for local/preview/staging, live mode for production); price IDs are read from environment variables, never hard-coded | Stripe strictly separates test and live data per account; using one account with mode-switching is the standard, supported pattern and keeps Section 12's plan table's price IDs swappable without a code change. | STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET, and one price-ID variable per SKU (STRIPE_PRICE_PRO_MONTHLY, STRIPE_PRICE_PRO_ANNUAL, STRIPE_PRICE_TEAM_MONTHLY, STRIPE_PRICE_TEAM_ANNUAL, STRIPE_PRICE_API_STARTER, STRIPE_PRICE_API_GROWTH, STRIPE_PRICE_API_SCALE, plus one metered-price ID per overage type) — all catalogued in Section 23.2 and consumed only by the billing module described in Section 12.7. Products and prices are created once via the Stripe dashboard or a one-time setup script the executor writes; the application never creates products at runtime. |
| EU data-residency deployment | Disabled by default; enable for Team plan customers who request it | Running a second regional stack (EU-region S3 buckets, EU-region worker containers, EU-region Postgres read/write) is meaningful infrastructure cost and operational surface. Most early customers do not need contractual data residency, so it ships as an optional, config-gated deployment rather than the default topology. | EU_RESIDENCY_ENABLED boolean environment variable gates the workspace-settings toggle described in Section 11.6; when enabled, a second infra/terraform/eu-region stack (region eu-central-1) is applied and the job router (Section 13.2) consults the workspace's data_residency column to route jobs to the matching regional queue and bucket. |
| Log retention window | 30 days hot (queryable), 1 year cold (archived, not queryable without restore) | Balances incident-investigation usefulness against storage cost and the security posture in Section 17 (logs never contain document content or secrets, so long retention carries limited privacy risk, but 30 days hot keeps query cost predictable). | LOG_RETENTION_HOT_DAYS and LOG_RETENTION_COLD_DAYS environment variables consumed by the log-shipping configuration in Section 18.4; the archival lifecycle rule lives in infra/terraform/logging.tf. |
| Error-tracking DSN | Sentry, one project per app (web, api, worker-media, worker-office) |
Matches the Sentry version line (Section 3.2) and its per-app project split lets alert routing and release tracking stay scoped to the component that actually broke. | SENTRY_DSN_WEB, SENTRY_DSN_API, SENTRY_DSN_WORKER_MEDIA, SENTRY_DSN_WORKER_OFFICE environment variables (Section 23.2); Sentry release tagging is wired into the CI release step described in Section 20.7. |
| Social login providers | Google and Microsoft personal-account OAuth sign-in enabled, alongside email/password; no other provider | This is an implementation choice for the sign-up flow, not a product requirement, so it belongs here rather than being fixed in Section 11.2. Google and Microsoft cover the large majority of the individual-professional segment's (Section 2.2.1) existing accounts, lowering signup friction without adding a provider the team must maintain. This is personal OAuth login only — it authenticates one Google or Microsoft account holder as one PDFWorks user. It has nothing to do with, and does not substitute for, enterprise identity federation (SSO/SAML/SCIM), which is explicitly out of scope (Section 2.8). | GOOGLE_OAUTH_CLIENT_ID/GOOGLE_OAUTH_CLIENT_SECRET and MICROSOFT_OAUTH_CLIENT_ID/MICROSOFT_OAUTH_CLIENT_SECRET environment variables (Section 23.2) configure the two OAuth providers registered in the auth module's socialProviders list (Section 11.2). Removing a provider's environment variables, or its entry in that list, turns it off; email/password sign-in and any remaining provider are unaffected. |
| Analytics tool | Self-hosted Plausible (or an equivalent self-hosted, cookieless, privacy-preserving analytics tool) — explicitly not a third-party ad-pixel platform and not a session-replay tool | Directly supports the product's privacy positioning stated in Section 2.1: a tool that tells users their file never leaves their device should not silently ship their browsing behavior to a third-party ad network, and should not record their on-screen actions either — session-replay tooling is a privacy regression this product's positioning cannot carry. Self-hosting keeps the analytics origin under the operator's control and outside the CSP concerns of Section 17.6. | ANALYTICS_HOST and ANALYTICS_SITE_ID environment variables consumed by the single analytics wrapper module described in Section 18.5; swapping tools means replacing that one module's implementation, not touching call sites, because every call site uses the wrapper's typed track(event, properties) function. Any substitute must be self-hosted and must not set advertising or session-replay cookies; a hosted, ad-network-backed analytics product is not a drop-in substitute no matter how the module is reconfigured. |
| OCR language packs | eng (English) only at install time; the OCR pipeline is architected for more (Section 9.5) but the non-goals in Section 2.8 limit translated UI to English, and installing every Tesseract language pack bloats the worker image for no launch-day benefit. |
Keeps the worker-office container image small and the OCR job's cold-start time low while leaving the mechanism ready to extend. |
The Tesseract language pack list is a build argument (OCR_LANGUAGES=eng) in infra/docker/worker-office.Dockerfile; adding a language is apt-get install tesseract-ocr-<lang> plus adding the ISO 639-2 code to the OCR_LANGUAGES build arg and to the language-selector enum in packages/contracts (Section 9.5). |
| Single-threaded WASM fallback | Build it (default: yes) | Section 3.8's cross-origin isolation requirement is not met by every visitor (some corporate proxies and older browser versions block credentialless), and the performance and availability quality bar in Section 18.6 promises every client-side tool keeps working across the supported browser matrix (Section 15.6). Skipping the fallback would silently break the product for a nontrivial slice of visitors. |
The fallback is a second Emscripten build target in packages/pdfcore/build (pdfcore.st.wasm, no -pthread); the loader in Section 3.3's worker-pool bootstrap selects it automatically when crossOriginIsolated is false or SharedArrayBuffer is undefined. Setting BUILD_ST_FALLBACK=false in the pdfcore build pipeline skips building it, which the executor should do only if they have independently verified 100% of their expected traffic is cross-origin-isolated. |
| Seed admin account | Created by a one-time seed script (pnpm --filter db seed:admin) reading SEED_ADMIN_EMAIL and generating a random 24-character password printed once to the console and never stored in the database or logs |
Every environment needs at least one workspace owner to log in and configure the rest, but a hard-coded default admin credential is a standing security liability (it is the single most common cause of default-credential breaches). | SEED_ADMIN_EMAIL environment variable (Section 23.2); the script itself lives at packages/db/scripts/seed-admin.ts and is idempotent — re-running it when an admin already exists is a no-op that exits 0 with a message, never a duplicate account. |
1.2 How to Use This Document #
Reading order. This document is written to be built from, not read front-to-back like a novel, but it has a natural build order for an executor working alone: Sections 1–4 (this section plus product framing, architecture, and conventions) first, because every later section assumes them. Then Section 5 (data model), because every feature section references its tables. Then Sections 6–10 (the processing engine and the tool catalogue) in numeric order, since later tools sometimes build on primitives introduced by earlier ones (for example, watermarking and Bates numbering in Section 9.2 reuse the page-content-stream utilities introduced for page operations in Section 7). Sections 11–14 (accounts, billing, batch orchestration, the public API) can be built in parallel once Section 5 and Section 6 exist, because they depend on the data model and the job state machine but not on each other's internals. Sections 15–19 (frontend architecture, design system, security, observability, testing) are cross-cutting and are best implemented incrementally alongside the feature sections rather than as a discrete final pass. Sections 20–23 (infrastructure, marketing site, milestones, appendices) close out the build.
How cross-references work. Every concern in this document has exactly one owning section, stated the first time the concern appears and never redefined afterward. A reference like "per the error envelope in Section 14.6" or "using the job state machine in Section 4.7" means: the full, binding definition lives at that section number, and every other mention is required to match it exactly. If an example elsewhere in this document appears to conflict with the owning section, the owning section is correct and the example has a defect — there is no case where two sections intentionally disagree.
What is canonical where. The following concerns are defined exactly once, each in the section named, and are only ever referenced by number elsewhere: the error envelope (14.6), the job state machine (4.7), the pagination and cursor model (14.7), the plans/limits/entitlements table (12.2), the dependency version table (3.2), the canonical conventions table (4.3), the redaction algorithm (9.1), the e-signature audit and hash-chain mechanism (10.4), the retention and deletion rules (6), the security baseline (17), the accessibility bar (16), and the performance budgets (18.6). Restating any of these elsewhere in this document, or in code built from it, is a defect: fix the duplicate, do not maintain two copies.
The one-owning-section rule. When building a feature that touches several of these concerns —
for example, a batch job that returns errors, transitions through job states, and is paginated in its
list endpoint — the implementation pulls from all three owning sections but the documentation for
that feature only needs to say which states, which error codes, and which pagination parameters
apply; it does not need to re-explain what a cursor is or what the processing_error type means.
This keeps the document's size proportional to genuinely new information rather than to repetition,
and it is the same discipline the executor should carry into their own code comments and internal
documentation once building begins.
2. Product Overview, Vision & Success Metrics #
2.1 Vision Statement #
PDFWorks is the PDF toolkit people reach for because it does not ask them to trust it. Most PDF work is small, repetitive, and involves documents the user has no business uploading to a stranger's server — a signed lease, a scanned passport, a client contract with a redlined clause. PDFWorks performs the large majority of its operations entirely inside the user's browser, using a WebAssembly build of production-grade PDF engines, so the file is read, edited, and re-saved without ever leaving the device. When an operation genuinely requires a server — because it needs a format converter too heavy to ship to a browser, or because it needs to coordinate multiple people signing a document — PDFWorks says so, plainly, before the user commits to it, and treats the uploaded bytes as a liability to be destroyed on a strict clock rather than an asset to retain. The product's competitive wedge is not a longer feature list than iLovePDF or Smallpdf; it is that every screen tells the user, honestly and before they act, whether their file is about to leave their device — and for most of the tool catalogue, the honest answer is no.
2.2 Target User Groups #
2.2.1 Individual Professional — "Dana, the contract-adjacent freelancer" #
- Who: A freelance consultant, paralegal, real-estate agent, or similar professional who touches PDFs daily but has no engineering background and no dedicated document-management budget.
- Jobs-to-be-done: Redact a client's Social Security number before forwarding a document; merge three inspection reports into one PDF to email a buyer; fill and sign a W-9 without printing it; compress a scanned lease under an email attachment limit; convert a signed PDF back to a Word document because a counterparty insists on tracked changes.
- Current workarounds: A rotating set of free tools (iLovePDF, Smallpdf, Sejda) hit one at a time as each one's free-tier cap or feature gate is reached; a desktop print-to-PDF driver for anything Acrobat-shaped; occasionally a screenshot pasted into Word because the "real" tool wanted a subscription for a five-second task.
- What would make Dana switch and stay: No account required for the handful of tools she uses weekly (merge, split, compress, rotate — see Section 2.6's guest tool list); a redaction tool she can trust because it explains, not just claims, that the text is gone (Section 9.1); a free tier generous enough that she never has to think about it for basic tasks, with a Pro upgrade path that is obviously worth $9/month the first time she needs OCR or a batch job.
2.2.2 Small Team — "The five-person closing coordination team at a boutique title company" #
- Who: A team of three to fifteen people who collaborate on documents that need multiple external signatures — closing packages, engagement letters, vendor agreements — and who currently pay for a named e-signature product plus a separate PDF editor.
- Jobs-to-be-done: Send a three-signer envelope (buyer, seller, agent) with a defined signing order and get a legally defensible audit trail; keep a shared template library so new hires don't rebuild the same envelope from scratch; see every teammate's job history and signature envelopes in one shared workspace; enforce MFA on the whole team after a phishing scare.
- Current workarounds: A separate e-signature subscription (DocuSign, Dropbox Sign) alongside a separate PDF editor subscription, with no shared audit log between the two and no cost benefit from bundling; manually re-uploading the same template every time; tracking envelope status in a shared spreadsheet because the e-signature tool's dashboard is not shared across the team's seats without a higher-tier plan.
- What would make the team switch and stay: One $15/user/month plan that replaces both subscriptions (Section 12.2); a shared workspace with shared templates and a shared audit log (Section 11.6); an admin who can enforce MFA workspace-wide (Section 17.3); confidence that the audit trail (Section 10.4) will hold up if a signature is ever challenged.
2.2.3 Developer — "The engineer at a proptech startup automating document intake" #
- Who: A backend or full-stack engineer building a product that needs to generate, convert, OCR, or collect signatures on PDFs as part of a larger workflow — lease generation, invoice OCR, contract intake — and does not want to operate PDFium, LibreOffice, and an OCR pipeline themselves.
- Jobs-to-be-done: Convert a generated HTML invoice to PDF from a backend job; OCR a batch of scanned intake documents and get structured text back; kick off a three-party signature envelope from an application event and get a webhook when it completes; keep API costs predictable and visible per environment.
- Current workarounds: Self-hosting an open-source stack (Stirling PDF plus a hand-rolled OCR pipeline) and absorbing the operational burden; or paying for a developer-unfriendly consumer tool's undocumented internal API by scraping it, which breaks without notice.
- What would make the developer switch and stay: A documented, versioned REST API (Section 14) with a published OpenAPI document and an official TypeScript SDK; predictable metered pricing with a configurable spend cap (Section 12.6); webhooks with a real signature scheme (Section 17.4) instead of "poll until it's done"; idempotency keys so a retried request never double-charges or double-sends an envelope (Section 14.8).
2.3 Competitive Analysis #
| PDFWorks | iLovePDF | Smallpdf | Sejda | Adobe Acrobat Web | PDF24 | Stirling PDF | |
|---|---|---|---|---|---|---|---|
| Entry price | Free tier; Pro $9/mo | Free tier; Premium ~$9/mo | Free tier; Pro ~$12/mo | Free tier; Pro ~$7.50/mo | Free tier; paid from ~$20/mo | Free | Free (self-hosted) |
| Privacy model | Most tools run client-side; server tools are time-boxed and disclosed per Section 6 | Server-side; files uploaded for every tool | Server-side; files uploaded for every tool | Server-side, with a stated 2-hour deletion window | Server-side, tied to an Adobe account | Server-side (hosted) or self-hosted desktop app | Self-hosted, so privacy depends entirely on who operates it |
| Tool coverage | Full catalogue in Section 2.6, unified UI | Broad | Broad | Broad, strong on OCR | Broad, strongest on advanced editing | Broad, community-maintained | Broad, but UI polish and mobile experience trail commercial tools |
| Public API | Yes, documented, versioned, metered (Section 14) | Yes, but positioned as an enterprise add-on with opaque pricing | Limited | No public API | Yes, enterprise-oriented, complex onboarding | No | Self-hosted, so "the API" is whatever the operator exposes |
| E-signature | Built in, shared audit log with the rest of the product (Section 10) | Add-on product | Add-on product | Add-on product | Built in (Adobe Sign), separate historical product line | No | No |
| Batch processing | Yes, plan-gated file-count limits (Section 12.2) | Yes, paid tiers | Yes, paid tiers | Yes, paid tiers | Yes, enterprise tiers | Limited | Depends on self-hosted configuration |
| Processing transparency | Explicit per-action indicator (Section 2.4) | None stated | None stated | Deletion window stated, execution location not | None stated | None stated | Whatever the operator discloses |
2.4 Differentiation Thesis #
The defensible differentiator is not any single tool — merge, split, and compress are commodity operations that every competitor in Section 2.3 already offers adequately. The defensible differentiator is the combination of (a) running the large majority of the catalogue client-side by architectural default rather than as a marketing claim, (b) disclosing execution location before the user acts through the Processing Location Indicator (Section 2.5), and (c) unifying that privacy-first document tooling with a genuinely competitive e-signature product under one audit log and one price. Points (a) and (b) are hard to copy because they are not a feature toggle — they require the underlying architecture described in Section 3 (a WASM-compiled engine that produces byte-identical output to its server counterpart, cross-origin isolation, OPFS-based local staging) and a UI discipline that surfaces execution location on every relevant screen without exception, which is enforced in this document as a CI-blocking contract (Section 2.5). Point (c) is hard to copy because it requires an operator willing to build and maintain both a document-editing engine and an e-signature audit/legal-consent system to the same quality bar, which is why the market has, to date, kept them as separate products. A competitor could copy any one of these three; copying all three without years of runway is unlikely.
2.5 The Processing Location Indicator #
Every tool page, every tool card in the tool grid, every batch job row, and every confirmation dialog shows a Processing Location Indicator before the user commits to an action:
| State | Glyph | Token color | Copy |
|---|---|---|---|
| Client-side | Lock | Green | "This file never leaves your device." |
| Server-side | Cloud | Amber | "This file is uploaded, encrypted, and deleted within 24 hours." |
This indicator is not decorative copy — it is a contractual statement about system behavior. The binding version of this contract — the full state table, glyph, token color, and copy shown above, plus the rule for when each state applies — is defined once, canonically, in Section 6.2, and is shown here because it is the product's central proof point (Section 2.4), not because this section owns it. Its visual specification lives in Section 16.4. Every end-to-end test suite that exercises a tool asserts the indicator shown matches the tool's actual execution location from the table in Section 2.6, and a pull request that changes a tool's execution location without updating its indicator fails the required check described in Section 4.11. There is no tool in this product for which the indicator is allowed to be wrong, approximate, or absent.
2.6 Complete Tool Inventory #
| Tool | Purpose | Execution location | Minimum plan | Owning section |
|---|---|---|---|---|
| Merge | Combine multiple PDFs into one, in a user-chosen order | Client-side | Guest | 7.1 |
| Split | Divide one PDF into multiple files by page ranges or a fixed page count | Client-side | Guest | 7.2 |
| Extract pages | Pull a page range or selection out as a new PDF | Client-side | Free | 7.6 |
| Organize (reorder/delete) | Drag-and-drop page reordering and deletion with a thumbnail grid | Client-side | Guest | 7.3 |
| Rotate | Rotate one, several, or all pages by 90/180/270 degrees | Client-side | Guest | 7.4 |
| Delete pages | Remove selected pages | Client-side | Free | 7.5 |
| Insert blank pages | Add blank pages at a chosen position, with a chosen page size | Client-side | Free | 7.7 |
| Crop | Trim page margins by a pixel or percentage box | Client-side | Free | 7.8 |
| Compress | Reduce file size via image downsampling and stream re-encoding | Client-side | Guest | 7.9 |
| Watermark | Stamp repeating or single text/image watermarks on every page | Client-side | Free | 9.2 |
| Page numbers | Add configurable page numbers in a chosen position and format | Client-side | Free | 9.3 |
| Bates numbering | Add sequential legal exhibit numbering with a configurable prefix | Client-side | Free | 9.4 |
| Protect (encrypt) | Add an owner and/or user password with permission flags | Client-side | Free | 9.5 |
| Unlock | Remove a password the user supplies | Client-side | Free | 9.6 |
| Flatten | Bake form fields and annotations into static page content | Client-side | Free | 9.7 |
| Redact | Permanently remove text, images, and metadata under a marked region | Client-side | Free | 9.1 |
| Annotate & shapes | Highlight, freehand draw, add shapes, sticky notes, callouts | Client-side | Free | 8.2 |
| Edit text & images | Directly edit existing text runs and replace/move embedded images | Client-side | Free | 8.1 |
| Fill forms | Fill an existing AcroForm's fields | Client-side | Free | 8.3 |
| Create fillable forms | Draw new form fields onto a PDF | Client-side | Free | 8.4 |
| Self-sign | Draw, type, or upload a signature onto your own document | Client-side | Free | 8.5 |
| PDF → JPG/PNG | Rasterize pages to image files | Client-side | Guest | 7.10 |
| JPG/PNG → PDF | Wrap one or more images into a PDF | Client-side | Guest | 7.11 |
| Repair | Reconstruct a malformed PDF's cross-reference table and object structure | Client-side | Free | 7.12 |
| Edit metadata | View and edit the /Info dictionary and XMP packet |
Client-side | Free | 7.13 |
| OCR | Add a searchable text layer to a scanned PDF | Server-side | Free (2/day cap) | 9.8 |
| PDF → DOCX/XLSX/PPTX | Convert to editable Office formats | Server-side | Free (2/day cap) | 9.9 |
| DOCX/XLSX/PPTX → PDF | Convert Office documents to PDF | Server-side | Free (2/day cap) | 9.9 |
| HTML → PDF | Render an HTML document or URL to PDF | Server-side | Free (2/day cap) | 9.9 |
| PDF → HTML | Convert a PDF to a static HTML representation | Server-side | Free (2/day cap) | 9.9 |
| Signature requests (envelopes) | Send a document to one or more people for signature | Server-side | Free (3/month) | 10 |
The Tool column above gives each tool's display name, not a URL or API identifier — every tool
has two deliberately distinct identifier namespaces built from that same name. The API operation
slug is a short form (for example merge), used as the tool value in public REST API job
creation calls and defined canonically in Section 14.5. The web route slug is a longer,
SEO-oriented, suffixed form (for example merge-pdf), used in the tool page's URL and defined
canonically in Section 15.1. These two namespaces are not an inconsistency to reconcile — a route
slug exists to read well in a browser address bar and a search result, while an operation slug exists
to be short and stable in code — and the table in Section 15.1 is the single place both forms are
mapped to each other and to the tool names above.
Any operation invoked through the public REST API runs server-side regardless of which row above it corresponds to, because the API has no browser to run WASM in (Section 14.1). Any batch job that contains at least one server-side tool runs entirely server-side (Section 13.2).
2.7 End-to-End User Journeys #
2.7.1 Guest merges two files and hits the daily cap #
- A visitor with no account lands on the marketing site and clicks "Merge PDF."
- The tool page loads with the Processing Location Indicator showing "On your device."
- The visitor drops two PDF files (18 MB total) onto the drop zone; the worker pool (Section 3.7) begins loading the WASM engine in the background while the drop zone remains interactive.
- The visitor reorders the two files by dragging thumbnails, then clicks "Merge."
- The merge runs entirely in-browser and completes in under a second; the visitor downloads the result directly from OPFS with no upload having occurred.
- The visitor repeats this four more times across the session (splitting a file, compressing another, rotating a third, merging again), consuming the guest daily cap of five tasks per device (Section 12.2, footnote 1).
- On the sixth attempt the tool page shows a non-blocking banner: "You've used today's 5 free tasks on this device. Create a free account for unlimited client-side tools." The merge button is disabled until either the visitor creates an account or the daily cap resets at midnight UTC.
- The visitor creates a free account in two fields (email, password) and is returned to the same tool page with the task immediately available again — no re-upload, since nothing was ever uploaded.
2.7.2 Freelancer redacts a contract #
- Dana (Section 2.2.1) signs in to her free account and opens the Redact tool.
- She uploads a 12-page vendor contract; the Processing Location Indicator confirms "On your device."
- She marks three regions across two pages: a bank account number, a home address, and a signature block she wants replaced later.
- She clicks "Apply redaction." The tool runs the nine-step redaction algorithm defined in Section 9.1 entirely client-side and produces a Redaction Verification Report showing three glyph runs removed, zero images affected, and a PASS verdict.
- She downloads both the redacted PDF and the verification report, and forwards the redacted file to her client with the report attached as proof the removal was verified, not just visually applied.
2.7.3 Small team sends a three-signer envelope #
- A workspace owner at the title company (Section 2.2.2) opens the Team workspace's shared template library and selects "Residential Closing — 3 Signer" template, previously built by a colleague.
- She uploads the finalized closing PDF, which the template maps onto: buyer signs first, then seller, then the agent countersigns, per the sequential routing rule in Section 10.5.
- She reviews the pre-placed fields from the template (signature, date-signed, initials on each page), adjusts one field position, and clicks "Send." This is a server-side operation (Section 2.6): the document uploads, encrypted at rest with a per-job data key (Section 3.9).
- Each signer receives an email at their turn in the sequence; the buyer opens the link, verifies
their email with a 6-digit code, accepts the Electronic Record and Signature Disclosure, completes
their fields, and signs by drawing a signature. The audit trail records
signer.viewed,signer.consented,field.completed, andsigner.signedevents with hash-chained document snapshots (Section 10.4). - After all three sign, the envelope reaches
envelope.completed; a Certificate of Completion page is appended, the document is flattened, and all three signers plus the sender receive the completed, flattened PDF by email. - The completed PDF and certificate remain downloadable from the shared workspace's audit log for 30 days (Section 6); the audit trail itself is retained for 7 years.
2.7.4 Developer integrates the API #
- An engineer at the proptech startup (Section 2.2.3) creates an API-plan workspace and generates a
live API key with a
documents:writeandjobs:readscope (Section 17.4). - She reads the published OpenAPI document at
https://docs.pdfworks.io/openapi.jsonand installs the official TypeScript SDK. - From a backend job, she calls the SDK's
documents.upload()with a generated HTML invoice, followed byjobs.create({ tool: "html-to-pdf", documentId }), passing anIdempotency-Keyheader so a network retry cannot create a duplicate job (Section 14.8). - She registers a webhook endpoint; when the job reaches
succeeded, PDFWorks POSTs ajob.succeededevent signed with HMAC-SHA256 (Section 17.4), and her endpoint verifies the signature before downloading the resulting PDF via a short-lived signed URL. - Over the following weeks, usage crosses the Starter plan's 2,000-operation monthly inclusion; the dashboard's usage graph and a proactive email at 80% utilization warn her before overage billing begins at $0.012/operation (Section 12.6).
2.7.5 Power user batch-Bates-numbers 180 exhibits #
- A litigation paralegal on a Pro plan opens the Batch tool and selects "Bates Numbering" as the batch operation.
- She uploads 180 individual PDF exhibits (well under the Pro plan's 100-files-per-job cap being
exceeded — she instead runs it in two batches of 90, per the plan limits in Section 12.2), sets the
Bates prefix to
SMITH-starting at000001, and confirms placement in the bottom-right corner. - Because Bates numbering is a client-side tool (Section 2.6) and the batch contains no server-side
tool, the entire batch — Section 13.2's routing rule — runs in the browser, processed by the worker
pool with a concurrency of
clamp(navigator.hardwareConcurrency - 1, 2, 4)(Section 3.7), showing a per-file progress row using the job state machine's states (Section 4.7). - When all 90 files reach
succeeded, she downloads the batch as a single zip archive, sequentially numberedSMITH-000001.pdfthroughSMITH-000090.pdf. - She repeats the process for the remaining 90 exhibits, continuing the Bates sequence from
000091, which the batch tool supports via a "starting number" field that defaults to one past the previous batch's last-used number when run in the same session.
2.8 Non-Goals #
- Native iOS/Android/desktop applications. A PWA covers the installable-app use case (Section 15.7) without the maintenance cost of separate native codebases; a future version could revisit native apps if offline-first mobile usage proves to be a significant share of demand.
- In-person identity verification or knowledge-based authentication for signers. The e-signature product targets ESIGN/UETA/eIDAS simple and advanced signatures (Section 10.1), not identity-proofed qualified signatures; a future version aimed at regulated industries (real estate closings requiring notarization, for example) would need to add this as a distinct, higher-assurance signing tier.
- Languages other than English at launch. The internationalization architecture is built in from day one (Section 16.6) so that adding a locale is a translation-file exercise, not a re-architecture, but translating the UI itself is out of scope for the initial launch.
- A white-label or reseller portal. Every customer uses the product under the operator's own brand; reselling under a partner's brand would require multi-tenant theming and billing changes not designed here.
- PDF/A archival compliance certification. The product produces valid, well-formed PDFs but does not claim or verify PDF/A conformance; an archival-compliance customer segment would need a dedicated conformance-checking and remediation tool.
- PKI-based digital certificate signatures. Explicitly out of scope for the e-signature product; see Section 10.1 for the full rationale and the exact mechanism used instead.
- Enterprise SSO/SAML/SCIM. Team workspaces use PDFWorks accounts with optional MFA (Section 17.3); large-enterprise identity federation is a natural addition once a Team-tier customer requests it contractually, but is not built speculatively.
- Built-in Google Drive/Dropbox/OneDrive pickers. Users work with files from their local filesystem or drag-and-drop; a future version could add cloud-picker integrations as a convenience once the client-side privacy story has clearly landed with users, since a picker necessarily involves a third-party authorization flow.
- Document management or version history beyond the retention windows in Section 6. PDFWorks is a processing tool, not a document repository; users needing long-term versioned storage are expected to keep their own copies.
2.9 Success Metrics #
North-star metric: weekly completed tool operations per active user (a completed operation is any tool run that reaches a terminal success state, client-side or server-side) — chosen because it reflects actual utility delivered rather than logins or page views, and it is measurable identically whether the operation ran in the browser or on a server.
| Metric | Launch target (first 90 days) | Instrumentation |
|---|---|---|
| Activation (guest or new account completes one tool operation within their first session) | 60% | Analytics event tool.completed fired client-side on terminal success, tagged with session_id, is_guest, tool_id (Section 18.5) |
| Guest-to-account conversion | 15% of guests who hit the daily cap create an account within 7 days | Cohort join on device_id (guest) to user_id (post-signup) via the analytics tool's server-side event bridge; never joined using any personally identifying data beyond the account email the user themselves supplied |
| Free-to-paid conversion | 4% of free accounts convert to Pro or Team within 60 days | Stripe subscription-created webhook cross-referenced against account creation date in the users table (Section 5) |
| Tool completion rate (started vs. reached a terminal success state) | 92% for client-side tools, 85% for server-side tools (server-side has more failure surface: upload interruption, OCR quality rejection, format conversion edge cases) | tool.started and tool.completed/tool.failed event pairs correlated by a client-generated operation_id |
| API adoption (workspaces with at least one live-mode API call in the trailing 30 days) | 8% of Team-eligible workspaces | Query against the api_keys table joined to request logs (Section 18.4), filtered to non-test-mode keys |
| Net Promoter Score | 40+ | In-app survey shown once per user, no more than every 90 days, triggered after a user's 10th completed operation |
| Monthly churn (paid subscriptions) | Under 4% monthly for Pro, under 3% monthly for Team | Stripe subscription-canceled and subscription-updated (downgrade) webhooks, aggregated monthly per plan |
3. Technology Stack & System Architecture #
3.1 Layer-by-Layer Stack #
Each layer below states the technology, its justification, and the leading alternative considered and rejected. The version table in Section 3.2 gives the line to cite for every dependency named here.
Language — TypeScript, end to end (web, API, workers-media, contracts, SDK). A single language
across the browser, the API server, and the media worker lets packages/contracts' Zod schemas be
imported, not re-implemented, everywhere except the Python-based worker-office service (Section
3.1, "Office worker language" below). The alternative considered was a Go API server for lower cold-
start latency; rejected because splitting the type system in two would have re-created the exact
hand-written-validator duplication the shared-contract discipline in Section 4.4 exists to eliminate.
Web framework — Next.js, App Router. Server components reduce client bundle size for content-heavy marketing and documentation routes, while the same framework's client components and route handlers serve the highly interactive tool pages. The alternative considered was a plain Vite SPA with a separate marketing static site; rejected because it would have meant two build pipelines and two deployment targets for what is, to the user, one product.
UI library — React. The dominant ecosystem for the class of component libraries this product depends on (Radix UI primitives, TanStack Table for job/history grids), and the framework Next.js is built around. No serious alternative was evaluated given the Next.js choice above.
Styling — Tailwind CSS, with the design tokens from Section 16.2 expressed as Tailwind theme extensions rather than a separate CSS-in-JS runtime. Utility classes keep the tool UI's dense, information-heavy layouts (page thumbnail grids, batch job tables) fast to build and avoid runtime style-computation cost, which matters for the performance budgets in Section 18.6. The alternative considered was vanilla-extract for zero-runtime CSS-in-JS; rejected because Tailwind's utility-first approach maps more directly onto the design system's constrained token set and has a larger contributor pool.
Headless component primitives — Radix UI. Provides accessible, unstyled primitives (dialog, dropdown, tabs, tooltip) that satisfy the WCAG 2.2 AA bar in Section 16 without hand-rolling focus management and ARIA wiring for every interactive component.
Icons — lucide-react. A single consistent icon set, tree-shakeable, matching the design system's line-icon visual language (Section 16.2).
Client state — Zustand, for ephemeral UI state (tool wizard step, selected pages, drag state) that does not belong in a server cache. The alternative considered was React Context alone; rejected because Context re-renders do not scale to the frequent, granular updates a page-thumbnail-grid drag interaction produces.
Server-state cache — TanStack Query, for every piece of data that originates from the API (job status polling, document lists, workspace membership). Handles cache invalidation, background refetch, and request deduplication so job-status polling (Section 13.5) does not need hand-rolled polling logic in every component that displays a job.
Data grids — TanStack Table, for the job history table, the batch job row list, and the API usage dashboard — all of which need client-side sorting, filtering, and virtualization at a scale (hundreds to low thousands of rows) that a hand-rolled table would handle poorly.
Forms — react-hook-form, with @hookform/resolvers binding directly to the Zod schemas from
packages/contracts (Section 4.4), so a form's validation rules are never restated — they are the
same schema the API validates against.
Schema and validation — Zod. The single source of truth for every request body, response body, and form value across the entire system, per Section 4.4.
Internationalization — next-intl. Chosen over a hand-rolled i18n layer because it integrates with the App Router's server components without a client-side waterfall, satisfying the i18n-architecture- without-translations scope stated in Section 2.8.
Local persistence (browser) — Dexie over IndexedDB, for metadata only (Section 3.7); never file bytes.
Worker RPC — Comlink, to give the main thread a promise-based, type-safe proxy to the WASM worker
pool instead of hand-rolled postMessage message routing.
HTML sanitization — DOMPurify, applied to any HTML rendered from a converted document (the PDF → HTML tool, Section 9.7) before it touches the DOM, closing the obvious XSS vector a naive HTML render would open.
API framework — Hono. A single lightweight, standards-based (Fetch API) router that runs
identically on the Node server and is trivially portable if a future edge deployment is wanted; it
hosts both the public /v1 and internal /internal route trees (Section 3.9). The alternative
considered was Express; rejected because Hono's smaller surface area and native TypeScript-first
routing produce less boilerplate for the OpenAPI-generation pipeline in Section 4.4.
ORM and schema — Drizzle ORM, chosen for SQL-first ergonomics (queries read like SQL, not like a
query-builder DSL) and a migration tool (drizzle-kit) that generates plain, reviewable SQL migration
files rather than an opaque binary format.
Primary database — PostgreSQL. Relational integrity for billing, entitlements, and the
signature-envelope state machine is non-negotiable; Postgres's native jsonb, partial indexes, and
row-level constraints cover every modeling need in Section 5 without a second database engine.
Cache and queue backing store — Redis, backing both BullMQ (below) and the rate-limiter token buckets (Section 14.9).
Job queue — BullMQ, providing priority lanes, delayed jobs (for signature reminders, Section 10.5), and retry/backoff (Section 4.8) on top of Redis without operating a separate broker like RabbitMQ or Kafka, which would be disproportionate to this system's throughput.
Object storage client — AWS SDK for JavaScript v3 (S3 client), targeting the S3-compatible store chosen in Section 1.1.
Payments — Stripe, server SDK for subscription and metered-billing management plus Stripe.js only for the hosted Checkout and Billing Portal redirect flows described in Section 3.8 — never Stripe Elements.
Auth — better-auth, a self-hosted session and credential library (rather than a third-party hosted auth SaaS) so that account data never leaves the operator's own database, consistent with the product's privacy posture; supports the password/MFA/session model specified in Section 17.
Transactional email — Resend, with React Email for templates, so every transactional email (signature invites, receipts, security alerts) is built as a reviewable React component rather than a string-concatenated HTML blob.
Logging — Pino, structured JSON logging with redaction paths configured per Section 4.9's secret- handling rule.
Tracing — OpenTelemetry JS API, propagating W3C traceparent from the browser through the API
into workers so a single request can be followed across every layer of Section 3.9's server runtime.
Error tracking — Sentry, for both the Next.js app and the Node services, per the per-app DSN setup in Section 1.1.
Unit and integration testing — Vitest. End-to-end testing — Playwright. Package build
tooling — Vite (used only for building packages/* library outputs, not for the Next.js app itself,
which uses Next's own build pipeline).
Image processing (server side) — sharp, for thumbnail generation and any raster resizing the
server-side conversion pipeline needs outside of pdfcore's own image handling.
ID generation — uuidv7, generated application-side (Section 4.3) rather than relying on a database default, so IDs are available before an insert and are naturally time-sortable.
PDF engine — PDFium plus QPDF, compiled once to WebAssembly, detailed fully in Section 3.6.1 below.
OCR — OCRmyPDF orchestrating the Tesseract OCR engine, with pikepdf as OCRmyPDF's underlying PDF manipulation library — a proven, actively maintained combination purpose-built for adding a searchable text layer to a scanned PDF without altering its visual appearance.
Office and HTML conversion — LibreOffice (headless) for Office-format conversions, Poppler utils and Ghostscript for supporting PDF rasterization and PostScript-adjacent operations in the office-conversion pipeline.
Office worker language — Python. LibreOffice's headless automation, pikepdf, and OCRmyPDF all
have their most mature, actively maintained integration surface in Python; rather than shelling out to
Python from a Node process for every call, worker-office is a dedicated Python service that consumes
BullMQ jobs directly via a Redis client, keeping the process boundary explicit instead of implicit.
3.2 Version Table #
This is the single, canonical version table for the entire document. It is the only place a specific dependency version number appears; every other section that names one of these dependencies cites it by number — "Section 3.2" — rather than restating a version line.
| Dependency | Line to cite |
|---|---|
| Node.js | 24.x LTS ("Krypton") |
| TypeScript | 7.x |
| Next.js | 16.x (App Router) |
| React | 19.x |
| Tailwind CSS | 4.x |
| Radix UI primitives | 1.x |
| lucide-react | 1.x |
| Zustand | 5.x |
| TanStack Query | 5.x |
| TanStack Table | 9.x |
| react-hook-form | 7.x (@hookform/resolvers 5.x) |
| Zod | 4.x |
| next-intl | 4.x |
| Dexie | 4.x |
| Comlink | 4.x |
| DOMPurify | 3.x |
| Hono | 4.x (@hono/node-server 2.x) |
| Drizzle ORM | 0.45.x (drizzle-kit matching) |
| PostgreSQL | 18 |
| Redis | 8.x (ioredis 6.x) |
| BullMQ | 6.x |
| AWS SDK for JavaScript v3 (S3 client) | 3.x |
| Stripe server SDK | 22.x |
| Stripe.js | 9.x |
| better-auth | 1.7.x |
| Resend | 6.x (React Email 1.x) |
| Pino | 10.x |
| OpenTelemetry JS API | 1.x |
| Sentry (Next.js + Node) | 10.x |
| Vitest | 4.x |
| Playwright | 1.62.x |
| Vite | 8.x (used by the package build pipeline, not by the Next.js app) |
| sharp | 0.35.x |
| uuidv7 | 1.x |
| PDFium | 2026 stable branch — pinned by commit in the build script, not by semver |
| QPDF | 12.x |
| OCRmyPDF | 17.x |
| Tesseract OCR engine | 5.x |
| pikepdf | 10.x |
| LibreOffice (headless) | 25.x |
| Poppler utils | 25.x |
| Ghostscript | 10.x |
| Python (workers) | 3.13.x |
These version lines are a known-good floor, not a lockfile. At build time, install the current stable
release of each dependency (pnpm add <pkg>@latest, or the ecosystem equivalent), confirm the major
line still matches, and let the lockfile record the exact resolved versions.
3.3 Monorepo Layout #
apps/
web/ Next.js 16 App Router — marketing, app, tool pages, dashboard
api/ Hono service — public REST API (/v1) + internal API (/internal)
signer/ Next.js route group inside apps/web, served at sign.pdfworks.io
worker-media/ Node container — OCR orchestration, rasterization, image work
worker-office/ Python container — LibreOffice, pdf2docx, Poppler, Ghostscript
packages/
pdfcore/ C++ sources + Emscripten build + TypeScript bindings (THE engine)
contracts/ Zod schemas + generated OpenAPI + shared types
db/ Drizzle schema, migrations, seed
ui/ Design system components + tokens
sdk-js/ Public API client, published to npm
config/ eslint/tsconfig/tailwind presets
infra/ Dockerfiles, compose, Helm chart, TerraformPackage purposes, one line each:
| Package/app | Purpose |
|---|---|
apps/web |
The marketing site, the authenticated app shell, every tool page, and the account/billing dashboard; the primary Next.js application. |
apps/api |
The Hono service hosting both the versioned public API (/v1) and the internal API (/internal) the web app calls; one auth middleware chain, two credential types. |
apps/signer |
A route group within apps/web, deployed under the sign.pdfworks.io domain, serving the no-account signer experience described in Section 10.5. |
apps/worker-media |
Consumes the ocr queue plus rasterization-adjacent jobs; orchestrates the Tesseract/OCRmyPDF pipeline. |
apps/worker-office |
Consumes the convert queue for Office and HTML conversions; the only Python service in the system. |
packages/pdfcore |
The PDFium+QPDF Emscripten build and its TypeScript bindings; the single engine described in Section 3.6.1. |
packages/contracts |
Every Zod schema, the generated OpenAPI document, and the shared TypeScript types derived from them (Section 4.4). |
packages/db |
The Drizzle schema definitions, migration files, and seed scripts, including the admin-seed script from Section 1.1. |
packages/ui |
The design system's React components and design tokens (Section 16.2), consumed by apps/web. |
packages/sdk-js |
The published, versioned npm client for the public API, generated in part from packages/contracts. |
packages/config |
Shared ESLint, TypeScript, and Tailwind configuration presets consumed by every other package and app. |
infra |
Dockerfiles for every service, a local docker-compose stack, the Helm chart for cluster deployment, and Terraform for cloud resources. |
3.4 System Architecture Diagram #
┌───────────────────────────┐
│ Browser │
│ app.pdfworks.io │
│ (COOP: same-origin, │
│ COEP: credentialless) │
│ │
│ ┌──────────┐ ┌─────────┐ │
│ │Main thread│─▶│ Worker │ │
│ │ (React, │ │ pool │ │
│ │ Zustand, │◀─│(pdfcore │ │
│ │ TanStack)│ │ WASM) │ │
│ └────┬─────┘ └────┬────┘ │
│ │ OPFS staging│ │
│ │ Dexie (meta)│ │
└───────┼──────────────┼──────┘
│ fetch (JSON) │ upload (server-side tools only)
▼ ▼
┌──────────────────────────────────────────────┐
│ apps/api (Hono, Node 24) │
│ /v1 (public REST API) /internal (web app) │
│ one auth chain — session cookie / API key │
└───────┬───────────────┬────────────────────────┘
│ │
┌──────▼─────┐ ┌─────▼──────┐
│ PostgreSQL │ │ Redis │
│ (Drizzle) │ │(BullMQ + │
│ │ │ rate limit) │
└────────────┘ └──────┬──────┘
│ enqueue
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
┌────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ worker-media │ │ worker-office │ │ (esign/batch/ │
│ (Node, gVisor) │ │ (Python, gVisor) │ │ webhook/janitor │
│ OCR, raster │ │ LibreOffice, HTML │ │ share the same │
└───────┬─────────┘ └────────┬──────────┘ │ worker pools by │
│ │ │ queue routing) │
└──────────┬────────────┘ └──────────────────┘
▼
┌─────────────────┐
│ S3-compatible │
│ object storage │
│ (per-job envelope│
│ encryption) │
└─────────────────┘
Stripe Checkout / Billing Portal: hosted, redirect — no embedded iframe (Section 3.8)3.5 Request Lifecycle #
3.5.1 Client-side operation (e.g., merge) #
- User drops files onto the tool page; files are written directly to OPFS-backed scratch storage, never read fully into main-thread memory as a single buffer.
- The main thread posts a job to the worker pool via Comlink, referencing the OPFS file handles.
- A pool worker loads (or reuses, if already resident) the
pdfcoreWASM module, opens each input via QPDF's object model, and performs the merge as a sequence of object-model operations, writing the result to a new OPFS file. - Progress updates (0–100, matching Section 4.7's job state machine vocabulary) are posted back to the main thread over the Comlink channel and reflected in the UI without any network request.
- On completion, the main thread offers a direct download from the OPFS-resident output file (via a
File System Accesssave flow or a generatedBlobdownload, depending on browser support) and records a lightweight metadata entry (filename, tool, timestamp — never bytes) in Dexie for the "recent files" list. - No network request contains file bytes at any point in this lifecycle. The only network activity is the initial page load and the WASM artifact fetch (cached per Section 18.6's performance budgets).
3.5.2 Server-side operation (e.g., OCR) #
- The client requests a signed upload URL from
/internal/documents(or, for API consumers,/v1/documents, Section 14.2). - The client uploads the file directly to S3-compatible storage via the signed URL; the API server never proxies file bytes through itself.
- On upload completion (an S3 event or a client-confirmed callback), the API creates a
documentsrow (Section 5) and, when the user submits the tool form, ajobsrow in statequeued, enqueuing a BullMQ job onto the appropriate queue (ocr,convert, and so on, Section 3.9) at the priority matching the user's plan (Section 12.2). - A worker in
worker-mediaorworker-officepicks up the job, downloads the encrypted object from S3-compatible storage into its per-job tmpfs, unwraps the per-job data key via KMS, decrypts, processes, and re-uploads the result under a new envelope-encrypted object. - The worker updates the
jobsrow tosucceededorfailedand publishes a completion event; the client, which has been polling/v1/jobs/{jobId}(or holding a long-lived connection, Section 13.5), reflects the terminal state and offers a download link. - If a webhook is registered for the workspace, the API publishes a signed
job.succeeded(orjob.failed) event per Section 17.4. - The retention janitor (Section 6, Section 13.6) later deletes the uploaded and result objects per the 2-hour-after-terminal / 24-hour-absolute rule.
3.6 The pdfcore Engine #
3.6.1 Design #
pdfcore is compiled once, from PDFium (rendering, text/layout extraction, image decoding, form
field interaction) and QPDF (object model manipulation, encryption, object streams, linearization),
plus zlib, libjpeg-turbo, libwebp, and Brotli, into a single Emscripten-built WebAssembly artifact with
a TypeScript binding layer over it. The same artifact runs unmodified in the browser worker pool
(Section 3.7) and in the worker-media Node container (Section 3.9) — there is no separate "server
engine." Given identical input bytes and an identical deterministicTimestamp parameter (which
replaces any wall-clock-derived value the underlying libraries would otherwise embed, such as a PDF's
/ModDate), an operation run in the browser and the same operation run through the public API produce
byte-identical output. This equality is enforced by an automated cross-host test that runs the full
golden-file corpus (Section 19.4) through both hosts and diffs the output
bytes.
3.6.2 Threading and the single-threaded fallback #
The primary build is compiled with pthreads support and requires SharedArrayBuffer, which in turn
requires the cross-origin isolation described in Section 3.8. A second build target, compiled without
-pthread, ships alongside it as the single-threaded fallback described in Section 1.1; the loader
selects between them at runtime based on self.crossOriginIsolated and the presence of
SharedArrayBuffer. The fallback is slower — operations that would otherwise be parallelized across
the worker pool's threads run sequentially within a single thread — but it is never wrong: the same
object-model code path executes, only the threading strategy differs.
3.6.3 Memory limits and streaming #
pdfcore does not use WASM64; the addressable heap per worker is bounded by the 32-bit WASM memory
model. For files above approximately 700 MB, the engine streams page ranges through OPFS-backed
scratch storage rather than loading the full document into WASM linear memory at once — an operation
like split or page extraction processes a bounded window of pages at a time, flushing completed output
to OPFS before advancing. This is transparent to the user; the only observable effect is that very
large files show incremental progress rather than an instantaneous jump to 100%.
3.6.4 Build pipeline, versioning, and caching #
The Emscripten build is a dedicated packages/pdfcore/build pipeline invoked in CI: it checks out
PDFium at the commit pinned in the build script (Section 3.2 — PDFium is pinned by commit, not
semver), checks out QPDF at the version line in Section 3.2, applies a small patch set (documented in
packages/pdfcore/patches) that exposes the specific object-model functions the TypeScript bindings
need, and produces pdfcore.wasm (threaded) and pdfcore.st.wasm (single-threaded fallback). Both
artifacts are named with a content hash (pdfcore.<hash>.wasm) and served with a one-year immutable
Cache-Control header, satisfying the performance budget in Section 18.6. The build script writes the
resolved PDFium commit and QPDF version into a pdfcore-build-manifest.json file checked into the
repository, so every deployed artifact is traceable to an exact source revision.
3.7 Browser Runtime #
- Worker pool. Sized
clamp(navigator.hardwareConcurrency - 1, 2, 4)— leaving one logical core free for the main thread's UI work, with a floor of 2 (so even a 2-core device gets some parallelism) and a ceiling of 4 (beyond which coordination overhead outweighs benefit for typical document sizes). Comlink wraps each worker with a typed RPC surface exposing one method perpdfcoreoperation (merge,split,redact, and so on), each returning a promise that resolves with the OPFS handle of the result and rejects with a typed error (Section 14.6). - OPFS staging. The Origin Private File System is the staging area for every input file,
intermediate artifact, and output file. File bytes are never placed in
localStorage,sessionStorage, or a base64 data URL — those mechanisms are unsuitable for multi-megabyte binary content and, forlocalStorage, are synchronous and would block the main thread. - Dexie over IndexedDB — metadata only. Recent-file entries (filename, tool, timestamp, an OPFS path reference), tool presets (a user's saved watermark configuration, for example), draft annotations awaiting a user's "Apply" action, and queued batch descriptors. Never file bytes.
- Purge rules. Everything OPFS-resident is purged when the tab closes and whenever the user triggers the explicit "Clear local data" control in account settings. As a backstop against a tab that is never properly closed (e.g., the OS kills the browser process), a janitor sweep runs on next app load and removes any OPFS entry older than 24 hours.
3.8 Cross-Origin Isolation #
The app sets Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: credentialless on every response from app.pdfworks.io. This combination is what unlocks
SharedArrayBuffer, which the threaded pdfcore build in Section 3.6.2 requires.
What it breaks. credentialless strips credentials (cookies, client certificates) from
cross-origin subresource requests unless the subresource response opts back in via
Cross-Origin-Resource-Policy: cross-origin. Any third-party embed that expects ambient credentials —
most critically, an embedded payment iframe like Stripe Elements — cannot function inside an isolated
document.
The resulting decision. Billing therefore uses Stripe Checkout (hosted, full-page redirect)
for new subscriptions and the Stripe Billing Portal (hosted, full-page redirect) for plan changes,
payment method updates, and cancellation. Stripe Elements is not used anywhere in this product. Any
other third-party embed considered in the future that cannot satisfy credentialless is either
dropped or moved to a non-isolated route segment (a route that does not set the isolation headers and
therefore cannot load the threaded WASM build); the tool routes themselves always stay isolated.
Fallback when crossOriginIsolated is false. Some environments (certain corporate proxies, older
browser versions, or an operator who has not yet propagated the isolation headers through an
intermediate CDN correctly) will report self.crossOriginIsolated === false. In that case the loader
selects the single-threaded pdfcore.st.wasm build from Section 3.6.2 automatically; every client-side
tool remains fully functional, only slower. The UI does not surface this as an error — a small,
dismissible notice ("Running in compatibility mode — some operations may be slower") is the only
user-visible signal, and it is informational, not blocking.
3.9 Server Runtime #
apps/apiruns Hono on Node 24, terminating both the public/v1API and the internal/internalAPI consumed byapps/web. One codebase, two route trees, one auth middleware chain that branches on credential type (session cookie for/internal, API key for/v1, Section 17.4).- Jobs run on BullMQ on Redis. Named queues:
ocr,convert,esign,batch,webhook,janitor. Two priority lanes —priorityfor Pro/Team/API paid workspaces andstandardfor Free — implemented as a BullMQ job-priority integer value on a single queue per queue name, not as separate queues, so queue depth and worker concurrency are managed once per job type rather than once per (job type × plan) combination. - Blob storage is S3-compatible (Section 1.1). Every object is encrypted with a per-job data key using AES-256-GCM envelope encryption: a fresh 256-bit data key is generated per job, used to encrypt the object client-side-of-storage (i.e., inside the API or worker process, before the PUT), then wrapped by a KMS master key and stored, wrapped, on the job's row. Deleting the wrapped key from the row cryptographically shreds the object immediately — the ciphertext bytes become permanently unrecoverable even before the janitor's byte-level deletion sweep runs, which is what makes the "delete now" guarantee in Section 6 instantaneous from the user's perspective.
- Workers run in gVisor-sandboxed containers: no outbound network access (a compromised OCR or Office-conversion dependency cannot exfiltrate data or phone home), a read-only root filesystem, a per-job tmpfs mount that is wiped on job completion, and a hard wall-clock timeout enforced by the container orchestrator independent of the application-level timeout budgets in Section 4.8.
3.10 Data Flow: Upload, Process, Download, Delete #
UPLOAD PROCESS DOWNLOAD DELETE
------ ------- -------- ------
Client requests ──▶ API creates `documents` ──▶ API creates `jobs` ──▶ Worker downloads,
signed PUT URL row (state: uploaded) row (state: queued) unwraps data key,
│ │ │ decrypts, processes
▼ ▼ ▼ │
Client PUTs bytes Object stored with a BullMQ job enqueued ▼
directly to S3 fresh per-job data key on the matching queue Worker re-uploads
(never through (AES-256-GCM), key at the plan's result under a NEW
the API process) wrapped by KMS, wrapped priority lane envelope-encrypted
key stored on the row object; `jobs` row
→ succeeded
│
▼
Client downloads via a
short-lived signed GET URL
(Section 14.2); webhook
fires if registered
── 2 hours after terminal state,
or 24 hours after upload,
or an explicit delete request ──
▼
Wrapped data key destroyed
(cryptographic shred,
immediate) → object bytes
removed by the janitor queue
within 60 secondsEach of the four stages above has its own failure and retry behavior, described individually below.
3.10.1 Upload #
The client requests a signed PUT URL scoped to a single object key and a maximum content length
matching the requesting plan's file-size limit (Section 12.2). The signed URL is valid for 5 minutes;
if the client does not complete the PUT within that window, it must request a new one. On a successful
PUT, S3-compatible storage emits an event (or, where the store does not support event notifications,
the client confirms completion via POST /internal/documents/{id}/confirm-upload), which flips the
documents row from pending to uploaded and triggers magic-byte and structural validation
(Section 17.5) before the document becomes eligible for any job. A PUT that never completes leaves an
orphaned pending row, swept by the janitor queue after 1 hour.
3.10.2 Process #
Once a job is enqueued, a worker claims it, downloads the encrypted object into its per-job tmpfs,
unwraps the data key via KMS, decrypts into memory (never to unencrypted disk), and performs the
operation. Every worker job handler wraps its core logic in the typed-result pattern from Section 4.5:
a processing failure (a corrupt input, an OCR engine crash, a LibreOffice timeout) is caught, mapped to
a processing_error in the error envelope (Section 14.6), and the job transitions to failed with the
mapped error recorded on the row — the raw underlying exception is logged (Section 4.3, logging row)
but never surfaced verbatim to the caller, since library-internal error text can leak implementation
detail.
3.10.3 Download #
The completed object is fetched only via a short-lived signed GET URL (default 15-minute expiry,
regenerated on each GET /v1/documents/{id}/download-url call, Section 14.2) — never a permanently
public object URL. Every download-URL issuance is logged with the requesting principal (user or API
key) so the audit log (Section 17) can answer "who downloaded this file and when" even though the file
itself is not retained past the windows in Section 6.
3.10.4 Delete #
Deletion is either scheduled (the default 2-hour-after-terminal / 24-hour-absolute rule) or immediate
(an explicit "Delete now" click or a DELETE /v1/documents/{id} call). Both paths converge on the same
two-step shred: destroy the wrapped data key on the row first (this is the moment the object becomes
cryptographically unrecoverable, and it completes within the deleting request itself), then enqueue a
janitor job that issues the physical DeleteObject call against the S3-compatible store, completing
within 60 seconds. The documents row itself is soft-deleted (deleted_at set, Section 4.3) rather
than removed, so billing and audit queries retain an accurate historical record without retaining any
file content.
3.11 Environments #
| Environment | Purpose | Data | Stripe mode | Isolation headers |
|---|---|---|---|---|
| Local | Individual development, via docker-compose in infra/ |
Synthetic seed data only | Test mode | Enabled (self-signed cert accepted locally) |
| Preview | One ephemeral environment per open pull request, deployed automatically | Synthetic seed data, reset on redeploy | Test mode | Enabled |
| Staging | Pre-production validation against production-shaped infrastructure | Anonymized or synthetic data only, never real customer data | Test mode | Enabled |
| Production | Live customer traffic | Real customer data, full retention rules of Section 6 apply | Live mode | Enabled |
Every environment runs the identical container images and Terraform-defined infrastructure shape; what differs is environment variables (Section 23.2), the Stripe mode, and the data seeded into it. No environment-specific code branches are permitted in the application layer — any behavior that must differ by environment is expressed as configuration, consistent with the twelve-factor discipline in Section 4.9.
3.12 Build and Release Overview #
Every push to a feature branch triggers CI: lint, type-check, unit tests, and a Preview environment
deploy. Merging to the trunk branch triggers the full test suite including Playwright E2E and the
pdfcore cross-host byte-equality check (Section 3.6.1), then an automatic deploy to Staging. A
production release is a manually triggered promotion of a specific, already-validated Staging build
artifact — production never builds from source directly. Container images are tagged with the Git
commit SHA and the pdfcore build manifest hash from Section 3.6.4, so a production incident can be
traced to an exact source revision and an exact engine build.
| Stage | Trigger | What runs | Blocking? |
|---|---|---|---|
| Fast checks | Every push to a feature branch | Lint, type-check, unit tests (Vitest) | Yes — blocks merge |
| Preview deploy | Every push to a feature branch with an open pull request | Full app stack deployed to an ephemeral, PR-numbered environment | No — informational, but linked in the PR |
| Contract check | Every push touching packages/contracts |
Regenerates openapi.json and diffs it against the committed copy; fails if they differ |
Yes — blocks merge |
| Full suite | Merge to the trunk branch | Everything in "fast checks" plus Playwright E2E across the browser matrix (Section 15.6) and the pdfcore cross-host byte-equality check |
Yes — blocks the Staging deploy |
| Staging deploy | Automatic, on a successful full suite run against the trunk branch | Build production container images, tag with commit SHA, deploy to Staging | N/A (automatic) |
| Production promotion | Manual trigger, selecting a specific Staging-validated image tag | Deploy the selected, already-built images to Production; no rebuild occurs at this step | N/A (manual gate) |
A production rollback is the same promotion mechanism run in reverse: selecting the immediately prior image tag and re-running the promotion step, which typically completes within the time it takes the container orchestrator to cycle running pods (well under the 99.9% monthly availability target's error budget, Section 18). The full CI/CD pipeline, environment-promotion tooling, and infrastructure-as- code layout are specified in Section 20.
4. Conventions & Engineering Standards #
4.1 TypeScript Strictness #
Every tsconfig.json in the repository extends a single base configuration in packages/config. The
base configuration sets:
| Compiler option | Value | Why |
|---|---|---|
strict |
true |
The umbrella flag; every sub-flag below is implied by it but listed explicitly so a future partial-opt-out is visible in a diff. |
noUncheckedIndexedAccess |
true |
Array and record index access returns T | undefined, forcing every items[i] or record[key] read to be handled — this is the single highest-value flag for a codebase full of page-array and record-map manipulation (Section 7–9's tool implementations). |
exactOptionalPropertyTypes |
true |
Distinguishes "property omitted" from "property explicitly set to undefined," which matters for the Zod schemas in packages/contracts where an omitted field and an explicit null can carry different API semantics (Section 14.6's param field, for instance). |
noImplicitOverride |
true |
Requires an explicit override keyword, catching accidental shadowing in the design system's component composition (Section 16). |
noFallthroughCasesInSwitch |
true |
Every switch over the job state machine (Section 4.7) or the error type enum (Section 14.6) must handle fallthrough explicitly or not at all. |
noPropertyAccessFromIndexSignature |
true |
Forces bracket notation for dynamic keys, keeping typed object shapes (Zod-inferred types) visually distinct from dynamic dictionaries at the call site. |
verbatimModuleSyntax |
true |
Makes type-only imports explicit (import type { ... }), which keeps the bundler's tree-shaking predictable and avoids accidental runtime imports of type-only modules. |
isolatedModules |
true |
Required for the Vite-based package build pipeline (Section 3.1) to transpile files independently. |
skipLibCheck |
true |
Third-party .d.ts files are not re-checked; keeps CI type-check time bounded as dependencies grow. |
moduleResolution |
"bundler" |
Matches how Next.js 16 and the Vite package builds actually resolve modules, avoiding the Node-resolution edge cases "node16" would introduce for path-alias imports. |
4.2 Linting, Formatting, Import Order, Path Aliases #
- ESLint uses a flat config in
packages/config/eslint, extendingtypescript-eslint's strict and stylistic rule sets, the React and React Hooks recommended sets, andjsx-a11y(feeding directly into the WCAG 2.2 AA bar from Section 16). Every app and package extends this one shared config rather than defining its own rule set. - Prettier is the sole formatter (no ESLint stylistic-formatting rules are enabled where Prettier already covers the case, avoiding the two tools fighting each other). Configuration: 2-space indentation, semicolons, double quotes for JSX attributes and single quotes elsewhere per Prettier's default JS convention, 100-character print width, trailing commas everywhere valid in the current target syntax.
- Import ordering is enforced by
eslint-plugin-import'sorderrule in four groups, blank-line separated: (1) Node built-ins, (2) external packages, (3) internal packages via path alias (below), (4) relative imports. Within each group, alphabetical. - Path aliases. Every app and package uses
@/*to mean "this package's ownsrc/root," and cross-package imports use the package's published name (@pdfworks/contracts,@pdfworks/ui,@pdfworks/db) resolved via pnpm workspace linking — never a relative path that crosses a package boundary (../../packages/contracts/src/...is disallowed by an ESLintno-restricted-importsrule). - No default exports, except pages. Every module exports named bindings; the sole exception is
Next.js's file-system-routing convention, which requires a default export for
page.tsx,layout.tsx,loading.tsx,error.tsx, androute.tsfiles. This keeps every other import statement self-documenting at the call site (import { MergeToolPage } from ...versus an anonymous default) and prevents accidental name drift between a module's file name and its exported symbol. - File and folder naming. Files:
kebab-case.ts/kebab-case.tsx. Folders:kebab-case. React component files are named after their primary export in kebab-case (tool-card.tsxexportsToolCard). Test files sit beside the code they test with a.test.ts/.test.tsxsuffix (tool-card.test.tsxbesidetool-card.tsx); Playwright E2E specs live in a top-levele2e/directory per app, named after the user journey they cover (e2e/guest-merge-daily-cap.spec.ts, matching Section 2.7.1). - Component naming.
PascalCase, matching the file's primary export. Compound components use a dot-namespace convention (Dialog.Root,Dialog.Trigger) matching the Radix UI convention they wrap. - Hook naming.
useCamelCase, always starting withuse, one hook per file, file named after the hook (use-job-polling.tsexportsuseJobPolling).
4.3 Canonical Conventions Table #
| Concern | Locked answer | Owning section |
|---|---|---|
| Identifiers | UUIDv7, generated application-side with the uuidv7 package, stored as a PostgreSQL uuid column |
5 |
| Public-facing IDs | Prefixed, base32-Crockford-encoded UUIDv7: doc_ (document), job_ (job), env_ (envelope), sig_ (signer), key_ (API key), wsp_ (workspace), usr_ (user), bat_ (batch), whk_ (webhook), evt_ (event), req_ (request, used only in error envelopes) |
5 |
| Database naming | snake_case, plural table names, snake_case columns |
5 |
| Timestamps | timestamptz, always stored and compared in UTC, columns named created_at / updated_at / deleted_at |
5 |
| Delete policy | Soft delete (deleted_at) for: users, workspaces, api_keys, templates, document metadata rows, envelopes. Hard delete for: every blob, every job payload, every OPFS artifact, and audit-event content (the hashes survive) |
5 |
| Migrations | drizzle-kit only, forward-only, one migration per pull request, every migration documented with its compensating (reversal) migration |
5 |
| Validation | Zod, defined once in packages/contracts, imported verbatim by the web app, the API, the workers, and the published SDK; no hand-written validators anywhere in the system |
4.4 |
| HTTP paths | Lowercase kebab-case, plural nouns, versioned: /v1/documents, /v1/jobs/{jobId} |
14 |
| Payload casing | camelCase in every JSON request and response body and every query parameter; the mapping to the database's snake_case happens only inside the Drizzle layer |
14 |
| Pagination | Cursor-only. Request: ?limit=&cursor=, limit default 25, max 100. Response: { "data": [...], "pagination": { "nextCursor": string | null, "hasMore": boolean } }. No offset pagination anywhere in the system |
14.7 |
| Single resource shape | Returned bare (not wrapped in a data envelope) — the envelope shape applies to lists only |
14.7 |
| Idempotency | Idempotency-Key header required on every public-API POST that creates a resource or spends quota; 24-hour replay window; a replay with an identical body returns the stored response; a replay with a conflicting body returns 409 idempotency_key_reuse |
14.8 |
| Rate limiting | Redis token bucket; response headers RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, plus Retry-After on a 429 |
14.9 |
| Enums | Database-level text column plus a CHECK constraint, mirrored by a Zod enum in packages/contracts; no native PostgreSQL enum type anywhere, because altering a Postgres enum's value set requires a blocking DDL operation that a CHECK constraint avoids |
5 |
| Money | Integer minor units (cents) plus an ISO-4217 currency code; never a floating-point amount | 12 |
| Logging | Pino, structured JSON, one line per request, requestId present on every line, secrets stripped by a configured Pino redaction path list |
18 |
| Tracing | OpenTelemetry, W3C traceparent header propagated from the browser through the API into every worker |
18 |
| Feature flags | A single feature_flags table read through one typed accessor function; no scattered environment-variable booleans for product behavior |
4.9 |
4.4 Shared-Contract Discipline #
packages/contracts holds exactly one Zod schema per API resource and per internal message shape
(job payloads, webhook event bodies, form values that mirror an API request). Every other package
imports these schemas rather than declaring its own:
- The web app binds
react-hook-formto a contract schema via@hookform/resolvers/zod, so a form's client-side validation is byte-for-byte the same rule the API will apply. - The API parses every request body and query-string object through the matching schema at the
route-handler boundary (
schema.parse(rawInput)), before any business logic runs — this is the one and only validation point; nothing downstream re-validates. - The workers parse the BullMQ job payload through its schema on dequeue, so a malformed payload (which should be impossible given the API validated it at enqueue time, but a defense-in-depth check regardless) fails loudly rather than corrupting a document mid-pipeline.
- The published SDK (
packages/sdk-js) imports the same schemas to type its method signatures, so a TypeScript consumer of the SDK gets the exact same type the server will accept, and a runtime input can optionally be validated client-side before the SDK issues the request.
How types flow. Every schema exports both the runtime validator and its inferred static type via
z.infer<typeof schema>; no type in the system is hand-written as a duplicate of a schema's shape.
Example:
// packages/contracts/src/documents.ts
import { z } from "zod";
export const documentSchema = z.object({
id: z.string().startsWith("doc_"),
filename: z.string().min(1).max(255),
sizeBytes: z.number().int().positive(),
mimeType: z.enum(["application/pdf"]),
status: z.enum(["uploaded", "processing", "ready", "deleted"]),
workspaceId: z.string().startsWith("wsp_").nullable(),
createdAt: z.string().datetime(),
deletedAt: z.string().datetime().nullable(),
});
export type Document = z.infer<typeof documentSchema>;How the OpenAPI document is generated. packages/contracts also declares, per route, a thin
route-metadata object (HTTP method, path, request schema, response schema, the error types the route
can return) using @hono/zod-openapi's createRoute helper:
// packages/contracts/src/routes/documents.ts
import { createRoute } from "@hono/zod-openapi";
import { documentSchema } from "../documents";
import { errorEnvelopeSchema } from "../errors";
export const getDocumentRoute = createRoute({
method: "get",
path: "/v1/documents/{documentId}",
request: {
params: z.object({ documentId: z.string().startsWith("doc_") }),
},
responses: {
200: { content: { "application/json": { schema: documentSchema } }, description: "The document." },
404: { content: { "application/json": { schema: errorEnvelopeSchema } }, description: "Not found." },
},
});A single build step in packages/contracts walks every registered route and emits openapi.json,
which is what docs.pdfworks.io renders and what the SDK's code-generation step consumes. The OpenAPI
document is never hand-written or hand-edited; a route whose behavior changes without its schema
changing is, by definition, undocumented drift and is caught by the contract test suite in Section
19.3.
4.5 Error Handling Philosophy #
- Typed result objects at every boundary. A function that can fail in an expected, handleable way
(a validation failure, a quota exceeded, a not-found lookup) returns a discriminated union
(
{ ok: true, value: T } | { ok: false, error: AppError }) rather than throwing. This applies at every layer boundary: route handler to service function, service function to repository function, worker job handler topdfcorebinding call. - Exceptions are reserved for programmer error — a violated invariant, an unreachable
switchbranch, a failed assertion — conditions that indicate a bug, not a valid input the caller should handle. These are allowed to throw and are caught only at the outermost boundary (the Hono error middleware, or a worker's top-level job handler), which converts them into theapi_errortype from the error envelope (Section 14.6) and reports them to Sentry (Section 1.1) with full context. - Never swallow an error. A
catchblock that does not either (a) convert the error into a typed result the caller can act on, (b) re-throw, or (c) log and re-throw is a defect. An emptycatch {}or acatchthat only logs and continues silently is disallowed by an ESLint rule (no-emptycombined with a repository-specific rule forbidding acatchblock with nothrowand no typed-result return).
4.6 The Error Envelope Rule #
There is exactly one error envelope shape in this system, defined once, canonically, in Section 14.6,
with its code catalogue in Section 23.1. It is never redefined or restated here. The rule this
section fixes, as a Convention: every layer of this system — route handlers, worker job handlers, the
SDK's error class, and the UI's error-display components — constructs, receives, or renders exactly
that Section 14.6 shape and never a locally invented one, even from a code path (a worker, a
background job, an internal-only route) that has no end user directly reading the response; and the
code catalogue is additive-only — a code, once shipped, is never repurposed to mean something else.
This uniformity is what lets a single client-side error handler and a single SDK error class work
everywhere. See Section 14.6 for the envelope's shape and Section 23.1 for the code catalogue.
4.7 The Job State Machine #
One state machine governs every asynchronous operation in the system — server-side jobs tracked in
the jobs table and client-side jobs tracked in the browser's local job store use the identical state
names, so the UI has exactly one vocabulary regardless of where an operation actually executes.
| From | To | Trigger |
|---|---|---|
| — | queued |
Job created (enqueued to BullMQ, or created in the local client-side job store) |
queued |
running |
A worker (or, client-side, the worker pool) picks up the job |
running |
succeeded |
The operation completes without error |
running |
failed |
The operation raises an unrecoverable error |
queued |
canceled |
The user cancels before a worker picked it up |
running |
canceled |
The user cancels while in progress; the operation's AbortSignal (Section 4.8) is triggered |
running |
expired |
The job exceeds its wall-clock timeout budget (Section 4.8) before reaching a terminal state |
Terminal states are exactly succeeded, failed, canceled, expired — no other states exist, and
no transition out of a terminal state is valid. Progress within running is represented as an integer
0–100 plus an optional free-text stage string (for example, "Extracting text layer" during an
OCR job), both of which are informational only and never used as a state-transition trigger.
4.8 Async Patterns, Cancellation, Retry #
- Cancellation propagates via the standard
AbortSignal/AbortControllerpair at every layer: a user clicking "Cancel" on a client-side tool aborts the signal passed into thepdfcoreComlink call, which the WASM binding layer checks between chunked operations (page-by-page during a large merge, for instance) and stops at the next checkpoint rather than mid-write. Server-side, canceling a job sets its row tocanceledand publishes a BullMQ event the worker's job handler listens for, checked at the same kind of checkpoint boundaries. Every function inpackages/pdfcore's TypeScript bindings and every API route handler that can run longer than a single tick accepts anAbortSignalas its last parameter — there is no separate cancellation channel to remember to wire up:
// packages/pdfcore/src/operations/merge.ts
export async function mergeDocuments(
inputs: OpfsFileHandle[],
signal: AbortSignal,
): Promise<Result<OpfsFileHandle, PdfCoreError>> {
const output = await createOutputHandle();
for (const [index, input] of inputs.entries()) {
if (signal.aborted) {
return { ok: false, error: { code: "operation_canceled", stage: `page_${index}` } };
}
await appendPages(output, input);
}
return { ok: true, value: output };
}- Retry and backoff. Every BullMQ job that fails with a transient error is retried with exponential
backoff and jitter. The exact per-queue attempt counts and backoff parameters (base delay, growth
factor, jitter percentage, and the cap between attempts) are a job-orchestration operating parameter,
not an engineering convention, and are defined once, in Section 13.3.5 — they are not restated here.
The customer-facing webhook delivery retry schedule shown to a workspace owner in the dashboard is a
related but distinct concern, defined separately in Section 14.10.6. What this convention fixes,
regardless of which queue a job runs on, is the classification that decides whether a retry
happens at all: an error is retried only when it is classified as transient (a network timeout
talking to S3-compatible storage, a LibreOffice worker that exceeded a soft memory limit and was
killed); an error classified as permanent (a malformed input PDF the structural validator in Section
17.5 rejected, an unsupported page count) fails immediately on the first attempt, since retrying a
permanent failure only delays the user's error message without any chance of success. A job that
exhausts its configured retry budget transitions to
failedwith the last attempt's error recorded on the row. - Timeout budgets per layer. API route handlers: 30 seconds (anything longer must be an async job,
not a synchronous request) — this is an API-layer convention, distinct from the job-level budgets
below, so it is fixed here rather than alongside them. Worker and job wall-clock timeouts (the
per-queue processing budgets for
worker-media,worker-office, andesignjobs) are defined once, in Section 13.3.8, and are not restated here. Client-sidepdfcoreoperations have no imposed timeout beyond the browser tab's own lifecycle, since they run entirely locally and blocking a pool worker thread does not affect the server. What this convention fixes, regardless of layer, is the outcome of an expiry: a server-side timeout expiry moves the job toexpired(Section 4.7), neverfailed— the distinction lets the dashboard and the API report "this took too long" separately from "this failed," which drives different remediation on the operator's side (a queue capacity problem versus apdfcoreor conversion-pipeline defect).
4.9 Configuration and Secret Handling #
Configuration follows twelve-factor principles: every environment-specific value is an environment
variable, never a checked-in config file with environment branches. No secret (API key, database
password, KMS key ARN, webhook signing secret) is ever committed to the repository; .env.example
files document every required variable name with a placeholder or safe default, never a real value.
The full environment-variable catalogue is Section 23.2.
Boot-time validation. Every app and worker validates its complete configuration through a Zod
schema (packages/config's env.ts pattern, one per app) at process start, before any request is
served or any job is dequeued. A missing required variable, a malformed URL, or an out-of-range
numeric value causes the process to exit immediately with a clear, single-line error naming the
invalid variable — the process never starts in a partially configured state and never fails on the
first request instead of at boot.
// apps/api/src/env.ts
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
S3_BUCKET_DOCUMENTS: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
KMS_MASTER_KEY_ARN: z.string().min(1),
NODE_ENV: z.enum(["development", "test", "production"]),
});
export const env = envSchema.parse(process.env); // throws and exits on invalid configFeature flags (Section 4.3) are read through one typed accessor (getFlag("euResidencyEnabled", workspaceId)) backed by the feature_flags table, never as a raw process.env boolean check
scattered through business logic — this keeps every flag's evaluation auditable and toggleable without
a redeploy.
4.10 Git Workflow and Review Standards #
- Trunk-based development. One long-lived branch (
main); every change is a short-lived feature branch merged back within, at most, a few days. No long-lived release branches. - Conventional commits. Every commit message follows
<type>(<scope>): <summary>, types limited tofeat,fix,refactor,test,docs,chore,perf,ci. The type and a squashed summary of the pull request's commits become the merge commit message, which in turn drives the automated changelog. - Pull request template requires: a one-paragraph description of the change, the specific section
of this specification it implements (when applicable), a manual test description, and a checklist
covering: tests added or updated, no new
TBD/placeholder content shipped, the Processing Location Indicator verified if a tool's execution location was touched (Section 2.5), and accessibility checked if a UI surface changed. - Required checks, all blocking merge: type-check, lint, unit tests, the contract test suite
(Section 19.3, verifying the OpenAPI document matches implemented routes), Playwright E2E on the
changed app, and the
pdfcorecross-host byte-equality check (Section 3.6.1) whenpackages/pdfcorechanges. - Review rules. Every pull request requires at least one approving review from someone other than
the author before merge. A pull request touching
packages/contractsrequires review from someone familiar with every consumer package (web, API, workers, SDK), since a schema change is, by construction, a breaking-change candidate for all four.
4.11 Definition of Done #
A change is done when: it implements the specified behavior with no TBD/placeholder content; it has
unit test coverage for its new logic and, where applicable, an E2E test for the user-facing journey it
affects; it passes every required check in Section 4.10; it does not regress the performance budgets
in Section 18.6 or the accessibility bar in Section 16; any tool whose execution location it touches has a verified,
matching Processing Location Indicator (Section 2.5) enforced by the CI check described there; its
error paths return the error envelope (Section 14.6) with an entry added to the error catalogue
(Section 23.1) if a new code was introduced; and, if it introduces or changes a shared contract, the
OpenAPI document (Section 4.4) has been regenerated and committed as part of the same pull request.
4.12 Documentation Standard #
Every exported function, type, and React component in packages/* (code intended for reuse across
apps) carries a doc comment (/** ... */) stating its purpose, its parameters if non-obvious from
their names and types, and any thrown-vs-returned error behavior per Section 4.5's discipline.
Application-level code in apps/* — route handlers, page components, one-off business logic — does
not require doc comments; its self-documentation is the typed function signature plus the shared
contract schema it validates against (Section 4.4), and a doc comment restating a Zod schema's already
-explicit shape is noise, not documentation. A doc comment is never a substitute for a clearer name or
a smaller function; if a doc comment is needed to explain why a function's obvious-looking code does
something non-obvious (a workaround for a specific PDFium quirk, for example), a plain // comment at
the relevant line is preferred over a doc comment, since the explanation belongs next to the code it
explains, not at the function's declaration.
// packages/pdfcore/src/operations/redact.ts
/**
* Permanently removes the content under each region in `redactions` from `input`,
* following the nine-step algorithm defined in this specification's redaction section.
* Never performs an incremental save; always returns a fully rewritten document.
*
* @param input - The source document handle. Not mutated; a new handle is returned.
* @param redactions - One or more page-relative regions to remove. Coordinates are in
* PDF user space (points, origin bottom-left), matching the page's own coordinate system.
* @returns `ok: false` with `code: "redaction_verification_failed"` if the post-redaction
* verification pass (re-extraction and re-render check) finds any surviving content —
* in that case, the output is discarded and never returned to the caller.
*/
export async function redactDocument(
input: OpfsFileHandle,
redactions: RedactionRegion[],
signal: AbortSignal,
): Promise<Result<OpfsFileHandle, PdfCoreError>> {
// implementation
}This is the reference shape every packages/pdfcore binding follows: a doc comment that would let a
caller use the function correctly without opening its implementation, and nothing more speculative
than that.
5. Data Model & Database Schema #
This section is the single canonical definition of persisted state for PDFWorks. Every other section that mentions a table, column, enum value, or index refers back to this one; none of them redefine it. All storage described here lives in a single PostgreSQL 18 database (packages/db in the monorepo), accessed exclusively through Drizzle ORM 0.45.x. No table in this section is optional: the executor creates all of them exactly as specified.
5.1 Entity-Relationship Overview #
5.1.1 Diagram #
The diagram groups the 44 tables into nine domains and shows the ownership edges that matter for cardinality and cascade behavior. Full column-level detail for every table follows in Sections 5.2–5.10.
graph TD
subgraph Identity["Identity & Sessions"]
users
user_sessions
user_mfa_factors
user_recovery_codes
email_verification_tokens
password_reset_tokens
end
subgraph Teams["Workspaces & Teams"]
workspaces
workspace_members
workspace_invitations
guest_devices
end
subgraph Billing["Billing & Entitlements"]
plans
subscriptions
subscription_items
usage_records
usage_daily_rollups
spend_caps
invoices
end
subgraph Docs["Documents & Storage"]
documents
document_blobs
end
subgraph Jobs["Jobs & Batch Processing"]
jobs
job_events
job_artifacts
batch_jobs
batch_job_items
end
subgraph API["API Access & Webhooks"]
api_keys
api_key_scopes
api_key_ip_rules
webhook_endpoints
webhook_deliveries
end
subgraph Esign["E-Signature"]
envelopes
envelope_documents
envelope_signers
envelope_fields
envelope_audit_events
envelope_reminders
signature_assets
end
subgraph Config["Templates & Configuration"]
templates
ocr_language_packs
feature_flags
end
subgraph Ops["Compliance, Audit & Operations"]
audit_log
rate_limit_state
data_export_requests
deletion_requests
email_log
end
users -->|1:N| user_sessions
users -->|1:N| user_mfa_factors
users -->|1:N| workspace_members
workspaces -->|1:N| workspace_members
workspaces -->|1:N| workspace_invitations
users -->|1:1| subscriptions
workspaces -->|1:1| subscriptions
plans -->|1:N| subscriptions
subscriptions -->|1:N| subscription_items
users -->|1:N| documents
workspaces -.->|0:N| documents
documents -->|1:N| document_blobs
users -->|1:N| jobs
jobs -->|1:N| job_events
jobs -->|1:N| job_artifacts
batch_jobs -->|1:N| batch_job_items
batch_job_items -->|0:1| jobs
users -->|1:N| api_keys
api_keys -->|1:N| api_key_scopes
api_keys -->|1:N| api_key_ip_rules
webhook_endpoints -->|1:N| webhook_deliveries
users -->|1:N| envelopes
envelopes -->|1:N| envelope_documents
envelopes -->|1:N| envelope_signers
envelope_signers -->|1:N| envelope_fields
envelopes -->|1:N| envelope_audit_events
envelopes -->|1:N| envelope_reminders
envelope_signers -.->|0:N| signature_assets
users -->|1:N| templates5.1.2 Entity Table #
| Entity | Purpose | Owner | Delete Policy | Retention |
|---|---|---|---|---|
users |
Account record and authentication root | user | soft | Indefinite while active; personal fields anonymized 30 days after a deletion request (Section 17.6) |
user_sessions |
Active login sessions (better-auth) | user | hard | Until expiry or revoke; absolute max 90 days |
user_mfa_factors |
Registered TOTP factors | user | hard | Indefinite while MFA enabled; removed on disable |
user_recovery_codes |
MFA single-use backup codes | user | hard | Indefinite until used or MFA disabled |
email_verification_tokens |
One-time email confirmation tokens | user | hard | 24 hours, then purged |
password_reset_tokens |
One-time password reset tokens | user | hard | 1 hour, then purged |
workspaces |
A Team's shared billing and membership boundary | workspace | soft | Indefinite while active |
workspace_members |
User-to-workspace membership and role | workspace | hard | Until membership ends |
workspace_invitations |
Pending email invitations to a workspace | workspace | hard | 7 days, then expired/purged |
guest_devices |
Anonymous device identity for guest quota tracking | system | hard | 30 days of inactivity |
plans |
Catalogue of billing plans and their entitlements | system | hard (rare, admin) | Indefinite |
subscriptions |
A user's or workspace's active Stripe subscription | user/workspace | none (status-driven) | Indefinite (financial record) |
subscription_items |
Line items on a subscription (base, seat, metered) | user/workspace | none (cascades with subscription) | Indefinite |
usage_records |
Individual billable/metered events | user/workspace | hard (partition drop) | 2 years, then archived |
usage_daily_rollups |
Daily aggregate usage per owner/metric | user/workspace | hard (periodic purge) | 2 years |
spend_caps |
Optional per-API-key monthly spend ceiling | user/workspace | hard (cascades with key) | Indefinite while key exists |
invoices |
Read-model mirror of Stripe invoices | user/workspace | none (financial record) | Indefinite |
documents |
Server-side file metadata (never bytes) | user | soft | Per plan job-history window (7/90 days) after blob purge, then hard-purged |
document_blobs |
Encrypted object storage pointer and wrapped key | document | hard | Max 24 hours from upload, or 2 hours after job terminal state, whichever first (Section 6) |
jobs |
One asynchronous or synchronous processing operation | user | none (visibility windowed, not deleted) | Indefinite row; unreadable past plan's job-history window |
job_events |
Progress/state transition log for a job | job | hard (partition drop) | 180 days |
job_artifacts |
Non-primary outputs of a job (logs, reports, thumbnails) | job | hard (with blob purge) | Tied to parent job's blob retention |
batch_jobs |
A batch of many single-tool jobs | user | none (visibility windowed) | Indefinite row |
batch_job_items |
One item within a batch job | batch_job | none (visibility windowed) | Indefinite row |
api_keys |
Public API credential | user/workspace | soft | Indefinite (kept for audit even after revoke) |
api_key_scopes |
Permission scopes granted to a key | api_key | hard (cascades with key) | Indefinite while key exists |
api_key_ip_rules |
CIDR allowlist entries for a key | api_key | hard (cascades with key) | Indefinite while key exists |
webhook_endpoints |
A customer-configured webhook target | user/workspace | soft | Indefinite |
webhook_deliveries |
One delivery attempt of one event to one endpoint | webhook_endpoint | hard (partition drop) | 30 days |
envelopes |
An e-signature request | user/workspace | soft | Indefinite row (small, referenced by the public verify endpoint); underlying documents purged per Section 6 |
envelope_documents |
Documents attached to an envelope | envelope | hard (cascades with envelope) | Indefinite with envelope |
envelope_signers |
A signer, approver, or CC recipient | envelope | hard (cascades with envelope) | Indefinite with envelope |
envelope_fields |
A placed signature/text/date/etc. field | envelope | hard (cascades with envelope) | Indefinite with envelope |
envelope_audit_events |
The append-only tamper-evident hash chain | envelope | hard (partition drop only, never row DELETE) | 7 years, personal fields anonymized on request |
envelope_reminders |
Scheduled/sent signer reminders | envelope | hard | Purged with envelope on completion/expiry |
signature_assets |
A stored drawn/typed/uploaded signature image | user/signer | hard | Indefinite for registered users; purged with envelope for one-off signer assets |
templates |
Saved tool presets and envelope templates | user/workspace | soft | Indefinite |
ocr_language_packs |
Available Tesseract language packs | system | hard (rare, admin) | Indefinite |
feature_flags |
Typed feature-flag accessor backing store | system | hard (rare, admin) | Indefinite |
audit_log |
Admin and security-relevant action log | system | hard (periodic purge) | 2 years |
rate_limit_state |
Long-lived security blocks that must survive a Redis flush | system | hard (TTL cleanup) | Until blocked_until + 24 hours |
data_export_requests |
DSAR data export jobs | user | hard | Row 90 days; download link 7 days after ready |
deletion_requests |
DSAR / account deletion jobs | user/workspace | none (compliance evidence) | 7 years |
email_log |
Outbound transactional email delivery log | system | hard (partition drop) | 180 days |
5.1.3 Identifier Format #
Every table's primary key is id uuid, generated application-side as a UUIDv7 by calling uuidv7() (from the uuidv7 package) before insert — never gen_random_uuid(), never a database default. UUIDv7 embeds a millisecond timestamp in its high bits, so primary keys are monotonically sortable by creation time without a separate created_at index for coarse ordering.
Resources that are addressable directly through the public REST API (Section 14) also have a public ID: the prefix for that resource type, followed by an underscore, followed by the 128 bits of the UUID encoded as 26 characters of Crockford Base32 (alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ, uppercase canonical, case-insensitive on decode, no padding character). The public ID is never stored as a separate column — it is a pure, deterministic function of the same 16 bytes as the internal id, computed in packages/contracts:
// packages/contracts/src/id.ts
import { z } from 'zod';
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
export function encodeId(prefix: string, id: string /* uuid, e.g. from users.id */): string {
const bytes = Buffer.from(id.replace(/-/g, ''), 'hex'); // 16 bytes
let bits = 0n;
for (const b of bytes) bits = (bits << 8n) | BigInt(b);
let out = '';
for (let i = 0; i < 26; i++) {
out = CROCKFORD[Number(bits & 0x1fn)] + out;
bits >>= 5n;
}
return `${prefix}_${out}`;
}
export function decodeId(publicId: string): string {
const [, encoded] = publicId.split(/_(.+)/); // split on first underscore only
let bits = 0n;
for (const ch of encoded.toUpperCase()) {
const v = CROCKFORD.indexOf(ch);
if (v === -1) throw new InvalidPublicIdError(publicId);
bits = (bits << 5n) | BigInt(v);
}
const hex = bits.toString(16).padStart(32, '0').slice(-32);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
export const publicIdSchema = (prefix: string) =>
z.string().regex(new RegExp(`^${prefix}_[0-9A-HJKMNP-TV-Z]{26}$`), 'invalid_public_id');A request handler that receives a public ID decodes it back to the raw UUID and queries the primary key directly — there is no separate lookup index to maintain, and a malformed public ID fails Zod validation before it ever reaches a query. Example: doc_01H8XGJ4RS3K9QY0VJXNVXFPTZ decodes to the same bytes as internal id 018e1e6b-9a4f-7c21-8b3d-2f5a9e7c4d10.
Locked prefix assignments — the only thirteen resource types with a public ID:
| Prefix | Table | Notes |
|---|---|---|
usr_ |
users |
|
wsp_ |
workspaces |
|
doc_ |
documents |
|
job_ |
jobs, batch_jobs |
Both are "jobs" from the caller's perspective (Section 13.1) |
env_ |
envelopes |
|
asset_ |
signature_assets |
A stored drawn/typed/uploaded signature or initials image (Section 5.8.7) |
key_ |
api_keys |
|
bat_ |
batch_job_items |
Individual item within a batch, distinct from the batch itself |
whk_ |
webhook_endpoints |
|
evt_ |
job_events, envelope_audit_events, webhook_deliveries |
All three are event/attempt logs, always reached nested under a parent resource, never as a bare top-level route |
req_ |
data_export_requests |
A DSAR data-export request |
del_ |
deletion_requests |
A DSAR / account deletion request, kept on a prefix distinct from req_ so the two DSAR flows are never confused in a URL, a log line, or a support ticket |
tpl_ |
templates |
Covers both saved tool presets and envelope templates (Section 5.9.1) |
Every other table's primary key is a UUIDv7 used only internally. It never appears in a URL or a JSON response body on its own; where a related row must be identified in a response, its parent's public ID is used together with a stable positional or role-based field (for example, an envelope_signer is addressed inside the envelope's JSON payload by its email and routingOrder, not by a bare ID).
5.1.4 Standard Columns #
To avoid repeating identical definitions 44 times, every table below has the following columns unless a per-table note says otherwise; they are omitted from the per-table column lists in Sections 5.2–5.10 and shown only in the Drizzle code:
| Column | Type | Null | Default | Present on |
|---|---|---|---|---|
id |
uuid |
not null | none (app-generated) | every table |
created_at |
timestamptz |
not null | now() |
every table |
updated_at |
timestamptz |
not null | now(), bumped by the application on every mutating write |
every table with mutable state after creation (noted per table where absent) |
deleted_at |
timestamptz |
null | null |
only the seven soft-delete tables named in Section 5.1.2 (users, workspaces, documents, api_keys, webhook_endpoints, envelopes, templates) |
All timestamp columns are timestamptz, always written and read in UTC. All foreign keys use ON UPDATE CASCADE uniformly (a no-op in practice since primary keys are immutable UUIDv7 values, but consistent behavior costs nothing); the ON DELETE behavior is chosen per relationship and stated explicitly for every foreign key below. Table names are snake_case and plural; column names are snake_case. No PostgreSQL enum type is used anywhere — every enumerated column is text plus a CHECK constraint, mirrored by a Zod enum in packages/contracts (full catalogue in Section 5.11).
5.1.5 Shared Drizzle Helpers #
Every code block in Sections 5.2–5.10 assumes these imports and helpers from packages/db/src/columns.ts:
import {
pgTable, uuid, text, integer, bigint, boolean, timestamp, jsonb,
smallint, numeric, date, customType, index, uniqueIndex, check, primaryKey,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const inet = customType<{ data: string }>({ dataType: () => 'inet' });
export const cidr = customType<{ data: string }>({ dataType: () => 'cidr' });
export const id = () => uuid('id').primaryKey();
export const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
export const updatedAt = () => timestamp('updated_at', { withTimezone: true }).notNull().defaultNow();
export const deletedAt = () => timestamp('deleted_at', { withTimezone: true });packages/db never uses .defaultRandom(), .$defaultFn(), or a Postgres-side UUID generator for id — the application layer (a single createId() wrapper around uuidv7()) always supplies the value, so the same generator that produced the internal ID is the one whose output gets Base32-encoded into the public ID.
5.2 Identity & Sessions #
5.2.1 users #
The account and authentication root. Owner: user (self). Delete policy: soft. Retention: indefinite while active.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
email |
text |
not null | — | Lowercased before storage by the application; uniqueness enforced on lower(email) |
email_verified_at |
timestamptz |
null | null |
Set once the user completes email_verification_tokens |
password_hash |
text |
not null | — | Argon2id hash, Section 17.2 |
display_name |
text |
not null | — | 1–80 characters, shown in the UI and on envelope certificates |
avatar_url |
text |
null | null |
|
default_workspace_id |
uuid |
null | null |
The workspace the app opens to; null for a user with no Team membership |
site_role |
text |
not null | 'user' |
user | support | admin — platform-level role, distinct from workspace roles |
locale |
text |
not null | 'en' |
BCP-47 tag; i18n architecture only, translations out of scope (Section 1) |
timezone |
text |
not null | 'UTC' |
IANA timezone name, display only — all storage is UTC |
mfa_enabled |
boolean |
not null | false |
Denormalized from user_mfa_factors for fast auth-flow checks |
stripe_customer_id |
text |
null | null |
Created lazily on first checkout |
Primary key id. Foreign keys: default_workspace_id → workspaces(id) ON DELETE SET NULL — a workspace's deletion must never delete or corrupt the user record that happened to default into it. Unique: lower(email); stripe_customer_id. Indexes: ux_users_email_lower (lower(email)) — serves login lookup and signup duplicate-check; ix_users_stripe_customer_id (stripe_customer_id) — serves Stripe webhook resolution; ix_users_active (id) WHERE deleted_at IS NULL — serves admin active-user counts.
export const users = pgTable('users', {
id: id(),
email: text('email').notNull(),
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
passwordHash: text('password_hash').notNull(),
displayName: text('display_name').notNull(),
avatarUrl: text('avatar_url'),
defaultWorkspaceId: uuid('default_workspace_id').references(() => workspaces.id, { onDelete: 'set null' }),
siteRole: text('site_role').notNull().default('user'),
locale: text('locale').notNull().default('en'),
timezone: text('timezone').notNull().default('UTC'),
mfaEnabled: boolean('mfa_enabled').notNull().default(false),
stripeCustomerId: text('stripe_customer_id'),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt(),
}, (table) => [
uniqueIndex('ux_users_email_lower').on(sql`lower(${table.email})`),
uniqueIndex('ux_users_stripe_customer_id').on(table.stripeCustomerId),
check('ck_users_site_role', sql`${table.siteRole} in ('user', 'support', 'admin')`),
]);5.2.2 user_sessions #
A better-auth login session. Owner: user. Delete policy: hard. Retention: until expiry or revoke, absolute max 90 days.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
user_id |
uuid |
not null | — | |
session_token_hash |
text |
not null | — | SHA-256 of the session token; the raw token lives only in the Secure cookie |
ip_address |
inet |
null | null |
Recorded at session creation, refreshed on rolling renewal |
user_agent |
text |
null | null |
Shown in the account "active sessions" list |
expires_at |
timestamptz |
not null | — | 30-day rolling window, capped at created_at + 90 days |
No updated_at on this table — a session row is replaced (new row) on rolling renewal rather than mutated, so the renewal history is auditable. Primary key id. Foreign keys: user_id → users(id) ON DELETE CASCADE — a session is meaningless once the account is gone. Unique: session_token_hash. Indexes: ux_user_sessions_token_hash (session_token_hash) — serves the per-request auth-middleware lookup (highest-frequency query in the system, Section 5.13 #1); ix_user_sessions_user_expiry (user_id, expires_at) — serves the account settings "active sessions" list and the janitor's expiry sweep.
export const userSessions = pgTable('user_sessions', {
id: id(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
sessionTokenHash: text('session_token_hash').notNull(),
ipAddress: inet('ip_address'),
userAgent: text('user_agent'),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: createdAt(),
}, (table) => [
uniqueIndex('ux_user_sessions_token_hash').on(table.sessionTokenHash),
index('ix_user_sessions_user_expiry').on(table.userId, table.expiresAt),
]);5.2.3 user_mfa_factors #
A registered TOTP factor. Owner: user. Delete policy: hard. Retention: indefinite while enabled.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
user_id |
uuid |
not null | — | |
type |
text |
not null | 'totp' |
Only value at launch; the column exists so a future factor type is additive |
secret_ciphertext |
bytea |
not null | — | TOTP secret, AES-256-GCM envelope-encrypted with the same KMS master key used for blob data keys |
secret_key_id |
text |
not null | — | KMS key version used to wrap secret_ciphertext |
verified_at |
timestamptz |
null | null |
Null until the user confirms one correct code; unverified factors are excluded from login enforcement |
No updated_at. Primary key id. Foreign keys: user_id → users(id) ON DELETE CASCADE. Unique: (user_id, type) — one TOTP factor per user. Indexes: ix_user_mfa_factors_user_id (user_id) — serves the login-flow "does this user have MFA" check.
export const userMfaFactors = pgTable('user_mfa_factors', {
id: id(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
type: text('type').notNull().default('totp'),
secretCiphertext: bytea('secret_ciphertext').notNull(),
secretKeyId: text('secret_key_id').notNull(),
verifiedAt: timestamp('verified_at', { withTimezone: true }),
createdAt: createdAt(),
}, (table) => [
uniqueIndex('ux_user_mfa_factors_user_type').on(table.userId, table.type),
check('ck_user_mfa_factors_type', sql`${table.type} = 'totp'`),
]);bytea is imported alongside the other drizzle-orm/pg-core types; it was omitted from the shared import list in Section 5.1.5 for brevity and is assumed available in every code block below that uses it.
5.2.4 user_recovery_codes #
A single-use MFA backup code. Owner: user. Delete policy: hard. Retention: indefinite until used or MFA disabled.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
user_id |
uuid |
not null | — | |
code_hash |
text |
not null | — | SHA-256 of the 10-character code; ten rows are inserted together when MFA is enabled |
used_at |
timestamptz |
null | null |
No updated_at. Primary key id. Foreign keys: user_id → users(id) ON DELETE CASCADE. Unique: (user_id, code_hash). Indexes: ix_user_recovery_codes_unused (user_id) WHERE used_at IS NULL — serves the MFA-challenge "how many codes remain" check and the code-consumption lookup.
export const userRecoveryCodes = pgTable('user_recovery_codes', {
id: id(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
codeHash: text('code_hash').notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
createdAt: createdAt(),
}, (table) => [
uniqueIndex('ux_user_recovery_codes_user_code').on(table.userId, table.codeHash),
index('ix_user_recovery_codes_unused').on(table.userId).where(sql`${table.usedAt} is null`),
]);5.2.5 email_verification_tokens #
A one-time email confirmation token. Owner: user. Delete policy: hard. Retention: 24 hours.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
user_id |
uuid |
not null | — | |
token_hash |
text |
not null | — | SHA-256 of the raw token; the raw token is emailed once and never stored |
new_email |
text |
null | null |
Set for an email-change re-verification flow; null means "verify the signup email" |
expires_at |
timestamptz |
not null | now() + interval '24 hours' |
|
consumed_at |
timestamptz |
null | null |
No updated_at. Primary key id. Foreign keys: user_id → users(id) ON DELETE CASCADE. Unique: token_hash. Indexes: ux_email_verification_tokens_hash (token_hash) — serves the verification-link click; ix_email_verification_tokens_user (user_id) WHERE consumed_at IS NULL — serves "resend verification" de-duplication.
export const emailVerificationTokens = pgTable('email_verification_tokens', {
id: id(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
tokenHash: text('token_hash').notNull(),
newEmail: text('new_email'),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull().default(sql`now() + interval '24 hours'`),
consumedAt: timestamp('consumed_at', { withTimezone: true }),
createdAt: createdAt(),
}, (table) => [
uniqueIndex('ux_email_verification_tokens_hash').on(table.tokenHash),
index('ix_email_verification_tokens_user_pending').on(table.userId).where(sql`${table.consumedAt} is null`),
]);5.2.6 password_reset_tokens #
A one-time password reset token. Owner: user. Delete policy: hard. Retention: 1 hour.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
user_id |
uuid |
not null | — | |
token_hash |
text |
not null | — | SHA-256 of the raw token |
expires_at |
timestamptz |
not null | now() + interval '1 hour' |
|
consumed_at |
timestamptz |
null | null |
|
requested_ip |
inet |
null | null |
Recorded for abuse investigation |
No updated_at. Primary key id. Foreign keys: user_id → users(id) ON DELETE CASCADE. Unique: token_hash. Indexes: ux_password_reset_tokens_hash (token_hash) — serves the reset-link click.
export const passwordResetTokens = pgTable('password_reset_tokens', {
id: id(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
tokenHash: text('token_hash').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull().default(sql`now() + interval '1 hour'`),
consumedAt: timestamp('consumed_at', { withTimezone: true }),
requestedIp: inet('requested_ip'),
createdAt: createdAt(),
}, (table) => [
uniqueIndex('ux_password_reset_tokens_hash').on(table.tokenHash),
]);5.3 Workspaces & Teams #
5.3.1 workspaces #
A Team's shared billing and membership boundary. Owner: workspace (self). Delete policy: soft.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
name |
text |
not null | — | 1–120 characters |
slug |
text |
not null | — | Kebab-case, used in URLs like app.pdfworks.io/w/{slug} |
owner_user_id |
uuid |
not null | — | The workspace owner; exactly one workspace_members row has role = 'owner' and it always matches this |
plan_id |
uuid |
not null | — | Always a team plan row in practice; the column is generic so a future plan type needs no schema change |
mfa_enforced |
boolean |
not null | false |
Team owner can force MFA workspace-wide (Section 17.3) |
eu_data_residency |
boolean |
not null | false |
Team-only feature; when true, job/document storage is pinned to an EU region bucket |
Primary key id. Foreign keys: owner_user_id → users(id) ON DELETE RESTRICT — ownership must be explicitly transferred before the owning user can be deleted, preventing an orphaned workspace; plan_id → plans(id) ON DELETE RESTRICT — a plan in active use cannot be removed from the catalogue. Unique: slug. Indexes: ux_workspaces_slug (slug) — serves URL routing; ix_workspaces_owner (owner_user_id).
export const workspaces = pgTable('workspaces', {
id: id(),
name: text('name').notNull(),
slug: text('slug').notNull(),
ownerUserId: uuid('owner_user_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
planId: uuid('plan_id').notNull().references(() => plans.id, { onDelete: 'restrict' }),
mfaEnforced: boolean('mfa_enforced').notNull().default(false),
euDataResidency: boolean('eu_data_residency').notNull().default(false),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt(),
}, (table) => [
uniqueIndex('ux_workspaces_slug').on(table.slug),
index('ix_workspaces_owner').on(table.ownerUserId),
]);5.3.2 workspace_members #
Membership and role. Owner: workspace. Delete policy: hard.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
workspace_id |
uuid |
not null | — | |
user_id |
uuid |
not null | — | |
role |
text |
not null | 'member' |
owner | admin | member |
invited_by |
uuid |
null | null |
|
joined_at |
timestamptz |
not null | now() |
No updated_at, no deleted_at — a role change is an update to role directly; removal is a row delete. Primary key id. Foreign keys: workspace_id → workspaces(id) ON DELETE CASCADE; user_id → users(id) ON DELETE CASCADE; invited_by → users(id) ON DELETE SET NULL — preserves the membership row even if the inviter's account is later deleted. Unique: (workspace_id, user_id). Indexes: ux_workspace_members_ws_user (workspace_id, user_id) — serves the authorization check on every workspace-scoped request (Section 5.13 #7); ix_workspace_members_user (user_id) — serves "list my workspaces".
export const workspaceMembers = pgTable('workspace_members', {
id: id(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
role: text('role').notNull().default('member'),
invitedBy: uuid('invited_by').references(() => users.id, { onDelete: 'set null' }),
joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
uniqueIndex('ux_workspace_members_ws_user').on(table.workspaceId, table.userId),
index('ix_workspace_members_user').on(table.userId),
check('ck_workspace_members_role', sql`${table.role} in ('owner', 'admin', 'member')`),
]);5.3.3 workspace_invitations #
A pending email invitation. Owner: workspace. Delete policy: hard.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
workspace_id |
uuid |
not null | — | |
email |
text |
not null | — | |
role |
text |
not null | 'member' |
owner | admin | member — an invitation can never grant owner at send time; ownership only transfers, never via invite |
invited_by |
uuid |
not null | — | |
token_hash |
text |
not null | — | SHA-256 of the invite-link token |
status |
text |
not null | 'pending' |
pending | accepted | revoked | expired |
expires_at |
timestamptz |
not null | now() + interval '7 days' |
Primary key id. Foreign keys: workspace_id → workspaces(id) ON DELETE CASCADE; invited_by → users(id) ON DELETE CASCADE — an invitation from a now-deleted account is meaningless and is withdrawn. Unique: token_hash; partial unique (workspace_id, lower(email)) WHERE status = 'pending' — at most one live invite per email per workspace, while historical accepted/revoked rows are kept. Check: ck_workspace_invitations_role: role IN ('owner','admin','member'). Indexes: ux_workspace_invitations_token (token_hash) — serves the accept-invite click; ux_workspace_invitations_ws_email_pending as above.
export const workspaceInvitations = pgTable('workspace_invitations', {
id: id(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
email: text('email').notNull(),
role: text('role').notNull().default('member'),
invitedBy: uuid('invited_by').notNull().references(() => users.id, { onDelete: 'cascade' }),
tokenHash: text('token_hash').notNull(),
status: text('status').notNull().default('pending'),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull().default(sql`now() + interval '7 days'`),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_workspace_invitations_token').on(table.tokenHash),
uniqueIndex('ux_workspace_invitations_ws_email_pending')
.on(table.workspaceId, sql`lower(${table.email})`)
.where(sql`${table.status} = 'pending'`),
check('ck_workspace_invitations_role', sql`${table.role} in ('owner', 'admin', 'member')`),
check('ck_workspace_invitations_status', sql`${table.status} in ('pending', 'accepted', 'revoked', 'expired')`),
]);Concurrent-invitation handling. The partial unique index ux_workspace_invitations_ws_email_pending means two admins inviting the same email to the same workspace at the same moment will have their INSERTs race: the first commits, and the second raises a PostgreSQL 23505 unique_violation against that index. The invite-creation handler catches exactly that constraint violation (matched by index name, not by a generic "any unique error" handler, so an unrelated collision on token_hash — cryptographically negligible but not impossible to code defensively against — is not silently swallowed) and, instead of surfacing a database error to the caller, re-selects the existing pending row for (workspace_id, lower(email)) and returns it with the same success shape the INSERT path would have produced. The invite is therefore idempotent under concurrency: the second admin's action always succeeds and always resolves to one single live invitation, and no duplicate email is ever sent. This is enforced in apps/api/src/routes/workspaces/invitations.ts and covered by rule 11 in Section 5.18.
5.3.4 guest_devices #
Anonymous per-device quota tracking for unauthenticated visitors. Owner: system. Delete policy: hard, 30 days of inactivity.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
device_fingerprint_hash |
text |
not null | — | SHA-256 of a client-generated persistent ID stored in a first-party cookie, combined with a coarse fingerprint, and salted with the current UTC day's guest-hashing salt (minted daily, Section 11.5; failure behavior in Section 5.18 rule 12); never a raw device identifier |
ip_hash |
text |
not null | — | Salted hash of the last-seen IP using the same daily salt as device_fingerprint_hash, for cross-device abuse correlation without storing a raw IP long-term |
tasks_today |
smallint |
not null | 0 |
|
task_window_date |
date |
not null | current_date |
UTC date; reset logic in Section 5.18 rule 8 |
Primary key id. No foreign keys — guests are unauthenticated by definition. Unique: device_fingerprint_hash. Indexes: ux_guest_devices_fingerprint (device_fingerprint_hash) — serves the per-request quota check; ix_guest_devices_ip_window (ip_hash, task_window_date) — serves abuse detection across devices sharing an IP.
export const guestDevices = pgTable('guest_devices', {
id: id(),
deviceFingerprintHash: text('device_fingerprint_hash').notNull(),
ipHash: text('ip_hash').notNull(),
tasksToday: smallint('tasks_today').notNull().default(0),
taskWindowDate: date('task_window_date').notNull().defaultNow(),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_guest_devices_fingerprint').on(table.deviceFingerprintHash),
index('ix_guest_devices_ip_window').on(table.ipHash, table.taskWindowDate),
]);5.4 Billing & Entitlements #
5.4.1 plans #
The catalogue of billing plans, mirroring Section 12.2. Owner: system. Delete policy: hard, admin-only, rare.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
key |
text |
not null | — | guest | free | pro | team | api_starter | api_growth | api_scale |
name |
text |
not null | — | Display name |
price_cents |
integer |
not null | 0 |
Monthly base price, integer minor units |
currency |
text |
not null | 'usd' |
ISO-4217 |
billing_interval |
text |
not null | 'month' |
month | year |
max_file_size_bytes |
bigint |
not null | — | 25 MB / 25 MB / 1 GB / 1 GB per Section 12.2 |
daily_server_task_cap |
integer |
null | null |
Null = unlimited |
batch_max_files |
integer |
null | null |
Null = batch processing not permitted on this plan |
envelope_monthly_cap |
integer |
null | null |
Null = unlimited (API plans meter instead; see usage_daily_rollups) |
job_history_days |
integer |
not null | — | 0 / 7 / 90 / 90 / 90 |
stripe_price_id |
text |
null | null |
Null for guest (no Stripe object) |
active |
boolean |
not null | true |
Inactive plans remain for historical subscription references but cannot be newly selected |
Primary key id. No foreign keys. Unique: key; stripe_price_id. Check: ck_plans_price_nonneg: price_cents >= 0; ck_plans_key: key IN (...); ck_plans_billing_interval: billing_interval IN ('month','year'). Indexes: ux_plans_key (key) — serves entitlement lookups by plan key throughout the codebase.
export const plans = pgTable('plans', {
id: id(),
key: text('key').notNull(),
name: text('name').notNull(),
priceCents: integer('price_cents').notNull().default(0),
currency: text('currency').notNull().default('usd'),
billingInterval: text('billing_interval').notNull().default('month'),
maxFileSizeBytes: bigint('max_file_size_bytes', { mode: 'number' }).notNull(),
dailyServerTaskCap: integer('daily_server_task_cap'),
batchMaxFiles: integer('batch_max_files'),
envelopeMonthlyCap: integer('envelope_monthly_cap'),
jobHistoryDays: integer('job_history_days').notNull(),
stripePriceId: text('stripe_price_id'),
active: boolean('active').notNull().default(true),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_plans_key').on(table.key),
uniqueIndex('ux_plans_stripe_price_id').on(table.stripePriceId),
check('ck_plans_price_nonneg', sql`${table.priceCents} >= 0`),
check('ck_plans_key', sql`${table.key} in ('guest', 'free', 'pro', 'team', 'api_starter', 'api_growth', 'api_scale')`),
check('ck_plans_billing_interval', sql`${table.billingInterval} in ('month', 'year')`),
]);5.4.2 subscriptions #
A user's or workspace's active Stripe subscription. Owner: user or workspace (exactly one). Delete policy: none — status-driven.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
workspace_id |
uuid |
null | null |
Set for a Team subscription |
user_id |
uuid |
null | null |
Set for a Pro or API subscription |
plan_id |
uuid |
not null | — | |
stripe_subscription_id |
text |
not null | — | |
status |
text |
not null | — | trialing | active | past_due | canceled | incomplete | incomplete_expired | unpaid |
current_period_start |
timestamptz |
not null | — | |
current_period_end |
timestamptz |
not null | — | |
cancel_at_period_end |
boolean |
not null | false |
|
canceled_at |
timestamptz |
null | null |
Primary key id. Foreign keys: workspace_id → workspaces(id) ON DELETE CASCADE; user_id → users(id) ON DELETE CASCADE; plan_id → plans(id) ON DELETE RESTRICT. Unique: stripe_subscription_id. Check: ck_subscriptions_owner_xor: num_nonnulls(workspace_id, user_id) = 1 — a subscription belongs to exactly one owner kind, never both, never neither. Indexes: ux_subscriptions_stripe_id (stripe_subscription_id) — serves Stripe webhook resolution; ux_subscriptions_workspace (workspace_id) WHERE workspace_id IS NOT NULL; ux_subscriptions_user (user_id) WHERE user_id IS NOT NULL — both serve the entitlement lookup on every quota check (Section 5.13 #5).
export const subscriptions = pgTable('subscriptions', {
id: id(),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
planId: uuid('plan_id').notNull().references(() => plans.id, { onDelete: 'restrict' }),
stripeSubscriptionId: text('stripe_subscription_id').notNull(),
status: text('status').notNull(),
currentPeriodStart: timestamp('current_period_start', { withTimezone: true }).notNull(),
currentPeriodEnd: timestamp('current_period_end', { withTimezone: true }).notNull(),
cancelAtPeriodEnd: boolean('cancel_at_period_end').notNull().default(false),
canceledAt: timestamp('canceled_at', { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_subscriptions_stripe_id').on(table.stripeSubscriptionId),
uniqueIndex('ux_subscriptions_workspace').on(table.workspaceId).where(sql`${table.workspaceId} is not null`),
uniqueIndex('ux_subscriptions_user').on(table.userId).where(sql`${table.userId} is not null`),
check('ck_subscriptions_owner_xor', sql`num_nonnulls(${table.workspaceId}, ${table.userId}) = 1`),
check('ck_subscriptions_status', sql`${table.status} in ('trialing', 'active', 'past_due', 'canceled', 'incomplete', 'incomplete_expired', 'unpaid')`),
]);5.4.3 subscription_items #
A line item on a subscription. Owner: follows the parent subscription. Delete policy: cascades with parent.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
subscription_id |
uuid |
not null | — | |
stripe_subscription_item_id |
text |
not null | — | |
stripe_price_id |
text |
not null | — | |
kind |
text |
not null | — | base | seat | metered_operations | metered_ocr_pages |
quantity |
integer |
not null | 1 |
Seat count for kind = 'seat', ignored for metered kinds |
unit_amount_cents |
integer |
null | null |
Null for metered items priced via Stripe Meters, whose price is looked up from plans/Section 12.6 at invoice time |
Primary key id. Foreign keys: subscription_id → subscriptions(id) ON DELETE CASCADE. Unique: stripe_subscription_item_id. Indexes: ix_subscription_items_subscription (subscription_id).
export const subscriptionItems = pgTable('subscription_items', {
id: id(),
subscriptionId: uuid('subscription_id').notNull().references(() => subscriptions.id, { onDelete: 'cascade' }),
stripeSubscriptionItemId: text('stripe_subscription_item_id').notNull(),
stripePriceId: text('stripe_price_id').notNull(),
kind: text('kind').notNull(),
quantity: integer('quantity').notNull().default(1),
unitAmountCents: integer('unit_amount_cents'),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_subscription_items_stripe_id').on(table.stripeSubscriptionItemId),
index('ix_subscription_items_subscription').on(table.subscriptionId),
check('ck_subscription_items_kind', sql`${table.kind} in ('base', 'seat', 'metered_operations', 'metered_ocr_pages')`),
]);5.4.4 usage_records #
One billable/metered event. Owner: user or workspace. Delete policy: hard, via partition drop (Section 5.14). Partitioned by month on occurred_at.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
workspace_id |
uuid |
null | null |
|
user_id |
uuid |
null | null |
|
api_key_id |
uuid |
null | null |
Null for browser-originated usage |
job_id |
uuid |
null | null |
|
metric |
text |
not null | — | operation | ocr_page | envelope_sent |
quantity |
integer |
not null | 1 |
|
occurred_at |
timestamptz |
not null | now() |
Partition key |
stripe_meter_event_id |
text |
null | null |
Set once reported to Stripe Meters |
No updated_at — usage events are immutable once written. Primary key (id, occurred_at), declared as a composite primaryKey() in the table definition below — PostgreSQL requires a declaratively partitioned table's partition key to be part of every primary key and unique constraint, so a bare id primary key would make the defining migration fail the moment PARTITION BY RANGE (occurred_at) is applied. No other table holds a foreign key against usage_records.id, so the composite has no downstream foreign-key consequence; every join onto this table's data (e.g., job_id) instead goes outward, from this table to its parents. Foreign keys: workspace_id → workspaces(id) ON DELETE CASCADE; user_id → users(id) ON DELETE CASCADE; api_key_id → api_keys(id) ON DELETE SET NULL — usage history survives key revocation; job_id → jobs(id) ON DELETE SET NULL. Indexes: ix_usage_records_workspace_time (workspace_id, occurred_at), ix_usage_records_user_time (user_id, occurred_at), ix_usage_records_api_key_time (api_key_id, occurred_at) — all three serve billing-period aggregation queries.
export const usageRecords = pgTable('usage_records', {
id: uuid('id').notNull(),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }),
jobId: uuid('job_id').references(() => jobs.id, { onDelete: 'set null' }),
metric: text('metric').notNull(),
quantity: integer('quantity').notNull().default(1),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
stripeMeterEventId: text('stripe_meter_event_id'),
createdAt: createdAt(),
}, (table) => [
primaryKey({ columns: [table.id, table.occurredAt] }),
index('ix_usage_records_workspace_time').on(table.workspaceId, table.occurredAt),
index('ix_usage_records_user_time').on(table.userId, table.occurredAt),
index('ix_usage_records_api_key_time').on(table.apiKeyId, table.occurredAt),
check('ck_usage_records_metric', sql`${table.metric} in ('operation', 'ocr_page', 'envelope_sent')`),
]);
// Declared PARTITION BY RANGE (occurred_at) in the migration SQL; Drizzle's schema builder
// models the logical column set, and partition DDL is hand-written in the migration file
// per Section 5.16 — drizzle-kit does not generate partition clauses.5.4.5 usage_daily_rollups #
Daily aggregate usage per owner and metric, the table entitlement checks actually read (Section 5.13 #5) so the hot path never scans raw usage_records. Owner: user or workspace. Delete policy: hard, periodic purge.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
owner_type |
text |
not null | — | user | workspace |
owner_id |
uuid |
not null | — | Polymorphic — no FK, validated at the application layer against the correct table for owner_type |
metric |
text |
not null | — | operation | ocr_page | envelope_sent | server_task |
usage_date |
date |
not null | — | UTC calendar date |
quantity |
integer |
not null | 0 |
Incremented by a single INSERT ... ON CONFLICT DO UPDATE per usage event |
No deleted_at. Primary key id. No foreign keys (polymorphic owner, see above). Unique: (owner_type, owner_id, metric, usage_date). Indexes: ux_usage_daily_rollups_owner_metric_date as above — serves both the upsert and the entitlement read.
export const usageDailyRollups = pgTable('usage_daily_rollups', {
id: id(),
ownerType: text('owner_type').notNull(),
ownerId: uuid('owner_id').notNull(),
metric: text('metric').notNull(),
usageDate: date('usage_date').notNull(),
quantity: integer('quantity').notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_usage_daily_rollups_owner_metric_date').on(table.ownerType, table.ownerId, table.metric, table.usageDate),
check('ck_usage_daily_rollups_owner_type', sql`${table.ownerType} in ('user', 'workspace')`),
check('ck_usage_daily_rollups_metric', sql`${table.metric} in ('operation', 'ocr_page', 'envelope_sent', 'server_task')`),
]);5.4.6 spend_caps #
An optional per-API-key monthly spend ceiling. Owner: follows the parent key. Delete policy: cascades with key.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
api_key_id |
uuid |
not null | — | |
cap_cents |
integer |
not null | — | |
period |
text |
not null | 'month' |
Only value at launch |
current_period_spend_cents |
integer |
not null | 0 |
Reset to 0 by the billing-period-close job |
enabled |
boolean |
not null | false |
Defaults to off per Section 12.6 |
Primary key id. Foreign keys: api_key_id → api_keys(id) ON DELETE CASCADE. Unique: api_key_id — at most one cap row per key. Check: ck_spend_caps_cap_positive: cap_cents > 0. Indexes: ux_spend_caps_api_key (api_key_id) — serves the pre-request spend check (Section 5.13 #11).
export const spendCaps = pgTable('spend_caps', {
id: id(),
apiKeyId: uuid('api_key_id').notNull().references(() => apiKeys.id, { onDelete: 'cascade' }),
capCents: integer('cap_cents').notNull(),
period: text('period').notNull().default('month'),
currentPeriodSpendCents: integer('current_period_spend_cents').notNull().default(0),
enabled: boolean('enabled').notNull().default(false),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_spend_caps_api_key').on(table.apiKeyId),
check('ck_spend_caps_cap_positive', sql`${table.capCents} > 0`),
check('ck_spend_caps_period', sql`${table.period} = 'month'`),
]);5.4.7 invoices #
A read-model mirror of Stripe invoices — never written by application billing logic, only synced from Stripe webhook events. Owner: user or workspace. Delete policy: none.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
workspace_id |
uuid |
null | null |
|
user_id |
uuid |
null | null |
|
stripe_invoice_id |
text |
not null | — | |
status |
text |
not null | — | draft | open | paid | uncollectible | void |
amount_due_cents |
integer |
not null | — | |
amount_paid_cents |
integer |
not null | 0 |
|
currency |
text |
not null | 'usd' |
|
hosted_invoice_url |
text |
null | null |
|
invoice_pdf_url |
text |
null | null |
|
period_start |
timestamptz |
null | null |
|
period_end |
timestamptz |
null | null |
Primary key id. Foreign keys: workspace_id → workspaces(id) ON DELETE CASCADE; user_id → users(id) ON DELETE CASCADE. Unique: stripe_invoice_id. Check: ck_invoices_owner_xor: num_nonnulls(workspace_id, user_id) = 1. Indexes: ux_invoices_stripe_id (stripe_invoice_id) — serves webhook upsert; ix_invoices_workspace (workspace_id, created_at DESC), ix_invoices_user (user_id, created_at DESC) — serve the billing-history page.
export const invoices = pgTable('invoices', {
id: id(),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
stripeInvoiceId: text('stripe_invoice_id').notNull(),
status: text('status').notNull(),
amountDueCents: integer('amount_due_cents').notNull(),
amountPaidCents: integer('amount_paid_cents').notNull().default(0),
currency: text('currency').notNull().default('usd'),
hostedInvoiceUrl: text('hosted_invoice_url'),
invoicePdfUrl: text('invoice_pdf_url'),
periodStart: timestamp('period_start', { withTimezone: true }),
periodEnd: timestamp('period_end', { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_invoices_stripe_id').on(table.stripeInvoiceId),
index('ix_invoices_workspace').on(table.workspaceId, table.createdAt),
index('ix_invoices_user').on(table.userId, table.createdAt),
check('ck_invoices_owner_xor', sql`num_nonnulls(${table.workspaceId}, ${table.userId}) = 1`),
check('ck_invoices_status', sql`${table.status} in ('draft', 'open', 'paid', 'uncollectible', 'void')`),
]);5.5 Documents & Storage #
5.5.1 documents #
Server-side file metadata only — file bytes are never stored in PostgreSQL. Owner: user (+ optional workspace). Delete policy: soft.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
owner_user_id |
uuid |
not null | — | |
workspace_id |
uuid |
null | null |
Set when created within a Team's shared context |
job_id |
uuid |
null | null |
The job that produced or ingested this document |
filename |
text |
not null | — | Original or generated filename, sanitized (no path separators) |
mime_type |
text |
not null | — | One of the 7 values in Section 5.11 |
page_count |
integer |
null | null |
Null for non-paginated formats before rasterization |
size_bytes |
bigint |
not null | — | |
sha256 |
text |
not null | — | Of the file bytes at creation |
status |
text |
not null | 'active' |
active | processing | expired | deleted |
Primary key id. Foreign keys: owner_user_id → users(id) ON DELETE CASCADE; workspace_id → workspaces(id) ON DELETE SET NULL — a workspace deletion demotes the document to personal rather than orphaning it; job_id → jobs(id) ON DELETE SET NULL. Check: ck_documents_size_positive: size_bytes > 0; ck_documents_mime_type (Section 5.11 list); ck_documents_status. Indexes: ix_documents_owner_created (owner_user_id, created_at DESC) WHERE deleted_at IS NULL — serves the "My Files" list (Section 5.13 #2); ix_documents_workspace (workspace_id) WHERE deleted_at IS NULL.
export const documents = pgTable('documents', {
id: id(),
ownerUserId: uuid('owner_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'set null' }),
jobId: uuid('job_id').references(() => jobs.id, { onDelete: 'set null' }),
filename: text('filename').notNull(),
mimeType: text('mime_type').notNull(),
pageCount: integer('page_count'),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
sha256: text('sha256').notNull(),
status: text('status').notNull().default('active'),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt(),
}, (table) => [
index('ix_documents_owner_created').on(table.ownerUserId, table.createdAt).where(sql`${table.deletedAt} is null`),
index('ix_documents_workspace').on(table.workspaceId).where(sql`${table.deletedAt} is null`),
check('ck_documents_size_positive', sql`${table.sizeBytes} > 0`),
check('ck_documents_status', sql`${table.status} in ('active', 'processing', 'expired', 'deleted')`),
]);5.5.2 document_blobs #
The encrypted object-storage pointer. Owner: follows the parent document. Delete policy: hard, never soft — this is the row whose deletion cryptographically shreds the underlying bytes (Section 5.15).
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
document_id |
uuid |
not null | — | |
storage_key |
text |
not null | — | S3/R2 object key |
storage_bucket |
text |
not null | — | |
wrapped_data_key |
bytea |
not null | — | AES-256-GCM data key, wrapped by the KMS master key |
kms_key_id |
text |
not null | — | KMS master key version used to wrap |
size_bytes |
bigint |
not null | — | |
checksum_sha256 |
text |
not null | — | |
expires_at |
timestamptz |
not null | — | min(uploaded_at + 24h, job_terminal_at + 2h), computed at write time |
No updated_at, no deleted_at — the row is deleted outright. Primary key id. Foreign keys: document_id → documents(id) ON DELETE CASCADE. Unique: storage_key. Indexes: ux_document_blobs_storage_key (storage_key); ix_document_blobs_expires_at (expires_at) — serves the janitor sweep (Section 5.13 #12, Section 5.15).
export const documentBlobs = pgTable('document_blobs', {
id: id(),
documentId: uuid('document_id').notNull().references(() => documents.id, { onDelete: 'cascade' }),
storageKey: text('storage_key').notNull(),
storageBucket: text('storage_bucket').notNull(),
wrappedDataKey: bytea('wrapped_data_key').notNull(),
kmsKeyId: text('kms_key_id').notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
checksumSha256: text('checksum_sha256').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: createdAt(),
}, (table) => [
uniqueIndex('ux_document_blobs_storage_key').on(table.storageKey),
index('ix_document_blobs_expires_at').on(table.expiresAt),
]);5.6 Jobs & Batch Processing #
5.6.1 jobs #
One asynchronous or synchronous processing operation, client-side or server-side. Owner: user (+ optional workspace). Delete policy: none — visibility windowed by plan, never physically deleted.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
owner_user_id |
uuid |
not null | — | |
workspace_id |
uuid |
null | null |
|
api_key_id |
uuid |
null | null |
Set when created through the public API |
batch_job_id |
uuid |
null | null |
Set when this job is one item of a batch |
tool |
text |
not null | — | Tool identifier, e.g. merge, ocr, pdf-to-docx (full catalogue in Sections 7–9) |
execution_location |
text |
not null | — | client | server |
queue |
text |
null | null |
ocr | convert | esign | batch | webhook | janitor; null for client-side jobs, which never enter BullMQ |
priority_lane |
text |
not null | 'standard' |
standard | priority, a BullMQ job-priority value not a separate queue (Section 3.9) |
state |
text |
not null | 'queued' |
The job state machine of Section 4.2: queued|running|succeeded|failed|canceled|expired |
progress |
smallint |
not null | 0 |
0–100 |
stage |
text |
null | null |
Free-text sub-stage label shown in the UI |
input_document_id |
uuid |
null | null |
|
output_document_id |
uuid |
null | null |
|
error_code |
text |
null | null |
From the catalogue in Section 23.1 |
error_message |
text |
null | null |
|
deterministic_timestamp |
timestamptz |
null | null |
The pinned timestamp passed to pdfcore so client and server runs of the same operation are byte-identical (Section 3) |
started_at |
timestamptz |
null | null |
|
finished_at |
timestamptz |
null | null |
|
expires_at |
timestamptz |
null | null |
Mirrors the associated blob's retention ceiling |
Primary key id. Foreign keys: owner_user_id → users(id) ON DELETE CASCADE; workspace_id → workspaces(id) ON DELETE SET NULL; api_key_id → api_keys(id) ON DELETE SET NULL; batch_job_id → batch_jobs(id) ON DELETE CASCADE; input_document_id → documents(id) ON DELETE SET NULL; output_document_id → documents(id) ON DELETE SET NULL — a job row must survive its documents' eventual purge, since job history outlives file retention. Check: ck_jobs_progress: progress BETWEEN 0 AND 100; ck_jobs_state, ck_jobs_execution_location, ck_jobs_priority_lane. Indexes: ix_jobs_owner_created (owner_user_id, created_at DESC) — serves job history (Section 5.13 #4); ix_jobs_state_queue (state, queue) WHERE state IN ('queued','running') — serves worker/dashboard polling; ix_jobs_batch (batch_job_id) WHERE batch_job_id IS NOT NULL; ix_jobs_id_poll (id) (the primary key itself, called out because client-side status polling — Section 5.13 #3 — is the single highest-volume read against this table).
export const jobs = pgTable('jobs', {
id: id(),
ownerUserId: uuid('owner_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'set null' }),
apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }),
batchJobId: uuid('batch_job_id').references(() => batchJobs.id, { onDelete: 'cascade' }),
tool: text('tool').notNull(),
executionLocation: text('execution_location').notNull(),
queue: text('queue'),
priorityLane: text('priority_lane').notNull().default('standard'),
state: text('state').notNull().default('queued'),
progress: smallint('progress').notNull().default(0),
stage: text('stage'),
inputDocumentId: uuid('input_document_id').references(() => documents.id, { onDelete: 'set null' }),
outputDocumentId: uuid('output_document_id').references(() => documents.id, { onDelete: 'set null' }),
errorCode: text('error_code'),
errorMessage: text('error_message'),
deterministicTimestamp: timestamp('deterministic_timestamp', { withTimezone: true }),
startedAt: timestamp('started_at', { withTimezone: true }),
finishedAt: timestamp('finished_at', { withTimezone: true }),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
index('ix_jobs_owner_created').on(table.ownerUserId, table.createdAt),
index('ix_jobs_state_queue').on(table.state, table.queue).where(sql`${table.state} in ('queued', 'running')`),
index('ix_jobs_batch').on(table.batchJobId).where(sql`${table.batchJobId} is not null`),
check('ck_jobs_progress', sql`${table.progress} between 0 and 100`),
check('ck_jobs_state', sql`${table.state} in ('queued', 'running', 'succeeded', 'failed', 'canceled', 'expired')`),
check('ck_jobs_execution_location', sql`${table.executionLocation} in ('client', 'server')`),
check('ck_jobs_priority_lane', sql`${table.priorityLane} in ('standard', 'priority')`),
]);5.6.2 job_events #
Progress/state transition log for a job. Owner: follows the parent job. Delete policy: hard, partition drop. Partitioned by month on occurred_at.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
job_id |
uuid |
not null | — | |
event_type |
text |
not null | — | queued | started | progress | succeeded | failed | canceled | expired | retried |
progress |
smallint |
null | null |
|
message |
text |
null | null |
|
occurred_at |
timestamptz |
not null | now() |
Partition key |
Primary key (id, occurred_at), declared as a composite primaryKey() below — PostgreSQL requires the partition key to be part of every primary key on a declaratively partitioned table, so a bare id primary key would make the defining PARTITION BY RANGE (occurred_at) migration fail. No other table holds a foreign key against job_events.id, so the composite has no downstream foreign-key consequence. Foreign keys: job_id → jobs(id) ON DELETE CASCADE. Indexes: ix_job_events_job_occurred (job_id, occurred_at) — serves the job-detail event timeline and SSE backfill.
export const jobEvents = pgTable('job_events', {
id: uuid('id').notNull(),
jobId: uuid('job_id').notNull().references(() => jobs.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(),
progress: smallint('progress'),
message: text('message'),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
createdAt: createdAt(),
}, (table) => [
primaryKey({ columns: [table.id, table.occurredAt] }),
index('ix_job_events_job_occurred').on(table.jobId, table.occurredAt),
check('ck_job_events_event_type', sql`${table.eventType} in ('queued', 'started', 'progress', 'succeeded', 'failed', 'canceled', 'expired', 'retried')`),
]);5.6.3 job_artifacts #
A non-primary output of a job: a log, a redaction report, a preview thumbnail. Owner: follows the parent job. Delete policy: hard, with the job's blob purge.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
job_id |
uuid |
not null | — | |
document_id |
uuid |
null | null |
Set when the artifact is itself a downloadable document (e.g. the Redaction Verification Report of Section 9.1) |
kind |
text |
not null | — | output_document | log | redaction_report | preview_thumbnail |
storage_key |
text |
null | null |
Set for artifacts not modeled as a full documents row |
Primary key id. Foreign keys: job_id → jobs(id) ON DELETE CASCADE; document_id → documents(id) ON DELETE SET NULL. Indexes: ix_job_artifacts_job (job_id).
export const jobArtifacts = pgTable('job_artifacts', {
id: id(),
jobId: uuid('job_id').notNull().references(() => jobs.id, { onDelete: 'cascade' }),
documentId: uuid('document_id').references(() => documents.id, { onDelete: 'set null' }),
kind: text('kind').notNull(),
storageKey: text('storage_key'),
createdAt: createdAt(),
}, (table) => [
index('ix_job_artifacts_job').on(table.jobId),
check('ck_job_artifacts_kind', sql`${table.kind} in ('output_document', 'log', 'redaction_report', 'preview_thumbnail')`),
]);5.6.4 batch_jobs #
A batch of many single-tool jobs. Owner: user (+ optional workspace). Delete policy: none, visibility windowed.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
owner_user_id |
uuid |
not null | — | |
workspace_id |
uuid |
null | null |
|
api_key_id |
uuid |
null | null |
|
tool |
text |
not null | — | |
state |
text |
not null | 'queued' |
Job state machine values (Section 4.7) |
total_items |
integer |
not null | — | |
completed_items |
integer |
not null | 0 |
|
failed_items |
integer |
not null | 0 |
|
finished_at |
timestamptz |
null | null |
Primary key id. Foreign keys: owner_user_id → users(id) ON DELETE CASCADE; workspace_id → workspaces(id) ON DELETE SET NULL; api_key_id → api_keys(id) ON DELETE SET NULL. Check: ck_batch_jobs_total_positive: total_items > 0. Indexes: ix_batch_jobs_owner_created (owner_user_id, created_at DESC).
export const batchJobs = pgTable('batch_jobs', {
id: id(),
ownerUserId: uuid('owner_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'set null' }),
apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }),
tool: text('tool').notNull(),
state: text('state').notNull().default('queued'),
totalItems: integer('total_items').notNull(),
completedItems: integer('completed_items').notNull().default(0),
failedItems: integer('failed_items').notNull().default(0),
finishedAt: timestamp('finished_at', { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
index('ix_batch_jobs_owner_created').on(table.ownerUserId, table.createdAt),
check('ck_batch_jobs_total_positive', sql`${table.totalItems} > 0`),
]);5.6.5 batch_job_items #
One item within a batch. Owner: follows the parent batch. Delete policy: none, visibility windowed.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
batch_job_id |
uuid |
not null | — | |
job_id |
uuid |
null | null |
Null until the item is dequeued and a jobs row is created for it |
sequence |
smallint |
not null | — | Position within the batch, 0-based |
state |
text |
not null | 'queued' |
Job state machine values (Section 4.2) |
Primary key id. Foreign keys: batch_job_id → batch_jobs(id) ON DELETE CASCADE; job_id → jobs(id) ON DELETE SET NULL. Unique: (batch_job_id, sequence). Indexes: ux_batch_job_items_batch_seq (batch_job_id, sequence) — serves ordered item listing.
export const batchJobItems = pgTable('batch_job_items', {
id: id(),
batchJobId: uuid('batch_job_id').notNull().references(() => batchJobs.id, { onDelete: 'cascade' }),
jobId: uuid('job_id').references(() => jobs.id, { onDelete: 'set null' }),
sequence: smallint('sequence').notNull(),
state: text('state').notNull().default('queued'),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_batch_job_items_batch_seq').on(table.batchJobId, table.sequence),
]);5.7 API Access & Webhooks #
5.7.1 api_keys #
A public API credential. Owner: user or workspace (exactly one). Delete policy: soft.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
workspace_id |
uuid |
null | null |
|
owner_user_id |
uuid |
null | null |
|
name |
text |
not null | — | User-assigned label |
key_prefix |
text |
not null | — | pk_live_ | pk_test_ |
key_hash |
text |
not null | — | SHA-256 of the full key — a slow KDF adds nothing here since the key itself is 32 bytes of CSPRNG entropy, and would only cost per-request latency (Section 17.4) |
last_four |
text |
not null | — | For display, e.g. "•••• 4f9c" |
environment |
text |
not null | — | live | test |
last_used_at |
timestamptz |
null | null |
|
revoked_at |
timestamptz |
null | null |
Instant revoke; distinct from deleted_at, which removes it from key-management UI listings |
Primary key id. Foreign keys: workspace_id → workspaces(id) ON DELETE CASCADE; owner_user_id → users(id) ON DELETE CASCADE. Unique: key_hash. Check: ck_api_keys_owner_xor: num_nonnulls(workspace_id, owner_user_id) = 1; ck_api_keys_prefix; ck_api_keys_environment. Indexes: ux_api_keys_key_hash (key_hash) — serves the auth check on every public API request (Section 5.13 #6, the single highest-frequency query against this table).
export const apiKeys = pgTable('api_keys', {
id: id(),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
keyPrefix: text('key_prefix').notNull(),
keyHash: text('key_hash').notNull(),
lastFour: text('last_four').notNull(),
environment: text('environment').notNull(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt(),
}, (table) => [
uniqueIndex('ux_api_keys_key_hash').on(table.keyHash),
check('ck_api_keys_owner_xor', sql`num_nonnulls(${table.workspaceId}, ${table.ownerUserId}) = 1`),
check('ck_api_keys_prefix', sql`${table.keyPrefix} in ('pk_live_', 'pk_test_')`),
check('ck_api_keys_environment', sql`${table.environment} in ('live', 'test')`),
]);5.7.2 api_key_scopes #
A permission scope granted to a key. Owner: follows the parent key. Delete policy: cascades with key.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
api_key_id |
uuid |
not null | — | |
scope |
text |
not null | — | documents:read | documents:write | jobs:read | jobs:write | envelopes:read | envelopes:write | webhooks:manage |
No updated_at. Primary key id. Foreign keys: api_key_id → api_keys(id) ON DELETE CASCADE. Unique: (api_key_id, scope). Indexes: ix_api_key_scopes_key (api_key_id) — serves the per-request scope check, read alongside the key-hash lookup.
export const apiKeyScopes = pgTable('api_key_scopes', {
id: id(),
apiKeyId: uuid('api_key_id').notNull().references(() => apiKeys.id, { onDelete: 'cascade' }),
scope: text('scope').notNull(),
createdAt: createdAt(),
}, (table) => [
uniqueIndex('ux_api_key_scopes_key_scope').on(table.apiKeyId, table.scope),
check('ck_api_key_scopes_scope', sql`${table.scope} in ('documents:read', 'documents:write', 'jobs:read', 'jobs:write', 'envelopes:read', 'envelopes:write', 'webhooks:manage')`),
]);5.7.3 api_key_ip_rules #
A CIDR allowlist entry for a key. Owner: follows the parent key. Delete policy: cascades with key.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
api_key_id |
uuid |
not null | — | |
cidr |
cidr |
not null | — | An empty allowlist (no rows) means "allow any IP" |
No updated_at. Primary key id. Foreign keys: api_key_id → api_keys(id) ON DELETE CASCADE. Unique: (api_key_id, cidr). Indexes: ix_api_key_ip_rules_key (api_key_id).
export const apiKeyIpRules = pgTable('api_key_ip_rules', {
id: id(),
apiKeyId: uuid('api_key_id').notNull().references(() => apiKeys.id, { onDelete: 'cascade' }),
cidr: cidr('cidr').notNull(),
createdAt: createdAt(),
}, (table) => [
uniqueIndex('ux_api_key_ip_rules_key_cidr').on(table.apiKeyId, table.cidr),
]);5.7.4 webhook_endpoints #
A customer-configured webhook target. Owner: user or workspace. Delete policy: soft.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
workspace_id |
uuid |
null | null |
|
owner_user_id |
uuid |
null | null |
|
url |
text |
not null | — | Must be HTTPS |
description |
text |
null | null |
|
secret_ciphertext |
bytea |
not null | — | KMS-wrapped HMAC signing secret |
secret_key_id |
text |
not null | — | |
previous_secret_ciphertext |
bytea |
null | null |
Populated for a 24-hour dual-secret rotation overlap |
previous_secret_expires_at |
timestamptz |
null | null |
|
subscribed_events |
text[] |
not null | '{}' |
e.g. job.succeeded, envelope.completed |
status |
text |
not null | 'active' |
active | failing | disabled — state machine defined below |
consecutive_failures |
smallint |
not null | 0 |
Consecutive failed delivery attempts across all events; reset to 0 by any single successful delivery |
Primary key id. Foreign keys: workspace_id → workspaces(id) ON DELETE CASCADE; owner_user_id → users(id) ON DELETE CASCADE. Check: ck_webhook_endpoints_owner_xor: num_nonnulls(workspace_id, owner_user_id) = 1. Indexes: ix_webhook_endpoints_workspace (workspace_id) WHERE deleted_at IS NULL; ix_webhook_endpoints_user (owner_user_id) WHERE deleted_at IS NULL.
The status state machine. active is the only state in which nothing unusual is happening; failing and disabled both mean the endpoint has a delivery problem, at increasing severity:
active → failing: the momentconsecutive_failuresreaches 3 as a delivery attempt's terminal failure is recorded (Section 14.10). The transition and the failure-recording write happen in the same database transaction, soconsecutive_failuresandstatuscan never disagree.- While
failing: deliveries are not paused — every subscribed event continues to be attempted on the normal retry schedule (Section 14.10.6) exactly as it would fromactive. The one behavioral difference is notification: at the instant of theactive → failingtransition, and again once every 24 hours for as long as the endpoint remainsfailing, a transactional email is sent to the workspace owner (or, for a personal key, the owning user) identifying the endpoint URL and the most recent failure's HTTP status/error. This is the only owner-facing signal that a webhook is unhealthy short of the endpoint's own monitoring. failing → disabled: the momentconsecutive_failuresreaches 10, OR the endpoint has been continuouslyfailingfor 14 days without a single successful delivery, whichever comes first. Oncedisabled, delivery attempts stop entirely — no further retries are scheduled for events that arrive while the endpoint is disabled, andwebhook_deliveriesrows are still written for observability but immediately markedexhaustedrather thanpending.failing → active: any single successful delivery (an HTTP2xxresponse recorded against any attempt) resetsconsecutive_failuresto0and movesstatusback toactiveimmediately. No manual action is required.disabled → active: requires an explicit action by the endpoint's owner — re-saving the endpoint (even with no field changes) in the dashboard or viaPATCH /v1/webhook-endpoints/{id}(Section 14.10). Reactivation resetsconsecutive_failuresto0, setsstatustoactive, and delivery resumes for events emitted from that point forward; events that arrived whiledisabledare not retroactively delivered.
Every transition above is applied by the same delivery-attempt handler that writes the corresponding webhook_deliveries row, inside one transaction, so webhook_endpoints.status/consecutive_failures and the delivery log can never drift apart.
export const webhookEndpoints = pgTable('webhook_endpoints', {
id: id(),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'cascade' }),
url: text('url').notNull(),
description: text('description'),
secretCiphertext: bytea('secret_ciphertext').notNull(),
secretKeyId: text('secret_key_id').notNull(),
previousSecretCiphertext: bytea('previous_secret_ciphertext'),
previousSecretExpiresAt: timestamp('previous_secret_expires_at', { withTimezone: true }),
subscribedEvents: text('subscribed_events').array().notNull().default(sql`'{}'::text[]`),
status: text('status').notNull().default('active'),
consecutiveFailures: smallint('consecutive_failures').notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt(),
}, (table) => [
index('ix_webhook_endpoints_workspace').on(table.workspaceId).where(sql`${table.deletedAt} is null`),
index('ix_webhook_endpoints_user').on(table.ownerUserId).where(sql`${table.deletedAt} is null`),
check('ck_webhook_endpoints_owner_xor', sql`num_nonnulls(${table.workspaceId}, ${table.ownerUserId}) = 1`),
check('ck_webhook_endpoints_status', sql`${table.status} in ('active', 'disabled', 'failing')`),
]);5.7.5 webhook_deliveries #
One delivery attempt of one event to one endpoint. Owner: follows the parent endpoint. Delete policy: hard, partition drop. Partitioned by month on created_at.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
webhook_endpoint_id |
uuid |
not null | — | |
event_type |
text |
not null | — | |
payload |
jsonb |
not null | — | |
attempt |
smallint |
not null | 1 |
|
status |
text |
not null | 'pending' |
pending | delivered | failed | exhausted |
response_status_code |
smallint |
null | null |
|
response_body_excerpt |
text |
null | null |
First 1 KB, for debugging |
next_retry_at |
timestamptz |
null | null |
Exponential backoff, Section 14.10.6 |
delivered_at |
timestamptz |
null | null |
No updated_at (status changes are appended as new attempt rows, not mutated in place, except the terminal status/delivered_at/response_* update on the current attempt). Primary key (id, created_at), declared as a composite primaryKey() below — PostgreSQL requires the partition key to be part of every primary key on a declaratively partitioned table, so a bare id primary key would make the defining PARTITION BY RANGE (created_at) migration fail. No other table holds a foreign key against webhook_deliveries.id, so the composite has no downstream foreign-key consequence. Foreign keys: webhook_endpoint_id → webhook_endpoints(id) ON DELETE CASCADE. Indexes: ix_webhook_deliveries_endpoint_created (webhook_endpoint_id, created_at DESC) — serves the delivery-log UI; ix_webhook_deliveries_retry (status, next_retry_at) WHERE status = 'pending' — serves the retry worker (Section 5.13 #10).
export const webhookDeliveries = pgTable('webhook_deliveries', {
id: uuid('id').notNull(),
webhookEndpointId: uuid('webhook_endpoint_id').notNull().references(() => webhookEndpoints.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(),
payload: jsonb('payload').notNull(),
attempt: smallint('attempt').notNull().default(1),
status: text('status').notNull().default('pending'),
responseStatusCode: smallint('response_status_code'),
responseBodyExcerpt: text('response_body_excerpt'),
nextRetryAt: timestamp('next_retry_at', { withTimezone: true }),
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
createdAt: createdAt(),
}, (table) => [
primaryKey({ columns: [table.id, table.createdAt] }),
index('ix_webhook_deliveries_endpoint_created').on(table.webhookEndpointId, table.createdAt),
index('ix_webhook_deliveries_retry').on(table.status, table.nextRetryAt).where(sql`${table.status} = 'pending'`),
check('ck_webhook_deliveries_status', sql`${table.status} in ('pending', 'delivered', 'failed', 'exhausted')`),
]);5.8 E-Signature #
5.8.1 envelopes #
An e-signature request (Section 10). Owner: user (+ optional workspace). Delete policy: soft.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
owner_user_id |
uuid |
not null | — | |
workspace_id |
uuid |
null | null |
|
title |
text |
not null | — | |
status |
text |
not null | 'draft' |
draft | sent | in_progress | completed | voided | expired | declined |
routing |
text |
not null | 'sequential' |
sequential | parallel |
expires_at |
timestamptz |
not null | now() + interval '14 days' |
Configurable 1–30 days at send time |
reminder_cadence_days |
smallint[] |
not null | '{3,7,12}' |
|
completed_at |
timestamptz |
null | null |
|
voided_at |
timestamptz |
null | null |
|
void_reason |
text |
null | null |
|
final_document_hash |
text |
null | null |
SHA-256 hex of the completed, flattened PDF |
Primary key id. Foreign keys: owner_user_id → users(id) ON DELETE CASCADE; workspace_id → workspaces(id) ON DELETE SET NULL. Check: ck_envelopes_status; ck_envelopes_routing; ck_envelopes_expiry_max: expires_at <= created_at + interval '30 days' — enforces the hard 30-day ceiling from Section 6 at the database layer, not just in application code. Indexes: ix_envelopes_owner_created (owner_user_id, created_at DESC); ix_envelopes_workspace (workspace_id) WHERE deleted_at IS NULL.
export const envelopes = pgTable('envelopes', {
id: id(),
ownerUserId: uuid('owner_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'set null' }),
title: text('title').notNull(),
status: text('status').notNull().default('draft'),
routing: text('routing').notNull().default('sequential'),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull().default(sql`now() + interval '14 days'`),
reminderCadenceDays: smallint('reminder_cadence_days').array().notNull().default(sql`'{3,7,12}'::smallint[]`),
completedAt: timestamp('completed_at', { withTimezone: true }),
voidedAt: timestamp('voided_at', { withTimezone: true }),
voidReason: text('void_reason'),
finalDocumentHash: text('final_document_hash'),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt(),
}, (table) => [
index('ix_envelopes_owner_created').on(table.ownerUserId, table.createdAt),
index('ix_envelopes_workspace').on(table.workspaceId).where(sql`${table.deletedAt} is null`),
check('ck_envelopes_status', sql`${table.status} in ('draft', 'sent', 'in_progress', 'completed', 'voided', 'expired', 'declined')`),
check('ck_envelopes_routing', sql`${table.routing} in ('sequential', 'parallel')`),
check('ck_envelopes_expiry_max', sql`${table.expiresAt} <= ${table.createdAt} + interval '30 days'`),
]);5.8.2 envelope_documents #
A document attached to an envelope. Owner: follows the parent envelope. Delete policy: cascades with envelope.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
envelope_id |
uuid |
not null | — | |
document_id |
uuid |
not null | — | |
sequence |
smallint |
not null | 1 |
Page-order position when an envelope has multiple source documents |
No updated_at. Primary key id. Foreign keys: envelope_id → envelopes(id) ON DELETE CASCADE; document_id → documents(id) ON DELETE RESTRICT — a document referenced by a live envelope cannot be independently deleted; the envelope must be voided or completed first, which releases the reference through the normal blob-retention path. Unique: (envelope_id, sequence). Indexes: ix_envelope_documents_envelope (envelope_id).
export const envelopeDocuments = pgTable('envelope_documents', {
id: id(),
envelopeId: uuid('envelope_id').notNull().references(() => envelopes.id, { onDelete: 'cascade' }),
documentId: uuid('document_id').notNull().references(() => documents.id, { onDelete: 'restrict' }),
sequence: smallint('sequence').notNull().default(1),
createdAt: createdAt(),
}, (table) => [
uniqueIndex('ux_envelope_documents_envelope_seq').on(table.envelopeId, table.sequence),
]);5.8.3 envelope_signers #
A signer, approver, or CC recipient. Owner: follows the parent envelope. Delete policy: cascades with envelope.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
envelope_id |
uuid |
not null | — | |
role |
text |
not null | — | signer | approver | cc |
routing_order |
smallint |
not null | 1 |
Signing sequence for routing = 'sequential'; ignored for parallel |
name |
text |
not null | — | |
email |
text |
not null | — | |
status |
text |
not null | 'pending' |
pending | notified | viewed | consented | in_progress | signed | declined | bounced | delegated — Section 10 defines the full state machine |
otp_code_hash |
text |
null | null |
SHA-256 of the 6-digit email-verification OTP, consumed before first field entry |
otp_verified_at |
timestamptz |
null | null |
|
consented_at |
timestamptz |
null | null |
Acceptance of the Electronic Record and Signature Disclosure |
signed_at |
timestamptz |
null | null |
|
declined_at |
timestamptz |
null | null |
|
decline_reason |
text |
null | null |
|
access_token_hash |
text |
not null | — | SHA-256 of the signer's per-session link token — signers never have an account |
Primary key id. Foreign keys: envelope_id → envelopes(id) ON DELETE CASCADE. Unique: access_token_hash. Check: ck_envelope_signers_role; ck_envelope_signers_status. Indexes: ux_envelope_signers_access_token (access_token_hash) — serves the unauthenticated signer-portal session lookup (Section 5.13 #8); ix_envelope_signers_envelope_order (envelope_id, routing_order) — serves sequential-routing progression checks.
export const envelopeSigners = pgTable('envelope_signers', {
id: id(),
envelopeId: uuid('envelope_id').notNull().references(() => envelopes.id, { onDelete: 'cascade' }),
role: text('role').notNull(),
routingOrder: smallint('routing_order').notNull().default(1),
name: text('name').notNull(),
email: text('email').notNull(),
status: text('status').notNull().default('pending'),
otpCodeHash: text('otp_code_hash'),
otpVerifiedAt: timestamp('otp_verified_at', { withTimezone: true }),
consentedAt: timestamp('consented_at', { withTimezone: true }),
signedAt: timestamp('signed_at', { withTimezone: true }),
declinedAt: timestamp('declined_at', { withTimezone: true }),
declineReason: text('decline_reason'),
accessTokenHash: text('access_token_hash').notNull(),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_envelope_signers_access_token').on(table.accessTokenHash),
index('ix_envelope_signers_envelope_order').on(table.envelopeId, table.routingOrder),
check('ck_envelope_signers_role', sql`${table.role} in ('signer', 'approver', 'cc')`),
check('ck_envelope_signers_status', sql`${table.status} in ('pending', 'notified', 'viewed', 'consented', 'in_progress', 'signed', 'declined', 'bounced', 'delegated')`),
]);5.8.4 envelope_fields #
A placed field: signature, initials, free text, checkbox, etc. Owner: follows the parent envelope. Delete policy: cascades with envelope.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
envelope_id |
uuid |
not null | — | |
envelope_document_id |
uuid |
not null | — | |
signer_id |
uuid |
not null | — | |
type |
text |
not null | — | signature | initials | date_signed | free_text | checkbox | radio_group | dropdown | attachment | full_name | email | title | company |
page_number |
integer |
not null | — | 1-based |
x_pct / y_pct |
numeric(6,4) |
not null | — | Position as a fraction of page width/height, 0–1, resolution-independent |
width_pct / height_pct |
numeric(6,4) |
not null | — | |
required |
boolean |
not null | true |
|
options |
jsonb |
null | null |
Choice list for dropdown/radio_group |
value |
text |
null | null |
Completed value |
completed_at |
timestamptz |
null | null |
Primary key id. Foreign keys: envelope_id → envelopes(id) ON DELETE CASCADE; envelope_document_id → envelope_documents(id) ON DELETE CASCADE; signer_id → envelope_signers(id) ON DELETE CASCADE. Check: ck_envelope_fields_type; ck_envelope_fields_page_positive: page_number > 0; ck_envelope_fields_x_pct: x_pct BETWEEN 0 AND 1; ck_envelope_fields_y_pct: y_pct BETWEEN 0 AND 1. Indexes: ix_envelope_fields_signer (signer_id) — serves "does this signer have unfilled required fields" at completion time; ix_envelope_fields_document (envelope_document_id, page_number) — serves field-overlay rendering per page.
export const envelopeFields = pgTable('envelope_fields', {
id: id(),
envelopeId: uuid('envelope_id').notNull().references(() => envelopes.id, { onDelete: 'cascade' }),
envelopeDocumentId: uuid('envelope_document_id').notNull().references(() => envelopeDocuments.id, { onDelete: 'cascade' }),
signerId: uuid('signer_id').notNull().references(() => envelopeSigners.id, { onDelete: 'cascade' }),
type: text('type').notNull(),
pageNumber: integer('page_number').notNull(),
xPct: numeric('x_pct', { precision: 6, scale: 4 }).notNull(),
yPct: numeric('y_pct', { precision: 6, scale: 4 }).notNull(),
widthPct: numeric('width_pct', { precision: 6, scale: 4 }).notNull(),
heightPct: numeric('height_pct', { precision: 6, scale: 4 }).notNull(),
required: boolean('required').notNull().default(true),
options: jsonb('options'),
value: text('value'),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
index('ix_envelope_fields_signer').on(table.signerId),
index('ix_envelope_fields_document_page').on(table.envelopeDocumentId, table.pageNumber),
check('ck_envelope_fields_type', sql`${table.type} in ('signature', 'initials', 'date_signed', 'free_text', 'checkbox', 'radio_group', 'dropdown', 'attachment', 'full_name', 'email', 'title', 'company')`),
check('ck_envelope_fields_page_positive', sql`${table.pageNumber} > 0`),
check('ck_envelope_fields_x_pct', sql`${table.xPct} between 0 and 1`),
check('ck_envelope_fields_y_pct', sql`${table.yPct} between 0 and 1`),
]);Coordinate conversion. x_pct, y_pct, width_pct, and height_pct are unitless fractions of the page's dimensions, measured from the page's top-left corner — the same origin the browser-based field-placement UI and the signer-portal rendering canvas use natively, so a field's stored position never needs a sign flip while it is being placed, dragged, or displayed. Page dimensions themselves are read from the underlying PDF's MediaBox in points (1 point = 1/72 inch) via pdf-lib. Because the PDF specification's own coordinate system places its origin at the page's bottom-left corner, the flattening step that stamps a field onto the final PDF (Section 10) and any API response reporting a field's position in absolute points (Section 14) both convert through the following formulas, given a page of pageWidthPt × pageHeightPt:
- Percentage → PDF points, used when flattening a field onto the page:
x_pt = x_pct * pageWidthPt width_pt = width_pct * pageWidthPt height_pt = height_pct * pageHeightPt y_pt = pageHeightPt - (y_pct * pageHeightPt) - height_pt // y_pt is the box's bottom edge, PDF's own reference point - PDF points → percentage, used when a field position arrives from a client as absolute points, or when re-deriving stored percentages from a PDF-space rectangle:
x_pct = x_pt / pageWidthPt width_pct = width_pt / pageWidthPt height_pct = height_pt / pageHeightPt y_pct = (pageHeightPt - y_pt - height_pt) / pageHeightPt
Both directions are implemented once, in a single shared helper (packages/contracts/src/field-coordinates.ts) imported by the API layer and the renderer alike, so the y-axis flip is never reimplemented independently in two places and cannot drift out of agreement.
5.8.5 envelope_audit_events #
The append-only tamper-evident hash chain per envelope. Full mechanics in Section 5.12. Owner: follows the parent envelope. Delete policy: hard delete of content is prohibited before 7 years; only partition drop at the retention boundary removes rows (Section 5.14–5.15). Partitioned by month on occurred_at.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
envelope_id |
uuid |
not null | — | |
sequence |
integer |
not null | — | 0-based chain position, unique per envelope |
event_type |
text |
not null | — | One of the 14 values in Section 5.11 / Section 10 |
signer_id |
uuid |
null | null |
Null for envelope-level events (envelope.created, envelope.sent, envelope.completed, envelope.declined, envelope.voided, envelope.expired) |
occurred_at |
timestamptz |
not null | now() |
Server clock, NTP-disciplined; partition key |
actor_email |
text |
null | null |
Nulled on anonymization (Section 5.15) |
actor_email_hash |
text |
null | null |
Salted hash, always present, survives anonymization |
actor_ip |
text |
null | null |
Nulled on anonymization |
actor_ip_hash |
text |
null | null |
Salted hash, always present, survives anonymization |
user_agent |
text |
null | null |
|
geo_country |
text |
null | null |
ISO 3166-1 alpha-2, from geo-IP lookup |
document_hash |
text |
not null | — | SHA-256 hex of the document bytes at this moment |
payload |
jsonb |
not null | — | Event-type-specific fields; canonicalization rules in Section 5.12 |
payload_hash |
text |
not null | — | SHA-256 hex of the canonical JSON serialization of payload |
prev_hash |
text |
null | — | The chain value as of the previous event; null only when sequence = 0 |
No updated_at — every column is immutable after insert except the anonymization update, which touches only actor_email/actor_ip and is the one narrowly-scoped exception described in Section 5.15. Primary key (id, occurred_at), declared as a composite primaryKey() below — PostgreSQL requires the partition key to be part of every primary key on a declaratively partitioned table, so a bare id primary key would make the defining PARTITION BY RANGE (occurred_at) migration fail. No other table holds a foreign key against envelope_audit_events.id, so the composite has no downstream foreign-key consequence; signature_assets and every other table that needs to reach a specific event does so by walking through envelope_id, never by referencing an audit-event row directly. Foreign keys: envelope_id → envelopes(id) ON DELETE RESTRICT — audit content must never cascade-delete; an envelope can be soft-deleted but its audit trail outlives that. signer_id → envelope_signers(id) ON DELETE SET NULL. Unique: (envelope_id, occurred_at, sequence) — the partition key (occurred_at) must be part of any unique constraint on a partitioned table, so it is included alongside the true uniqueness driver, sequence. Check: ck_envelope_audit_events_prev_hash_seq0: (sequence = 0) = (prev_hash IS NULL). Indexes: ux_envelope_audit_events_envelope_seq (envelope_id, occurred_at, sequence) — serves both the uniqueness guarantee and the ordered chain read (Section 5.13 #9).
export const envelopeAuditEvents = pgTable('envelope_audit_events', {
id: uuid('id').notNull(),
envelopeId: uuid('envelope_id').notNull().references(() => envelopes.id, { onDelete: 'restrict' }),
sequence: integer('sequence').notNull(),
eventType: text('event_type').notNull(),
signerId: uuid('signer_id').references(() => envelopeSigners.id, { onDelete: 'set null' }),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
actorEmail: text('actor_email'),
actorEmailHash: text('actor_email_hash'),
actorIp: text('actor_ip'),
actorIpHash: text('actor_ip_hash'),
userAgent: text('user_agent'),
geoCountry: text('geo_country'),
documentHash: text('document_hash').notNull(),
payload: jsonb('payload').notNull(),
payloadHash: text('payload_hash').notNull(),
prevHash: text('prev_hash'),
createdAt: createdAt(),
}, (table) => [
primaryKey({ columns: [table.id, table.occurredAt] }),
uniqueIndex('ux_envelope_audit_events_envelope_seq').on(table.envelopeId, table.occurredAt, table.sequence),
check('ck_envelope_audit_events_event_type', sql`${table.eventType} in ('envelope.created', 'envelope.sent', 'email.delivered', 'email.bounced', 'signer.viewed', 'signer.consented', 'field.completed', 'signer.signed', 'signer.declined', 'envelope.completed', 'envelope.declined', 'envelope.voided', 'envelope.expired', 'reminder.sent')`),
check('ck_envelope_audit_events_prev_hash_seq0', sql`(${table.sequence} = 0) = (${table.prevHash} is null)`),
]);The application's runtime database role has only SELECT and INSERT privileges on this table — UPDATE and DELETE are revoked in the migration that creates it (REVOKE UPDATE, DELETE ON envelope_audit_events FROM pdfworks_app;), so the append-only guarantee is enforced by PostgreSQL, not just by convention (Section 5.18 rule 4). The one sanctioned mutation path, anonymization, runs as a separate, narrowly-scoped database role that can UPDATE only the actor_email and actor_ip columns.
5.8.6 envelope_reminders #
A scheduled or sent signer reminder. Owner: follows the parent envelope. Delete policy: hard, purged with the envelope on completion/expiry.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
envelope_id |
uuid |
not null | — | |
signer_id |
uuid |
not null | — | |
scheduled_for |
timestamptz |
not null | — | Derived from envelopes.reminder_cadence_days |
sent_at |
timestamptz |
null | null |
|
canceled_at |
timestamptz |
null | null |
Set if the signer completes before the reminder fires |
No updated_at. Primary key id. Foreign keys: envelope_id → envelopes(id) ON DELETE CASCADE; signer_id → envelope_signers(id) ON DELETE CASCADE. Indexes: ix_envelope_reminders_due (scheduled_for) WHERE sent_at IS NULL AND canceled_at IS NULL — serves the reminder-scheduler's due-now scan.
export const envelopeReminders = pgTable('envelope_reminders', {
id: id(),
envelopeId: uuid('envelope_id').notNull().references(() => envelopes.id, { onDelete: 'cascade' }),
signerId: uuid('signer_id').notNull().references(() => envelopeSigners.id, { onDelete: 'cascade' }),
scheduledFor: timestamp('scheduled_for', { withTimezone: true }).notNull(),
sentAt: timestamp('sent_at', { withTimezone: true }),
canceledAt: timestamp('canceled_at', { withTimezone: true }),
createdAt: createdAt(),
}, (table) => [
index('ix_envelope_reminders_due').on(table.scheduledFor).where(sql`${table.sentAt} is null and ${table.canceledAt} is null`),
]);5.8.7 signature_assets #
A stored drawn/typed/uploaded signature image. Public ID prefix asset_ (Section 5.1.3). Owner: user (registered self-sign favorites) or signer (one-off, no account). Delete policy: hard.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
owner_user_id |
uuid |
null | null |
Set for a registered user's reusable signature |
signer_id |
uuid |
null | null |
Set for a one-off signature captured during a signing session |
kind |
text |
not null | — | drawn | typed | uploaded |
typeface |
text |
null | null |
caveat | dancing_script | homemade_apple | sacramento — set only for kind = 'typed' |
storage_key |
text |
not null | — |
No updated_at. Primary key id. Foreign keys: owner_user_id → users(id) ON DELETE CASCADE; signer_id → envelope_signers(id) ON DELETE CASCADE. Check: ck_signature_assets_owner_xor: num_nonnulls(owner_user_id, signer_id) = 1; ck_signature_assets_kind. Indexes: ix_signature_assets_owner (owner_user_id) WHERE owner_user_id IS NOT NULL — serves the self-sign "my saved signatures" picker.
export const signatureAssets = pgTable('signature_assets', {
id: id(),
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'cascade' }),
signerId: uuid('signer_id').references(() => envelopeSigners.id, { onDelete: 'cascade' }),
kind: text('kind').notNull(),
typeface: text('typeface'),
storageKey: text('storage_key').notNull(),
createdAt: createdAt(),
deletedAt: deletedAt(),
}, (table) => [
index('ix_signature_assets_owner').on(table.ownerUserId).where(sql`${table.ownerUserId} is not null`),
check('ck_signature_assets_owner_xor', sql`num_nonnulls(${table.ownerUserId}, ${table.signerId}) = 1`),
check('ck_signature_assets_kind', sql`${table.kind} in ('drawn', 'typed', 'uploaded')`),
]);5.9 Templates & Configuration #
5.9.1 templates #
A saved tool preset or envelope template, personal or shared. Public ID prefix tpl_ (Section 5.1.3). Owner: user (creator, always set) + optional workspace (set when shared to a Team). Delete policy: soft.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
owner_user_id |
uuid |
not null | — | Creator; retained even after sharing |
workspace_id |
uuid |
null | null |
Set when shared = true within a Team |
kind |
text |
not null | — | tool_preset | envelope_template |
name |
text |
not null | — | |
tool |
text |
null | null |
Set when kind = 'tool_preset', e.g. watermark |
config |
jsonb |
not null | '{}' |
Tool-specific settings (e.g. watermark text/opacity) or the envelope field layout |
shared |
boolean |
not null | false |
Primary key id. Foreign keys: owner_user_id → users(id) ON DELETE CASCADE; workspace_id → workspaces(id) ON DELETE SET NULL — a shared template becomes personal again rather than vanishing when the workspace is deleted. Check: ck_templates_kind. Indexes: ix_templates_owner (owner_user_id) WHERE deleted_at IS NULL; ix_templates_workspace_shared (workspace_id) WHERE shared AND deleted_at IS NULL.
export const templates = pgTable('templates', {
id: id(),
ownerUserId: uuid('owner_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'set null' }),
kind: text('kind').notNull(),
name: text('name').notNull(),
tool: text('tool'),
config: jsonb('config').notNull().default({}),
shared: boolean('shared').notNull().default(false),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt(),
}, (table) => [
index('ix_templates_owner').on(table.ownerUserId).where(sql`${table.deletedAt} is null`),
index('ix_templates_workspace_shared').on(table.workspaceId).where(sql`${table.shared} and ${table.deletedAt} is null`),
check('ck_templates_kind', sql`${table.kind} in ('tool_preset', 'envelope_template')`),
]);5.9.2 ocr_language_packs #
The catalogue of available Tesseract language packs. Owner: system. Delete policy: hard, admin-only, rare.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
code |
text |
not null | — | Tesseract language code, e.g. eng, fra, deu, jpn, chi_sim |
name |
text |
not null | — | Display name, e.g. "French" |
traineddata_tag |
text |
null | null |
The Tesseract .traineddata artifact tag bundled in the worker image — not a software dependency, so it carries no version-table entry |
enabled |
boolean |
not null | true |
Primary key id. No foreign keys. Unique: code. Indexes: ux_ocr_language_packs_code (code) — serves the OCR tool's language picker.
export const ocrLanguagePacks = pgTable('ocr_language_packs', {
id: id(),
code: text('code').notNull(),
name: text('name').notNull(),
traineddataTag: text('traineddata_tag'),
enabled: boolean('enabled').notNull().default(true),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_ocr_language_packs_code').on(table.code),
]);5.9.3 feature_flags #
The backing store for the single typed feature-flag accessor referenced throughout (Section 4). Owner: system. Delete policy: hard, admin-only, rare.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
key |
text |
not null | — | e.g. redaction-v2, eu-residency-beta |
description |
text |
not null | — | |
enabled |
boolean |
not null | false |
Global default |
rollout_percentage |
smallint |
not null | 100 |
Percentage of eligible users/workspaces enrolled when enabled = true, via a stable hash of the entity ID |
workspace_overrides |
jsonb |
not null | '{}' |
Map of workspace_id → boolean for targeted rollout or kill-switch |
Primary key id. No foreign keys (the keys inside workspace_overrides are validated at the application layer, not by a database FK, since a JSONB map cannot carry one). Unique: key. Check: ck_feature_flags_rollout: rollout_percentage BETWEEN 0 AND 100. Indexes: ux_feature_flags_key (key) — serves the accessor's per-request read (cached at the edge, Section 18.4).
export const featureFlags = pgTable('feature_flags', {
id: id(),
key: text('key').notNull(),
description: text('description').notNull(),
enabled: boolean('enabled').notNull().default(false),
rolloutPercentage: smallint('rollout_percentage').notNull().default(100),
workspaceOverrides: jsonb('workspace_overrides').notNull().default({}),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_feature_flags_key').on(table.key),
check('ck_feature_flags_rollout', sql`${table.rolloutPercentage} between 0 and 100`),
]);5.10 Compliance, Audit & Operations #
5.10.1 audit_log #
Admin and security-relevant action log (distinct from envelope_audit_events, which covers signing events only). Owner: system. Delete policy: hard, periodic purge.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
actor_user_id |
uuid |
null | null |
Null for system-initiated actions |
actor_type |
text |
not null | 'user' |
user | system |
action |
text |
not null | — | e.g. workspace.mfa_enforced_enabled, api_key.revoked, user.impersonated |
target_type |
text |
null | null |
e.g. workspace, api_key |
target_id |
uuid |
null | null |
|
ip_address |
inet |
null | null |
|
metadata |
jsonb |
not null | '{}' |
Action-specific detail |
No updated_at. Primary key id. Foreign keys: actor_user_id → users(id) ON DELETE SET NULL — the log entry outlives the actor's account. Check: ck_audit_log_actor_type. Indexes: ix_audit_log_actor_created (actor_user_id, created_at DESC); ix_audit_log_target (target_type, target_id) — serves "show me every action taken on this resource".
export const auditLog = pgTable('audit_log', {
id: id(),
actorUserId: uuid('actor_user_id').references(() => users.id, { onDelete: 'set null' }),
actorType: text('actor_type').notNull().default('user'),
action: text('action').notNull(),
targetType: text('target_type'),
targetId: uuid('target_id'),
ipAddress: inet('ip_address'),
metadata: jsonb('metadata').notNull().default({}),
createdAt: createdAt(),
}, (table) => [
index('ix_audit_log_actor_created').on(table.actorUserId, table.createdAt),
index('ix_audit_log_target').on(table.targetType, table.targetId),
check('ck_audit_log_actor_type', sql`${table.actorType} in ('user', 'system')`),
]);5.10.2 rate_limit_state #
Routine per-second/per-minute request throttling lives entirely in Redis token buckets (Section 14.9) and is never persisted here — that traffic is too high-frequency for a relational table, and losing a bucket on a Redis restart merely resets a rolling window, which is an acceptable trade-off. This table exists only for longer-lived, security-relevant blocks that must survive a Redis cache flush without silently un-blocking an abusive actor: cooldowns after repeated authentication failures, and manual abuse blocks placed by the fraud-detection job. Owner: system. Delete policy: hard, TTL cleanup.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
subject_type |
text |
not null | — | ip | api_key | user |
subject_key |
text |
not null | — | Raw IP text, or the subject's UUID as text |
reason |
text |
not null | — | auth_failure_cooldown | abuse_block |
blocked_until |
timestamptz |
not null | — |
Primary key id. No foreign keys — subject_key is polymorphic by subject_type and resolved at the application layer. Unique: (subject_type, subject_key, reason). Indexes: ux_rate_limit_state_subject_reason as above — serves the pre-auth block check; ix_rate_limit_state_blocked_until (blocked_until) — serves the cleanup job.
export const rateLimitState = pgTable('rate_limit_state', {
id: id(),
subjectType: text('subject_type').notNull(),
subjectKey: text('subject_key').notNull(),
reason: text('reason').notNull(),
blockedUntil: timestamp('blocked_until', { withTimezone: true }).notNull(),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
uniqueIndex('ux_rate_limit_state_subject_reason').on(table.subjectType, table.subjectKey, table.reason),
index('ix_rate_limit_state_blocked_until').on(table.blockedUntil),
check('ck_rate_limit_state_subject_type', sql`${table.subjectType} in ('ip', 'api_key', 'user')`),
check('ck_rate_limit_state_reason', sql`${table.reason} in ('auth_failure_cooldown', 'abuse_block')`),
]);5.10.3 data_export_requests #
A DSAR data export job. Public ID prefix req_ (Section 5.1.3). Owner: user. Delete policy: hard.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
user_id |
uuid |
not null | — | |
status |
text |
not null | 'queued' |
queued | running | ready | failed | expired |
storage_key |
text |
null | null |
Set once the export archive is generated |
requested_at |
timestamptz |
not null | now() |
|
completed_at |
timestamptz |
null | null |
|
expires_at |
timestamptz |
null | null |
Download link expiry, 7 days after completed_at |
Primary key id. Foreign keys: user_id → users(id) ON DELETE CASCADE. Check: ck_data_export_requests_status. Indexes: ix_data_export_requests_user (user_id, created_at DESC).
export const dataExportRequests = pgTable('data_export_requests', {
id: id(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
status: text('status').notNull().default('queued'),
storageKey: text('storage_key'),
requestedAt: timestamp('requested_at', { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp('completed_at', { withTimezone: true }),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
index('ix_data_export_requests_user').on(table.userId, table.createdAt),
check('ck_data_export_requests_status', sql`${table.status} in ('queued', 'running', 'ready', 'failed', 'expired')`),
]);5.10.4 deletion_requests #
A DSAR / account deletion job — the row itself is retained as compliance evidence that the request was received and honored. Public ID prefix del_, distinct from data_export_requests' req_ (Section 5.1.3). Owner: user (+ optional workspace). Delete policy: none.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
user_id |
uuid |
not null | — | |
workspace_id |
uuid |
null | null |
Set for a full workspace deletion request by its owner |
status |
text |
not null | 'queued' |
queued | running | completed | failed |
reason |
text |
null | null |
Optional free-text reason from the requester |
requested_at |
timestamptz |
not null | now() |
|
completed_at |
timestamptz |
null | null |
Primary key id. Foreign keys: user_id → users(id) ON DELETE CASCADE; workspace_id → workspaces(id) ON DELETE CASCADE. Check: ck_deletion_requests_status. Indexes: ix_deletion_requests_user (user_id, created_at DESC).
export const deletionRequests = pgTable('deletion_requests', {
id: id(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
status: text('status').notNull().default('queued'),
reason: text('reason'),
requestedAt: timestamp('requested_at', { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
}, (table) => [
index('ix_deletion_requests_user').on(table.userId, table.createdAt),
check('ck_deletion_requests_status', sql`${table.status} in ('queued', 'running', 'completed', 'failed')`),
]);5.10.5 email_log #
Outbound transactional email delivery log (Resend). Owner: system. Delete policy: hard, partition drop. Partitioned by month on created_at.
| Column | Type | Null | Default | Description |
|---|---|---|---|---|
to_email |
text |
not null | — | |
template |
text |
not null | — | e.g. email_verification, password_reset, envelope_invite, reminder |
related_type |
text |
null | null |
e.g. envelope, workspace_invitation |
related_id |
uuid |
null | null |
|
provider_message_id |
text |
null | null |
Resend message ID |
status |
text |
not null | 'queued' |
queued | sent | delivered | bounced | complained | failed |
error_message |
text |
null | null |
|
sent_at |
timestamptz |
null | null |
No updated_at (status is updated in place by inbound Resend webhooks, but the row is never otherwise mutated; this is the one deliberate exception to "no bare mutation" and is safe because delivery status is monotonic). Primary key (id, created_at), declared as a composite primaryKey() below — PostgreSQL requires the partition key to be part of every primary key on a declaratively partitioned table, so a bare id primary key would make the defining PARTITION BY RANGE (created_at) migration fail. No other table holds a foreign key against email_log.id, so the composite has no downstream foreign-key consequence — consistent with related_id already being an unenforced, application-resolved polymorphic pointer rather than a real foreign key. No foreign keys — related_id is polymorphic by related_type and resolved at the application layer for support tooling only, never for a join in a hot path. Indexes: ix_email_log_to_created (to_email, created_at DESC); ix_email_log_related (related_type, related_id).
export const emailLog = pgTable('email_log', {
id: uuid('id').notNull(),
toEmail: text('to_email').notNull(),
template: text('template').notNull(),
relatedType: text('related_type'),
relatedId: uuid('related_id'),
providerMessageId: text('provider_message_id'),
status: text('status').notNull().default('queued'),
errorMessage: text('error_message'),
sentAt: timestamp('sent_at', { withTimezone: true }),
createdAt: createdAt(),
}, (table) => [
primaryKey({ columns: [table.id, table.createdAt] }),
index('ix_email_log_to_created').on(table.toEmail, table.createdAt),
index('ix_email_log_related').on(table.relatedType, table.relatedId),
check('ck_email_log_status', sql`${table.status} in ('queued', 'sent', 'delivered', 'bounced', 'complained', 'failed')`),
]);5.11 Enumerated Values #
No column in this schema is a PostgreSQL enum type — every one is text plus a CHECK constraint, for the reason given in Section 5.1.4: altering a native enum's value set requires either an exclusive table lock (ADD VALUE outside a transaction is the exception, but removal or renaming is not supported at all) or a full type-recreation migration, while a CHECK constraint is dropped and re-added in a single ordinary DDL statement. Every enum below is mirrored verbatim by a Zod enum in packages/contracts/src/enums.ts, imported by web, API, workers, and the SDK so there is exactly one source of truth for the value set:
// packages/contracts/src/enums.ts (excerpt — full file mirrors every row below)
import { z } from 'zod';
export const jobStateSchema = z.enum(['queued', 'running', 'succeeded', 'failed', 'canceled', 'expired']);
export const envelopeStatusSchema = z.enum(['draft', 'sent', 'in_progress', 'completed', 'voided', 'expired', 'declined']);
export type JobState = z.infer<typeof jobStateSchema>;
export type EnvelopeStatus = z.infer<typeof envelopeStatusSchema>;| Zod schema | Column(s) | Values | Meaning |
|---|---|---|---|
siteRoleSchema |
users.site_role |
user, support, admin |
Platform-level privilege, independent of any workspace role |
mfaFactorTypeSchema |
user_mfa_factors.type |
totp |
Reserved for future factor types |
workspaceRoleSchema |
workspace_members.role, workspace_invitations.role |
owner, admin, member |
owner manages billing and can delete the workspace; admin manages members and settings; member uses shared resources |
invitationStatusSchema |
workspace_invitations.status |
pending, accepted, revoked, expired |
|
planKeySchema |
plans.key |
guest, free, pro, team, api_starter, api_growth, api_scale |
Section 12.2 |
billingIntervalSchema |
plans.billing_interval |
month, year |
|
subscriptionStatusSchema |
subscriptions.status |
trialing, active, past_due, canceled, incomplete, incomplete_expired, unpaid |
Mirrors Stripe's subscription status verbatim |
subscriptionItemKindSchema |
subscription_items.kind |
base, seat, metered_operations, metered_ocr_pages |
|
usageMetricSchema |
usage_records.metric |
operation, ocr_page, envelope_sent |
|
rollupMetricSchema |
usage_daily_rollups.metric |
operation, ocr_page, envelope_sent, server_task |
server_task additionally tracks the Free-plan 2/day server-side cap, which is not a billed metric |
invoiceStatusSchema |
invoices.status |
draft, open, paid, uncollectible, void |
Mirrors Stripe's invoice status verbatim |
documentMimeTypeSchema |
documents.mime_type |
application/pdf, image/jpeg, image/png, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.openxmlformats-officedocument.presentationml.presentation, text/html |
The only formats PDFWorks reads or writes |
documentStatusSchema |
documents.status |
active, processing, expired, deleted |
|
executionLocationSchema |
jobs.execution_location |
client, server |
Section 1 |
queueSchema |
jobs.queue |
ocr, convert, esign, batch, webhook, janitor |
Section 3.9 |
priorityLaneSchema |
jobs.priority_lane |
standard, priority |
|
jobStateSchema |
jobs.state, batch_jobs.state, batch_job_items.state |
queued, running, succeeded, failed, canceled, expired |
The single job state machine, defined once in Section 4.7 |
jobEventTypeSchema |
job_events.event_type |
queued, started, progress, succeeded, failed, canceled, expired, retried |
|
jobArtifactKindSchema |
job_artifacts.kind |
output_document, log, redaction_report, preview_thumbnail |
|
apiKeyPrefixSchema |
api_keys.key_prefix |
pk_live_, pk_test_ |
|
apiKeyEnvironmentSchema |
api_keys.environment |
live, test |
|
apiKeyScopeSchema |
api_key_scopes.scope |
documents:read, documents:write, jobs:read, jobs:write, envelopes:read, envelopes:write, webhooks:manage |
|
webhookEndpointStatusSchema |
webhook_endpoints.status |
active, failing, disabled |
Section 5.7.4, Section 14.10 |
webhookDeliveryStatusSchema |
webhook_deliveries.status |
pending, delivered, failed, exhausted |
|
envelopeStatusSchema |
envelopes.status |
draft, sent, in_progress, completed, voided, expired, declined |
|
envelopeRoutingSchema |
envelopes.routing |
sequential, parallel |
|
envelopeSignerRoleSchema |
envelope_signers.role |
signer, approver, cc |
|
envelopeSignerStatusSchema |
envelope_signers.status |
pending, notified, viewed, consented, in_progress, signed, declined, bounced, delegated |
Section 10 defines the full state machine |
envelopeFieldTypeSchema |
envelope_fields.type |
signature, initials, date_signed, free_text, checkbox, radio_group, dropdown, attachment, full_name, email, title, company |
The last four are signer-profile-backed single-line text variants of free_text, split out so the signing UI can pre-fill and validate them distinctly (Section 10) |
envelopeAuditEventTypeSchema |
envelope_audit_events.event_type |
envelope.created, envelope.sent, email.delivered, email.bounced, signer.viewed, signer.consented, field.completed, signer.signed, signer.declined, envelope.completed, envelope.declined, envelope.voided, envelope.expired, reminder.sent |
Section 10, Section 5.12; envelope.declined is the envelope-level consequence recorded the moment any one signer's signer.declined event moves the whole envelope to envelopes.status = 'declined' |
signatureAssetKindSchema |
signature_assets.kind |
drawn, typed, uploaded |
|
signatureTypefaceSchema |
signature_assets.typeface |
caveat, dancing_script, homemade_apple, sacramento |
The four bundled typed-signature fonts |
templateKindSchema |
templates.kind |
tool_preset, envelope_template |
|
rateLimitSubjectTypeSchema |
rate_limit_state.subject_type |
ip, api_key, user |
|
rateLimitReasonSchema |
rate_limit_state.reason |
auth_failure_cooldown, abuse_block |
|
dataExportStatusSchema |
data_export_requests.status |
queued, running, ready, failed, expired |
|
deletionRequestStatusSchema |
deletion_requests.status |
queued, running, completed, failed |
|
emailLogStatusSchema |
email_log.status |
queued, sent, delivered, bounced, complained, failed |
|
errorTypeSchema |
(not a table column; used by the error envelope) | invalid_request_error, authentication_error, permission_error, not_found_error, conflict_error, rate_limit_error, quota_error, processing_error, api_error |
Section 14.6, listed here for completeness of the enum catalogue |
5.12 The Hash Chain #
Each envelope_audit_events row stores three hash-related values — document_hash, payload_hash, prev_hash — that together make the chain tamper-evident without a PKI signature (Section 10). This subsection defines exactly how each is computed, in enough detail that two independent implementations produce byte-identical hashes.
5.12.1 Canonical JSON serialization #
Before hashing, payload (a plain JSON-compatible object) is converted to a canonical string form:
- Every object's keys are sorted lexicographically by UTF-16 code unit at every nesting level, recursively. Array element order is preserved as-is (arrays are ordered data, not sorted).
- No insignificant whitespace: object entries are separated by
,and keys from values by:, with no leading, trailing, or interstitial spaces — equivalent toJSON.stringifywith nospaceargument, after key-sorting. - Strings are Unicode-normalized to NFC before serialization and encoded as UTF-8 bytes for hashing.
- Numbers are serialized exactly as
JSON.stringifyrenders a JavaScriptnumber— no thousands separators, no forced trailing.0on integers, no leading zeros. The payload schema never places money or any high-precision decimal insidepayload; those values, where relevant to an event, are pre-formatted as strings (e.g.,"documentHash"is always a lowercase hex string, never a numeric type). nullis serialized as the literalnull;undefinedvalues are omitted from the object entirely (never serialized asnull), so a field's presence or absence is unambiguous.
// packages/contracts/src/audit-hash.ts
export function canonicalize(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
const keys = Object.keys(value as Record<string, unknown>).sort();
const entries = keys
.filter((k) => (value as Record<string, unknown>)[k] !== undefined)
.map((k) => `${JSON.stringify(k)}:${canonicalize((value as Record<string, unknown>)[k])}`);
return `{${entries.join(',')}}`;
}Nothing outside the logical event fields is ever included in payload — no database-generated id, no created_at insert timestamp, no payload_hash, no prev_hash, no document_hash. Those are sibling columns on the row, not embedded inside the hashed structure, so there is no risk of a field hashing itself. A signer.signed payload, for example, is exactly:
{ "signerId": "018e2a3f-...", "signatureAssetId": "018e2a40-...", "ipAddress": "203.0.113.42" }5.12.2 Computing the three values #
import { createHash } from 'node:crypto';
import { canonicalize } from './audit-hash';
function sha256Hex(input: string | Buffer): string {
return createHash('sha256').update(input).digest('hex');
}
export function computeDocumentHash(documentBytes: Buffer): string {
return sha256Hex(documentBytes);
}
export function computePayloadHash(payload: Record<string, unknown>): string {
return sha256Hex(canonicalize(payload));
}
// prevHash is null only when sequence === 0 (the genesis event, envelope.created).
// This row's own position in the chain is never stored as a separate column — it is
// always derivable on demand as sha256Hex(prevHash ?? '' + payloadHash), which becomes
// the *next* row's prev_hash, computed by the application before that row is inserted.
export function computeNextPrevHash(thisPrevHash: string | null, thisPayloadHash: string): string {
return sha256Hex((thisPrevHash ?? '') + thisPayloadHash);
}Appending an event is therefore always a read-then-write within a single serializable transaction: read the highest sequence and its resulting chain value for the envelope (or treat it as the genesis case), compute payload_hash, compute this new row's prev_hash as the prior chain value, insert, and — because a concurrent writer on the same envelope would otherwise be able to interleave and corrupt the sequence — the insert takes an explicit SELECT ... FOR UPDATE advisory lock on the parent envelopes row first:
BEGIN;
SELECT id FROM envelopes WHERE id = $1 FOR UPDATE;
-- application computes payload_hash and prev_hash here, then:
INSERT INTO envelope_audit_events
(id, envelope_id, sequence, event_type, signer_id, occurred_at, actor_email, actor_email_hash,
actor_ip, actor_ip_hash, user_agent, geo_country, document_hash, payload, payload_hash, prev_hash)
VALUES ($2, $1, $3, $4, $5, now(), $6, $7, $8, $9, $10, $11, $12, $13, $14, $15);
COMMIT;5.12.3 End-to-end verification #
The public verify endpoint (GET https://sign.pdfworks.io/verify/{envelopeId}, Section 10) and any internal auditor tool both call this function:
export async function verifyEnvelopeChain(
db: Database,
envelopeId: string,
): Promise<{ valid: boolean; brokenAtSequence: number | null }> {
const rows = await db
.select()
.from(envelopeAuditEvents)
.where(eq(envelopeAuditEvents.envelopeId, envelopeId))
.orderBy(asc(envelopeAuditEvents.sequence));
let expectedPrevHash: string | null = null;
for (const row of rows) {
if (row.sequence === 0 ? row.prevHash !== null : row.prevHash !== expectedPrevHash) {
return { valid: false, brokenAtSequence: row.sequence };
}
const recomputedPayloadHash = computePayloadHash(row.payload as Record<string, unknown>);
if (recomputedPayloadHash !== row.payloadHash) {
return { valid: false, brokenAtSequence: row.sequence };
}
expectedPrevHash = computeNextPrevHash(row.prevHash, row.payloadHash);
}
return { valid: true, brokenAtSequence: null };
}A companion SQL-only verification (used by an offline compliance auditor with database access but no application code) uses the pgcrypto extension's digest() function, enabled once in the initial migration (CREATE EXTENSION IF NOT EXISTS pgcrypto;):
WITH ordered AS (
SELECT sequence, prev_hash, payload_hash,
encode(digest(coalesce(prev_hash, '') || payload_hash, 'sha256'), 'hex') AS computed_next_prev_hash,
lead(prev_hash) OVER (ORDER BY sequence) AS actual_next_prev_hash
FROM envelope_audit_events
WHERE envelope_id = $1
ORDER BY sequence
)
SELECT sequence, (computed_next_prev_hash IS NOT DISTINCT FROM actual_next_prev_hash) AS link_valid
FROM ordered
WHERE computed_next_prev_hash IS DISTINCT FROM actual_next_prev_hash;
-- an empty result set means the chain is intact end to end.Uploading a PDF to the verify endpoint additionally recomputes sha256(documentBytes) and compares it against the document_hash of the most recent envelope.completed event, reporting MATCH, ALTERED, or UNKNOWN (envelope not found or not yet completed).
5.13 Indexing and Query Patterns #
The twelve highest-traffic queries against this schema, in descending order of call frequency, each with the index it hits and the plan shape it must produce. "Plan shape" states what the executor's EXPLAIN must show in CI's query-plan regression test (Section 19) — a plan that degrades to the wrong shape (e.g. a Seq Scan appearing where an Index Scan is required) fails that test.
1. Session validation on every authenticated request
SELECT u.* FROM user_sessions s JOIN users u ON u.id = s.user_id
WHERE s.session_token_hash = $1 AND s.expires_at > now() AND u.deleted_at IS NULL;Index: ux_user_sessions_token_hash. Plan: Index Scan on user_sessions by the unique hash index (cost dominated by index descent, single row), nested-loop into users by primary key. No sequential scan of either table under any data volume.
2. "My Files" dashboard list
SELECT * FROM documents WHERE owner_user_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 25;Index: ix_documents_owner_created (partial, WHERE deleted_at IS NULL). Plan: Index Scan Backward satisfying both the filter and the ORDER BY from the index directly — no separate Sort node.
3. Client-side job-status poll (called every 1–2 seconds while a server-side job runs)
SELECT id, state, progress, stage, error_code FROM jobs WHERE id = $1;Index: primary key. Plan: Index Scan on the primary key, cost ≈ constant regardless of table size — this is the single highest-call-volume query in the system and is deliberately narrow (five columns, no join).
4. Job history list, filtered by state
SELECT * FROM jobs WHERE owner_user_id = $1 AND state = ANY($2)
ORDER BY created_at DESC LIMIT 25;Index: ix_jobs_owner_created, with a Filter applied for state (not a separate index — state has low cardinality, six values, so a composite index on it is not selective enough to be worth the write overhead). Plan: Index Scan Backward on (owner_user_id, created_at) with an inline filter; acceptable because owner_user_id already restricts the row count to a single user's jobs before the filter runs.
5. Entitlement / quota check before starting a server-side job
SELECT quantity FROM usage_daily_rollups
WHERE owner_type = $1 AND owner_id = $2 AND metric = 'server_task' AND usage_date = $3;Index: ux_usage_daily_rollups_owner_metric_date. Plan: Index Only Scan — every column referenced is in the index, so PostgreSQL never touches the heap (subject to visibility-map freshness, maintained by routine autovacuum).
6. Public API key authentication (every /v1 request)
SELECT k.*, array_agg(s.scope) AS scopes FROM api_keys k
LEFT JOIN api_key_scopes s ON s.api_key_id = k.id
WHERE k.key_hash = $1 AND k.revoked_at IS NULL
GROUP BY k.id;Index: ux_api_keys_key_hash. Plan: Index Scan into api_keys by the unique hash, nested-loop into api_key_scopes by its ix_api_key_scopes_key index — both index-only lookups, never a sequential scan of either table.
7. Workspace membership / authorization check
SELECT role FROM workspace_members WHERE workspace_id = $1 AND user_id = $2;Index: ux_workspace_members_ws_user. Plan: Index Only Scan, single-row result, constant-time regardless of workspace size.
8. Signer-portal session resolution (unauthenticated, by opaque link token)
SELECT es.*, e.status AS envelope_status FROM envelope_signers es
JOIN envelopes e ON e.id = es.envelope_id
WHERE es.access_token_hash = $1;Index: ux_envelope_signers_access_token. Plan: Index Scan on the unique hash index, nested-loop into envelopes by primary key.
9. Append and re-read the audit chain tail for an envelope
SELECT sequence, prev_hash, payload_hash FROM envelope_audit_events
WHERE envelope_id = $1 ORDER BY sequence DESC LIMIT 1;Index: ux_envelope_audit_events_envelope_seq. Plan: Index Scan Backward, LIMIT 1 short-circuits after the first matching row — this runs inside the transaction described in Section 5.12.2 immediately before every insert.
10. Webhook delivery retry worker scan
SELECT * FROM webhook_deliveries WHERE status = 'pending' AND next_retry_at <= now()
ORDER BY next_retry_at LIMIT 100 FOR UPDATE SKIP LOCKED;Index: ix_webhook_deliveries_retry (partial, WHERE status = 'pending'). Plan: Index Scan on the partial index bounded by next_retry_at, LockRows node for FOR UPDATE SKIP LOCKED so concurrent worker replicas never double-send.
11. Per-request spend-cap check on a metered API key
SELECT cap_cents, current_period_spend_cents FROM spend_caps
WHERE api_key_id = $1 AND enabled = true;Index: ux_spend_caps_api_key. Plan: Index Scan, single-row lookup; the common case (no cap configured) returns zero rows in the same amount of time.
12. Janitor sweep for expired blobs
DELETE FROM document_blobs WHERE id IN (
SELECT id FROM document_blobs WHERE expires_at <= now()
ORDER BY expires_at LIMIT 500 FOR UPDATE SKIP LOCKED
)
RETURNING id, storage_key, storage_bucket, document_id;Index: ix_document_blobs_expires_at. Plan: Index Scan bounded by expires_at, LockRows with SKIP LOCKED so multiple janitor workers partition the sweep safely without double-deleting the same object (Section 5.15).
Every partial index used above (WHERE deleted_at IS NULL, WHERE status = 'pending', WHERE owner_user_id IS NOT NULL, etc.) exists specifically because the unfiltered condition would otherwise dominate a large fraction of the table's rows in steady state; PostgreSQL's query planner only chooses a partial index when the query's WHERE clause provably implies the index predicate, so every query above is written to match its partial index's predicate exactly.
5.14 Partitioning and Growth #
Five tables grow without bound and use PostgreSQL 18 declarative range partitioning by month: job_events, usage_records, webhook_deliveries, envelope_audit_events, email_log. Each is created with PARTITION BY RANGE (<timestamp column>) in its defining migration, hand-written SQL rather than Drizzle-generated (drizzle-kit does not emit partition DDL; the Drizzle schema in Sections 5.6–5.10 models the logical column set that every partition inherits).
-- excerpt from the migration that creates job_events
CREATE TABLE job_events (
id uuid NOT NULL,
job_id uuid NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
event_type text NOT NULL,
progress smallint,
message text,
occurred_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);
CREATE TABLE job_events_2026_08 PARTITION OF job_events
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE job_events_2026_09 PARTITION OF job_events
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');A PostgreSQL requirement drives two schema decisions already reflected in Sections 5.6–5.10: (1) every unique or primary key constraint on a partitioned table must include the partition key column, which is why all five partitioned tables — job_events, usage_records, webhook_deliveries, envelope_audit_events, and email_log — declare a composite primaryKey({ columns: [table.id, table.<partitionKeyColumn>] }) of (id, occurred_at) or (id, created_at) instead of a bare id primary key, in both the prose above and the Drizzle table definition itself; and (2) envelope_audit_events' logical uniqueness driver is (envelope_id, sequence), so its unique index is declared as (envelope_id, occurred_at, sequence) to satisfy the requirement while keeping sequence as the true uniqueness guarantee (Section 5.8.5). None of the five is referenced by a foreign key from any other table, so the composite primary key has no downstream foreign-key consequence anywhere in this schema — every relationship into a partitioned table's data instead flows outward, from the partitioned table to its non-partitioned parent (job_id → jobs, envelope_id → envelopes, webhook_endpoint_id → webhook_endpoints, and so on), never the reverse.
Partition lifecycle, run by a janitor queue job on the 25th of each month:
| Table | Partition key | Hot window (attached, queryable) | Archive action | Drop after |
|---|---|---|---|---|
job_events |
occurred_at |
180 days (6 partitions) | Detach, export to Parquet in the cold-storage bucket | 180 days |
usage_records |
occurred_at |
2 years (24 partitions) | Detach, export to Parquet — kept for billing-dispute resolution | 2 years |
webhook_deliveries |
created_at |
30 days (2 partitions) | None — no legal or support need beyond the delivery log UI's window | 30 days |
envelope_audit_events |
occurred_at |
7 years (84 partitions) | Detach, export to Parquet, keep the export queryable | Exactly 7 years, then the export is destroyed — nothing derived from this table survives past the 7-year boundary |
email_log |
created_at |
180 days (6 partitions) | None | 180 days |
The job always creates the next 3 months of partitions ahead of the current date (so a clock skew or a missed run never causes an INSERT to fail with "no partition found"), and only detaches a partition whose entire date range has aged past the table's hot window. Detach uses ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY (PostgreSQL 14+, still current in 18) so the operation does not block concurrent reads or writes on sibling partitions; the detached table is then either archived and dropped (most tables) or, for envelope_audit_events, archived and left in place as an unattached, still-queryable table named envelope_audit_events_archive_2019_08 — the live partitioned parent is never touched for that row's sake before then. That archive table is itself destroyed, in full, the moment the original partition's data reaches 7 years of age from its occurred_at range: the same janitor job that would normally leave an archive in place forever for other tables instead issues DROP TABLE envelope_audit_events_archive_2019_08; on the 25th-of-the-month run that first finds the archive older than 7 years, and logs the drop to audit_log (Section 5.10.1) as a system-actor envelope_audit_events.archive_destroyed action so the destruction event itself is traceable. No copy of the archive is kept anywhere else. This is what makes the 7-year figure stated in Section 6, Section 10.8, and Section 17.8.1 true without qualification: seven years means the data — live partition and archived export alike — ceases to exist, not merely that it stops being queried by the application.
5.15 Retention Enforcement at the Data Layer #
The janitor is a single janitor queue consumer (Section 3.9) that runs each of the following sweeps on its own schedule. Every sweep is table-driven from one constant map rather than special-cased per table, so the 7-year audit-metadata exception is expressed as one longer number in that map, not as a branch in application code. For every other partitioned table, days bounds only the live, attached partitions, and the sweep's job is to detach and drop; for envelope_audit_events the same days value additionally bounds the archived Parquet export created in Section 5.14, and the sweep's job on the run that crosses that boundary is to drop the archive table outright — the number is shared, and so is the destruction step it drives:
// apps/api/src/jobs/janitor/retention-policy.ts
export const RETENTION_POLICY = {
job_events: { days: 180, partitioned: true },
usage_records: { days: 730, partitioned: true },
webhook_deliveries: { days: 30, partitioned: true },
envelope_audit_events: { days: 2555, partitioned: true }, // 7 years — same mechanism, longer number
email_log: { days: 180, partitioned: true },
audit_log: { days: 730, partitioned: false },
rate_limit_state: { days: 1, partitioned: false, column: 'blocked_until' },
} as const;Blob deletion — the ordering that guarantees the wrapped data key dies before the bytes. This is the sweep with the strictest correctness requirement in the system: if the bytes are deleted before the key, nothing goes wrong; if the key is deleted after the bytes but the process crashes in between, the result is an orphaned, permanently unreadable ciphertext object, which is harmless. The only unacceptable ordering is one that could ever leave readable bytes without a destroyed key, so the key destruction is always the first, transactional, fast step, and the byte deletion is always the second, best-effort, retryable step:
-- Phase 1 — transactional, sub-second: destroy the key by deleting the row that holds it.
BEGIN;
WITH expired AS (
SELECT id, storage_key, storage_bucket, document_id FROM document_blobs
WHERE expires_at <= now()
ORDER BY expires_at
LIMIT 500
FOR UPDATE SKIP LOCKED
)
DELETE FROM document_blobs WHERE id IN (SELECT id FROM expired)
RETURNING id, storage_key, storage_bucket, document_id;
-- application also runs, in the SAME transaction, for every returned document_id:
UPDATE documents SET status = 'deleted', deleted_at = now() WHERE id = ANY($1);
COMMIT;// Phase 2 — outside the DB transaction, idempotent, retried on failure by the next sweep.
for (const blob of deletedBlobs) {
await s3.send(new DeleteObjectCommand({ Bucket: blob.storageBucket, Key: blob.storageKey }));
}If Phase 2 fails or the process crashes before it runs, the object is orphaned ciphertext with no wrapped key anywhere in the system — cryptographically unreadable, and therefore not a retention-policy violation even though the bytes are still technically present in the bucket. As a belt-and-suspenders backstop against exactly this case, every blob is written with an S3/R2 object tag pdfworks-expires-at=<unix-timestamp> and the bucket carries a native lifecycle rule that unconditionally deletes any object whose tag value is more than 25 hours in the past, independent of whether the janitor ever ran.
The 7-year audit-metadata exception as a column-level policy. Deleting a user's account (Section 5.10.4) never deletes their envelope_audit_events rows or shortens the 2,555-day entry in RETENTION_POLICY above — instead, a narrowly-scoped pdfworks_anonymizer database role, granted UPDATE on exactly two columns of that table, runs a targeted update as part of deletion-request processing:
-- Executed by the pdfworks_anonymizer role, which has no other write privilege on this table.
UPDATE envelope_audit_events
SET actor_email = NULL, actor_ip = NULL
WHERE actor_email_hash = $1; -- salted hash of the deleted user's email, computed once at request timeactor_email_hash and actor_ip_hash are never cleared, so the hash chain's payload_hash values — which were computed before anonymization and never recomputed — remain verifiable forever; anonymization touches only the two plaintext columns that were never part of any hash input in the first place (Section 5.12.1 explicitly excludes row-level sibling columns from the hashed payload). The actual row deletion that eventually removes this data happens exclusively through partition drop at the 7-year boundary (Section 5.14), which is the same generic partitioned-table mechanism every other partitioned table uses — there is no DELETE FROM envelope_audit_events anywhere in the application.
5.16 Migrations #
All schema change goes through drizzle-kit, generating SQL from the TypeScript schema in packages/db/src/schema/*.ts into versioned files in packages/db/migrations/.
- Naming:
NNNN_verb_noun.sql, four-digit zero-padded sequence, e.g.0042_add_spend_caps_enabled_column.sql. The sequence number is the only ordering signal; migrations never depend on wall-clock filenames. - One migration per pull request. A PR that needs two logically separate changes (e.g. adding a column and backfilling it) still gets two migration files, reviewed together but numbered sequentially, because the backfill must be independently re-runnable if the PR is reverted and reapplied.
- Forward-only. There is no
down.sql. Every migration file opens with a comment block stating its intent and its compensating migration — the forward migration that would undo it, written in prose if not yet needed as code:-- 0042_add_spend_caps_enabled_column.sql -- Intent: add `enabled` to spend_caps, defaulting existing rows to false (Section 12.6 default-off). -- Compensation: a future migration `NNNN_drop_spend_caps_enabled_column.sql` would DROP COLUMN; -- no data migration needed to reverse since the column carries no other table's foreign key. ALTER TABLE spend_caps ADD COLUMN enabled boolean NOT NULL DEFAULT false; - Destructive changes ship expand → backfill → contract, across a minimum of three releases:
- Expand (release N): add the new column/table nullable or defaulted; deploy application code that writes to both old and new shapes.
- Backfill (release N, post-deploy job): a one-off script populates the new shape from the old for existing rows, in batches (
UPDATE ... WHERE id IN (SELECT id FROM ... LIMIT 1000)), never a single unbounded statement against a large table. - Contract (release N+2, after confirming release N+1 shipped clean and nothing reads the old shape): drop the old column/table.
- CI: every PR runs
drizzle-kit check(schema drift detection against the migration history), then applies all migrations to a fresh ephemeral PostgreSQL 18 instance, then runs the full test suite against it. A migration thatdrizzle-kit checkflags as inconsistent with the current schema snapshot fails CI before any test runs. - Deploy: migrations run as a blocking pre-deploy step in a dedicated one-shot job/pod that runs
drizzle-kit migratebefore the new application version receives traffic. The job wraps its run inSELECT pg_advisory_lock(<fixed-app-id>);/pg_advisory_unlock(...)so that if two deploys race (a rare but real possibility during a rapid rollback-then-redeploy), the second waits for the first rather than corrupting the migration ledger. - Rollback-by-compensation. Rolling back a bad release never means running a
downmigration against production data; it means deploying the compensating forward migration described in that migration's header comment, which is why every migration file is required to state one even when it is initially just prose.
5.17 Seed Data #
packages/db/src/seed.ts, run via pnpm --filter db seed, populates two tiers:
- Reference data (all environments, idempotent via
ON CONFLICT DO UPDATE): the sevenplansrows matching Section 12.2 exactly (includingguest, which has nostripe_price_id); the initialocr_language_packsset (eng,fra,deu,spa,ita,por,nld,pol,swe,dan,nor,jpn,kor,chi_sim,chi_tra— fifteen packs covering the primary Latin, Nordic, and CJK languages); the launchfeature_flagsrows, allenabled: falseexceptredaction-verification-report, which ships enabled from day one since Section 9.1's redaction guarantee depends on it. - Development fixtures (
NODE_ENV=developmentonly, gated by an explicit check at the top of the script so it can never run against a production connection string): threeusers(owner@example.test,member@example.test,guest-quota-test@example.test), oneworkspacesrow with the first two asowner/member, asubscriptionsrow on theteamplan for that workspace, five sampledocumentswith matchingdocument_blobspointing at fixture files inpackages/db/fixtures/, threejobsin varied terminal states (succeeded,failed,canceled) to exercise history UI states without waiting on real processing, and one fullycompletedenvelopesrow with a two-event-shortenvelope_audit_eventschain so a developer can immediately exercise the verify endpoint locally.
5.18 Data Integrity Rules That Cannot Be Expressed as Constraints #
| # | Rule | Enforced at | Test that proves it |
|---|---|---|---|
| 1 | A job's output_document_id must belong to the same owner as the job |
Application (job-completion handler) | Integration test: attempt to attach another user's document as output, assert rejection |
| 2 | envelope_signers.routing_order values must be contiguous starting at 1 for routing = 'sequential' before an envelope can move draft → sent |
Application (envelope-send validator) | Unit test: build signer sets with gaps and duplicates, assert both are rejected |
| 3 | envelope_audit_events is append-only — no row is ever updated (except the narrow anonymization path) or deleted outside partition drop |
PostgreSQL privilege grant: REVOKE UPDATE, DELETE ON envelope_audit_events FROM pdfworks_app |
Integration test connecting as the pdfworks_app role, asserting UPDATE and DELETE both fail with permission denied for table envelope_audit_events |
| 4 | document_blobs.expires_at never exceeds 24 hours from created_at |
Application (blob-creation service computes it, never accepts it as user input) | Nightly data-integrity job: SELECT count(*) FROM document_blobs WHERE expires_at > created_at + interval '24 hours' must always return 0; alerts on any non-zero result |
| 5 | Exactly one workspace_members row per workspace has role = 'owner' |
Application (ownership-transfer is a single transaction that demotes the old owner and promotes the new one atomically) | Concurrency test: fire two simultaneous transfer requests for the same workspace, assert exactly one succeeds and the invariant holds afterward |
| 6 | A raw API key value is never persisted or logged anywhere except the single creation response | Application (Pino redact path list, Section 18.2, strips any string matching `pk_(live | test)_[A-Za-z0-9]{32,}` from every log line before it is written) |
| 7 | The Free-tier and guest-device daily counters reset at UTC midnight consistently, never at the server's local midnight | Application: both usage_daily_rollups.usage_date and guest_devices.task_window_date are always computed by the single shared getUsageDateUTC() helper in packages/contracts, never by ad hoc new Date() arithmetic |
Unit test pinning the system clock to 23:59:59 UTC and then 00:00:01 UTC, asserting the two calls return distinct dates |
| 8 | An envelope cannot transition to completed while any required = true envelope_fields row has value IS NULL |
Application (the signer.signed handler for the last outstanding signer checks this before writing envelope.completed) |
Integration test: mark all signers signed while one required field is empty, assert the completion transition is rejected |
| 9 | A document_blobs row is never hard-deleted without its parent documents row transitioning to status = 'deleted' in the same instant |
Application (Section 5.15's Phase 1 SQL performs both writes in one transaction) | Integration test asserting documents.status and the absence of the document_blobs row change atomically — a simulated crash between the two is impossible because they are one statement-group in one transaction |
| 10 | A workspace's plan_id-driven entitlements (seats, batch size, envelope cap) are recalculated the moment subscriptions.status changes, not just at the next billing-cycle boundary |
Application (the Stripe webhook handler for customer.subscription.updated synchronously recalculates and caches the workspace's effective entitlement snapshot before returning 200 to Stripe) |
Integration test: simulate a downgrade webhook mid-cycle, assert the next entitlement check reflects the new plan's limits within the same request cycle, not the old ones |
| 11 | Two concurrent invitations of the same email to the same workspace never produce two pending rows or a database error surfaced to either caller; the second request resolves to the first request's invitation |
Application (workspace_invitations insert handler catches the 23505 violation on ux_workspace_invitations_ws_email_pending specifically and re-selects the winning row, Section 5.3.3) |
Concurrency test: fire two simultaneous invite requests for the same (workspace_id, email), assert both HTTP responses are 2xx, both carry the same invitation ID, and exactly one row exists in workspace_invitations for that pair |
| 12 | If the daily guest-hashing salt (minted once per UTC day and consumed by guest_devices.device_fingerprint_hash/ip_hash, Section 11.5) is not refreshed for a given UTC day, the previous day's salt is reused for at most 24 further hours rather than left to expire mid-day, an on-call alert fires the moment the minting job's expected run is more than 5 minutes late, and guest quota enforcement fails closed — the daily cap in guest_devices.tasks_today continues to be checked and enforced against hashes computed with the stale salt, never bypassed or treated as unlimited just because the salt is stale |
Application (the guest-quota middleware reads salt freshness before every hash computation and always enforces the cap regardless of freshness; it has no code path that grants unlimited access) | Integration test: freeze the salt-minting job, advance the clock past the normal daily boundary and past the 24-hour reuse window, assert that a guest request past that window is still rejected once tasks_today is exhausted rather than silently allowed through |
6. The Processing Engine: Client-Side and Server-Side #
6.1 The privacy split #
The product's central architectural commitment is that a PDF file, in the overwhelming majority of cases, never leaves the device it was opened on. Every tool is assigned to exactly one execution location by default, and that assignment is fixed — it is not a runtime toggle, a plan upsell, or something an operator can flip in a config file. The table below is the complete and authoritative assignment. Where a tool's implementation lives is specified per tool in the sections listed in the "Owning section" column; this table exists so the assignment itself is never ambiguous.
| Tool | Execution location | Why | Minimum plan | Owning section |
|---|---|---|---|---|
| Merge | Client | No file leaves the device by default; pure page-graph manipulation the WASM engine handles entirely in-browser | Guest | 7.1 |
| Split | Client | Same reasoning as Merge | Guest | 7.2 |
| Organize (reorder/rotate/delete/duplicate) | Client | Interactive page-grid editing has no reason to round-trip to a server | Guest | 7.3 |
| Rotate | Client | Metadata-only page transform | Guest | 7.4 |
| Extract pages | Client | Subset of the page graph, same engine call as Split | Free | 7.6 |
| Delete pages | Client | Metadata-only page-tree edit | Free | 7.5 |
| Insert blank pages | Client | Page-tree edit, no content to process | Free | 7.7 |
| Crop | Client | Writes a /CropBox value; no rasterization required |
Free | 7.8 |
| Compress | Client | Image downsampling and font subsetting run in WASM at native-adjacent speed | Guest | 7.9 |
| Watermark (text + image) | Client | Content-stream overlay, no external dependency | Free | 9.2 |
| Page numbers | Client | Content-stream overlay | Free | 9.3 |
| Bates numbering | Client | Content-stream overlay with sequence state kept in the browser session | Free | 9.4 |
| Protect (encrypt) | Client | Encryption is applied with a user-supplied password that must never be transmitted; Protect never offers the opt-in server fallback in Section 6.3, even on a capacity failure | Free | 9.5 |
| Unlock (remove a known password) | Client | Same reasoning — the password never leaves the device; Unlock never offers the opt-in server fallback in Section 6.3, even on a capacity failure | Free | 9.6 |
| Flatten | Client | Rewrites annotations and form fields as static content, no external process | Free | 9.7 |
| Redact | Client | The whole point of redaction is that the sensitive content is never transmitted anywhere, including to us; Redact never offers the opt-in server fallback in Section 6.3, even on a capacity failure | Free | 9.1 |
| Annotate & shapes | Client | Interactive canvas tool | Free | 8.2 |
| Edit text & images | Client | Direct content-stream editing | Free | 8.1 |
| Fill forms | Client | Form field values are frequently sensitive (SSNs, medical data) | Free | 8.3 |
| Create fillable forms | Client | Authoring form fields is a page-tree edit | Free | 8.4 |
| Self-sign (draw/type/upload onto your own document) | Client | Single-party signature with no counter-party, no envelope, no audit trail requirement; Self-sign never offers the opt-in server fallback in Section 6.3, even on a capacity failure | Free | 8.5 |
| PDF → JPG/PNG | Client | Rasterization is a PDFium operation with no OS-level renderer dependency | Guest | 7.10 |
| JPG/PNG → PDF | Client | Image decode plus page composition, no OS-level renderer dependency | Guest | 7.11 |
| Repair | Client | Structural recovery is a QPDF/PDFium object-scan operation | Free | 7.12 |
| Edit metadata | Client | Small dictionary edit | Free | 7.13 |
| OCR | Server | Requires Tesseract OCR engine 5.x, a native binary with no WASM-viable build at production quality/speed | Free — daily cap applies (Section 12.2) | 9.8 |
| PDF → DOCX/XLSX/PPTX | Server | Requires LibreOffice headless, a full desktop-office rendering stack | Free — daily cap applies (Section 12.2) | 9.9 |
| DOCX/XLSX/PPTX → PDF | Server | Same dependency | Free — daily cap applies (Section 12.2) | 9.9 |
| HTML → PDF | Server | Requires a full browser rendering engine for layout fidelity | Free — daily cap applies (Section 12.2) | 9.9 |
| PDF → HTML | Server | Requires the same conversion stack, run in reverse | Free — daily cap applies (Section 12.2) | 9.9 |
| Signature requests sent to other people (envelopes) | Server | A counter-party must receive, view, and sign the document; the document must exist somewhere both parties can reach it | Free — monthly envelope cap applies (Section 12.2) | 10 |
| Any batch job containing at least one server-side tool | Server | The whole batch runs where its least-client-friendly member tool must run, so results are produced by one pipeline, not two | Pro | 13 |
| Every operation invoked through the public REST API | Server | The API has no browser and therefore no WASM host; every API-invoked operation runs the identical engine artifact server-side (Section 6.4) so results are byte-identical to the client-side result for the same operation and inputs | API | 14 |
Two rules resolve every edge case that the table above does not make self-evident:
- Image conversions are client-side, without exception. PDF → JPG/PNG and JPG/PNG → PDF both run in the browser. Only Office conversions (DOCX/XLSX/PPTX in either direction) and HTML conversions (either direction) are server-side. A future contributor who reasons "images are heavy, maybe they should be server-side too" is wrong; this is a deliberate, permanent product commitment, not an oversight to be optimized away.
- Every operation invoked through the public REST API runs server-side, because the API is called from scripts, backend services, and CI pipelines that have no browser and therefore no WASM host to run the client engine in. This is not a degraded experience: Section 6.4 defines a single
pdfcoreartifact that runs unmodified in both the browser and the Node worker process, so the API path produces byte-identical output to the same operation run in-browser, given the same inputs and the samedeterministicTimestamp(Section 6.7). There is no "server edition" of any tool with different behavior.
A third invariant sits above both of these and is never subordinated to them: Redact, Protect, Unlock, and Self-sign are never offered the opt-in server fallback (Section 6.3) under any circumstance, regardless of why the client-side job failed. That exclusion is detailed in full in Section 6.3.
6.2 The Processing Location Indicator as a contract #
The Processing Location Indicator is not decorative UI polish; it is a contractual assertion about where a user's file is about to go, and it is treated with the same rigor as the error envelope or the job state machine. It renders in exactly two states:
| State | Glyph | Token color | Copy |
|---|---|---|---|
| Client-side | Lock | Green (design token defined in Section 16.3) | "This file never leaves your device." |
| Server-side | Cloud | Amber (design token defined in Section 16.3) | "This file is uploaded, encrypted, and deleted within 24 hours." |
Placement and copy variant per surface:
| Surface | Variant | Copy shown |
|---|---|---|
| Tool grid card | Compact badge: glyph + 3-word label | "On your device" / "On our servers" |
| Tool page header | Full badge, glyph + full sentence | Full copy from the table above |
| Confirmation dialog (before a server-side job starts) | Full sentence plus an explicit "I understand" acknowledgment for first-time use per account, remembered thereafter via a serverUploadAcknowledged flag on the user record |
Full copy, plus: "Your file is encrypted in transit and at rest, processed in an isolated environment, and permanently deleted (Section 17 covers the retention schedule in full)." |
| Batch row | Per-file glyph when a batch mixes tool types is impossible by definition (the batch-composition rule in Section 6.1's table forces the whole batch to one location), so the batch row shows one indicator for the entire batch | Full copy from the table above |
| Job history row | Permanent, non-interactive glyph + label recording what actually happened for that job, sourced from the job row's execution_location column, never recomputed from the tool's current default |
"Processed on your device" / "Processed on our servers" |
| API docs (Section 14) | A static callout on every operation's reference page, since the API path is always server-side | "This operation always runs on our servers, because the public API has no browser to run the client engine in." |
The CI check. packages/contracts exports a single source of truth, TOOL_REGISTRY, a Record<ToolKey, { location: "client" | "server"; minPlan: PlanKey; apiOperation: string }> covering every row of the table in Section 6.1. A representative excerpt:
// packages/contracts/src/tool-registry.ts
export const TOOL_REGISTRY = {
merge: { location: "client", minPlan: "guest", apiOperation: "merge" },
split: { location: "client", minPlan: "guest", apiOperation: "split" },
compress: { location: "client", minPlan: "guest", apiOperation: "compress" },
redact: { location: "client", minPlan: "free", apiOperation: "redact" },
ocr: { location: "server", minPlan: "free", apiOperation: "ocr" },
pdfToDocx: { location: "server", minPlan: "free", apiOperation: "pdf_to_docx" },
htmlToPdf: { location: "server", minPlan: "free", apiOperation: "html_to_pdf" },
signatureRequest: { location: "server", minPlan: "free", apiOperation: "envelope_create" },
// ...one entry per row of the table in Section 6.1
} as const satisfies Record<string, { location: "client" | "server"; minPlan: string; apiOperation: string }>;
``` A build-time script, `scripts/check-tool-locations.ts`, runs as part of the Turborepo `verify` task and does two independent checks that must agree:
1. **Static import-graph check.** For each tool's UI entry point (`apps/web/app/tools/[tool]/page.tsx` or equivalent route), the script resolves whether the "Run" action's call graph imports `packages/pdfcore` directly (client execution) or calls `apps/api`'s `/internal/jobs` endpoint (server dispatch), and asserts the result matches `TOOL_REGISTRY[tool].location`. For Redact, Protect, Unlock, and Self-sign specifically, this same check additionally asserts that no code path in the tool's call graph can reach the server-fallback modal component at all — not merely that it isn't reached by default — since these four tools must never be able to render an upload path, opt-in or otherwise (Section 6.3).
2. **Runtime network assertion.** A Playwright E2E suite runs every client-side tool against a fixture file with network request interception active and asserts **zero** requests were made to `api.pdfworks.io/v1/documents` or `/internal/jobs` during execution; it runs every server-side tool and asserts **exactly one** upload request was made. For Redact, Protect, Unlock, and Self-sign, this suite additionally forces each of `out_of_memory`, `disk_quota_exceeded`, and `unsupported_feature` via a test hook and asserts the network interception still records zero requests, proving the exclusion holds even under failure.
Either check failing fails the build. A tool whose declared location in `TOOL_REGISTRY` disagrees with its actual code path is treated as a P0 defect, identical in severity to the opt-in fallback rule in Section 6.3.
### 6.3 The opt-in server fallback
A client-side job can fail for reasons unrelated to the file itself — most commonly the device is memory-constrained (a phone processing a 900-page scanned PDF) or the browser lacks a required capability (no `SharedArrayBuffer`, forcing the slower single-threaded WASM artifact, which can still exceed a low-end device's available heap). When this happens, the product's answer is never to upload the file silently. It is always an explicit, single-purpose offer — and for four specific tools, it is never offered at all.
**When it is offered:** the client-side job's terminal error is exactly one of `out_of_memory`, `disk_quota_exceeded` (Section 6.6), or `unsupported_feature` (a WASM capability the current browser lacks, distinct from a file-format capability gap covered in Section 6.8), **and** the tool is not one of the four named in the exclusion below. Any other error (invalid input, encrypted file with no password, corrupt structure with no repair path) does not offer server fallback, because uploading the file would not fix those failures.
**The exclusion list — a hard product invariant.** **Redact, Protect, Unlock, and Self-sign never offer the server fallback described in this section, under any circumstance, even when the terminal error is exactly one of the three qualifying codes above.** These are the four tools whose entire value proposition, stated in Section 6.1's table and repeated in the product's marketing copy (Section 21), is an absolute claim that the file — or, for Protect and Unlock, the password — never leaves the device: "the sensitive content is never transmitted anywhere, including to us" for Redact, "must never be transmitted" for the Protect password, "the password never leaves the device" for Unlock, and the single-party, no-counter-party guarantee for Self-sign. Offering an upload path for any of these four tools, even opt-in, even framed as a last resort, would let a user redacting a sensitive contract, encrypting or decrypting a sensitive document, or applying their own signature be prompted to upload the very file the tool exists to keep off any server. This exclusion is a hard product invariant: it is asserted by the static import-graph and runtime network-assertion CI checks in Section 6.2, and shipping a build that offers the fallback modal for one of these four tools — or that silently uploads for one of them — is a **P0 defect, triaged with the same urgency as a security incident.**
**Device-side remediation instead, for the excluded four.** When Redact, Protect, Unlock, or Self-sign fails with `out_of_memory`, `disk_quota_exceeded`, or `unsupported_feature`, the error state shows a device-side remediation message rather than any upload offer:
- Title: "This file is too large to process on this device."
- Body (for `out_of_memory` or `unsupported_feature`): "Your device ran out of memory processing this file locally. This tool never uploads your file to finish a job, so there's no server option here. Try one of the following: close other browser tabs to free up memory, split the document into smaller page ranges and process each one separately, switch to a device with more available memory, or use a desktop browser instead of a mobile device."
- Body (for `disk_quota_exceeded`): the same message with "ran out of memory" replaced by "ran out of local storage space" and "close other browser tabs to free up memory" replaced by "free up storage space on your device."
- Actions: "Try again," a "Split this file" action linking directly to Split (Section 7.2) so the user can divide the document into smaller page ranges and process each range separately, and "Cancel." No action in this dialog ever uploads anything.
**Modal copy, exact, for every tool other than the excluded four:**
- Title: "Finish this on our servers instead?"
- Body: "Your device ran out of memory processing this file locally. We can finish the job on our servers instead. Your file will be uploaded over an encrypted connection, processed in an isolated environment, and permanently deleted within 24 hours (Section 17 has the full retention schedule)."
- Primary button: "Upload and finish on our servers"
- Secondary button: "Cancel"
**What is uploaded:** exactly the same input bytes the client-side engine was given — no partial output, no intermediate state, no telemetry payload beyond the standard request metadata (`requestId`, authenticated user or device ID, tool key, file size, file hash). The job then runs through the full server-side pipeline described in Section 6.9, using the identical `pdfcore` artifact, so the result is the one the client-side run would have produced had the device had enough memory.
**The consent record written**, one row per accepted fallback, stored on the resulting job:
```json
{
"jobId": "job_01K7Y3M2QF8V6X",
"fallbackReason": "out_of_memory",
"consentedAt": "2026-08-19T14:02:11.000Z",
"consentedBy": "usr_01K7Y1A0N3B8CD",
"originalExecutionLocation": "client",
"actualExecutionLocation": "server"
}This record is what job history's Processing Location Indicator (Section 6.2) reads to show "Processed on our servers" even though the tool's default is client-side, and it is what the CI network-assertion check in Section 6.2 exempts — a fallback job is expected to make an upload request even though its TOOL_REGISTRY entry says client, because the fallback is a distinct, explicitly consented code path, not a disagreement between declared and actual location. No such record can ever exist for Redact, Protect, Unlock, or Self-sign, since no code path that could produce one is reachable for those four tools.
The absolute rule: server fallback never happens without a click, for any tool eligible for it. There is no timeout that triggers it automatically, no "try locally, then silently retry on the server" behavior, and no background pre-upload "just in case." A build that uploads a file without the modal's primary button having been clicked is a P0 defect, reported and triaged with the same urgency as a security incident, because it breaks the product's core promise. For Redact, Protect, Unlock, and Self-sign specifically, even presenting the modal at all — let alone uploading — is itself the P0 defect, since these four tools must never present an upload path in the first place.
6.4 pdfcore API surface #
pdfcore is the single TypeScript-facing interface the rest of the application programs against, whether it is running inside a browser worker or inside the Node worker process described in Section 6.9. It wraps one compiled WASM artifact (Section 3.6) with document handles, page handles, and reference counting so callers never touch raw WASM pointers or manage the Emscripten heap directly.
// packages/pdfcore/src/index.ts
export interface DocumentHandle {
readonly id: string; // uuidv7, scoped to the current pdfcore instance, not a public-facing doc_ ID
readonly pageCount: number;
readonly isEncrypted: boolean;
readonly hasXfa: boolean;
readonly hasStructTree: boolean; // tagged PDF, see 6.8
readonly hasDigitalSignature: boolean; // pre-existing third-party PKI signature, see 6.8
}
export interface PageHandle {
readonly docId: string;
readonly index: number; // 0-based
readonly rotation: 0 | 90 | 180 | 270;
readonly mediaBox: [number, number, number, number];
readonly cropBox: [number, number, number, number];
}
export type PdfCoreErrorCode =
| "encrypted"
| "wrong_password"
| "corrupt_xref"
| "not_a_pdf"
| "unsupported_feature"
| "out_of_memory"
| "disk_quota_exceeded"
| "storage_evicted"
| "page_limit_exceeded"
| "cancelled"
| "invalid_input"
| "engine_panic";
export interface PdfCoreError {
code: PdfCoreErrorCode;
message: string;
pageIndex?: number;
cause?: unknown;
}
export interface OpenOptions {
password?: string;
/** ISO-8601 UTC. When present, every timestamp the engine would otherwise derive from wall-clock
* is replaced by this value. See Section 6.7. */
deterministicTimestamp?: string;
}
export interface ProgressEvent {
jobId: string;
progress: number; // integer 0-100, same field the job state machine (Section 4.7) uses
stage?: string;
pageIndex?: number;
pageCount?: number;
}
export interface OperationHandle<TResult> {
readonly jobId: string;
readonly progress: AsyncIterable<ProgressEvent>;
readonly result: Promise<TResult>;
cancel(reason?: string): void;
}
export interface PdfCore {
/** Opens a document. Accepts an ArrayBuffer for files already fully materialized, or a
* ReadableStream<Uint8Array> for OPFS-backed files above the 32 MB in-memory threshold, in
* which case pdfcore memory-maps the file through OPFS random access instead of copying it
* into WASM linear memory up front. Returns a handle with an internal refcount of 1. */
open(bytes: ArrayBuffer | ReadableStream<Uint8Array>, options?: OpenOptions): Promise<DocumentHandle>;
/** Increments the handle's refcount. Call when a second consumer needs the document to outlive
* the first consumer's release() call — e.g. Organize keeping a document open across an
* undo/redo stack while a background thumbnail renderer also holds it open. */
retain(doc: DocumentHandle): void;
/** Decrements the handle's refcount. At refcount 0, the WASM-side object graph is freed
* synchronously within the same microtask (no finalizer, no GC dependency — WASM linear
* memory is not garbage collected), and any OPFS scratch file backing the document is deleted. */
release(doc: DocumentHandle): void;
/** Returns a page handle. Implicitly retains the parent document for the page handle's lifetime;
* releasePage() releases that implicit retain. A page handle does not hold its own copy of page
* content — it is a lightweight index plus cached geometry (mediaBox/cropBox/rotation). */
getPage(doc: DocumentHandle, index: number): Promise<PageHandle>;
releasePage(page: PageHandle): void;
/** Renders a page to an ImageBitmap at the given DPI. Used by thumbnails, the page-grid editor
* (Section 7.3), and the crop tool's visual editor (Section 7.8). Streams: no — a single
* ImageBitmap per call, because partial-raster streaming has no useful consumer in the UI. */
renderPage(page: PageHandle, options: { dpi: number; colorSpace?: "rgb" | "gray" }): Promise<ImageBitmap>;
/** Serializes a document to bytes. This is the only call that performs a full-file rewrite
* (Section 6.7 — pdfcore never writes incrementally). Streams: yes, returns a
* ReadableStream<Uint8Array> for documents whose serialized size is estimated above 32 MB, so
* the output can be piped directly to an OPFS FileSystemWritableFileStream or an HTTP response
* body without holding the full output in memory twice. */
serialize(doc: DocumentHandle, options?: { deterministicTimestamp?: string }): Promise<Uint8Array | ReadableStream<Uint8Array>>;
/** Every tool operation (merge, split, compress, and so on) is exposed as a named method with a
* tool-specific options type and result type, and every one returns an OperationHandle. All
* operation methods share this cancellation contract: cancel() sets a cooperative flag the
* engine checks between page-level units of work, so cancellation latency is bounded by the
* time to finish the current page (typically under 200ms) rather than being instantaneous. A
* cancelled operation never returns a partial result — result rejects with a PdfCoreError of
* code "cancelled" and any partially-written output is discarded, not returned. */
merge(inputs: DocumentHandle[], options: MergeOptions): OperationHandle<DocumentHandle>;
split(input: DocumentHandle, options: SplitOptions): OperationHandle<DocumentHandle[]>;
organize(input: DocumentHandle, options: OrganizeOptions): OperationHandle<DocumentHandle>;
rotate(input: DocumentHandle, options: RotateOptions): OperationHandle<DocumentHandle>;
deletePages(input: DocumentHandle, options: DeletePagesOptions): OperationHandle<DocumentHandle>;
extractPages(input: DocumentHandle, options: ExtractPagesOptions): OperationHandle<DocumentHandle>;
insertBlankPages(input: DocumentHandle, options: InsertBlankPagesOptions): OperationHandle<DocumentHandle>;
crop(input: DocumentHandle, options: CropOptions): OperationHandle<DocumentHandle>;
compress(input: DocumentHandle, options: CompressOptions): OperationHandle<DocumentHandle>;
pdfToImage(input: DocumentHandle, options: PdfToImageOptions): OperationHandle<Blob[]>;
imageToPdf(inputs: Blob[], options: ImageToPdfOptions): OperationHandle<DocumentHandle>;
repair(input: ArrayBuffer): OperationHandle<DocumentHandle>;
editMetadata(input: DocumentHandle, options: EditMetadataOptions): OperationHandle<DocumentHandle>;
}Per-operation options and result types. Every operation method in the interface above takes a strongly typed options object; these match, field for field, the options tables given per tool in Section 7:
export interface PageSizeOption {
preset: "matchAdjacent" | "letter" | "a4" | "legal" | "custom";
customWidthPt?: number; // required when preset is "custom"
customHeightPt?: number; // required when preset is "custom"
orientation: "portrait" | "landscape";
}
export interface MergeOptions {
order: string[]; // ordered DocumentHandle ids
mergeBookmarks: boolean; // default true
formFieldCollision: "rename" | "error"; // default "rename"
attachmentHandling: "merge" | "drop"; // default "merge"
deterministicTimestamp?: string;
}
export interface SplitOptions {
mode: "range" | "everyN" | "bookmarkLevel" | "fileSize" | "blankPage" | "selected";
ranges?: string; // required for mode "range" or "selected"
everyN?: number; // 2-500, required for mode "everyN"
bookmarkLevel?: number; // 1-6, required for mode "bookmarkLevel"
targetSizeMb?: number; // 1-1000, required for mode "fileSize"
blankPageSensitivity?: "strict" | "lenient"; // default "lenient"
packaging: "separateFiles" | "zip"; // default "zip" when more than one output results
deterministicTimestamp?: string;
}
export interface OrganizeOptions {
instructions: Array<
| { type: "move"; pageIndex: number; toIndex: number }
| { type: "rotate"; pageIndex: number; degrees: 90 | 180 | 270 }
| { type: "delete"; pageIndex: number }
| { type: "duplicate"; pageIndex: number }
| { type: "insertBlank"; atIndex: number; pageSize: PageSizeOption }
| { type: "insertFrom"; atIndex: number; source: DocumentHandle; sourcePageIndex: number }
>;
deterministicTimestamp?: string;
}
export interface RotateOptions {
target: "all" | "selected" | "odd" | "even";
degrees: 90 | 180 | 270;
selectedPages?: number[]; // required when target is "selected"
perPageOverride?: Record<number, 90 | 180 | 270>; // applied after target/degrees
deterministicTimestamp?: string;
}
export interface DeletePagesOptions {
pages: string; // range syntax, e.g. "1-3,5,7-9"
deterministicTimestamp?: string;
}
export interface ExtractPagesOptions {
pages: string; // ordered list, not required to be ascending
outputMode: "single" | "separateFiles";
deterministicTimestamp?: string;
}
export interface InsertBlankPagesOptions {
position: "beforePage" | "afterPage" | "atStart" | "atEnd";
pageNumber?: number; // required for "beforePage" / "afterPage"
count: number; // 1-500
pageSize: PageSizeOption;
deterministicTimestamp?: string;
}
export interface CropOptions {
unit: "in" | "mm" | "pt";
preset: "none" | "narrow" | "moderate" | "wide" | "custom";
margins?: { top: number; right: number; bottom: number; left: number }; // required for "custom"
applyTo: "currentPage" | "allPages" | "range";
range?: string; // required when applyTo is "range"
lockAspectRatio: boolean;
deterministicTimestamp?: string;
}
export interface CompressOptions {
preset: "bestQuality" | "recommended" | "strong" | "maximum";
grayscaleOverride?: boolean;
preserveMetadataOverride?: boolean;
deterministicTimestamp?: string;
}
export interface PdfToImageOptions {
scope: "all" | "range" | "currentPage";
range?: string; // required when scope is "range"
format: "jpg" | "png";
dpi: number; // 36-600
transparency?: boolean; // PNG only, default false
colorSpace: "rgb" | "cmyk" | "grayscale";
filenameTemplate: string; // default "{name}-page-{page}.{ext}"
deterministicTimestamp?: string;
}
export interface ImageToPdfOptions {
pageSize: PageSizeOption;
orientation: "auto" | "portrait" | "landscape";
marginPt: number;
fitMode: "fitWithinMargins" | "fillCropping" | "stretch";
order: string[]; // ordered array of input image blob references
deterministicTimestamp?: string;
}
export interface EditMetadataOptions {
title?: string | null; // null clears the field entirely
author?: string | null;
subject?: string | null;
keywords?: string[] | null;
creationDate?: string | null;
customProperties?: Array<{ key: string; value: string }> | null;
deterministicTimestamp?: string;
}Document handle lifecycle, concretely: open() allocates the document's object graph in WASM linear memory (or memory-maps it through OPFS for large files) and returns a handle at refcount 1. Every tool operation that produces a new logical document (merge, split, image-to-pdf) returns a new DocumentHandle, distinct from its inputs — inputs are never mutated in place, which is what makes undo in the Organize editor (Section 7.3) possible without a separate undo log at the engine level. Callers must release() every handle they open() or receive as an operation result once they are done with it (typically: after serialize() succeeds and the bytes are written to OPFS or handed to a download). Failing to release a handle leaks WASM heap for the lifetime of the tab; the worker pool coordinator (Section 6.5) tracks outstanding handles per job and force-releases them when a job's OperationHandle settles, so a UI bug cannot leak memory past a single operation's lifetime even if a component fails to clean up explicitly.
Representative usage, showing the cancellation and progress-reporting contract in practice — a tool page component driving Compress (Section 7.9):
const doc = await pdfcore.open(inputBytes);
const operation = pdfcore.compress(doc, { preset: "recommended", deterministicTimestamp: undefined });
for await (const event of operation.progress) {
setProgress(event.progress); // drives the 0-100 progress bar
setStage(event.stage); // "analyzing" | "recompressing" | "subsetting-fonts" | "writing"
}
try {
const result = await operation.result;
const bytes = await pdfcore.serialize(result);
await writeToOpfsOutput(bytes);
pdfcore.release(result);
} catch (err) {
if ((err as PdfCoreError).code === "cancelled") {
// user clicked Cancel mid-operation; no partial output exists to clean up
}
throw err;
} finally {
pdfcore.release(doc);
}
// Elsewhere, wired to the Cancel button:
cancelButton.onclick = () => operation.cancel("user_requested");6.5 Worker pool and scheduling #
Pool sizing: clamp(navigator.hardwareConcurrency - 1, 2, 4) workers, computed once at application boot and never resized during a session. This reserves one logical core for the main thread (UI responsiveness, Comlink message dispatch) while capping at 4 to avoid diminishing returns and excessive memory overhead from multiple WASM instances, each of which carries its own heap. A separate, single dedicated low-priority worker — outside this pool and always exactly one instance — handles thumbnail and page-preview rendering, so a heavy foreground operation never gets starved waiting for preview work, and preview work never blocks on a heavy operation either.
Task queue: the main thread owns a priority queue keyed by (priority, enqueuedAt). Three priority levels, highest first:
interactive— thumbnail and page-preview rendering the user is actively looking at.foreground— the operation the user explicitly triggered (clicked "Merge", "Compress", and so on).background— prefetch and speculative preview generation for pages not yet scrolled into view.
Each idle worker pulls the highest-priority task; ties break FIFO by enqueue time. A worker is never assigned more than one task at a time — pdfcore's WASM instance per worker is not designed for concurrent operations against the same document, since document mutation is not thread-safe at the object-graph level.
Splitting a single logical operation across workers: only embarrassingly parallel, page-local work is split — image recompression during Compress (Section 7.9), and rasterization during PDF → JPG/PNG (Section 7.10). For an N-page document with a pool of w workers, the coordinator assigns contiguous page ranges of ceil(N / w) pages to each worker; each worker recompresses or rasterizes its range independently and returns encoded page bytes as a Transferable ArrayBuffer (zero-copy, moved via the postMessage transfer list, not cloned). Operations that mutate a single shared object graph — Merge, Split, Organize, Rotate, Delete pages, Insert blank pages, Crop, Repair, Edit metadata — run entirely on one worker, because concurrent writers to one xref table is a correctness hazard, not a performance opportunity. The final serialize() call for every operation, parallel or not, always runs on a single worker, since only one worker may hold write access to a document's object graph at a time.
Progress reporting granularity: the same integer 0-100 field the job state machine (Section 4.7) defines, with an optional stage string. Page-parallel operations report progress = pagesCompleted / totalPages * 100, aggregated across all workers assigned to that operation. Pipeline operations with distinct phases (Compress is the canonical example) report progress as a weighted sum of phase completion: analyze 10%, downsample/recompress images 60%, subset fonts 15%, write output 15% — so a user watching Compress sees the bar move smoothly through phases with obviously different per-page cost rather than jumping in four large discontinuous steps.
Cancellation: OperationHandle.cancel() sets a flag read by every worker assigned to that job; each worker finishes its current page (or current phase step) and then stops, discarding any buffered output for that job. The coordinator waits for all assigned workers to acknowledge the cancellation, then rejects result with a PdfCoreError of code cancelled. No partial output is ever written to OPFS or offered for download.
The coordinator's scheduling surface, implemented in apps/web's worker-pool module and consumed by every tool's page component:
// apps/web/lib/worker-pool/scheduler.ts
export type TaskPriority = "interactive" | "foreground" | "background";
export interface ScheduledTask<TResult> {
jobId: string;
priority: TaskPriority;
enqueuedAt: number;
run(worker: PdfCoreWorkerProxy): Promise<TResult>;
}
export interface WorkerPoolScheduler {
readonly poolSize: number; // clamp(hardwareConcurrency - 1, 2, 4)
enqueue<TResult>(task: ScheduledTask<TResult>): Promise<TResult>;
cancel(jobId: string): void;
activeJobCount(): number;
queueDepth(priority?: TaskPriority): number;
}A worker becoming idle always dequeues the highest-priority, oldest-enqueued task available; the scheduler does not attempt fairness across priority tiers (interactive work can indefinitely delay background work under sustained load), which is a deliberate choice — an idle preview never being backlogged is worth more to perceived responsiveness than guaranteeing background prefetch throughput.
6.6 OPFS storage model #
OPFS (Origin Private File System) is the only place file bytes are staged client-side. localStorage and base64 data URLs are never used for file content, without exception — both have hard practical size ceilings and both would defeat the point of processing files in a private, origin-scoped filesystem instead of an in-memory string.
Directory layout:
/pdfworks/
inputs/{sessionId}/{fileId}.bin
scratch/{sessionId}/{jobId}/{workerIndex}.bin
outputs/{sessionId}/{jobId}/{fileId}.bin
thumbnails/{sessionId}/{fileId}/{pageIndex}.webp| Directory | Contents | Lifetime |
|---|---|---|
inputs/{sessionId}/ |
Raw bytes of every file dropped into a tool this session, keyed by fileId |
Until the job(s) reading it complete, per the per-job cleanup rule below |
scratch/{sessionId}/{jobId}/ |
Intermediate artifacts a worker writes mid-operation (e.g., per-worker recompressed page chunks before serialize() reassembles them) |
Deleted immediately when the job settles, success or failure |
outputs/{sessionId}/{jobId}/ |
Finished output files, until downloaded or explicitly kept in the recent-files list | Governed by the purge lifecycle below |
thumbnails/{sessionId}/{fileId}/ |
Per-page WebP preview images generated on demand by the interactive-priority renderer |
Evicted alongside the source file's session directory |
sessionId is a uuidv7 generated once at application boot and recorded, with its creation timestamp, in a Dexie metadata table (sessions) — never regenerated mid-session, so all OPFS content produced by one tab session groups under one directory the janitor sweep can evaluate as a unit. fileId and jobId are uuidv7 values generated the same way public-facing IDs are (Section 4), though these particular IDs stay internal to the client and are never exposed as doc_/job_ prefixed public IDs unless the file is uploaded to the server, at which point the server mints its own prefixed ID independent of the client-side fileId.
Quota estimation: before accepting a new file into the input queue, the client calls navigator.storage.estimate() to read { usage, quota }. The upload is refused client-side, with the message "Not enough local storage available for this file," if quota - usage < fileSize * 3 — the 3x multiplier accounts for a job typically holding its input copy, a scratch/intermediate copy, and an output copy in OPFS simultaneously during processing. At this same first-use moment — the first file accepted into the input queue each session, before any operation has run — the client also calls navigator.storage.persist() to request persistent storage (reducing the browser's likelihood of evicting OPFS content under storage pressure, including the mid-job eviction case covered below); the call is made once per session and never blocks any user-facing flow on its outcome, since the permission is advisory in every supporting browser.
What happens when quota is exceeded mid-job: OPFS write calls that exceed the browser's granted quota throw a QuotaExceededError, surfaced by pdfcore as PdfCoreErrorCode: "disk_quota_exceeded", distinct from WASM heap exhaustion ("out_of_memory") because the remediation differs — the user needs to free device disk space or (for every tool except Redact, Protect, Unlock, and Self-sign, per the exclusion in Section 6.3) fall back to the server, not simply retry. disk_quota_exceeded qualifies for the opt-in server fallback offer defined in Section 6.3, since it is a device storage constraint outside the file's own validity — except for the four excluded tools, which show the device-side remediation message from Section 6.3 instead.
Eviction under system storage pressure. OPFS is also subject to the browser's general storage-eviction policy: under severe device storage pressure, the browser may evict an entire origin's storage bucket, including everything under /pdfworks/, without the tab's involvement. This is distinct from the quota-exceeded write failure above, which is a synchronous, in-tab rejection of a single write against remaining capacity; eviction removes the underlying storage bucket itself, at any time, including mid-write.
Eviction is detected two ways: (1) synchronously, when a pdfcore write — a scratch-file write mid-operation, or the streamed output of serialize() — fails with a NotFoundError or InvalidStateError from the File System Access API rather than the QuotaExceededError the disk-quota path throws, since eviction removes the storage handle itself rather than merely refusing a write against remaining capacity; and (2) at application boot, when the janitor sweep below finds a sessionId recorded in the Dexie sessions table with no corresponding directory left in OPFS, meaning it was evicted rather than cleaned up through the normal purge lifecycle.
When an in-progress job's write fails this way, pdfcore surfaces PdfCoreErrorCode: "storage_evicted" (Section 6.4), and the job's row transitions straight to failed — never to a partial succeeded result, since a truncated output would be indistinguishable from a genuine one without the loss being obvious to the user. The UI shows: "Your device freed up storage space this job needed, so it couldn't finish. Nothing was saved." with a single "Try again" action. The job cannot be resumed from where it left off: eviction removes the origin's storage bucket wholesale, so the job's inputs/ and scratch/ entries are gone along with the in-progress output, not just the specific write that failed. "Try again" reopens the tool with the file cleared from the picker, requiring the user to re-select it from their device and start the job over — there is no server-side copy to recover from, consistent with the product never uploading a file without an explicit fallback click (Section 6.3), and, for Redact, Protect, Unlock, and Self-sign, with those tools never uploading at all.
Purge lifecycle:
- Per-job cleanup: a job's
inputs/andscratch/entries are deleted immediately once itsoutputs/entry is written and either downloaded or explicitly kept in the session's recent-files list (DexierecentFilestable, metadata only — Section 3.7). - Boot-time janitor sweep: on every application load, a background task enumerates all
sessionIddirectories under/pdfworks/, compares each session'screatedAt(from Dexie) against the current time, and deletes any directory whose session is older than 24 hours, regardless of whether that tab is still open elsewhere. This is the backstop for tab crashes and missedpagehideevents, since browsers do not reliably run asynchronous OPFS cleanup during unload. The same sweep is what surfaces storage eviction, per the detection method above, when a Dexie-recorded session has no matching OPFS directory at all. - Tab close: a
pagehidelistener attempts the same per-session cleanup as a best-effort optimization, but the product never depends on it firing — the 24-hour janitor sweep is the only cleanup path the system treats as guaranteed. - "Clear local data" control (Settings → Privacy): synchronously deletes the entire
/pdfworks/root and clears every Dexie table (recentFiles,toolPresets,draftAnnotations,queuedBatches,sessions). A confirmation dialog warns: "This deletes all files and drafts stored on this device, including anything from an in-progress job. This can't be undone." The control is available at any time, including mid-job, in which case the in-progress job is cancelled first (Section 6.5) before the deletion runs.
6.7 Determinism #
Two runs of the same operation over the same input bytes, with the same deterministicTimestamp, produce byte-identical output — whether both runs happen in-browser, both happen server-side, or one of each. This is what makes the cross-host equality guarantee in Section 6.4 and Section 3.6 verifiable rather than aspirational.
The deterministicTimestamp parameter: an ISO-8601 UTC string (e.g., "2026-08-19T00:00:00Z"), accepted by open() and serialize() (Section 6.4). When present, every timestamp the engine would otherwise derive from wall-clock is replaced by this fixed value. When absent — the default for ordinary interactive use — the engine uses the actual UTC wall-clock time, and two runs are expected to differ, which is correct: a user merging two files a minute apart should get a document stamped a minute apart. The parameter exists for testing (Section 6.7's CI check) and for any future feature that needs reproducible output; it is not exposed in the ordinary tool UI.
/ID handling: the PDF /ID trailer entry is a pair of 16-byte strings. Because pdfcore never performs an incremental save (every write is a full rewrite — the same rule Section 9.1's redaction algorithm follows, generalized to every operation, precisely because an incremental save leaves prior bytes recoverable in the file), both /ID entries are regenerated on every write, computed as MD5(timestamp || outputByteLength || firstPageContentHash), where timestamp is deterministicTimestamp when supplied and the actual wall-clock ISO string otherwise. Two runs with identical inputs and an identical deterministicTimestamp therefore produce an identical /ID.
/CreationDate: set only when an operation logically produces a new document — Merge, Split, Insert blank pages, JPG/PNG → PDF, Repair (the repaired file is a new object graph). Operations that transform an existing document in place — Rotate, Crop, Compress, Edit metadata, Delete pages, Extract pages — preserve the original /CreationDate unchanged.
/ModDate: set to deterministicTimestamp (or wall-clock time) on every write, without exception, since every operation modifies the document by definition.
Font subset naming: the PDF specification requires embedded font subsets to carry a 6-uppercase-letter tag prefix (e.g., ABCDEF+Helvetica) and only requires that the tag be consistent within the file — it does not require randomness. pdfcore derives the tag deterministically as the first 6 letters of a base-26 encoding of SHA-256(font program bytes), so the same embedded font always subsets to the same tag across every run, on every host, rather than the random tag many PDF libraries generate.
The cross-host equality test: a CI job (Section 19 owns the broader test infrastructure) runs each tool operation twice against the same fixture from the golden-file corpus with an identical deterministicTimestamp — once through a Playwright-driven Chromium browser exercising the client code path, once by invoking the Node worker path directly — and SHA-256-compares the two output files byte-for-byte. Any mismatch fails the build. This test is what keeps the "one engine, two hosts" guarantee in Section 3.6 honest rather than aspirational.
6.8 Failure taxonomy for processing #
| Condition | Detection | User-facing message | Recovery path |
|---|---|---|---|
| Encrypted input | open() reads the encryption dictionary before any content parse and returns PdfCoreErrorCode: "encrypted" |
"This PDF is password protected. Enter the password to continue." with an inline password field | User supplies the password and the same open() call is retried inline. If the user does not know the password, there is no recovery — the product never attempts to crack or bypass encryption it was not given the key to. |
| Corrupt xref table | Structured parse fails while reading the cross-reference table or trailer | "This PDF's internal structure is damaged." | The UI offers to run Repair (Section 7.12) automatically as a first attempt; if Repair also fails, the opt-in server fallback (Section 6.3) is offered as a last resort, since the server pipeline's QPDF-based repair path has a wider recovery envelope than the WASM build. |
| Linearized files with a damaged hint table | Linearization dictionary is present in the first object, but its hint-table checksum does not validate | No error shown to the user in the common case — the engine transparently falls back to a non-linearized, full-scan parse and proceeds | Fully automatic; the file is treated exactly as a non-linearized file from that point forward. If the fallback parse also fails, it falls into the corrupt-xref path above. |
| XFA forms | /AcroForm dictionary contains an /XFA key |
"This PDF contains a dynamic XFA form, which isn't supported. Static content displays normally, but form fields may not fill correctly." | Non-form operations (Merge, Split, Compress, and so on) proceed normally against the document's static rendering. Fill Forms (Section 8.4) specifically blocks with unsupported_feature rather than silently mis-filling a field the XFA layer actually controls. |
| Tagged PDFs (accessibility structure) | /StructTreeRoot is present in the document catalog |
No error for most tools. Redact (Section 9.1) shows: "This is a tagged, accessible PDF. Redaction removes structure tags for the affected regions so the tag tree cannot leak removed content." | Documented, non-blocking behavior — the operation proceeds. |
| Pre-existing digital signature | /AcroForm contains a signature field (/FT /Sig) with a populated /V signature dictionary, detected at open() and exposed as DocumentHandle.hasDigitalSignature (Section 6.4) |
One-time warning, per document per session, on every content-modifying tool: "This PDF already has a digital signature applied by a third party. Any change you make here will invalidate that signature." | Non-blocking — the user may proceed after acknowledging. The tool carries the change through normally; the existing /Sig dictionary is left in the object graph untouched (no tool attempts to strip it), but its cryptographic validation will correctly fail against the modified content, since that is the expected consequence of changing a signed document's bytes, not a defect in the tool. |
| PDFs over 5,000 pages | Page count read at open() exceeds 5,000 |
"This PDF has {n} pages, which is above the 5,000-page limit for browser-based processing." | The opt-in server fallback (Section 6.3) is offered for every tool except Redact, Protect, Unlock, and Self-sign, which show the device-side remediation message from Section 6.3 instead; where the fallback is offered, the server-side pipeline (Section 6.9) runs the identical engine with a raised ceiling of 20,000 pages, since server workers are not constrained by a single device's available memory. |
| CJK and RTL text | Unicode script-range detection runs during text extraction | No error for tools that do not depend on font substitution. For OCR and Office conversion (Section 9), if the source has no embedded font for a detected script and no system font is available in the sandbox, the pipeline substitutes a bundled Noto font for that script and continues | Automatic substitution; the resulting text remains selectable and searchable even when the visual font differs from the original. |
| PDFs with no text layer | Text extraction returns zero characters across a sample of pages | An informational banner, not an error, on any tool: "This file has no selectable text. Run OCR to make it searchable." | Suggests OCR (Section 9.5); does not block the current tool. |
| Files that are not PDFs at all | Magic-byte sniffing (the PDF-specific instance of the general upload validation in Section 17.6) finds no %PDF- signature in the first 1024 bytes |
"This doesn't look like a PDF file." | Rejected at the drop zone before any parser runs. If the rejected file is a recognized image format and the active tool only accepts PDFs, the UI suggests the JPG/PNG → PDF tool (Section 7.11) instead. |
| Owner-password-only encryption (usage restrictions, no user password) | open() succeeds without a password but the permissions dictionary denies one or more operations (e.g., printing, extraction) |
No blocking error — the file opens normally. A tool whose action the permissions dictionary denies (for example, Extract pages when content-extraction is disallowed) shows: "This file's permissions restrict this action." | The Protect tool (Section 9.4) or the Unlock tool (Section 9.6) can remove the owner-password restriction the same way either removes a user password, once the file is open |
PDF portfolios (multi-file containers, /Collection present) |
/Collection key detected in the document catalog |
"This file is a PDF portfolio containing multiple embedded documents. Tools operate on the portfolio's cover page only; embedded files are preserved but not individually editable." | Page-level tools (Merge, Split, Organize, and so on) operate on the portfolio's own pages; embedded files carry through unmodified in the output's embedded-file tree, the same mechanism Merge's attachment handling (Section 7.1) uses |
6.9 Server-side pipeline overview #
Every server-side job — whether it originated from the API, a batch containing a server-side tool, an opt-in fallback, or a directly server-only tool like OCR — moves through the same six-stage pipeline. Orchestration (queueing, retries, priority lanes) is owned by Section 13; the sandbox each stage executes inside is owned by Section 17.
- Ingest. The uploaded file is streamed directly into per-job object storage (Section 3.9) over TLS; it is never written to the API process's own disk. A
jobrow is created in statequeued(Section 4.7) with a freshly generatedjob_public ID. - Validate. Magic-byte sniffing, PDF structural validation, and the object-count/nesting-depth/decompressed-size limits from Section 17.6 run before any tool-specific code touches the bytes. A file failing validation moves the job straight to
failedwith aninvalid_request_errorand never reaches a worker container. - Decrypt to tmpfs. The job's per-job AES-256-GCM data key (Section 3.9) is unwrapped inside the worker container, and the file is decrypted from object storage into the container's per-job
tmpfsmount — never onto the container's persistent disk, sincetmpfsis memory-backed and vanishes with the container. This step decrypts only the server's own storage-at-rest encryption; it never decrypts a source document's own user password, because a document password is never transmitted to the server under any circumstance — the same absolute claim Section 6.1's table makes for Protect and Unlock. If the file a user is submitting for a server-side operation is itself password-protected, the browser runs the Unlock path (Section 9.6) locally, before any upload begins: it prompts for the password inline, decrypts the document entirely client-side using the samepdfcoreartifact described in Section 6.4, and only the resulting plaintext bytes are added to the upload payload — the password itself is discarded from memory the moment client-side decryption completes and is never included in the job request, never logged, and never stored on the job row. The upload-consent dialog (Section 6.2) is shown only after this client-side decryption succeeds, and its copy states plainly that the file was decrypted on-device before upload; the standardserverUploadAcknowledgedconsent flow (Section 6.3) still applies on top of it. If the user declines to supply the password, no upload starts and no job is created — the tool returns to its ordinary encrypted-input state (Section 6.8) as though a server-side operation had never been requested. If the browser cannot decrypt the file (wrong password, or a PDF encryption variantpdfcoredoes not support), the upload is refused client-side with the message "This file couldn't be unlocked on your device, so it can't be uploaded to our servers either. Confirm the password and try again," and no bytes — encrypted or otherwise — are ever transmitted. As a consequence, a job request never carries a password field for source-document decryption: the bytes this stage decrypts from object storage are, for an originally password-protected source, already the client-decrypted plaintext the browser produced and uploaded. - Process. The worker invokes the same
pdfcoreartifact (or, for OCR/Office/HTML tools, the corresponding native toolchain — Tesseract, LibreOffice headless, Poppler, Ghostscript, all named without version numbers here since only the locked table in Section 3 carries version lines) inside the gVisor-sandboxed, network-isolated container (Section 17.5), writing progress updates the job row polls the same way the client-side worker pool reports progress (Section 6.5) — same0-100integer, same optionalstagestring, same terminal states. - Encrypt output. The result is re-encrypted with the job's data key before it is written back to object storage. At no point does plaintext output persist outside the container's
tmpfs. - Notify. The job row transitions to
succeededorfailed; a webhook fires if one is registered for the job's event type (Section 14's webhook system), and, for interactive (non-API) jobs, the web app's polling or WebSocket connection (Section 15) delivers the result to the UI. Each stage runs inside the same container invocation — there is no separate ingest service and process service to coordinate, which keeps the failure surface small:
| Stage | Runs in | Wall-clock budget |
|---|---|---|
| Ingest | apps/api request handler, streaming to object storage |
Bounded by the upload itself, not a fixed budget |
| Validate | Worker container, before tmpfs decrypt |
30 seconds |
| Decrypt to tmpfs | Worker container | 30 seconds |
| Process | Worker container | Per-tool budget defined in Section 13.3.8, which owns every worker and job wall-clock timeout in the product |
| Encrypt output | Worker container | 30 seconds |
| Notify | apps/api, outside the worker container |
Bounded by webhook delivery retry policy (Section 14) |
| Shred | Container runtime, on exit | Immediate |
A job that exceeds its process stage budget is killed by the container runtime and its job row transitions to expired, the same terminal state the client-side job model uses for the equivalent condition (Section 4.7) — there is exactly one state machine, not a server-specific variant.
- Shred. The container's
tmpfsis unmounted and its backing memory released the moment the container exits, which happens immediately after stage 6 regardless of success or failure — no container is kept warm across jobs. Object storage bytes are deleted 2 hours after the job reaches a terminal state or within 24 hours of upload, whichever comes first, unless the job belongs to an e-signature envelope, in which case the envelope retention schedule in Section 10.8 governs instead.
7. Tool Specifications A: Page Operations, Compression, Image Conversion #
Every tool in this section follows one shared output-naming convention unless its own subsection states otherwise: the output file is named {originalBaseName}-{suffix}.pdf, where {originalBaseName} is the input filename with its extension stripped and {suffix} is the tool-specific suffix given below. If a file of that exact name already exists in the destination (the OPFS outputs/ directory for client-side runs, the browser's download folder, or the target the API caller specified), a numeric disambiguator is appended before the extension: -2, -3, and so on.
Every tool also follows one shared UI state model: empty (drop zone, no file selected), loading (file selected, validating and parsing), configuring (options panel, valid file loaded), progress (operation running, progress bar bound to the 0-100 value from Section 6.5), success (result preview, download and any tool-specific follow-on actions), and error (a specific message drawn from Section 6.8's taxonomy or the tool's own validation rules, with a recovery action where one exists). Only the states and transitions specific to a tool are called out below; the shared model is not repeated per tool.
Every tool's equivalent public API operation is invoked as POST /v1/jobs with the operation field set to the snake_case slug given in each subsection; the request/response shapes, authentication, and idempotency rules are owned entirely by Section 14 and are not restated here.
Every tool below is client-side by default per Section 6.1's assignment table, with a Guest or Free minimum plan. When one of these tools is run as part of a batch (Section 13) that also contains a server-side tool, the entire batch — including these otherwise-client-side operations — runs server-side, per the batch-composition rule in Section 6.1; nothing below needs to be re-read differently in that case, since the same pdfcore artifact and the same options types produce the same result on either host.
7.1 Merge #
Purpose: combine two or more PDF files into a single output document in a user-specified order. Execution location: client (Section 6.1). Minimum plan: Guest.
Inputs and validation:
| Rule | Limit |
|---|---|
| Accepted formats | PDF only |
| Minimum files | 2 (a single file submitted alone is accepted as a no-op passthrough, producing an identical copy renamed per the output convention) |
| Maximum files | 50 |
| Per-file size | Plan's file-size ceiling (Section 12.2) |
| Combined output page count | 20,000 pages hard cap; above 5,000 combined pages the file streams through OPFS-backed scratch storage (Section 3.7) rather than loading fully into WASM linear memory |
| Encrypted inputs | Each encrypted file must be unlocked with its password before it can be added to the merge order; an encrypted file with no password supplied blocks submission |
Options:
| Option | Type | Values | Default |
|---|---|---|---|
| Order | Ordered list of file references | Drag-and-drop or keyboard reorder | Upload order |
| Merge bookmarks/outlines | Boolean | — | On — each source file's outline nests under a top-level bookmark named after that file |
| Form field collisions | Enum | rename, error |
rename — colliding field names get a numeric suffix (signature_date → signature_date_2) |
| Attachment handling | Enum | merge, drop |
merge — embedded files from every source are combined into the output's embedded-file tree, renaming on collision the same way form fields are |
Algorithm:
- Validate every input file (format, size, page count, encryption state).
- For each encrypted input with a supplied password, decrypt in place before proceeding.
- Construct the combined page sequence from the user's ordering.
- For each source document in order, import its pages into the output object graph, preserving each page's own
/MediaBox,/CropBox, and/Rotate— mixed page sizes and orientations are never normalized. - If bookmark merging is enabled, nest each source's outline tree under a synthetic top-level entry named after that source file; otherwise flatten all outlines into one top-level list in merge order.
- Resolve form field name collisions per the selected policy.
- Merge embedded-file attachments, renaming on collision.
- Assign a fresh
/IDand/ModDateper Section 6.7. - Serialize the output as a full rewrite (never incremental).
Output naming: {originalBaseName of the first file}-merged.pdf.
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Drop zone accepting multi-file drag-and-drop or multi-select from the OS file picker; the Processing Location Indicator (Section 6.2) shows "On your device" above the drop zone before any file is chosen |
| Loading | Each dropped file parses independently with its own inline spinner in the growing file list, so one large file does not block the rest from appearing |
| Configuring | The reorderable file list, each row showing filename, page-count badge, and file size; the bookmark-merging, form-field-collision, and attachment options in a collapsed "Options" panel; the Merge button disabled until at least 2 valid files are present |
| Progress | "Merging page {n} of {total}" with the phase-weighted progress bar from Section 6.5 |
| Success | A scrollable thumbnail strip of the combined output, page count, resulting file size, and a Download button |
| Error — file too large | Inline on the offending row: "This file exceeds your plan's file-size limit." with a link to Section 12's plan comparison |
| Error — password protected | Inline on the offending row: a password field with "This file is password protected" |
| Error — not a PDF | Inline on the offending row: "This doesn't look like a PDF file," row removable but not mergeable |
| Error — page-count cap exceeded | A blocking banner above the file list: "Combined page count ({n}) exceeds the 20,000-page limit," Merge button stays disabled |
Keyboard operation:
| Key | Action |
|---|---|
| Tab / Shift+Tab | Move focus through the file list |
| Space | Select or deselect the focused file |
| Arrow Up / Down | Move the focused (or selected) file one position within the merge order |
| Home / End | Jump the focused file to the top or bottom of the order |
| Delete | Remove the focused file from the list |
| Enter (on the Merge button) | Start the operation |
Each reorder announces its result through a live region (e.g., "quarterly-report.pdf moved to position 2 of 4") so the outcome is available without relying on the visual list, consistent with the drag-and-drop-plus-keyboard-reorder requirement called out for this tool.
Edge cases: single-file merge (passthrough copy); duplicate filenames across sources (kept distinct internally, both listed by name plus a truncated file-size hint to disambiguate); a file that fails mid-parse after others succeeded (removed from the list with an inline error, remaining files stay ready to merge); an XFA-form input (proceeds using its static rendering per Section 6.8); a merge exceeding the 20,000-page cap (blocked before processing starts, with a count of how many pages are over the limit).
Equivalent API operation: merge.
Acceptance criteria:
- Given three unencrypted PDFs of 5, 3, and 7 pages arranged in that order, the output is a single 15-page PDF with pages in that exact sequence.
- Given two inputs that each define a form field named
signature_date, the output contains both fields independently fillable, with the second renamedsignature_date_2. - Given an input list containing one password-protected file with no password supplied, the Merge button remains disabled and the file list shows an inline "password protected" error until the file is removed or unlocked.
- Given inputs with Letter-size and A4-size pages respectively, the output preserves each page's original
/MediaBoxrather than resizing either to match the other.
7.2 Split #
Purpose: divide one PDF into multiple output files or a single subset, by one of several selection strategies. Execution location: client. Minimum plan: Guest.
Inputs and validation: single PDF input, plan file-size ceiling (Section 12.2), unencrypted or unlocked before submission.
Options:
| Option | Type | Values | Default |
|---|---|---|---|
| Mode | Enum | range, everyN, bookmarkLevel, fileSize, blankPage, selected |
range |
Page ranges (mode range) |
String, validated against ^\d+(-\d+)?(,\d+(-\d+)?)*$ |
e.g. 1-3,5,7-9 |
— |
N (mode everyN) |
Integer | 2-500 | 10 |
Bookmark level (mode bookmarkLevel) |
Integer | 1-6 | 1 (top-level bookmarks start new files) |
Target size MB (mode fileSize) |
Number | 1-1000, bounded by the plan's per-file ceiling | 10 |
Blank-page sensitivity (mode blankPage) |
Enum | strict (fully white pixel-for-pixel), lenient (≤0.5% non-white coverage) |
lenient |
Selected pages (mode selected) |
Page list, same syntax as ranges | — | — |
| Output packaging | Enum | separateFiles, zip |
zip when the operation produces more than one file |
Algorithm:
- Validate the input and the mode-specific parameter.
- Compute page-group boundaries: for
range, one group per comma-separated range; foreveryN, groups of N consecutive pages (a final short group if the page count is not a multiple of N); forbookmarkLevel, a new group starts at every outline entry at or above the chosen level; forfileSize, pages are appended to the current group until adding the next page would exceed the target size, at which point a new group starts (splits always happen on a page boundary, never mid-page, so exact size is not guaranteed); forblankPage, a new group starts after every page classified blank per the sensitivity setting, and the blank pages themselves are dropped from the output; forselected, a single group containing exactly the listed pages, in ascending page order. - For each group, build a new document importing only that group's pages, preserving per-page
/MediaBox,/CropBox, and/Rotate. - Assign
/IDand/ModDateper Section 6.7 to each output document independently. - If packaging is
zip, archive all outputs into one ZIP; otherwise offer each as a separate download.
Output naming: {originalBaseName}-part-{n}-of-{total}.pdf for range/everyN/fileSize/blankPage modes; {originalBaseName}-pages-{range}.pdf for selected mode; the ZIP, when produced, is named {originalBaseName}-split.zip.
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Single-file drop zone |
| Loading | Spinner while the file parses and its outline/page structure is read |
| Configuring | Mode selector with the mode-specific parameter inline; a live page-count preview per prospective output group updates as the parameter changes, before the Split action runs |
| Progress | "Splitting: building file {n} of {total}" |
| Success | A list of each output file with its page range and size, a per-file download link, and a "Download all as ZIP" action when packaging is zip |
| Error — invalid range syntax | Inline under the range field: "Enter page numbers or ranges, like 1-3, 5, 7-9" |
| Error — no bookmarks | Below the mode selector when bookmarkLevel is chosen against a file with no outline tree: "This file has no bookmarks to split by," Split action disabled |
| Error — N out of bounds | Inline under the N field: "Enter a number between 2 and 500" |
Keyboard operation: Tab through mode options; for range and selected, a page-thumbnail grid supports Shift+Click and Shift+Arrow range selection identical to Section 7.3's multi-select.
Edge cases: bookmarkLevel mode against a document with no outline tree shows "This file has no bookmarks to split by" and disables the Split button until a different mode is chosen; blankPage mode with strict sensitivity against a lightly scanned "blank" page (faint scanner noise) may not classify it as blank — this is documented, not treated as a bug; fileSize mode where a single page's own content exceeds the target size produces a one-page group larger than the target, since a page is never split mid-content.
Equivalent API operation: split.
Acceptance criteria:
- Given a 20-page document split with
rangeset to1-5,6-20, the output is exactly two files of 5 and 15 pages. - Given a 25-page document split with
everyNset to 10, the output is three files of 10, 10, and 5 pages, in order. - Given a document with no outline tree, selecting
bookmarkLevelmode disables the Split action and shows the no-bookmarks message rather than producing a single-file "split." - Given
selectedmode with pages3,1,7, the output file contains pages 1, 3, and 7 in ascending order, not upload order.
7.3 Organize #
Purpose: an interactive page-grid editor for reordering, rotating, deleting, duplicating, and inserting pages, applied as a single batch of changes when the user commits. Execution location: client. Minimum plan: Guest.
Inputs and validation: single PDF, plan file-size ceiling; unencrypted or unlocked.
Editor capabilities:
| Capability | Behavior |
|---|---|
| Reorder | Drag-and-drop of one or more selected thumbnails; keyboard reorder via Arrow keys moves the focused/selected page(s) one position per press |
| Rotate | Per-page, 90° increments, applied to the thumbnail preview immediately and to the /Rotate value on commit |
| Delete | Marks selected pages for removal; deleted thumbnails are dimmed and struck through, not removed from the grid, until commit, so delete is itself undoable |
| Duplicate | Inserts a copy of the selected page(s) immediately after the original(s) |
| Insert blank | Opens the Insert Blank Pages options (Section 7.7) inline, inserting at the focused position |
| Insert from another PDF | Opens a secondary file picker; imported pages are inserted at the focused position, preserving their own size |
| Multi-select | Click+Ctrl/Cmd for discontiguous selection, Click+Shift for range selection |
| Undo/redo | Stack depth 50 steps; each discrete user action (one drag, one rotate, one delete toggle, one duplicate, one insert) is one stack entry; the 51st action drops the oldest entry |
| Thumbnail virtualization | Only thumbnails within the viewport plus a 10-thumbnail buffer above and below are rendered; documents up to 5,000 pages remain scrollable at native scroll performance because off-screen thumbnails are unmounted, not merely hidden |
Algorithm (applied on commit, i.e., when the user clicks "Apply"):
- Replay the accumulated edit list (reorders, rotations, deletions, duplications, insertions) as a single ordered instruction sequence against the original document's page tree.
- Build the new page order first, then apply per-page rotation deltas, then physically drop pages marked deleted.
- Prune any outline entries and internal links that pointed exclusively at deleted pages.
- Renumber the page tree.
- Assign
/IDand/ModDateper Section 6.7. - Serialize as a full rewrite.
Output naming: {originalBaseName}-organized.pdf.
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Single-file drop zone |
| Loading | A skeleton grid renders immediately, filling in with real thumbnails as they resolve (virtualized per Section 7.3's capability table) |
| Configuring | The live-editing grid, undo/redo toolbar always visible, a "Reset" action that discards all uncommitted edits and reloads the original grid, an "Apply" button showing a live count of pending changes |
| Progress | Appears only after "Apply" is clicked (all prior editing is instantaneous, in-memory, not engine processing) — "Applying {n} changes" |
| Success | The committed result grid plus Download; the undo/redo stack is cleared |
| Error — zero pages remaining | Blocking banner when the pending edit set would delete every page: "A document must have at least one page," Apply disabled |
| Error — insert-from source invalid | Inline in the secondary file picker: the same per-file validation errors as Merge (Section 7.1) |
Keyboard operation. Because the page-grid editor is the tool with the most surface area for pointer-only interaction, every action has a keyboard equivalent, announced through a live region so a screen-reader user hears the result of each action (e.g., "Page 5 moved to position 3. 12 pages total.") per the accessibility requirements in Section 16:
| Key | Action |
|---|---|
| Arrow keys | Move grid focus one thumbnail in the corresponding direction |
| Shift+Arrow | Extend a range selection from the current focus |
| Space | Toggle selection of the focused thumbnail |
R |
Rotate the current selection 90° clockwise |
Delete / Backspace |
Toggle deletion of the current selection |
Ctrl+D / Cmd+D |
Duplicate the current selection |
Ctrl+Z / Cmd+Z |
Undo |
Ctrl+Shift+Z / Cmd+Shift+Z (or Ctrl+Y) |
Redo |
I |
Enter insert mode; subsequent Arrow key input chooses before/after the focused thumbnail, Enter confirms the insertion point |
Enter (outside insert mode) |
Apply the accumulated edits |
Escape |
Exit insert mode without inserting, or clear the current multi-selection |
Edge cases: a 5,000-page document (thumbnail virtualization keeps the grid responsive per the capability above); inserting pages from a source with a different page size than the surrounding document (the inserted pages keep their own /MediaBox, consistent with Merge's mixed-size rule in Section 7.1); undo past the 50-step limit (oldest step silently unavailable, no error shown since this is expected, bounded behavior); committing with zero pages remaining after deletions is blocked with "A document must have at least one page."
Equivalent API operation: organize, accepting an ordered array of instructions (move, rotate, delete, duplicate, insertBlank, insertFrom) equivalent to the client's replayed edit list.
Acceptance criteria:
- Given a 10-page document with pages 3 and 7 marked deleted and page 5 rotated 90°, committing produces an 8-page document where former page 5 (now at position 5) carries the rotation and pages 3 and 7 are absent.
- Given 51 sequential edits, the undo stack retains the most recent 50; undoing 50 times returns to the state after the first edit, not the original unedited state.
- Given a 5,000-page document, scrolling from page 1 to page 5,000 never renders more than roughly 30 thumbnails (viewport plus buffer) simultaneously in the DOM.
- Given a document reduced to zero pages by deletion, clicking "Apply" is blocked and no output is produced.
7.4 Rotate #
Purpose: rotate one, several, or all pages by a fixed increment. Execution location: client. Minimum plan: Guest.
Options:
| Option | Type | Values | Default |
|---|---|---|---|
| Target | Enum | all, selected, odd, even |
all |
| Degrees | Enum | 90, 180, 270 (clockwise) |
90 |
| Per-page override | Map of page index to degrees | — | Empty |
Algorithm: for each targeted page, set /Rotate to (currentRotate + requestedDegrees) mod 360; the content stream itself is never rewritten, which is why Rotate is one of the fastest operations in the product. Per-page overrides apply after the target/degrees pass, so a user can rotate "all" 90° and then flip two specific pages back with an override.
Output naming: {originalBaseName}-rotated.pdf.
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Single-file drop zone |
| Loading | Skeleton grid while thumbnails resolve |
| Configuring | Page grid with a rotation control per thumbnail plus a bulk target/degrees control above the grid; rotations preview instantly in the thumbnail without waiting for commit |
| Progress | "Rotating page {n} of {total}" (typically sub-second for documents under a few hundred pages, since only /Rotate values change) |
| Success | The rotated grid plus Download |
| Error | The tool has no operation-specific error beyond the shared taxonomy (Section 6.8) — there is no user input that can make a rotation invalid |
Keyboard operation: R rotates the focused/selected page(s) 90° clockwise, repeatable up to 270°; Shift+R rotates counter-clockwise.
Edge cases: a page with a pre-existing non-zero /Rotate value (the new rotation is additive, not absolute, matching how PDF viewers already interpret the field); annotations and form field widgets on a rotated page rotate with the page, since they are positioned relative to the page's rotated coordinate space by the PDF specification itself, not by anything this tool does.
Equivalent API operation: rotate.
Acceptance criteria:
- Given a page with
/Rotate0 rotated 90° via this tool, the output page's/Rotateis 90. - Given a page with
/Rotate270 rotated 180° via this tool, the output page's/Rotateis 90 ((270+180) mod 360). - Given
target: odd, degrees: 180on a 6-page document, pages 1, 3, and 5 are rotated and pages 2, 4, and 6 are untouched. - Given
target: all, degrees: 90with a per-page override setting page 4 to 270, every page's/Rotateincreases by 90 except page 4, whose/Rotatereflects the override value instead.
7.5 Delete pages #
Purpose: remove one or more pages from a document. Execution location: client. Minimum plan: Free.
Options: page selection, same range syntax as Section 7.2 (1-3,5,7-9).
Algorithm: parse the selection into a page-index set; remove the corresponding page objects from the page tree; renumber remaining pages; prune outline entries and internal links whose sole target was a removed page; assign /ID//ModDate per Section 6.7; full rewrite.
Output naming: {originalBaseName}-pages-removed.pdf.
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Single-file drop zone |
| Loading | Skeleton grid while thumbnails resolve |
| Configuring | Page-thumbnail grid with click-to-mark-for-deletion (dimmed with a trash badge); a running "{n} of {total} pages marked" counter |
| Confirming | Shown specifically when the marked selection covers more than 50% of the document's pages: "You're about to delete {n} of {total} pages. Continue?" with Continue/Cancel |
| Progress | "Removing pages" with the shared progress bar |
| Success | Resulting page count plus Download; if any internal links were pruned, a one-line note: "{n} internal links were removed because they pointed to deleted pages" |
| Error — all pages selected | Blocking banner: "A document must have at least one page," Delete action disabled before the confirmation dialog is ever reached |
Keyboard operation: Space or Delete marks/unmarks the focused thumbnail; Shift+Arrow extends a range mark.
Edge cases: a selection covering every page is blocked outright — "A document must have at least one page" — never reaching even the 50% confirmation dialog; deleting a page that is the sole target of an internal navigation link removes that link, with a one-time summary note in the success state ("2 internal links were removed because they pointed to deleted pages") rather than a silent change.
Equivalent API operation: delete_pages.
Acceptance criteria:
- Given a 10-page document with selection
2,4,6, the output has 7 pages, and former page 3 becomes the new page 2. - Given a selection of all 10 pages of a 10-page document, the Delete action is disabled and no output is produced.
- Given a selection covering 6 of 10 pages, a confirmation dialog appears before the operation runs; declining it leaves the document unmodified.
7.6 Extract pages #
Purpose: produce a single new document containing only the selected pages, in the order selected. Execution location: client. Minimum plan: Free.
Options: page selection, same syntax as Section 7.2/7.5; an "output as separate files" toggle (default off) that, when enabled, produces one single-page PDF per selected page instead of one multi-page PDF, packaged as a ZIP when more than one file results.
Algorithm: parse the selection preserving the order the user specified (not necessarily ascending, unlike Split's selected mode, since Extract is meant for reordering a subset as well as filtering it); build a new document importing exactly those pages in that order; assign /ID//ModDate; full rewrite.
Output naming: {originalBaseName}-extracted.pdf, or, when "separate files" is enabled, {originalBaseName}-page-{n}.pdf per file, ZIP named {originalBaseName}-extracted.zip.
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Single-file drop zone |
| Loading | Skeleton grid while thumbnails resolve |
| Configuring | Page-thumbnail grid identical in interaction pattern to Delete pages (Section 7.5), but selected (not unselected) thumbnails are the ones that appear in the output, previewed as an ordered filmstrip beside the grid so the resulting sequence is visible before committing |
| Progress | "Extracting page {n} of {total selected}" |
| Success | The extracted document's thumbnail strip (or, in separateFiles mode, a list of per-page files) plus Download |
| Error — empty selection | Extract action disabled with a helper line under the grid: "Select at least one page" |
Keyboard operation: same as Section 7.5; additionally, once pages are selected, Arrow Up/Down within the ordered filmstrip reorders the extraction order independent of the source document's page order.
Edge cases: an empty selection disables the Extract action; selecting every page produces a full copy of the source, which is allowed (unlike Delete pages' equivalent, since the output here is always at least one page).
Equivalent API operation: extract_pages.
Acceptance criteria:
- Given selection order
5,1,3on any document with at least 5 pages, the output is a 3-page document whose pages are, in order, the source's page 5, page 1, and page 3. - Given "separate files" enabled and a 3-page selection, the result is a ZIP containing three single-page PDFs.
- Given an empty selection, the Extract action remains disabled.
- Given a selection equal to every page in the source document, the output is a complete copy with pages in their original order.
7.7 Insert blank pages #
Purpose: add one or more blank pages at a specified position. Execution location: client. Minimum plan: Free.
Options:
| Option | Type | Values | Default |
|---|---|---|---|
| Position | Enum | beforePage, afterPage, atStart, atEnd |
atEnd |
Page number (for beforePage/afterPage) |
Integer | 1 to document page count | — |
| Count | Integer | 1-500 | 1 |
| Page size | Enum | matchAdjacent, letter, a4, legal, custom |
matchAdjacent |
| Custom width/height | Number, points | 1-14,400 each | — |
| Orientation | Enum | portrait, landscape |
portrait |
Algorithm: resolve the insertion index from position; construct count blank page objects with a /MediaBox derived from pageSize (for matchAdjacent, the size of the page immediately before the insertion point, or the first page's size if inserting at the very start) and orientation; splice them into the page tree at the resolved index; renumber; assign /ID//ModDate; full rewrite.
Output naming: {originalBaseName}-with-blank-pages.pdf.
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Single-file drop zone |
| Loading | Skeleton grid while thumbnails resolve |
| Configuring | Position picker overlaid on a page-thumbnail strip so the insertion point is visually unambiguous; a live count of the resulting total page count updates as count changes |
| Progress | "Inserting {count} blank pages" |
| Success | The updated thumbnail strip with the newly inserted pages visually marked, plus Download |
| Error — custom size too small | Inline under the width/height fields: "Page size must be at least 1 point" |
| Error — page number out of range | Inline under the page-number field: "Enter a page number between 1 and {total}" |
Keyboard operation: Arrow Left/Right moves the insertion marker between page gaps in the thumbnail strip; number entry for count and pageNumber uses standard input field behavior.
Edge cases: matchAdjacent on an empty document (impossible — a document reaching this tool always has at least one page) falls back to Letter size only if the document literally has zero pages, which cannot occur; a custom size below 1 point in either dimension is rejected with "Page size must be at least 1 point."
Equivalent API operation: insert_blank_pages.
Acceptance criteria:
- Given a 5-page Letter document with
position: afterPage, pageNumber: 2, count: 1, the output has 6 pages with a blank Letter page at position 3. - Given
position: atStart, count: 3, the output's first three pages are blank and the original page 1 becomes page 4. - Given
pageSize: matchAdjacentinserted after an A4 page, the inserted blank page's/MediaBoxmatches A4 dimensions, not Letter. - Given
count: 500(the maximum), the operation completes and produces exactly 500 additional pages; givencount: 501, the request is rejected before processing starts.
7.8 Crop #
Purpose: adjust the visible/printable region of one or more pages without altering their underlying content. Execution location: client. Minimum plan: Free.
MediaBox versus CropBox: Crop writes only /CropBox. /MediaBox is left untouched. This is deliberate: /CropBox defines the visible and printable region a viewer or printer honors, while /MediaBox remains the record of the page's full original extent. Because the underlying content stream and /MediaBox are never modified, cropping is fully reversible — a "Reset crop" action simply removes the /CropBox override, restoring the original visible area with no data loss.
Options:
| Option | Type | Values | Default |
|---|---|---|---|
| Unit | Enum | in, mm, pt |
in |
| Preset | Enum | none, narrow (0.5 in all sides), moderate (1 in all sides), wide (1.5 in all sides), custom |
none |
| Per-side margins (custom) | Number ≥ 0, in the selected unit | — | 0 |
| Apply to | Enum | currentPage, allPages, range (same syntax as Section 7.2) |
allPages |
| Lock aspect ratio | Boolean | — | Off |
Algorithm: for each targeted page, compute the new crop rectangle in the page's user-space coordinates by insetting the current /CropBox (or /MediaBox if no /CropBox exists yet) by the resolved margins, accounting for the page's /Rotate value so the margins the user sees on screen (which reflects the rotated view) map to the correct unrotated coordinate axes; clamp the result so the new /CropBox is always fully contained within /MediaBox; reject a resulting rectangle with zero or negative area.
Output naming: {originalBaseName}-cropped.pdf.
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Single-file drop zone |
| Loading | Skeleton page preview while the first page renders |
| Configuring | A visual crop overlay on a live page preview with draggable handles per side, synced bidirectionally with the numeric margin inputs; switching applyTo between currentPage and allPages updates a thumbnail strip highlighting which pages will be affected |
| Progress | "Cropping page {n} of {total}" |
| Success | The cropped preview plus Download, and a "Reset crop" action available for as long as the result stays open in the tab |
| Error — zero-area crop | Blocking message under the preview: "This crop would leave no visible page content," Apply disabled until margins are reduced |
Keyboard operation:
| Key | Action |
|---|---|
| Tab / Shift+Tab | Cycle focus between the four crop handles (top, right, bottom, left) |
| Arrow keys | Nudge the focused handle by 1 unit in the selected measurement unit |
| Shift+Arrow | Nudge the focused handle by 0.1 unit, for fine adjustment |
| Enter | Commit the crop and start processing |
| Escape | Discard the current handle adjustment and revert to the last committed margin values |
Edge cases: a page with a pre-existing non-standard /CropBox narrower than /MediaBox (the new crop insets from the existing /CropBox, not from /MediaBox, so repeated cropping is cumulative and consistent with what the user sees on screen); a rotated page (margin directions are always relative to the page as displayed, transformed internally to the correct unrotated rectangle); a crop that would leave zero visible area is blocked with "This crop would leave no visible page content."
Equivalent API operation: crop.
Acceptance criteria:
- Given a Letter page (612×792 pt) with a 1-inch (
moderate) crop applied on all sides, the output/CropBoxis[72, 72, 540, 720]and/MediaBoxremains[0, 0, 612, 792]. - Given a crop applied and then "Reset crop" clicked before download, the output has no
/CropBoxoverride, i.e., the effective visible area equals/MediaBox. - Given a page rotated 90° with a crop margin entered as "0.5 in from the top as displayed," the resulting
/CropBoxreflects that inset correctly in the page's underlying, unrotated coordinate space. - Given margins that would reduce the crop rectangle to zero or negative area, the Apply action is disabled and no output is produced.
7.9 Compress #
Purpose: reduce file size through image recompression, downsampling, font subsetting, and structural optimization, with a predictable quality/size tradeoff. Execution location: client. Minimum plan: Guest.
Quality presets — the exact parameters each sets:
| Preset | Image downsample threshold/target | JPEG quality | Grayscale | Font subsetting | Object stream compression | Duplicate image dedup | Metadata stripping |
|---|---|---|---|---|---|---|---|
| Best Quality | Downsample images above 300 DPI to 300 DPI | 90 | Off | On (always on, every preset) | On (always on, every preset) | On (always on, every preset) | None — metadata preserved |
| Recommended (default) | Downsample images above 200 DPI to 150 DPI | 75 | Off | On | On | On | Strip XMP thumbnail only; /Info title/author/subject/keywords preserved |
| Strong | Downsample images above 150 DPI to 120 DPI | 60 | Off (togglable) | On | On | On | Strip all metadata except structure tags required for tagged PDFs |
| Maximum | Downsample all images to 96 DPI | 40 | On (togglable) | On | On | On | Strip all metadata, including structure tags on untagged content |
Options beyond the preset:
| Option | Type | Values | Default |
|---|---|---|---|
| Preset | Enum | bestQuality, recommended, strong, maximum |
recommended |
| Grayscale override | Boolean | — | Per-preset default above |
| Preserve metadata override | Boolean | — | Per-preset default above |
Predicted versus actual size report: before processing, the tool samples the first 10 pages (or all pages if fewer than 10), applies the selected preset's parameters to that sample in-memory, measures the compression ratio achieved on the sample, and extrapolates a predicted output size range (sampleRatio × totalInputSize, shown as a range of ±15% to account for content variance across the full document). After processing, the actual output size is shown alongside the prediction with the realized delta ("Predicted 4.2-5.6 MB — actual 4.8 MB").
Before/after visual comparison: a side-by-side thumbnail viewer with a draggable divider slider over a representative page (the page with the largest embedded image, chosen automatically), so visible quality loss from downsampling and JPEG re-encoding is inspectable before download.
What happens when compression makes the file larger: this can happen on already-optimized files, especially at Best Quality where re-encoding near-lossless images can add overhead. The tool always compares the compressed output's byte size against the original before offering it: if the output is not smaller, the original file's bytes are returned unchanged (renamed per the output convention) and the success state shows "This file is already optimized — we kept the original to avoid making it larger" instead of a size-reduction figure. The larger, recompressed candidate is discarded and never offered for download.
Algorithm:
- Sample the first 10 pages to produce the predicted-size estimate.
- For every image XObject in the document, compute a content hash; images sharing a hash are recompressed once and referenced by every page that used them (duplicate image deduplication).
- For each unique image, downsample to the preset's target DPI if its effective DPI (pixel dimensions ÷ displayed size) exceeds the threshold, convert to grayscale if enabled, and re-encode as JPEG at the preset's quality.
- Subset every embedded font to only the glyphs actually used, applying the deterministic tag naming from Section 6.7.
- Rewrite the object table using compressed object streams.
- Strip metadata per the preset's policy.
- Compare output size to input size; if not smaller, discard and return the original (see above).
- Assign
/ID//ModDateper Section 6.7; full rewrite.
Output naming: {originalBaseName}-compressed.pdf.
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Single-file drop zone |
| Loading | Spinner while the file parses and the 10-page sample analysis runs |
| Configuring | Four preset cards, each with a one-line description and an estimated size-reduction badge from the sample analysis, refreshed per preset on selection; grayscale/metadata override toggles below the selected card |
| Progress | The phase-weighted bar from Section 6.5: analyze 10%, recompress images 60%, subset fonts 15%, write output 15% |
| Success | The before/after visual comparison slider, the predicted-versus-actual size figures, and Download |
| Success — no reduction achieved | Same layout, but the size figures are replaced with: "This file is already optimized — we kept the original to avoid making it larger," and no comparison slider is shown since input and output are byte-identical |
| Error — tagged PDF at Maximum | A one-time warning shown in the Configuring state before processing starts, not blocking: "Maximum compression removes accessibility structure tags from this file" |
Keyboard operation: Arrow Left/Right cycles preset cards; Enter selects the focused preset and starts compression; the before/after slider responds to Arrow Left/Right when focused.
Edge cases: a vector-only document with no embedded images (image steps are no-ops; size reduction comes entirely from font subsetting and object streams, typically modest, and the UI does not overstate the expected reduction for this case); a tagged PDF at the Maximum preset (structure tags are stripped per that preset's explicit policy, and the configuring state shows a one-time warning: "Maximum compression removes accessibility structure tags from this file"); an already-grayscale scanned document with Maximum's grayscale toggle on (no visible change, harmless).
Equivalent API operation: compress.
Acceptance criteria:
- Given a document whose only content is three copies of the same 4 MB photo embedded at three different scales,
Recommendedpreset compression produces exactly one recompressed copy of the underlying image data, referenced three times, not three independently recompressed copies. - Given a file that is already near-minimal in size, running
Best Qualitycompression and finding the result larger than the input returns the original file's exact byte-for-byte content (same SHA-256 hash) under the output naming convention. - Given the
Maximumpreset applied to a tagged PDF, the output's/StructTreeRootis absent and the configuring state displayed the accessibility-structure warning before the user confirmed. - Given the
Recommendedpreset, an embedded image at an effective 250 DPI is downsampled to 150 DPI in the output.
7.10 PDF to JPG/PNG #
Purpose: rasterize PDF pages to image files. Execution location: client. Minimum plan: Guest.
Options:
| Option | Type | Values | Default |
|---|---|---|---|
| Scope | Enum | all, range (Section 7.2 syntax), currentPage |
all |
| Format | Enum | jpg, png |
jpg |
| DPI | Enum or custom integer | 72, 150, 300, 600, custom 36-600 |
150 |
| Transparency (PNG only) | Boolean | — | Off — PDF pages are rendered against a white background by default, matching how they print |
| Color space | Enum | rgb, cmyk (JPG only, for print workflows), grayscale |
rgb |
| ZIP packaging | Boolean | — | Auto-on when more than one image results |
| Filename template | String, tokens {name}, {page}, {ext} |
— | {name}-page-{page}.{ext} |
Algorithm: for each page in scope, rasterize via PDFium at the requested DPI into an RGB (or grayscale) bitmap; if png with transparency enabled, render only true page transparency groups as transparent rather than compositing to white; encode via libjpeg-turbo for jpg or the PNG encoder for png; for cmyk, convert the rendered RGB bitmap to CMYK before JPEG encoding, since PDFium's rasterizer itself always produces an RGB/gray buffer; package as a ZIP when more than one image results.
Output naming: per the filename template, default {originalBaseName}-page-{n}.{ext}; when packaged, the ZIP is {originalBaseName}-images.zip.
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Single-file drop zone |
| Loading | Skeleton preview while the first page parses |
| Configuring | A live single-page preview at the selected DPI and format so the user can judge quality before committing to the full export; scope/format/DPI/color-space controls beside it |
| Progress | "Rendering page {n} of {total}" |
| Success | A thumbnail grid of the resulting images, per-image download links, and "Download all as ZIP" when packaging applies |
| Error — memory exceeded | The opt-in server-fallback modal from Section 6.3, triggered when the estimated raster buffer size for the requested DPI would exceed the device-memory heuristic |
Keyboard operation: Arrow Left/Right in scope currentPage/preview mode moves the previewed page; standard form controls for the rest.
Edge cases: a very large page size at 600 DPI custom (e.g., an architectural drawing at 36×48 inches) can require several hundred megabytes of raw bitmap memory; if the estimated raster buffer size exceeds the device-memory heuristic pdfcore uses internally, the operation fails with out_of_memory and offers the opt-in server fallback (Section 6.3) rather than attempting and crashing the tab; CMYK conversion of a page containing transparency (flattened to the requested background before color conversion, since CMYK JPEG has no alpha channel).
Equivalent API operation: pdf_to_image.
Acceptance criteria:
- Given a 3-page document exported as PNG at 150 DPI with scope
all, the result is a ZIP containing exactly 3 PNG files, each rendered at 150 DPI. - Given
currentPagescope on page 2 of a document, exactly one image file is produced and it depicts page 2's content. - Given
pngformat with transparency enabled on a page containing a genuine transparency group, the exported PNG's alpha channel is non-opaque in that region; given transparency disabled, the same region renders as opaque white. - Given
jpgformat withcolorSpace: cmyk, the resulting JPEG file's color components decode as CMYK, not RGB.
7.11 JPG/PNG to PDF #
Purpose: compose one or more images into a single PDF, one image per page. Execution location: client. Minimum plan: Guest.
Options:
| Option | Type | Values | Default |
|---|---|---|---|
| Page size | Enum | matchImage, letter, a4, legal, custom |
matchImage |
| Custom width/height | Number, points | 1-14,400 each | — |
| Orientation | Enum | auto (per image aspect ratio), portrait, landscape |
auto |
| Margin | Enum or custom | none, 0.5in, 1in, custom |
none |
| Fit mode | Enum | fitWithinMargins, fillCropping, stretch |
fitWithinMargins |
| Image order | Ordered list | Drag-and-drop or keyboard reorder, same interaction pattern as Merge (Section 7.1) | Upload order |
| DPI assumption when no metadata present | Fixed | 96 | 96 |
EXIF rotation handling: before layout, each image's EXIF orientation tag (values 1-8) is read and the pixel data is rotated/flipped to its displayed orientation; the EXIF orientation tag is then stripped from the output so downstream viewers never double-apply the rotation.
DPI to physical size mapping: when pageSize: matchImage, the page's physical dimensions are computed as pixelDimensions / imageDPI × 72 points; the image's own embedded DPI metadata is used when present, and the fixed 96 DPI assumption applies when it is absent, which is the common case for screenshots and web-sourced images.
Algorithm:
- Decode each image; for CMYK-encoded JPEGs, convert to RGB for consistent on-screen and cross-viewer rendering.
- Apply EXIF rotation and strip the tag.
- Compute each page's
/MediaBoxperpageSizeandorientation. - Place the image within the page per
fitModeandmargin:fitWithinMarginsscales the image down (never up) to fit inside the margin-inset rectangle, centered;fillCroppingscales to fully cover the rectangle and crops overflow, centered;stretchscales independently on each axis to exactly fill the rectangle, ignoring aspect ratio. - Embed each image as a page-content XObject, one page per image, in the specified order.
- Assign
/ID//CreationDate//ModDateper Section 6.7 (this operation always produces a new document, so/CreationDateis set).
Output naming: for a single image input, {originalBaseName}-converted.pdf; for multiple images, images-merged.pdf (there is no single source name to derive from).
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Drop zone accepting multi-image drag-and-drop or multi-select |
| Loading | Per-image thumbnail decode with an inline spinner per image, same pattern as Merge |
| Configuring | The reorderable thumbnail list, page-size/orientation/margin/fit-mode options in a side panel, and a live per-image preview of how it will be placed on the page under the current settings |
| Progress | "Placing image {n} of {total}" |
| Success | The resulting PDF's thumbnail strip plus Download |
| Error — corrupted image | Inline on the offending row: "This image couldn't be read," row removable, remaining valid images still convert |
| Error — oversized image | Inline, non-blocking: "This image was reduced from {n} to 50 megapixels before conversion" |
Keyboard operation: identical reorder pattern to Section 7.1 (Arrow Up/Down to move, Delete to remove, Space to select).
Edge cases: an image above 50 megapixels is downsampled to 50 megapixels before layout with a one-time notice, since embedding it at full resolution would produce an unreasonably large PDF for no visible quality gain at any practical print or screen size; a corrupted image file within a multi-image batch is skipped with an inline per-file error, and the remaining valid images still convert; an image with no EXIF data (orientation assumed already correct, no rotation applied).
Equivalent API operation: image_to_pdf.
Acceptance criteria:
- Given three images converted with default options, the output is a 3-page PDF with one image per page in upload order.
- Given an image with EXIF orientation value 6 (rotated 90° CW), the output page displays the image upright, and the embedded image data itself reflects the corrected orientation with no EXIF orientation tag present.
- Given
pageSize: matchImageon a 1200×1800 pixel image with no DPI metadata, the resulting page is1200/96×72 = 900by1800/96×72 = 1350points. - Given one corrupted file among three valid images, the output is a 2-page PDF from the valid images, and the corrupted file is reported with an inline error rather than aborting the whole conversion.
7.12 Repair #
Purpose: recover a structurally damaged PDF into a valid, openable document. Execution location: client. Minimum plan: Free.
What is recoverable: a corrupt or missing cross-reference table (rebuilt by a linear scan for N G obj markers); a missing or invalid trailer (reconstructed from the object scan by locating the document catalog); a truncated file where the truncation occurs after the last complete object (the file is read up to the last valid object boundary and rebuilt from there); invalid or inconsistent object generation numbers (normalized during rebuild); broken linearization (the file is delinearized and rebuilt as an ordinary, non-linearized PDF); duplicate object IDs (the object appearing latest in the byte stream wins, consistent with how the PDF specification resolves incremental updates, with a warning noted in the repair summary).
What is not recoverable: encrypted content with no password or key supplied (Repair cannot bypass encryption any more than any other tool can); a page whose content stream is entirely and irrecoverably absent, as opposed to merely malformed (a malformed content stream is repaired by discarding only the unparseable operators, not the whole stream); a file where fewer than 10% of its bytes form recognizable PDF object syntax; a file that is not a PDF at all (Section 6.8 handles this before Repair is ever invoked).
Algorithm:
- Attempt a normal structured parse.
- On failure, fall back to a linear byte-level scan for
N G obj ... endobjpatterns, collecting every recoverable object regardless of the damaged xref table. - Locate the document catalog among recovered objects (identified by its
/Type /Catalogentry); if none is found, locate the object with the most page-tree-like structure as a best-effort catalog candidate. - Rebuild the page tree from recovered page objects, in the order their object IDs were encountered during the scan when no reliable
/Kidsordering survives. - Discard unparseable content-stream operators page by page rather than discarding whole pages, so a page with one damaged drawing command still retains its other content.
- Purge objects unreachable from the rebuilt catalog.
- Assign
/ID//ModDateper Section 6.7; full rewrite (a repaired file is never saved incrementally, since the entire point is to produce a clean object graph). - Produce a repair summary: which damage classes were found and what was done about each.
Output naming: {originalBaseName}-repaired.pdf.
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Single-file drop zone; unlike every other tool, this drop zone's validation only rejects files that are not PDFs at all — the whole point of Repair is to accept files every other tool's validation step would reject |
| Loading | "Analyzing file structure" spinner while the structured-parse attempt and, if needed, the fallback scan run |
| Progress | "Rebuilding document structure" — this tool has no separate configuring step, since there are no user-facing options |
| Success | The repair summary (e.g., "Rebuilt cross-reference table. Recovered 42 of 42 pages. 1 duplicate object ID resolved.") alongside Download; if recovery was partial, the summary names exactly which page numbers were dropped |
| Success — no damage found | Summary reads "No structural damage detected," and the download is a byte-for-byte copy of the input |
| Error — too damaged | "This file is too damaged to repair automatically," with no server-fallback offer, since the server path uses the same recovery strategy and would fail identically |
Keyboard operation: standard — this tool has no interactive canvas, only an upload step and a "Repair" action.
Edge cases: a file that opens fine already (no damage detected) still completes successfully with a summary of "No structural damage detected" and returns an unchanged copy under the output naming convention; a file damaged badly enough to fall below the 10%-recoverable threshold fails with a distinct message: "This file is too damaged to repair automatically," with no server-fallback offer, since the server pipeline's QPDF-based repair uses the same recovery strategy and is expected to fail identically — offering it would be a false promise, so it is not offered for this specific failure.
Equivalent API operation: repair.
Acceptance criteria:
- Given a PDF with a deliberately corrupted xref table but otherwise intact objects, the output opens normally and contains every original page.
- Given a PDF with two objects sharing the same object ID, the repair summary reports one duplicate-object-ID resolution, and the output uses the later-occurring object's content.
- Given an already-valid PDF submitted to Repair, the output is a byte-for-byte copy of the input and the summary states no damage was detected.
- Given a file with less than 10% recognizable PDF structure, Repair fails with the "too damaged to repair automatically" message and does not offer server fallback.
7.13 Edit metadata #
Purpose: view and edit a PDF's document information dictionary and XMP metadata. Execution location: client. Minimum plan: Free.
Fields:
| Field | Type | Limit | Notes |
|---|---|---|---|
| Title | String | 255 characters | Mirrored to XMP dc:title |
| Author | String | 255 characters | Mirrored to XMP dc:creator |
| Subject | String | 255 characters | Mirrored to XMP dc:description |
| Keywords | Array of strings, comma-separated in the UI | 50 keywords, 100 characters each | Mirrored to XMP pdf:Keywords as a single comma-joined string; duplicates are removed on save |
| Creation date | Date-time | — | Editable; defaults to the existing /CreationDate if present |
| Modification date | Date-time | — | Not user-editable; always set to the save time per Section 6.7 |
| Custom properties | Array of {key: string, value: string} |
20 properties, 64-character keys, 512-character values | Stored in /Info as custom dictionary keys and mirrored into a pdfx: custom XMP namespace |
XMP handling: the /Info dictionary and the XMP packet are kept in sync in both directions — editing a standard field updates both; unrelated XMP fields the tool does not expose (color profile data, PDF/X or PDF/A conformance claims, custom third-party namespaces) are read and preserved verbatim, never dropped, on every save.
Algorithm:
- Read existing
/Infoand XMP values to populate the form. - On save, validate each field against its limit.
- An empty field value clears that key entirely from
/Info(the key is removed, not written as an empty string) and removes the corresponding XMP element. - Write updated
/Infoentries as UTF-16BE with a byte-order mark, per the PDF specification's requirement for non-ASCII string values. - Regenerate the XMP packet, preserving every field the tool does not expose.
- Assign
/ModDateper Section 6.7 (always the save time, never user-editable, per the table above). - Full rewrite.
Output naming: {originalBaseName}-metadata.pdf.
UI flow, screen by screen:
| State | What renders |
|---|---|
| Empty | Single-file drop zone |
| Loading | Spinner while /Info and XMP are read to populate the form |
| Configuring | A form pre-populated with existing values; a "Custom properties" section supporting adding/removing key-value rows; per-field character counters |
| Progress | "Updating metadata" (typically sub-second, since no content stream is touched) |
| Success | Confirms which fields changed (e.g., "Title, Author, and 2 custom properties updated") plus Download |
| Error — field over limit | Inline under the offending field: "Title must be 255 characters or fewer" |
| Error — too many custom properties | Inline under the custom-properties list: "You can add up to 20 custom properties," Save disabled until the count is reduced |
Keyboard operation: standard form navigation (Tab/Shift+Tab between fields); Enter within the custom-properties key or value field adds a new empty row.
Edge cases: non-ASCII text in any field (correctly encoded as UTF-16BE, verified round-trip in the golden-file corpus referenced by Section 6.7's testing bar); a keywords list with duplicate entries after trimming whitespace and case-insensitive comparison (deduplicated silently on save); a document with pre-existing custom /Info keys beyond the 20-property limit this tool enforces for new edits (existing keys beyond the limit are preserved untouched, since the limit applies only to additions made through this tool, not to what already exists in the file).
Equivalent API operation: edit_metadata.
Acceptance criteria:
- Given Title set to "Q3 Financial Summary" and saved, the output's
/Info /Titleand XMPdc:titleboth read "Q3 Financial Summary". - Given the Author field cleared (left empty) and saved, the output's
/Infodictionary has no/Authorkey at all, rather than an empty-string value. - Given a Title containing non-ASCII characters (e.g., "Résumé — Développeur"), the output round-trips through UTF-16BE encoding and re-reads identically.
- Given 22 custom properties added through the form, the save is rejected with a validation error before any write occurs, since the limit is 20.
8. Tool Specifications B: Editing, Annotation, Forms #
All five tools in this section run entirely client-side: the file is opened, edited, and exported in the browser via the WebAssembly engine described in Section 3.2, and no file bytes cross the network at any point. Every tool page in this section renders the "On your device" state of the Processing Location Indicator defined in Section 6.2 for the entire session, including during export. None of these five tools is available to guests; a guest who opens one is shown the standard "Create a free account to use this tool" prompt with a one-line explanation of what the account unlocks, per the guest-tool policy in Section 12.2. Once signed in, all five are unlimited on every paid or free tier under the client-side entitlement row of the plan table in Section 12.2 — only server-side task counts are metered. None of the five tools in this section is exposed to interactive batch queuing; batch processing (Section 13) is for unattended, repeatable operations, and these five are inherently attended, single-document editing sessions. Every tool still exposes an equivalent operation when invoked through the public REST API (Section 14), because an API caller has no browser and every API-invoked operation runs server-side against the identical engine build, per the byte-identical output guarantee in Section 3.2.
Shared conventions used throughout this section:
- Operation identifiers. Each tool corresponds to one
typevalue in the job model of Section 4.2 when invoked as a job (locally, jobs run in-process rather than through the queue, but they report state through the same five-state vocabulary —queued → running → succeeded, orfailed,canceled,expired— so the UI has one progress vocabulary everywhere). Thetypevalues used below (edit_text_image,annotate,fill_form,create_fillable_form,self_sign) aresnake_case, matching the DB-level enum convention in Section 4. - Errors. Every error surfaced by these tools — whether shown inline in the editing canvas or
returned by the API equivalent — uses the error envelope defined in Section 14.6. Client-side tools
render the envelope's
messagefield as the on-screen copy and log the full envelope (withrequestIdset to a locally generatedreq_identifier, per the identifier scheme in Section 5) to the local diagnostics buffer described in Section 18. - Local persistence. Working state (open document, edit history, draft annotations, in-progress
form values) is staged in OPFS for file bytes and Dexie for metadata and edit-history records, per
Section 3.7. Nothing in this section writes to
localStorageor holds file bytes in a JavaScript string or base64 form at any point — doing so is a P0 defect per the same section. - Undo model. Where an undo/redo stack is specified below, "command" means a single user-visible action (not a single low-level engine call); a drag-resize of an image is one command, not one command per animation frame.
8.1 In-PDF text and image editing #
This is the most technically demanding tool in the product: it edits the actual content stream of an existing, arbitrary-origin PDF, not a document PDFWorks authored. It is specified in full here because a partial specification would leave the executor to invent behavior in exactly the areas most likely to corrupt a user's file.
8.1.1 Purpose #
Let a user click on existing text or an existing image anywhere in a PDF and change it in place — correcting a typo, updating a date, swapping a logo — while preserving everything else about the page exactly as it was, and export a new PDF that looks identical except for the edited content.
8.1.2 Minimum plan #
Free (signed-in account required). Unlimited use on Free and above, per the client-side entitlement row of Section 12.2. Not available to guests.
8.1.3 Inputs and validation #
| Input | Accepted | Validation | Rejection copy |
|---|---|---|---|
| Source file | One PDF, drag-drop, file picker, or "Open recent" (Dexie-backed recent-file list) | MIME sniffed by magic bytes (%PDF-), never trusted from Content-Type or extension, per Section 17 |
"This file doesn't look like a PDF. Choose a different file." |
| File size | Up to the signed-in user's plan ceiling (Section 12.2) | Checked before the file is read into OPFS | "This file is {size}. Your plan allows files up to {limit}. [Upgrade] or choose a smaller file." |
| Structural integrity | Must parse as a valid PDF object graph (cross-reference table or stream resolves, page tree resolves) | Run through the same structural validator used on upload, applied locally to the OPFS-staged bytes, per Section 17 | "This file appears to be damaged. Try [Repair] first (Section 7.9), then reopen it here." |
| Encryption | Password-protected files are supported if the user supplies the correct password | User is prompted for a password before the file opens; three failed attempts trigger a 30-second client-side cooldown to slow brute-force scripting | "This file is password-protected. Enter the password to continue." / "That password didn't work." |
| Page count | Up to 5,000 pages, per the memory-streaming ceiling in Section 3.7 | Pages beyond the first are streamed page-range at a time from OPFS; the canvas never holds the full rendered document in memory at once | N/A — handled transparently |
Pre-existing digital signatures. Detection of a third-party PKI signature already present on the opened PDF follows the canonical detection entry in the processing engine's failure taxonomy (Section 6.8) rather than a separate check defined here. If detected, the canvas stays locked and a one-time warning is shown before editing can begin: "This document has a digital signature that wasn't created in PDFWorks. Editing it — even a single character — will invalidate that signature. [Continue editing] [Cancel]." Choosing Continue editing dismisses the warning for the remainder of the session (it does not reappear on every keystroke) and unlocks the canvas; choosing Cancel returns the user to the empty state (8.1.15) without opening the file for editing.
8.1.4 Text discovery: from glyphs to editable runs #
The engine (PDFium's text-page API over the parsed content stream) exposes individual positioned glyphs, not editable text. The tool reconstructs an editable structure from those glyphs in four passes, run once when a page is first opened for editing and cached in the in-memory page model until the page is closed or an edit invalidates it:
- Glyph extraction. For every glyph on the page, capture: Unicode code point (via the font's
ToUnicodeCMap, falling back to the font's built-in encoding if no CMap is present), font resource name, resolved font family and weight/style, font size, fill color, text rise, glyph origin (baseline x, y in unrotated page space), and glyph advance width. - Run merging (glyph → run). Adjacent glyphs merge into one editable run when all of the
following hold: same font resource, same size (±0.01 pt, to absorb floating-point noise), same
fill color, same text rise, and a horizontal gap between the trailing edge of one glyph and the
leading edge of the next of no more than 0.35 × the font's em size. A gap larger than that but
no more than 1.0 × em starts a new run on the same line (a "word gap"); a gap larger than
1.0 × em or a vertical baseline shift closes the line entirely (see step 3). This tolerance
catches both normal inter-character spacing and PDF producers that emit one
Tjoperator per character (common in some legacy generators) without merging genuinely distinct fields. - Line grouping (run → line). Runs merge into a line when their baselines fall within ±0.15 × the larger of the two runs' font sizes of each other, after normalizing for page rotation. Lines are then ordered left-to-right within themselves (right-to-left for runs whose dominant script is RTL, per 8.1.9) and the resulting line list is sorted top-to-bottom.
- Paragraph and reading-order reconstruction (line → block). Consecutive lines merge into one editable block when the vertical gap between them is no more than 1.4 × the line height of the shorter line and their left edges (or right edges, for RTL blocks) differ by no more than 2 × the average character width — i.e., they look like a justified or ragged paragraph rather than two unrelated pieces of page furniture. Column detection runs before paragraph grouping: the page is partitioned into vertical bands by finding whitespace gutters at least 3 × the average character width wide that run for at least 60% of the page's content height; reading order proceeds column-by-column, top-to-bottom within a column, left-to-right across columns (mirrored for RTL page direction). This is a heuristic, not a semantic layout parser: it deliberately over-segments (splitting one true paragraph into two blocks) rather than under-segments (merging two unrelated text elements), because over-segmentation only costs the user an extra click while under-segmentation risks corrupting content the user did not intend to touch.
The resulting structure:
interface EditableGlyph {
unicode: string; // one Unicode scalar value (or empty for a to-be-fixed glyph, 8.1.6)
advance: number; // glyph advance width in text space units
}
interface EditableRun {
runId: string; // stable within the editing session, not persisted
glyphs: EditableGlyph[];
fontResourceName: string; // as declared in the page's /Resources /Font dictionary
fontFamily: string;
fontWeight: 400 | 700;
fontStyle: "normal" | "italic";
sizePt: number;
colorRgb: [number, number, number];
rise: number;
origin: { x: number; y: number };
}
interface EditableLine {
runs: EditableRun[];
baselineY: number;
direction: "ltr" | "rtl";
}
interface EditableBlock {
blockId: string;
lines: EditableLine[];
boundingBox: { x: number; y: number; width: number; height: number };
writingMode: "horizontal-tb" | "vertical-rl";
}8.1.5 Font substitution fallback chain #
When the user types a character, the tool must render and, on export, embed that character's glyph. Embedded font programs in the wild are almost always subsets — they contain only the glyphs the original document used. Typing a new character (a currency symbol, an accented letter, an em dash) frequently requires a glyph the subset does not have. The chain, evaluated in order, on every keystroke that introduces a code point not yet available in the run's current font:
- Try the subset as embedded. If the subset's
ToUnicode/encoding already maps a glyph to the requested code point (rare, but happens with subsets built generously), use it. No indicator. - Try a fully embedded copy of the same family. If the source PDF, taken as a whole, embeds a different, non-subset font resource of the same family/weight/style elsewhere (common in multi-page documents with a mix of subset and full embeds), borrow glyphs from that resource for this run. No indicator — this is still the original typeface, just sourced from elsewhere in the same file.
- Substitute a bundled metric-compatible face. If no source glyph exists anywhere in the document, fall back to one of four bundled, metric-compatible replacement families shipped with the product: a sans face metric-compatible with Helvetica/Arial, a serif face metric-compatible with Times New Roman, a monospace face metric-compatible with Courier New, and a CJK fallback face covering the CJK Unified Ideographs and Hangul Syllables blocks. "Metric-compatible" means glyph advance widths match the reference family closely enough that existing line breaks and layout do not shift for the unedited portions of a run. The mapping from a source font's declared family name to a bundled replacement uses a static lookup table of ~40 common family-name aliases (e.g. "Arial", "Arial-BoldMT", "ArialMT" → the bundled sans face); an unrecognized family name falls back to the bundled sans face.
- Warn before committing. The moment step 3 is reached, the character is rendered on-canvas
immediately (so typing never feels frozen) with a dotted underline under the substituted
character(s) and a small superscript triangle glyph at the end of the affected run. Hovering or
focusing the triangle (keyboard:
Tabreaches it as a focusable element within the run) shows a tooltip: "{character}isn't in this document's font, so PDFWorks substituted a similar typeface for it. The rest of this text is unaffected." The substitution is not silently reverted if the user moves on — it persists until the user either removes the character or accepts it by exporting or navigating away, at which point the dotted underline is dropped from the exported PDF (it is an editing-time indicator only, never baked into the output).
The visible indicator is mandatory whenever step 3 fires; steps 1–2 never show an indicator because the visual result is indistinguishable from the original font.
8.1.6 Reflow behavior #
Editing text that changes a run's rendered width (typing, deleting, retyping in a different length) triggers reflow within the edited block only:
- Recompute line breaks within the block using the block's original bounding-box width as the wrap constraint, using the block's original text alignment (left, right, center, justified — detected from the original run positions during block reconstruction) and original line height.
- If the reflowed content fits within the block's original height, only the edited line and any lines below it within the block shift; nothing outside the block moves.
- If the reflowed content exceeds the block's original height, the block's bounding box grows downward (never upward, never sideways) to accommodate it. Growing downward may visually overlap content below the block on the same page; the tool does not prevent this and does not attempt to push subsequent content down. Instead, a page-level warning badge appears: "This edit made the text box taller than the original. Check that it doesn't overlap anything below it."
- The user may instead choose Shrink to fit from the block's context toolbar, which reduces the
block's effective font size in 0.5 pt steps (down to a floor of 6 pt) until the content fits the
original height, then stops and shows "Text shrunk to
{size}pt to fit." if the floor is hit before it fits, the block reverts to growing downward and shows the overlap warning instead.
Reflow explicitly does not cross block or page boundaries. Text that overflows one block never flows into an adjacent block or onto the next page, even if the adjacent block is empty. Rationale, stated here because it is the single most-asked "why doesn't it just..." question: a PDF page is a fixed layout, not a flow container — there is no general rule for what an unrelated block "should" do when a different block grows, and guessing wrong silently corrupts a document the user trusted the tool not to touch. Giving the user an explicit, visible choice (accept the overlap, or shrink to fit) is slower than automatic reflow but never surprises the user with content moved somewhere they didn't look.
8.1.7 Editing inside a form field versus page content #
Clicking inside the visible rectangle of an AcroForm widget annotation (a text field, checkbox, etc.)
never enters page-content text editing. Instead the click is intercepted and the tool shows an inline
callout: "This is a form field. Switch to Fill Forms to edit its value." with a Switch to Fill
Forms button that reopens the same document in the tool of Section 8.3 at the same page and field.
The two tools are kept strictly separate because they edit different things: page-content editing
rewrites glyph-drawing operators in the content stream (this section); filling a form field writes an
appearance stream and a /V value against a field object in the AcroForm dictionary (Section 8.3) and
never touches the surrounding page content stream. A field's static label text (e.g., "Name:" printed
next to a text field) is page content and is editable normally by this tool, since it is not part of
the field widget's own appearance.
8.1.8 Right-to-left and vertical text #
- RTL (Arabic, Hebrew). Each run's dominant direction is classified using the Unicode Bidirectional Algorithm (UAX #9) over its code points. A line containing both LTR and RTL runs (e.g. an Arabic sentence with an embedded English product name) is laid out using the full bidi algorithm rather than a simple whole-line flip, so numerals and embedded Latin text render left-to-right within the RTL line, matching standard bidi behavior. The text-entry caret follows logical (not visual) cursor movement: pressing the right arrow key moves the caret to the next character in reading order for the run's base direction, which is visually leftward inside an RTL run.
- Vertical text (CJK). A block whose original glyph origins advance top-to-bottom rather than
left-to-right is classified
writingMode: "vertical-rl"; editing preserves vertical layout, line advance runs right-to-left column by column, and the reflow rule in 8.1.6 applies with height and width swapped (a vertical block grows leftward, not downward, when content overflows — the vertical analogue of "downward").
8.1.9 Ligatures and kerning #
Some embedded fonts substitute a single glyph for a character sequence (e.g. "fi", "fl") at the font
level; the content stream draws one glyph but represents two logical characters. Before an edit can
happen inside such a run, the tool decomposes any ligature glyph in the run back to its base
characters using the font's ToUnicode CMap, so that character-level insertion and deletion behave
predictably (the user never sees or types a ligature directly — ligature substitution is a rendering
concern applied automatically at export if the target font supports it, not an editing concern).
Kerning (pair-wise advance-width adjustment between specific glyph pairs) is preserved from the
original font's kerning table for unedited glyph pairs and is recomputed from the substitute font's
own kerning table (bundled fonts ship standard kerning tables) for any pair touching an edited or
substituted glyph. If a run's font has no ToUnicode CMap at all (glyph-only fonts with no reverse
mapping, occasionally seen in older PDF producers), the run cannot be safely decomposed; the tool
refuses inline character edits on that specific run and offers only whole-run replacement ("Replace
this text" — type new content that entirely replaces the run rather than editing inside it), with the
copy: "This text can't be edited character-by-character because the original font doesn't record
which letters it draws. You can replace the whole line instead."
8.1.10 Images #
| Operation | Behavior |
|---|---|
| Select | Click hit-tests against the image XObject's placement rectangle on the current page, at any rotation. |
| Move | Drag; arrow keys nudge 1 px per press, 10 px with Shift held, at the current zoom level. |
| Resize | Drag a corner or edge handle. Aspect ratio is locked by default (matches the image's original placement ratio); holding Shift while dragging toggles lock off for a free resize; a dedicated toolbar toggle sets the default. Minimum size 8×8 px on the rendered page (below which the image is treated as removed with a confirmation). |
| Crop | Enter crop mode (toolbar button or C); drag crop handles inside the image bounds; Enter commits, Esc cancels. Cropping clips the image XObject with a new bounding box rather than re-encoding pixel data, so it is always lossless and reversible via undo. |
| Replace | File picker or drag-drop a new image onto the selected placeholder; the new image is placed at the same rectangle, scaled to fill using the same aspect-lock rule as resize. |
| Delete | Delete/Backspace on a selected image removes the XObject reference from the page content stream; the XObject itself is purged at export if no other page references it (a shared logo image used on 40 pages and deleted from one page stays on the other 39). |
| Re-encode | On export, an image whose pixel data was touched (crop excepted, replace, or an explicit "Compress this image" action) is re-encoded: JPEG source stays JPEG (re-encoded at quality 85); PNG source with no alpha channel is re-encoded as JPEG at quality 85 for size; PNG source with an alpha channel stays PNG. Images that were not touched are passed through byte-identical — editing one image never re-compresses every other image in the file. |
| Transparency | An image with an alpha channel or a soft-mask (/SMask) preserves it through move/resize/crop. Replace with a non-transparent image drops any existing mask on that placement; replace with a transparent image (PNG with alpha) creates a new /SMask. |
New images are added via the Add Image toolbar tool: click-drag on empty canvas to define a placement rectangle, or click once to place at a default 200×200 pt box centered on the click point, then pick a file (PNG, JPEG, or WebP source; anything else is rejected with "Choose a PNG, JPEG, or WebP image."). New text boxes are added via Add Text: click to place an insertion point, type immediately; the new box defaults to 11 pt, the bundled sans face, black fill, left-aligned, and behaves exactly like an editable block from 8.1.4 onward — it participates in the same reflow, undo, and export rules.
8.1.11 Options table #
| Option | Type | Range / values | Default |
|---|---|---|---|
| Show substitution indicators | boolean | on/off | on |
| Snap new objects to page margins | boolean | on/off | on |
| Grid snap for image placement | boolean, with size | off, 4 pt, 8 pt, 16 pt | 8 pt |
| Default new-text font | enum | bundled sans, bundled serif, bundled mono | bundled sans |
| Default new-text size | number | 6–96 pt | 11 pt |
| Autosave interval | number | 3–30 s | 5 s |
| Undo history depth | number (fixed, not user-configurable) | — | 100 commands |
| Image re-encode quality | number | 60–100 | 85 |
8.1.12 Undo/redo model #
Every discrete user action (type a character run and pause 500 ms, move an object, resize, delete,
replace, crop-commit, font-substitution acceptance) pushes one command onto an in-memory stack, capped
at 100 commands; pushing a 101st command evicts the oldest. Ctrl+Z/Cmd+Z undoes;
Ctrl+Shift+Z/Cmd+Shift+Z (and Ctrl+Y on Windows) redoes. Redo history is cleared by any new
command after an undo (standard linear-history behavior — no undo tree). The stack is per-document,
held in memory only; it is not persisted across a tab close, and closing the tab with unsaved changes
triggers the browser's native "leave site" confirmation.
8.1.13 Autosave and crash recovery #
Every 5 seconds (configurable, 8.1.11), if at least one command has been pushed since the last
autosave, the tool serializes the current in-memory page-model diff to a working copy in OPFS and
writes a pointer row (document name, last-modified, page count, thumbnail) to the local Dexie
metadata store, per Section 3.7. On next load of the editing tool (including after a browser crash or
an accidental tab close), the tool checks for an orphaned working-copy pointer whose parent tab is no
longer open and, if found, shows a recovery banner: "We found unsaved changes to {document name}
from {relative time} ago. [Resume editing] [Discard]." Resuming re-hydrates the page model from the
OPFS working copy and replays the command log so undo history survives the crash; discarding purges
the working copy and its Dexie pointer. Working copies older than 24 hours are purged by the same
janitor sweep described in Section 3.7 regardless of whether the user ever sees the recovery banner.
8.1.14 What the tool refuses to edit, and why #
| Condition | Detection | Refusal copy | Redirect |
|---|---|---|---|
| Scanned page with no text layer | The page has zero text-showing operators but at least one full-page image XObject | "This page looks like a scan — there's no text to edit, only an image of text. Run OCR first to add a text layer, then come back to edit it." | Link to the OCR tool (Section 9.4). Image editing (8.1.10) still works on a scanned page; only text editing is refused. |
| Secured document (edit permission denied) | The PDF's encryption dictionary permission bits deny content modification, and the user did not supply the owner password | "This document's owner has disabled editing. You'll need the owner password to make changes." | Prompt for owner password; if supplied and it unlocks full permissions, editing proceeds normally. |
| XFA form | The document's AcroForm dictionary contains an /XFA entry |
"This is a dynamic XFA form, which uses a layout format PDFWorks doesn't edit. Try Fill Forms (Section 8.3) if you only need to fill it in — some XFA forms also render a static AcroForm fallback." | No redirect if no static fallback exists; the tool opens in read-only preview. |
8.1.15 UI flow #
- Empty state. Tool landing page: drop zone, "Choose file," recent-files list (from Dexie, up to 10 entries, each with a thumbnail and last-opened time), and the Processing Location Indicator showing "On your device."
- Loading. After a file is chosen: a determinate progress bar tied to page-by-page parsing progress ("Opening page 12 of 340…" for large files; for files under ~20 pages the loading state is typically sub-second and shown as an indeterminate spinner instead, switching to determinate only past a 20-page threshold).
- Editing canvas. Page thumbnails rail on the left (keyboard-navigable, 8.1.16), main canvas center, contextual property panel on the right that changes based on selection (text run selected → font/size/color; image selected → crop/resize/replace controls; nothing selected → page-level controls). A persistent top bar shows Undo/Redo, zoom, page indicator, and Export.
- In-progress editing. No blocking progress state — edits apply live on canvas. The only in-canvas progress indicator is the autosave dot (a small filled circle that pulses briefly on each autosave write, per 8.1.13) and the substitution triangle from 8.1.5.
- Export. Clicking Export shows a determinate progress bar ("Rewriting page 3 of 340…") while the engine regenerates the content streams for every touched page and writes a fresh cross-reference table (never an incremental update, matching the rewrite-the-whole-file rule that governs export generally, stated canonically for redaction in Section 9.1 and applied here for any edit).
- Success. A download prompt with the filename
{original name} (edited).pdf, a "Save another copy" option, and a thumbnail confirming the result; the working OPFS copy and its Dexie pointer are cleared once the user confirms the download completed (a "Keep editing" affordance remains if the user wants to continue instead of exporting). - Error states. Structural-parse failure, size-limit rejection, and password failure use the inputs table in 8.1.3; an export-time engine failure (out-of-memory on a very large file, a malformed object the parser tolerated on read but the encoder rejects on write) shows: "PDFWorks couldn't finish saving this file. Your edits are still here — try exporting again, or export a smaller page range." with a page-range export fallback offered inline.
8.1.16 Keyboard operation and screen-reader behavior #
- Full tool is operable without a pointer.
Tab/Shift+Tabmoves focus through: page thumbnail rail → canvas objects in reading order (per 8.1.4's block reading order, then image placements by z-order) → property panel controls → top bar controls. - The canvas itself is not a bare
<canvas>for accessibility purposes: overlaying it is a hidden, screen-reader-only DOM structure — one focusable element per editable block and per image, each exposing its content as an accessibletextbox(blocks) orimg-role element with alt-text drawn from the image's existing/Altstructure-tree entry if present, else "Image, unlabeled" — a keyboard/screen-reader user edits through this structure, and canvas repaint is driven by the same edits, so the two surfaces never diverge. - Entering an editable block (
EnterorSpaceon a focused block) moves focus into an ARIAtextboxreflecting that block's text; standard text-editing keys apply;Escapeexits back to block selection. - Object manipulation (image move/resize, new text-box placement) has a keyboard-driven placement
mode: pressing
Mon a focused image enters move mode, arrow keys move it (1 px / 10 px withShift, per 8.1.10),Entercommits,Escapecancels and restores the prior position. Resize mode (R) is analogous, arrow keys grow/shrink from the anchored corner. - Live-region announcements (
aria-live="polite"): "Substituted font applied to 1 character," "Image resized to 320 by 240 points," "Autosaved," "3 pages exported." - Font-substitution indicators (8.1.5) are exposed to the accessibility tree as a description on the affected text range, not only as a visual dotted underline, so a screen-reader user reading through the block hears the substitution note in place.
8.1.17 Edge cases #
| Case | Behavior |
|---|---|
| Editing text that uses a Type 3 (procedurally drawn) font | Not decomposable to standard glyph outlines; the tool refuses inline edits on Type 3 runs with the same "replace whole line" fallback as 8.1.9's no-ToUnicode case. |
| Overlapping runs from a PDF producer that draws text twice (e.g. a faux-bold produced by double-striking) | Both strikes are grouped into the same run via 8.1.4's merge rule (identical origin, near-identical everything else) and edited as one; export writes a single corrected run, intentionally collapsing the double-strike. |
| A block spans a page rotation boundary conceptually (rotated page) | All glyph and bounding-box math in 8.1.4–8.1.6 operates in unrotated page space, then applies the page's /Rotate value only at render and hit-test time, so rotated pages behave identically to unrotated ones from the user's perspective. |
| User pastes multi-paragraph text into a single-line block | Pasted text containing newlines splits the block into multiple lines immediately (not deferred to reflow), each following the same wrap/grow rules as 8.1.6. |
| Very large export (5,000-page document, only 2 pages touched) | Only touched pages are re-encoded; untouched pages are copied through as existing object streams, keeping export time proportional to edits made, not document size. |
| Font family present but weight/style missing (bold requested, font has no bold variant) | The tool synthesizes bold via a fixed-ratio stroke-emulation (matching common viewer "faux bold" rendering) and shows the same substitution-style dotted-underline indicator with copy: "This font doesn't have a true bold style — PDFWorks approximated one." |
8.1.18 Public API operation #
type: "edit_text_image", submitted per Section 14 as a job whose params carry a list of block- and
image-level patch operations (add/replace run text, move/resize/replace/delete image) rather than a
live editing session, since the API has no canvas. The API-side engine applies the same
glyph-grouping, font-fallback, and reflow algorithms specified above.
8.1.19 Acceptance criteria #
- Editing a single character inside a justified paragraph reflows only that paragraph's own lines; the pixel content of every other block on the page, hashed before and after, is byte-identical.
- Typing a code point absent from the source font's embedded subset, when no other embedded copy of the family exists in the document, results in the bundled metric-compatible substitute being used, a visible dotted-underline indicator on the affected character, and the substitution note being present in the accessible description of that text range.
- Opening a page with zero text-showing operators and one full-page image shows the scanned-page refusal copy from 8.1.14 when the user attempts to select text, while image editing on the same page remains fully functional.
- Force-closing the tab mid-edit and reopening the tool shows the crash-recovery banner, and resuming restores both the edited content and the full undo stack up to the point of the crash.
- A rectangular red image with an alpha-channel soft mask, moved and resized with aspect lock engaged, preserves its exact aspect ratio and its soft mask through export, verified by re-decoding the exported image and comparing its mask to the original.
8.2 Annotate and shapes #
8.2.1 Purpose #
Mark up a PDF without altering the underlying page content: highlight, comment, draw, stamp, and measure, all as PDF annotation objects layered above the page.
8.2.2 Minimum plan #
Free (signed-in account required). Unlimited on Free and above (Section 12.2).
Pre-existing digital signatures. Detection follows the same canonical entry in the processing engine's failure taxonomy (Section 6.8) used throughout this section. Because exporting from this tool rewrites the whole file rather than saving an annotation-only incremental update, adding even one annotation to a digitally signed PDF invalidates that signature. On open, a detected signature shows a one-time warning before any annotation tool becomes usable: "This document has a digital signature that wasn't created in PDFWorks. Adding markup and exporting will invalidate that signature. [Continue editing] [Cancel]." Continue editing dismisses the warning for the session; Cancel returns to the empty state (8.2.10) without opening the file.
8.2.3 Annotation types #
Every markup this tool produces is written as a standard PDF annotation object with a real annotation
subtype and a generated appearance stream (/AP /N), so the result renders correctly in any
PDF-conformant viewer, not only in PDFWorks.
| Tool | PDF annotation subtype | Appearance stream generation |
|---|---|---|
| Highlight | Highlight |
Quad points computed from the selected text run's glyph bounding boxes (reusing the run/line/block model from 8.1.4, read-only here); appearance stream fills the quads at 35% opacity in the chosen color using a multiply-like blend approximated via constant alpha, since PDF has no native blend-mode annotation primitive. |
| Underline | Underline |
A single stroked line 1 pt below each quad's baseline, stroke width scaled to font size (0.06 × size, minimum 0.75 pt). |
| Strikethrough | StrikeOut |
A single stroked line through each quad's vertical midpoint, same stroke-width rule as Underline. |
| Squiggly | Squiggly |
A sawtooth path generated at a fixed amplitude of 1.2 pt and wavelength of 4 pt, tiled along the quad's baseline. |
| Free text | FreeText |
Appearance stream is the rendered text itself, using the property panel's chosen font/size/color; the annotation's /DA (default appearance) string is kept in sync with the appearance stream so viewers that regenerate appearances still render correctly. |
| Sticky note | Text (a "popup" icon annotation) |
A fixed 20×20 pt icon glyph (one of 4 bundled icon styles: comment, question, note, key, chosen in the property panel); the note body lives in the annotation's /Contents and a paired Popup annotation. |
| Callout with leader line | FreeText with /CL (callout line) array set |
Appearance stream draws the leader line with the chosen line ending style (8.2.4) plus the text box; the callout's knee point is user-draggable. |
| Freehand ink | Ink |
/InkList stores one or more stroke point arrays; appearance stream strokes each path with the chosen color/width. Where the input device reports pressure (stylus via the Pointer Events API pressure field), stroke width is modulated per-segment (0.5×–1.5× the base width) by re-sampling the path into short segments with individually set widths, approximated as a sequence of short Ink list segments since the PDF ink annotation format has no native per-point width. |
| Straight line | Line |
/L coordinate pair; line-ending styles per 8.2.4 written to /LE. |
| Arrow | Line with /LE open- or closed-arrow ending |
Same as Line, with a default closed-arrow ending on the second point. |
| Rectangle | Square |
/RD (rectangle differences) accounts for stroke width so the visible box matches the drawn box exactly. |
| Ellipse | Circle |
Bounding-box /Rect; PDF's Circle subtype covers ellipses, not only circles. |
| Polygon | Polygon |
Closed /Vertices path, filled if a fill color is set in the property panel, always stroked. |
| Polyline | PolyLine |
Open /Vertices path with optional line-ending styles on either end. |
| Stamp | Stamp |
Bundled stamps (a fixed library of 24: "Approved," "Draft," "Confidential," "Void," date-stamped "Received," and others) use a pre-authored appearance stream scaled to the placement rectangle; a custom image stamp embeds the user's image as the appearance's XObject, same re-encoding rule as image editing in 8.1.10. |
| Measurement | Line/PolyLine with /Measure dictionary |
Scale is set once per document (e.g. "1 inch = 1 foot") in a measurement-scale control that appears the first time a measurement tool is used; the /Measure dictionary's ratio subdictionary encodes it so conformant viewers display the same real-world length PDFWorks computed. |
Text markup tools (highlight/underline/strikethrough/squiggly) work on rotated pages because they are
computed from the same rotation-normalized glyph geometry as 8.1.4; the annotation's /QuadPoints are
written in unrotated page space and every conformant viewer, including PDFWorks itself, un-rotates
consistently at render time.
8.2.4 Property panel #
The property panel is contextual to the selected tool or selected annotation and exposes exactly the properties that subtype supports:
| Property | Applies to | Type | Values | Default |
|---|---|---|---|---|
| Color | all | swatch picker | A fixed 12-swatch accessible palette defined once in the design system (Section 16) — every swatch pair (fill vs. the canvas background, and the swatch's own label text) meets a 3:1 contrast minimum against its surrounding chrome, plus a "custom" hex entry | Swatch 1 (yellow) for highlight; swatch 5 (red) for shapes/ink |
| Opacity | highlight, shapes, ink, stamp | slider, 10% steps | 10–100% | 100% (35% is baked into the highlight appearance stream itself per 8.2.3, layered under this control) |
| Stroke width | line, arrow, rectangle, ellipse, polygon, polyline, ink | slider | 0.5–12 pt | 2 pt |
| Line ending style | line, arrow, polyline (each end independently) | dropdown | none, open arrow, closed arrow, circle, square, diamond, slash | none / closed arrow (arrow tool only) |
| Font | free text, callout | dropdown | bundled sans, bundled serif, bundled mono | bundled sans |
| Font size | free text, callout | number | 6–96 pt | 12 pt |
| Fill | rectangle, ellipse, polygon | swatch or "none" | same 12-swatch palette + none | none |
8.2.5 Layers, z-order, and selection #
Annotations stack in creation order by default (z-index increments monotonically per new
annotation). A Bring to front / Send to back / Bring forward / Send backward action set is
available on any selection and rewrites the page's /Annots array order to match — PDF's paint order
for annotations is array order, so reordering the array is a first-class, spec-compliant operation.
Multi-select works by shift-click or a marquee drag (empty-canvas drag draws a selection rectangle;
any annotation whose bounding box intersects it is selected); a multi-selection supports group move,
group delete, and group color change (setting one color applies it to every selected annotation of a
type that supports color). Copying an annotation (Ctrl+C/Cmd+C) and pasting on a different page
(Ctrl+V/Cmd+V) duplicates the annotation object at the same relative position on the target page,
generating a fresh annotation object ID.
8.2.6 Annotation sidebar #
A collapsible right-hand panel lists every annotation in the document in page order, each row showing:
a type icon, a one-line preview (highlighted text excerpt, or the first line of a note's content, or
"Rectangle" for a shape with no text), the author name (8.2.7), and the page number. Clicking a row
scrolls the canvas to that annotation and selects it ("jump-to"); the row itself is keyboard-focusable
and Enter performs the same jump. The sidebar has a type filter (checkboxes per annotation type) and
a text search box that matches annotation content and highlighted-text excerpts.
8.2.7 Reply threads and author name #
Any annotation that carries /Contents (sticky note, free text, callout, and — via their paired popup
— highlight/underline/strikethrough/squiggly/shapes) supports threaded replies, written as chained
Annot objects of subtype Text with /IRT (in-reply-to) pointing at the parent and /RT set to
Reply, exactly matching the reply convention used by desktop PDF viewers so replies remain visible
and threaded outside PDFWorks. The sidebar (8.2.6) renders a thread as an indented list under its
parent annotation. Author name defaults to the signed-in user's display name (account setting, not
re-specified here) and is written to each annotation's /T entry; a signed-out session cannot create
threaded content requiring an author because this tool requires sign-in (8.2.2).
8.2.8 Flatten on export, as a per-export option #
Exporting from this tool offers a checkbox, off by default: "Flatten annotations into the page". Checked, every annotation's current appearance stream is drawn directly into the page content stream and the annotation objects are removed, producing a PDF where the markup is no longer editable or independently listable — visually identical, structurally different. Unchecked (the default), annotations export as live, still-editable PDF annotation objects. This per-export checkbox is intentionally distinct from the standalone Flatten tool described in Section 9, which flattens an already-saved file's annotations (and form fields) as a dedicated operation on its own tool page; the checkbox here exists so a user does not have to make two trips through two tools for the common case of "annotate, then flatten immediately."
8.2.9 Options table #
| Option | Type | Range / values | Default |
|---|---|---|---|
| Default annotation color | swatch | 12-swatch palette | yellow (matches highlight default) |
| Show annotation sidebar | boolean | on/off | on |
| Snap ink strokes to a straightness threshold | boolean | on/off | off |
| Pressure sensitivity | boolean | on/off (only shown when a pressure-capable pointer is detected) | on |
| Flatten on export | boolean | on/off | off |
| Measurement scale | string, set once per document | e.g. "1 in = 1 ft" | prompted on first measurement use |
8.2.10 UI flow #
- Empty state. Same drop-zone/recent-files pattern as 8.1.15.
- Loading. Same page-by-page determinate/indeterminate pattern as 8.1.15; annotation reconstruction from the source PDF's existing
/Annotsarrays (if any) happens during this phase and existing annotations appear in the sidebar immediately on open. - Editing canvas. A left toolbar of annotation tools (icon + keyboard shortcut shown on hover), main canvas, contextual property panel on the right, collapsible annotation sidebar (8.2.6) further right or as an overlay on narrow viewports.
- In-progress (drawing). Shapes and ink show a live preview stroke as the pointer moves before the action commits on release/
Enter; free text and callout show a blinking text-entry caret immediately on placement. - Success (export). Same download pattern as 8.1.15's success state, respecting the flatten checkbox from 8.2.8.
- Errors. Shares the input-validation table from 8.1.3 (same file-open path); an annotation-specific error is measurement scale not yet set when a measurement tool is first used — this is not an error state but a required one-time prompt, not a rejection.
8.2.11 Keyboard operation and screen-reader behavior #
- Tool selection: number keys
1–9and0map to the first ten tools in the left toolbar in display order;Escapereturns to the selection/pan tool. - Placement mode mirrors 8.1.16: pressing
Enteron a focused canvas region with a shape tool active opens a keyboard placement flow — arrow keys move a crosshair,Entersets the first point, arrow keys extend to the second point,Entercommits,Escapecancels. - Every annotation is independently focusable in the accessibility tree (not only via the sidebar);
Delete/Backspaceon a focused annotation removes it after a confirmation for shapes with fill (accidental large-area deletes are the most common regret action reported in usability testing of comparable tools). - The annotation sidebar (8.2.6) is a full keyboard alternative to canvas interaction for reviewing and navigating markup — a screen-reader user can read every annotation's content and jump to it without ever needing pointer-based canvas interaction.
- Live-region announcements: "Highlight added, page 4," "Annotation deleted," "Reply added to note."
8.2.12 Edge cases #
| Case | Behavior |
|---|---|
| Highlighting text that spans a line break | One Highlight annotation with multiple /QuadPoints entries, one quad per visual line segment, matching standard viewer behavior for multi-line highlights. |
| Ink stroke drawn partly off the visible page (canvas scrolled) | Points are clamped to the page's media box at commit time; the visible portion is kept, the off-page portion is discarded, with a toast: "Part of that stroke was outside the page and was trimmed." |
| Deleting a reply-thread parent | Prompts "This note has 2 replies. Delete the whole thread?"; confirming removes the parent and every /IRT-chained reply. |
| Pasting an annotation onto a page whose dimensions differ from the source page | Position is clamped proportionally (percentage of page width/height preserved) rather than copying raw coordinates, so a paste from a Letter-size page onto an A4 page lands in a sensible relative spot. |
| Stamp placed near a page edge | Stamp is clamped so its full bounding box stays on the page; it never places partially off-page. |
8.2.13 Public API operation #
type: "annotate", submitted per Section 14 with a params.annotations array where each entry
carries the subtype, geometry, and property fields from the tables in 8.2.3–8.2.4 directly (no live
canvas session exists server-side).
8.2.14 Acceptance criteria #
- A highlight placed over justified text that spans two lines produces one annotation object with two quad entries whose union exactly covers the selected glyphs, verified against the glyph bounding boxes from 8.1.4.
- A freehand ink stroke drawn with a pressure-capable input device produces a stored
Inkannotation whose per-segment stroke widths are not uniform (at least two segments differ by more than 5% of the base width), and the identical stroke path drawn with a non-pressure device (mouse) produces a stored annotation whose segment widths are all equal to the base width, with no error or missing-feature message in either case. - Checking "Flatten annotations into the page" before export produces a file with zero entries in
every page's
/Annotsarray, while the unchecked export produces a file whose annotation count and subtypes exactly match what was placed on canvas. - Every annotation placed via pointer interaction can also be placed, selected, edited, and deleted using only the keyboard, verified by an automated keyboard-only Playwright pass per Section 19.
- A reply added to a sticky note appears in the annotation sidebar nested under its parent and is
written to the exported PDF as a
Textannotation with/IRTpointing at the parent's object reference.
8.3 Fill forms #
8.3.1 Purpose #
Detect an existing PDF's fillable fields, present them as an interactive overlay, and let the user complete, validate, save, and export the filled document — without needing Acrobat or any other desktop tool.
8.3.2 Minimum plan #
Free (signed-in account required). Unlimited on Free and above (Section 12.2).
Pre-existing digital signatures. Detection follows the canonical entry in the processing engine's failure taxonomy (Section 6.8). Writing a filled value into any field, or exporting a flat-form-filler text placement (8.3.8), rewrites the whole file and invalidates a pre-existing third-party PKI signature exactly as any other edit in this section does. On open, a detected signature shows a one-time warning before the field overlay becomes interactive: "This document has a digital signature that wasn't created in PDFWorks. Filling it in and exporting will invalidate that signature. [Continue editing] [Cancel]." Continue editing dismisses the warning for the session and unlocks the overlay; Cancel returns to the empty state (8.3.12) without opening the file.
8.3.3 Field detection and the interactive overlay #
On open, the tool reads the document's AcroForm dictionary (/AcroForm in the document catalog) and
enumerates every field in /Fields, resolving the field tree (a field may have kids that are further
fields or pure widget annotations) into a flat list of leaf fields, each with its full inheritance
chain resolved (a field with no explicit /Ff, /DA, or /Q inherits from its parent field or from
/AcroForm itself, per the AcroForm inheritance model). Each resolved field renders as a live overlay
element positioned exactly over its widget annotation's /Rect on the page, styled by the design
system (Section 16) rather than by the field's own often-inconsistent native appearance, except where
the field's format mask or font is user-visible content (e.g., a field that must render in a specific
font per the form author's /DA).
| Field type | AcroForm /FT |
Overlay control |
|---|---|---|
| Text (single line) | Tx (no multiline flag) |
Text input, maxlength from /MaxLen if set |
| Multiline text | Tx with Ff bit 13 (multiline) set |
Textarea, auto-growing up to the widget's rect height then scrolling |
| Password | Tx with Ff bit 14 (password) set |
Password input (masked), never persisted in plain form to Dexie (8.3.7 covers the storage rule) |
| Checkbox | Btn with neither radio nor pushbutton flags |
Checkbox, toggles between the field's "on" appearance state name and /Off |
| Radio group | Btn with Ff bit 16 (radio) set |
Radio button group, one control per kid widget, mutually exclusive within the group |
| Combo box | Ch with Ff bit 18 (combo) set |
Native-styled select, editable if Ff bit 19 (edit) is also set |
| List box | Ch without the combo flag |
Multi-select listbox if Ff bit 22 (multiselect) is set, else single-select |
| Button (non-field, pushbutton) | Btn with Ff bit 17 (pushbutton) set |
Rendered as a static button; if it has a /AA action other than a recognized calculation trigger (8.3.6), the action is not executed (this tool does not run arbitrary PDF actions — see 8.3.6 for the calculation exception) |
| Signature | Sig |
Not filled by this tool; clicking it shows "This is a digital signature field, which PDFWorks doesn't create digital signatures for (Section 10 explains why). Use Self-Sign (Section 8.5) to place a visual signature elsewhere on the page instead." |
8.3.4 Field validation #
Validation runs at three points: on blur (immediate feedback), on Tab-away (same as blur), and as a pre-download check (8.3.5).
- Format mask. A field's
/AA /F"format" JavaScript action, when it matches one of the standard, recognizedAFNumber_Format,AFPercent_Format,AFDate_FormatEx, orAFSpecialFormatcalls emitted by common PDF form authoring tools, is parsed for its parameters (decimal places, currency symbol, date pattern, or special format such as ZIP/SSN/phone) and enforced as a native input mask and a Zod-equivalent client-side pattern check, without executing any JavaScript. A format action using any other function call is not executed (this tool never runs arbitrary embedded JavaScript); the field instead behaves as plain text with a banner: "This field has a custom format PDFWorks can't automatically apply. Enter its value manually." - Validation action. A field's
/AA /V"validate" action follows the identical recognized-vs-custom split as format: standardAFRange_Validate(numeric min/max) is enforced natively; anything else is not executed and the same manual-entry banner applies to validation only (the field otherwise remains usable). - Required-field enforcement. A field with
Ffbit 2 (required) set is tracked in a required-field list; an unfilled required field is outlined in the design system's error color (Section 16) once the user attempts to leave it, and is included in the pre-download check.
8.3.5 Calculated fields and the calculation order array #
The AcroForm dictionary's /CO entry lists, in evaluation order, every field with a calculation
action. For each field in /CO, its /AA /C "calculate" action is inspected: if it matches the
standard AFSimple_Calculate(cFunction, cFields) pattern (the common case, generated automatically by
most form-authoring tools for "sum of fields A, B, C" style calculations) with cFunction one of
SUM, PRD (product), AVG, MIN, MAX, the tool evaluates it directly in TypeScript against the
current values of the named cFields, in the order given by /CO, and writes the result into the
calculated field's overlay as read-only text (with a small calculator icon indicating it is derived,
not user-entered). If a field's calculate action is anything else — arbitrary embedded JavaScript —
the tool does not execute it (this is a deliberate, product-wide decision: see 8.4.6 for the full
rationale, which applies identically here). The field instead remains a plain, directly editable input
with a banner: "This field normally calculates automatically, but its formula isn't one PDFWorks
supports running. Enter its value manually." Recalculation re-runs the full /CO chain, in order,
whenever any field named in any cFields list changes.
8.3.6 Required-field enforcement and the pre-download check #
Before Export/Download is enabled, the tool runs a single pass over every required field. If any are
empty, Export is not blocked outright but shows an interstitial: "{n} required field(s) are empty:
{list of field names, each a jump-to link}. Download anyway, or fill them in?" with Download
anyway and Review fields actions — the tool never silently blocks a download, since some users
legitimately want a partially completed form (e.g., to finish later, or to hand off to a co-signer),
but it never lets required fields go unmentioned either.
8.3.7 Tab order and field highlighting #
Tab order follows the field tree's array order in /Fields by default (the PDF-conformant default —
most form-authoring tools let the form author set this explicitly, and the tool respects whatever
order the document declares); if the document sets an explicit /TabOrder value of /Row or /Col
on a page, fields are instead ordered by widget position (row-major or column-major) per that setting.
A Highlight fields toggle (default on, matching common desktop viewer behavior so the experience
feels familiar) tints every field's background using the design system's field-highlight token
(Section 16) so fields are visually discoverable even when their own appearance gives no visual cue
(a common issue with forms authored to look "print-like").
8.3.8 Forms with no AcroForm layer #
If /AcroForm is absent or /Fields is empty, the document has no fillable layer at all — common for
scanned forms or forms designed to be printed and filled by hand. The tool detects this and shows:
"This PDF doesn't have fillable fields built in. PDFWorks can still let you type on top of it — click
anywhere to add a text box." and switches into a flat-form filler mode: a lightweight version of
the Add Text flow from 8.1.10, scoped to placing new, plain text boxes anywhere on the page, with no
field semantics (no validation, no tab order, no export-data formats) — it is explicitly typed-on-top
content, not a form field, and the tool says so plainly rather than pretending it detected fields that
do not exist.
8.3.9 Saving and resuming #
Filled values (both true AcroForm field values and flat-form-filler text placements) autosave to OPFS and a Dexie pointer using the identical mechanism as 8.1.13, on the same 5-second interval, with one addition: values from password-type fields (8.3.3) are held only in the in-memory field-value store and are excluded from the OPFS/Dexie autosave payload entirely, so a crash-recovery resume prompts the user to re-enter any password-masked field values rather than persisting them at rest, even locally.
8.3.10 Export formats: FDF, XFDF, JSON, CSV #
Field values can be exported independently of the filled PDF (useful for aggregating many people's responses to the same form) and re-imported into a blank copy of the same form.
| Format | Shape | Notes |
|---|---|---|
| FDF | Legacy PDF Forms Data Format, %FDF-1.2 header, /FDF /Fields array of /T (name) / /V (value) pairs |
Included for compatibility with older desktop workflows that still expect it. |
| XFDF | XML equivalent of FDF, <xfdf><fields><field name="…"><value>…</value></field></fields></xfdf> |
Preferred over FDF for anything web- or API-driven; human-readable. |
| JSON | { "fields": { "<fully-qualified field name>": "<value>", ... } }; checkbox/radio values are the field's "on" state name, not a boolean |
Field names use the fully qualified dotted form for nested field trees, e.g. applicant.address.zip. |
| CSV | One row per export event, one column per field, header row is the fully qualified field name list | Intended for accumulating many filled instances of the same form into one spreadsheet; a CSV import expects the same header shape it exported. |
Import (any of the four formats) matches values to fields by fully qualified name; a name present in
the import file with no matching field in the open document is skipped and reported in a post-import
summary ("3 of 42 values imported. 1 value skipped: no field named applicant.middleName."); a
value that fails the target field's format mask or validation range (8.3.4) is imported but flagged
for review rather than silently dropped.
8.3.11 Options table #
| Option | Type | Range / values | Default |
|---|---|---|---|
| Field highlighting | boolean | on/off | on |
| Autosave interval | number | 3–30 s | 5 s |
| Export data format | enum | FDF, XFDF, JSON, CSV | XFDF |
| Warn on empty required fields before download | boolean | on/off | on |
8.3.12 UI flow #
- Empty state / loading. Identical pattern to 8.1.15/8.2.10.
- Field detection. On successful open, a one-line summary appears above the canvas: "
{n}fillable fields found across{p}pages.", or the no-AcroForm message from 8.3.8. - Filling. Overlay controls per 8.3.3; a floating field-count badge ("12 of 42 required fields complete") updates live.
- Pre-download check. The interstitial from 8.3.6 when applicable.
- Success. Download prompt as in 8.1.15's success state, plus a secondary Export data action offering the four formats from 8.3.10.
- Errors. Shares 8.1.3's file-open errors; a fill-specific error is an import file whose format cannot be parsed at all (not just partially matched): "That file doesn't look like a valid
{format}export. Check the file and try again."
8.3.13 Keyboard operation and screen-reader behavior #
- Tab order follows 8.3.7. Every overlay control is a real, focusable form control (native
<input>,<select>,<textarea>, or ARIA-equivalent for the signature-field notice), so standard screen-reader form navigation (heading/form-field quick-navigation keys) works without any custom handling. - Each field's accessible label is built from, in priority order: the field's
/TU(tooltip/user name) if present, else its fully qualified field name, else a generated "Field{n}on page{p}}." - Required fields expose
aria-required="true"; fields flagged by the pre-download check receivearia-invalid="true"and an inline error message referenced viaaria-describedby. - Live-region announcements: "12 of 42 required fields complete," "3 values imported, 1 skipped."
8.3.14 Edge cases #
| Case | Behavior |
|---|---|
| Radio group whose kids have inconsistent "on" state names | Each kid's own on-state name is preserved and used for that specific radio button's value; the group's value is whichever kid's on-state is currently selected, matching AcroForm's actual (if occasionally messy) semantics rather than assuming uniform names. |
Combo box with no /Opt (options) array but an editable flag |
Renders as a plain editable text input; no dropdown is shown because there is nothing to populate it with. |
| Field appears on more than one page (shared widget) | Extremely rare but valid; the tool treats it as one logical field with two overlay locations, and typing in either updates both, matching the single underlying /V value. |
| A field's rectangle is off-page or zero-size (malformed source PDF) | The field is still listed in the field-count summary and is fillable via the annotation-sidebar-style field list (reusing the pattern from 8.2.6, scoped to fields), even though no on-canvas overlay can be drawn for it. |
| CSV import with a header row that doesn't match any field | Whole-file skip, not a per-row skip, with: "None of the columns in this file match a field in this document." |
8.3.15 Public API operation #
type: "fill_form", submitted per Section 14 with params.values in the JSON shape from 8.3.10 and
an optional params.exportFormat to also receive a data export alongside the filled PDF in the job
result.
8.3.16 Acceptance criteria #
- Opening a form whose calculated total field uses a standard
AFSimple_Calculate(SUM, ...)action produces a live-updating, read-only sum as its dependent fields change, evaluated in the exact order given by the document's/COarray. - A required text field left empty triggers the pre-download interstitial listing that field by name with a working jump-to link, and choosing "Download anyway" produces a file with the field still empty (no value is invented to satisfy the check).
- Exporting filled values as XFDF and re-importing them into a freshly reopened copy of the same document reproduces every field's value exactly, including a multi-select list box's full selected set.
- Opening a document with no
/AcroFormentry shows the flat-form-filler message from 8.3.8 rather than an error, and text typed via that mode appears at the clicked location in the exported PDF. - Inspecting the OPFS working-copy payload after filling a password-type field and letting an autosave cycle complete shows no trace of that field's value anywhere in the persisted payload, while every other filled field's value is present.
8.4 Create fillable forms #
8.4.1 Purpose #
Turn a static PDF into an interactive one by placing new AcroForm fields directly on the page canvas — the authoring counterpart to Fill Forms (Section 8.3).
8.4.2 Minimum plan #
Free (signed-in account required). Unlimited on Free and above (Section 12.2). Shared templates (8.4.7) are a Team-only capability per the workspace entitlement row of Section 12.2.
Pre-existing digital signatures. Detection follows the canonical entry in the processing engine's failure taxonomy (Section 6.8). Placing a new AcroForm field and exporting rewrites the whole file and invalidates a pre-existing third-party PKI signature exactly as any other edit in this section does. On open, a detected signature shows a one-time warning before the field-placement toolbar becomes usable: "This document has a digital signature that wasn't created in PDFWorks. Adding form fields and exporting will invalidate that signature. [Continue editing] [Cancel]." Continue editing dismisses the warning for the session; Cancel returns to the empty state (8.4.13) without opening the file.
8.4.3 Field placement, snapping, and alignment #
Fields are placed by selecting a field type from the left toolbar, then either click-placing at a default size or click-dragging to define a custom rectangle, directly on the rendered page canvas. While dragging or moving a field:
- Snapping activates against three guide sources, each shown as a thin colored guide line when triggered within a 6-px catch radius at the current zoom level: the page margins (detected as the largest common inset across existing page content, or a fixed 0.5-inch default on a blank page), other fields' edges (edge-to-edge and center-to-center), and an optional visible grid (8.4.9).
- Alignment tools operate on a multi-selection: align left/right/top/bottom edges, center horizontally/vertically, and distribute evenly (horizontal or vertical), matching the standard object-alignment conventions used throughout the design system's canvas-based tools (Section 16).
8.4.4 Field types and full property set #
Every field, regardless of type, carries this common property set:
| Property | Type | Constraint | Default |
|---|---|---|---|
| Name | string | 1–255 chars; letters, digits, ., _, -; must be unique within its sibling scope (duplicate detection below) |
auto-generated, e.g. text_field_1, incrementing per type |
Tooltip (/TU) |
string | 0–255 chars | empty |
| Required | boolean | — | false |
| Read-only | boolean | — | false |
| Default value | string (type-appropriate) | must itself pass the field's own format/validation rule | empty |
| Appearance: border | enum | none, solid, dashed, beveled, inset, underline | solid |
| Appearance: border width | number | 0–4 pt | 1 pt |
| Appearance: background | swatch or none | 12-swatch palette (Section 16) + none | none |
| Appearance: font | enum | bundled sans, bundled serif, bundled mono | bundled sans |
| Appearance: font size | number or "auto" | 4–96 pt, or auto-size-to-fit | auto |
| Alignment | enum | left, center, right | left |
Type-specific additions:
| Field type | Additional properties |
|---|---|
| Text (single/multiline) | Maximum length (0 = unlimited); multiline toggle; format mask (none, number, percent, date, ZIP, phone, SSN — each maps to a standard AFxxx_Format action per 8.3.4 so the field is fully interoperable with other viewers, not only PDFWorks); comb (evenly spaced character cells, common for SSN/phone-style fields) with a cell count. |
| Password | Same as text, minus format mask (masked input is incompatible with visible formatting). |
| Checkbox | On-state name (default Yes); check style (check, cross, star, circle, diamond, square). |
| Radio group | Member list (add/remove buttons in the property panel), each member's own on-state name, default-selected member. |
| Combo box | Options list (label/value pairs, reorderable); editable toggle; default selection. |
| List box | Options list; multi-select toggle; visible row count (2–10). |
| Signature | Placement only — this tool places a /Sig field placeholder for a future digital-signature workflow but does not itself create digital signatures, consistent with the scope boundary in Section 10; the field renders in Fill Forms (8.3.3) with the "not filled by this tool" notice. |
8.4.5 Radio group construction #
Placing a radio button field always creates or joins a radio group: the first radio button placed
starts a new group and prompts for a group name; every subsequent radio button placed while "add to
existing group" is selected in the toolbar becomes an additional kid widget under that same group
field, each with its own on-state name (defaulting to Option1, Option2, ...). The property panel
for any member of a group shows the full member list with inline rename and delete, and deleting the
last remaining member deletes the group field entirely.
8.4.6 Conditional show/hide: decision and rationale #
Conditional field visibility (show field B only when field A has a specific value) is a frequently
requested capability, and this specification makes a deliberate choice about how — and how not — to
implement it: PDFWorks-created forms never contain embedded PDF JavaScript. Conditional logic is
instead stored as a proprietary, namespaced dictionary (/PDFWorksFormLogic) attached to the
document's /AcroForm dictionary, holding a flat list of { fieldName, dependsOn: fieldName, showWhen: value | value[] } rules. This dictionary is inert data: it is never executed, never interpreted as a
script, and is read only by PDFWorks's own renderers (the web app's Fill Forms tool and the
equivalent API operation); every other conformant PDF viewer simply ignores the unrecognized key and
shows every field as always visible and enabled — the form remains completely valid and fully fillable
everywhere, just without the show/hide behavior outside the PDFWorks ecosystem. This is stated to the
form author at the point they add their first conditional rule: "Conditional fields only show/hide
automatically when this form is filled in PDFWorks. In other PDF apps, all fields will always be
visible." A reader evaluating the security implications of a data-only, renderer-interpreted dictionary
sitting inside a form's /AcroForm entry should see the ingest-sanitizer treatment of it in Section
17.4, which acknowledges this dictionary by name and confirms it carries no executable payload for the
sanitizer to strip.
The rationale, stated once here because it governs every place embedded JavaScript would otherwise be tempting: PDF JavaScript is a well-established attack vector, support for it is inconsistent across viewers (many mobile and web-based viewers ignore form-level JavaScript outright, which would make the feature silently non-functional for a large share of recipients regardless of security concerns), and — decisively — the ingest sanitizer specified in Section 17.4 unconditionally strips embedded JavaScript and launch actions from every file the platform touches. A form authored with embedded JavaScript logic and later re-uploaded to PDFWorks for any server-side operation (OCR, an Office conversion, an e-signature envelope) would have that logic silently deleted by the platform's own security baseline, which would be a confusing, self-inflicted defect. The proprietary-dictionary approach sidesteps this entirely: nothing in it looks like or behaves like executable content, so the sanitizer in Section 17.4 has nothing to strip, and the logic survives every trip through the platform.
8.4.7 Field naming rules and duplicate detection #
Field names must match ^[A-Za-z][A-Za-z0-9._-]{0,254}$ (must start with a letter, to avoid ambiguity
with numeric array indices some legacy tools assume). A . in a name denotes a hierarchical field
(matching AcroForm's own fully-qualified naming convention, e.g. applicant.address.zip creates the
same nested field tree that 8.3.10's JSON export/import format expects). Placing a field whose name
already exists elsewhere on the page, or anywhere else in the document outside an intentional radio
group (8.4.5), shows an inline error on the name property: "A field named {name} already exists on
page {p}. Field names must be unique." and the field is not committed to the document until renamed.
8.4.8 Tab order editor #
A dedicated Tab order mode (toolbar toggle) overlays a numbered badge on every field in its
current tab-order position; dragging a badge to a different field renumbers the sequence, and a
Reset to layout order action restores the row-major default. The resulting order is written to the
page's /TabOrder as /Struct order matching the badge sequence (an explicit array-order write,
since relying on /Fields array order alone would not survive later field additions predictably).
8.4.9 Preview and test mode #
A Preview toggle switches the canvas from authoring mode (drag handles, property panel, snapping
guides) to a live rendering of the form exactly as Fill Forms (Section 8.3) would present it,
including conditional show/hide behavior (8.4.6) and calculated-field evaluation if any standard
AFSimple_Calculate rules were configured (8.4.10). Preview mode does not save any values entered
into it; it is scoped to the current authoring session and clearly labeled "Preview — nothing typed
here is saved."
8.4.10 Templates: personal and Team-shared #
A completed form (fields, layout, conditional rules, but not filled values) can be saved as a
template for reuse. A template is identified as tpl_<UUIDv7> following the identifier scheme in
Section 5.1, stored as a row referencing its blob in the same encrypted-at-rest object storage described
in Section 3.5, with an owner_type of user (personal, visible only to its creator) or workspace
(Team-shared, visible to every member of the owning workspace — available only on the Team plan, per
Section 12.2). Personal templates additionally cache locally in Dexie for fast "New from template"
listing, per the local-metadata pattern in Section 3.7; Team templates are always fetched from the
workspace's server record so every member sees the same current version. Applying a template to a new
document maps fields onto the new document's pages by page index and relative position, and warns if
the new document has fewer pages than the template references: "This template places fields on {n}
pages, but this document only has {m}. Fields on missing pages were skipped."
8.4.11 Generating a form from a scanned document #
If the working file is a scanned document (per the same content signal used in 8.1.14: no text-showing
operators, a full-page image XObject), the tool prompts: "This looks like a scan. Run OCR first
(Section 9.4) so PDFWorks can suggest field placements from the recognized layout, or continue and
place fields manually." When OCR has already been run (the document carries a text layer with layout
regions), the tool offers an auto-detect fields pass: it looks for common visual patterns —
horizontal underscores or rule lines of a minimum length, boxed rectangles with no fill, and a nearby
label (text ending in : or immediately to the left/above a detected line) — and proposes a text field
at each detected location, shown as a batch of placeholder fields the user reviews, renames, and
accepts or discards individually before committing. Detection is intentionally conservative (skipping
ambiguous cases rather than guessing) — a missed field costs the user one manual placement; a wrongly
placed field costs a cleanup step, which is the worse outcome.
8.4.12 Options table #
| Option | Type | Range / values | Default |
|---|---|---|---|
| Snap to margins/fields | boolean | on/off | on |
| Visible grid | boolean, with size | off, 4 pt, 8 pt, 16 pt | off |
| Field highlight in authoring mode | boolean | on/off | on |
| Default field type | enum | text, checkbox, radio, combo, list, signature | text |
| Auto-detect fields from a scanned/OCR'd layout | boolean | on/off | off (opt-in per 8.4.11) |
8.4.13 UI flow #
- Empty state / loading. Same pattern as 8.1.15/8.2.10, plus a Start from template option alongside Choose file when the user has at least one saved template (8.4.10).
- Authoring canvas. Field-type toolbar, main canvas with snapping guides, contextual property panel, and a field list panel (reusing the sidebar pattern of 8.2.6, scoped to fields) for bulk review.
- Placing a field. Live drag preview of the field's rectangle; releasing commits it and opens the property panel focused on the Name field, since naming immediately (8.4.7) avoids the more disruptive rename-after-the-fact-with-dependent-rules case in 8.4.6.
- Preview/test mode. Full-canvas takeover per 8.4.9, with a persistent "Exit preview" bar.
- Success (export/save-as-template). Export follows 8.1.15's success pattern; Save as template opens a small dialog: template name, personal-vs-workspace toggle (workspace option present only for Team workspace members), Save.
- Errors. Duplicate-name error per 8.4.7; template-page-mismatch warning per 8.4.10; all file-open errors shared with 8.1.3.
8.4.14 Keyboard operation and screen-reader behavior #
- Field placement has a full keyboard flow mirroring 8.1.16/8.2.11: select a field type via the
toolbar (arrow keys cycle types,
Enterselects), then a keyboard placement mode positions and sizes the field rectangle via arrow keys withEnterto commit. - Every placed field is independently focusable and its property panel is reachable via
Tabwithout requiring a pointer click on the canvas. - The field list panel (8.4.13) provides a complete non-canvas alternative for reviewing, renaming, and deleting fields, following the same pattern established for the annotation sidebar in 8.2.6.
- Live-region announcements: "Text field
applicant.nameadded, page 1," "Field renamed," "3 fields auto-detected, review required."
8.4.15 Edge cases #
| Case | Behavior |
|---|---|
| Two radio buttons in the same group placed on different pages | Fully valid (AcroForm groups are not page-scoped); the group's field-list entry shows both page numbers. |
A conditional rule's dependsOn field is later deleted |
The dependent rule is deleted automatically along with it, with a toast: "The conditional rule on {field} was removed because {deleted field} no longer exists." |
| Applying a template to a document that already has fields at the same names | Template field names are suffixed (_2, _3, ...) to avoid the duplicate-name rejection in 8.4.7, and a summary lists every renamed field. |
| Auto-detected field overlaps an existing manually placed field | The auto-detect pass skips proposing a field wherever its box would overlap an existing field by more than 20% of its area. |
| Comb text field with a maximum length that doesn't evenly divide the field width | Cell width is computed as width / maxLength, always at least 4 pt; below that floor the comb layout is disabled and the field falls back to plain text with a note in the property panel. |
8.4.16 Public API operation #
type: "create_fillable_form", submitted per Section 14 with params.fields describing each field's
type, placement, and properties from 8.4.4, and an optional params.templateId to apply a saved
template (8.4.10) instead of an inline field list.
8.4.17 Acceptance criteria #
- Two fields placed with snapping enabled, dragged within 6 px of each other's edge at 100% zoom,
snap edge-to-edge exactly, verified by comparing the two fields'
/Rectcoordinates after commit. - A form saved as a workspace template by a Team member is visible and applicable by a second member of the same workspace, and is not visible to a member of a different workspace.
- A conditional rule hiding field B when field A is unchecked correctly shows/hides field B when the
same document is opened in Fill Forms (Section 8.3), and field B is always visible when the exported
PDF is inspected with the
/PDFWorksFormLogicdictionary stripped, confirming the base form remains valid without it. - Attempting to name a new field with a name already used elsewhere in the document is rejected inline before the field commits, and the field can be renamed and committed successfully afterward.
- Running auto-detect on an OCR'd scanned form proposes at least one field for every underscore-style blank line at least 40 pt long on the test page, and proposes zero fields inside a region already covered by a manually placed field.
8.5 Self-sign #
8.5.1 Purpose #
Let a user place their own signature onto their own document — entirely on-device, with no signer invitation, no envelope, and no server round trip. This is the "sign what's in front of me right now" tool, distinct from requesting a signature from someone else.
8.5.2 Minimum plan #
Free (signed-in account required). Unlimited on Free and above (Section 12.2).
8.5.3 How this differs from requesting signatures from others #
| Self-sign (this tool) | Signature requests (Section 10) | |
|---|---|---|
| Execution location | Client-side, on-device, per the client-side/server-side tool table in Section 6.1 | Server-side; the source and in-progress PDF are uploaded for the envelope's lifetime |
| Who signs | Only the person using the tool, on their own document | One or more named signers, each completing their own session, potentially on a different device |
| Identity mechanism | None — it is your own document; no email verification is performed | Verified-email identity with a 6-digit OTP per Section 10 |
| Audit trail | None generated | Full append-only hash-chained audit trail and Certificate of Completion per Section 10 |
| Legal consent screen | Not shown — self-sign is not an ESIGN/UETA-consent-gated flow, because there is no counterparty relying on a legally attributable signature exchange | Required before the first field, per Section 10 |
| When to choose it | Signing a document only you need to sign (an internal form, your own copy of a lease, initialing your own notes) | Getting a signature (or several, in sequence or in parallel) from other people, with legal defensibility |
The tool itself surfaces this distinction the first time a signed-in user opens it, as a one-time dismissible callout: "Self-Sign places your signature on your own document, right here, right now. If you need someone else to sign, use Request Signatures instead." with a link to the Section 10 flow.
No server fallback. Self-sign is one of the operations excluded from the opt-in server-fallback path described in Section 6.3: if the client-side export fails for device-memory reasons, the tool never offers to finish the job on a server. This follows from 8.5.1's premise — a self-signed document's entire value is that it never left the device — so the failure path instead offers a page-range export (matching the fallback offered for an ordinary export-time engine failure elsewhere in this section, per 8.1.15) rather than an upload option.
8.5.4 Creating a signature or initials asset #
Three input methods, each producing a signature (or, separately, initials) asset:
- Draw. A pointer/touch-driven canvas (Pointer Events API), captured as a smoothed vector path (Catmull-Rom smoothing over raw pointer samples to avoid a jagged, sample-rate-dependent line), stored as an SVG path plus a rendered PNG fallback for contexts that need a raster preview.
- Type. The user types their name; it renders in one of four bundled signature typefaces ("Signature Script," "Signature Formal," "Signature Casual," "Signature Bold"), selectable via a live preview switcher, black by default with the same 12-swatch color option as annotations (Section 16).
- Upload. A PNG or JPEG image of a handwritten signature; if the source is JPEG (no alpha channel), the tool offers an automatic background removal pass (a simple luminance-threshold keying tuned for a signature-on-white-paper photo — pixels above a brightness threshold become transparent) with a before/after preview and an accept/skip choice, since a photographed signature on visibly white paper looks wrong pasted onto a PDF page.
Each created asset is named (default "Signature" / "Initials," editable) and stored locally in OPFS
(the asset's raster/vector data) with a metadata row in Dexie, per Section 3.7. A signed-in user may
additionally choose Sync to my account on any asset, which uploads it to an encrypted, per-user
signature_assets blob store (Section 3.5's encryption model) identified as asset_<UUIDv7> per the
identifier scheme in Section 5.1, so the same signature is available when the user opens PDFWorks on a
different device. Sync is opt-in per asset, never automatic, consistent with the platform-wide rule
that anything crossing the client-side/server-side boundary requires an explicit action (Section 6.3).
8.5.5 Placement, resize, and per-page repeat #
A saved asset is placed by dragging it from an asset tray onto the page, or by clicking a page location with an asset pre-selected. Placement supports:
- Move and resize with the identical handle/keyboard model as image editing in 8.1.10, including aspect-ratio lock (default on, since a stretched signature looks visibly wrong).
- Per-page repeat. A Place on every page toggle, available once at least one placement exists, duplicates that placement at the same relative page position (percentage of page width/height, so it lands sensibly even on pages of a different size) onto every remaining page — the common case for initialing every page of a multi-page agreement. Repeated placements remain individually movable and deletable afterward; moving the original placement after repeating does not retroactively move the copies.
8.5.6 Date and text stamps #
Alongside the signature asset itself, a Date stamp tool inserts the current date (device local date, formatted per the user's locale settings from Section 16's internationalization architecture) as a new text box using the same Add Text mechanism as 8.1.10, with a small calendar-glyph prefix icon baked into the stamp's rendered appearance. A Text stamp tool is a thin preset over Add Text for short annotative labels ("Reviewed," "Copy," a printed name) — functionally identical to placing a new text box, offered as a separate toolbar entry only because it is a distinct, frequent user intent.
8.5.7 Flatten on export #
Self-sign always flattens on export — unlike Annotate (8.2.8), there is no toggle. A self-signed document's signature, initials, and stamps are placed as new page content (drawn directly into the content stream, following the same "add new content" mechanics as adding text/images in 8.1.10) at commit time, not as separate movable objects retained in the exported file; this matches user expectation that a "signed" document is final, and avoids a self-signed PDF later being reopened elsewhere and having its signature accidentally dragged or deleted as if it were an ordinary annotation.
8.5.8 Options table #
| Option | Type | Range / values | Default |
|---|---|---|---|
| Default signature typeface (type method) | enum | 4 bundled faces | Signature Script |
| Ink smoothing (draw method) | boolean | on/off | on |
| Background removal (upload method) | boolean | on/off, prompted per upload | prompted |
| Aspect lock on placement | boolean | on/off | on |
| Date stamp format | enum | locale default, MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD |
locale default |
| Sync assets to account | boolean, per-asset | on/off | off |
8.5.9 UI flow #
- Empty state. Same drop-zone/recent-files pattern as prior tools; if the user has zero saved signature assets, the first action after opening a file is a Create your signature panel (draw/type/upload tabs) rather than the placement canvas.
- Loading. Shared pattern with 8.1.15.
- Placement canvas. Asset tray (left), main canvas (center), a lightweight property panel (right) for size/opacity/repeat controls on the selected placement.
- In-progress. No blocking state; placement is live-drag like annotation shapes (8.2.10).
- Success (export). Shared download pattern with 8.1.15; the flatten step (8.5.7) is folded into export progress with the label "Finalizing signed document…".
- Errors. Shares 8.1.3's file-open errors. An asset-specific error: uploading an unsupported image format shows "Choose a PNG or JPEG image for your signature." (matching the image-add rejection copy in 8.1.10).
8.5.10 Keyboard operation and screen-reader behavior #
- Asset creation: the draw canvas is a pointer-primary interaction with no meaningful keyboard equivalent for the stroke itself (freehand drawing is not keyboard-operable in principle), so the Type method is the documented, fully keyboard-accessible path to producing a signature asset — the tool's onboarding for keyboard-only and switch-access users defaults to the Type tab rather than Draw.
- Placement, move, and resize follow the identical keyboard model as 8.1.16 (image manipulation) and
8.2.11 (shape placement):
Tabto focus an asset in the tray,Enterto arm placement, arrow keys to position,Enterto commit,Escapeto cancel; a focused placement supportsM/Rmove/resize modes exactly as in 8.1.16. - Live-region announcements: "Signature placed, page 1," "Signature added to 6 pages," "Document signed and flattened."
8.5.11 Edge cases #
| Case | Behavior |
|---|---|
| "Place on every page" on a document where the current page is the last page | Repeats onto every other page only; the current page's existing placement is left untouched (never duplicated onto itself). |
| Drawn signature with fewer than 3 pointer samples (a tap, not a stroke) | Rejected as too short to be a signature: "That looks like a tap, not a signature. Try drawing again."; no asset is created. |
| Uploading a signature image larger than 5 MB | Downscaled client-side to a maximum 2000 px on the long edge before storage, since a signature asset never needs source-photo resolution; the original is not retained. |
| Deleting a synced asset | Prompts "Delete {name} everywhere, or just on this device?" — "everywhere" removes the server-side signature_assets row and blob; "just on this device" removes only the local OPFS/Dexie copy and leaves the sync target intact for other devices. |
| Placing a signature on a page whose content the in-PDF editor (8.1) has pending unsaved edits | Both tools share the same OPFS working copy and autosave mechanism (8.1.13/8.3.9's pattern), so a self-sign placement made after an editing session in the same tab operates on the already-edited content, not the original file. |
8.5.12 Public API operation #
type: "self_sign", submitted per Section 14 with params.placements (asset reference or inline
asset data, page, position, size, repeat-on-every-page flag) and params.flatten always implicitly
true, matching 8.5.7's no-toggle rule.
8.5.13 Acceptance criteria #
- A signature drawn on-canvas, placed, and exported produces a PDF whose signature pixels are present directly in the page content stream of every page it was placed on, with zero remaining annotation or form-field objects for that placement (confirming flatten-always per 8.5.7).
- "Place on every page" on a 10-page document with one existing placement on page 3 results in placements on pages 1–2 and 4–10, each at the same relative position as page 3's, and page 3's original placement is not duplicated.
- An asset created via the Type method, with no pointer or touch input at any point, can be created, placed, resized, and flattened into an exported document using only the keyboard.
- Deleting a synced signature asset with "just on this device" selected removes it from the local asset tray immediately while a second signed-in session for the same account, on a different device, still lists it.
- Uploading a 6,000 px signature photograph is downscaled to a 2,000 px long edge before any local storage write, verified by inspecting the stored asset's pixel dimensions.
9. Tool Specifications C: Redaction, Watermarks & Numbering, Document Security, OCR, Office & HTML Conversion #
This section specifies nine tools: redaction (9.1), watermarking (9.2), page numbering (9.3), Bates numbering (9.4), encryption/protect (9.5), unlock (9.6), flatten (9.7), OCR (9.8), and Office/HTML conversion (9.9). Sections 9.1–9.7 run client-side by default per the execution-location rules in Section 6; Sections 9.8–9.9 run server-side unconditionally, because neither OCR nor Office/HTML conversion is on the client-side tool list. Every tool in this section, when invoked through the public REST API, runs server-side regardless of its default execution location, because the API has no browser (Section 6). The Processing Location Indicator, specified in Section 16, is displayed on every tool page named below, before the user acts; the contractual requirement that its state always match the tool's actual execution location for the given invocation path (app UI vs. API) is defined in Section 6.2.
All client-side tools in this section share the browser runtime described in Section 6: a bounded Comlink worker pool, OPFS-resident file bytes, and Dexie-resident metadata only. All server-side tools share the worker fleet described in Section 6: gVisor-sandboxed containers, no outbound network except where explicitly granted (9.9's URL-mode HTML→PDF), envelope-encrypted blob storage, and the retention schedule in Section 6.
9.1 Redact (client-side) #
Redaction is the product's flagship correctness feature: a redaction must be true removal, not a black rectangle painted over recoverable content. Every other design decision in this subsection is subordinate to that guarantee.
9.1.1 Purpose, execution location, minimum plan #
Removes selected content — text, images, annotations, form fields, and metadata — from a PDF such that the removed content cannot be recovered by any means short of having kept a copy of the original file. Runs client-side, using the shared engine described in Section 3 (PDFium for parsing and rendering, QPDF for object-model rewriting). Not available to unauthenticated guests (it is not one of the six guest tools listed in Section 11); minimum plan is a Free account. Unlimited on every paid and free tier, since it is client-side and therefore does not consume the server-side daily task cap in Section 12.2.
Redact is on the hard exclusion list for the opt-in device-to-server fallback described in Section 6.3: if the client-side engine cannot complete a redaction — most commonly a device-memory failure on a very large file — the tool never offers to upload the file to finish the job. It fails with a device-side remediation message ("This file is too large to redact on this device. Try closing other tabs, working with a smaller page range, or a device with more memory.") and a Retry action. The same rule applies to Protect (9.5) and Unlock (9.6): a redaction mark, a document password, and the decrypted bytes those two tools handle are the exact material this product's privacy model exists to keep off the server, so none of these three tools ever presents an upload path, under any failure condition.
9.1.2 Inputs and validation #
| Input | Validation |
|---|---|
| Source PDF | Magic-byte and structural validation per Section 17 before the tool opens it. Maximum size per the caller's plan (Section 12.2). |
| Redaction marks | At least one mark required before "Apply" is enabled. Soft warning above 500 marks per document ("this may take longer to process and verify"); hard cap 5,000 marks per document, enforced client-side with error redaction_mark_limit_exceeded. |
| Fill color | 6-digit hex, defaults to #000000. Must resolve to a fully opaque color — the tool does not expose an opacity control for redaction fill (Section 9.1.7 explains why). |
| Search pattern (search-and-redact) | Literal string: 1–500 characters. Regex: validated against a linear-time-only subset (no unbounded backreferences, no nested quantifiers of the form (a+)+) and rejected client-side with error unsafe_pattern if it fails a static check; execution is additionally capped at 250 ms per page, after which that page reports pattern_timeout for that page and is excluded from results, not from the whole search. |
If the file opens and reports an encryption dictionary requiring a user password, the tool blocks with the password prompt from Section 9.6 before any redaction UI is shown; once unlocked, redaction proceeds against the decrypted in-memory representation and the eventual applied output carries no encryption unless the user separately runs Protect (9.5) afterward.
9.1.3 Selection UI #
Five ways to create a mark, all available from one toolbar with a segmented control:
- Draw box — click-drag a rectangle on the rendered page. Snapped to the page's own coordinate space (accounting for page rotation), independent of zoom level. Multiple boxes per page and across pages are all independent marks.
- Click-select text — click-drag across rendered text the way a text selection works in any PDF viewer. The mark is computed from the actual glyph bounding boxes of the selected run, not a loose rectangle, so the true-removal algorithm (9.1.7) operates on exact glyph geometry.
- Select image — click directly on a placed image; the mark becomes that image XObject's full placement rectangle on that page.
- Select whole page — a "Redact this page" action in each page thumbnail's context menu and in the page toolbar; the mark covers the full page content area (the page's CropBox).
- Select across pages — after drawing one box, a "Repeat at this position on pages…" control accepts a page range (e.g., "1–40" or "odd") and replicates a mark at the identical coordinates on every page in range. This is the primary mechanism for redacting a recurring element such as a case number in a running header across an entire exhibit.
A marks panel lists every mark for the open document: page number, type icon, a thumbnail crop of the marked region, and a delete control. Clicking a list entry scrolls the viewer to that mark and briefly highlights it. Marks are visually rendered as a semi-transparent orange overlay with a solid orange border while in the "marked" (not yet applied) state — this color is deliberately different from the final black fill so a user can never mistake an unapplied mark for a completed redaction.
9.1.4 Search-and-redact #
A search panel with three modes, selectable via tabs: Literal, Regex, Pattern packs.
- Literal: case-insensitive by default (toggle to case-sensitive), whole-word toggle. Searches the extracted text layer of every page.
- Regex: same page-by-page search against extracted text, subject to the safety and timeout limits in 9.1.2. Case-sensitive/insensitive toggle.
- Pattern packs: built-in detectors, each a regex plus an optional validator function:
| Pattern pack | Detection | Validator |
|---|---|---|
| US SSN | \d{3}-\d{2}-\d{4} (also matches with spaces or no separators) |
Rejects known-invalid ranges (area number 000, 666, 900–999) |
| Credit card | 13–19 digit runs, with or without space/dash grouping | Luhn checksum must pass |
| IBAN | 2 letters + 2 digits + up to 30 alphanumeric | ISO 7064 mod-97 checksum must equal 1 |
| Phone (NANP) | (\d{3})[-. ]?\d{3}[-. ]?\d{4} and generic E.164 \+[1-9]\d{7,14} |
Area/prefix codes checked against a "not a valid NANP prefix" exclusion table |
| DOB | Date-like tokens (MM/DD/YYYY, DD Month YYYY, YYYY-MM-DD) occurring within 40 characters of the case-insensitive keywords "DOB", "date of birth", or "born" |
None beyond the date grammar itself |
| UK NI number | [A-CEGHJ-PR-TW-Z]{2}\d{6}[A-D] |
Prefix letter pairs BG, GB, NK, KN, TN, NT, ZZ are excluded per the official format rules |
| Driver's license (US, generic) | A per-state format table (e.g., 1 letter + 7 digits for New York format, 9 digits for many others) selected by a "state" dropdown in the pack's options; falls back to a broad [A-Z0-9]{6,12} heuristic when no state is chosen |
None — stated as best-effort in the UI copy |
| Passport number | [A-Z0-9]{6,9} occurring within 40 characters of the keyword "passport" |
None — the UI copy states passport formats vary by issuing country and this pack is best-effort |
| RFC 5322-simplified pattern | Domain must contain at least one dot |
Every search mode produces a match review list: each row shows a short context snippet, the page number, the source (literal/regex/pack name), and per-row Accept/Reject controls, plus Accept all and Reject all. Accepted matches become ordinary marks in the marks panel (9.1.3) and can be individually removed there afterward. Rejected matches are discarded and do not appear again unless the search is re-run.
9.1.5 Redaction marks versus applied redactions #
Two distinct, unmistakable states:
| State | Meaning | Reversible | Visual |
|---|---|---|---|
| Marked | A region has been flagged for removal. Nothing in the underlying file has changed. Stored only as local metadata (Dexie) plus an overlay layer; the source PDF bytes in OPFS are untouched. | Fully — delete the mark, nothing happened. | Translucent orange overlay with orange border. |
| Applied | The true-removal algorithm (9.1.7) has run and passed verification (the final step of 9.1.7). The content is gone from the file. | Not reversible. | Solid, fully opaque fill in the configured color, drawn as ordinary page content. |
Moving from Marked to Applied requires clicking "Apply — This Cannot Be Undone," styled with the destructive-action token from Section 16. The click opens a confirmation dialog that states the number of marks about to be applied and requires the user to check a box labeled "I understand this cannot be undone" before the Apply button in the dialog becomes enabled — the button is disabled, not hidden, so its presence and the reason it is disabled are both discoverable by assistive technology. There is no secondary "type REDACT to confirm" text field; the explicit checkbox plus the irreversibility copy is judged sufficient friction for a client-side, no-account-risk action, while still meeting the "unmistakable" requirement.
Immediately after a successful Apply and a passing verification pass, the pre-redaction original file bytes are purged from OPFS — not retained as an undo buffer — so that no later action in the same tab (export, re-share, "recent files") can accidentally surface the unredacted original. This is a deliberate privacy decision: keeping the original around for convenience would recreate the exact failure mode redaction exists to prevent.
9.1.6 Options #
| Option | Type | Default | Notes |
|---|---|---|---|
fillColor |
hex string | #000000 |
Always rendered at 100% opacity; no opacity control is offered (9.1.7). |
fillLabel |
string, 0–40 chars, optional | empty | Optional text drawn centered in the fill box (e.g., "REDACTED", an exhibit code) in a bundled font (the same Noto Sans / standard-14 set specified in 9.2.4), auto-sized to fit the box height. |
redactWholeImageOnPartialSelect |
boolean | false |
When false, a mark that only partially overlaps an image overwrites only the intersecting pixels. When true, any intersection removes the entire image. |
includeAnnotationsUnderMark |
boolean | true |
Deletes any annotation, link, or form field whose rectangle intersects a mark, even if the mark was drawn as a text or image selection rather than a box. |
redactAcrossPages (per repeated mark) |
page range string | — | See 9.1.3 item 5. |
9.1.7 The true-removal algorithm #
Applying redaction executes these twelve steps, in order, against the in-memory object model, and only writes output after step 12 passes. Three of these steps (4, 5, 6) close bypass vectors that a black-box view of the page never reveals: the tagged-PDF structure tree, page thumbnails, and page-piece dictionaries can each hold a verbatim copy of the original content even after the visible page content is clean.
- Content-stream operator surgery. Parse each affected page's content stream into its operator
sequence. For every glyph-drawing operator (
Tj,TJ,',") whose current text-rendering matrix places any glyph's bounding box so that it intersects a mark region, remove that glyph. ATJarray mixes string runs and inter-glyph spacing adjustments; the algorithm walks each string run character-by-character using the font's width table to reconstruct per-glyph x-positions, splits the run at the exact character boundary where intersection begins and ends, and re-emits the surviving left and right sub-runs as their own array elements with a recomputed spacing adjustment between them so the remaining glyphs keep their original visual position. ATj/'/"operator whose string is partially covered is rewritten as one or twoTjoperators (surviving prefix, surviving suffix) or aTJarray if a spacing gap must be preserved between the surviving pieces. Ligature glyphs (a single glyph ID representing multiple characters, e.g. "fi") are treated as atomic: if any part of a ligature's bounding box intersects the mark, the whole ligature is removed, even the portion that visually fell outside the box, because a ligature cannot be split into a partial glyph. Text drawn via a Type 3 font (a font whose glyphs are themselves arbitrary content streams, not simple character codes) is not amenable to character-boundary surgery; the algorithm detects Type 3 font usage under a mark and falls back to the pixel-overwrite method of step 2, applied to a rasterization of just that glyph region, and records this fallback in the Redaction Verification Report (9.1.8). Every marked-content identifier (MCID) and content-stream marked-content sequence that was wholly or partly removed here is recorded for use by step 4. - Partial-image pixel overwrite and re-encode. For every image XObject intersecting a mark:
decode the image to raw pixels, overwrite the pixels within the mark's region (transformed from page
space into image pixel space via the image's placement matrix) with the configured fill color, and
re-encode the image using its original filter where practical (
DCTDecode/JPEG,FlateDecode/PNG- style,CCITTFaxDecodere-rendered toFlateDecodesince fax encoding cannot represent an arbitrary fill color efficiently). IfredactWholeImageOnPartialSelectis set, or if the intersected region covers more than 90% of the image's area, the entire image XObject is deleted instead of partially overwritten. Image XObjects shared across multiple pages (a common resource for a repeated logo or letterhead) are never modified in place: the algorithm first determines, across the whole document, every page that places the object and whether that placement intersects a mark. If every intersecting placement needs the identical edit, the shared object is edited once. Otherwise — the ordinary case, where only some of the pages sharing the object carry a mark over it — the algorithm clones the XObject into a new, page-specific object before overwriting or deleting it, rewrites only the marked page's content stream to reference the clone, and leaves every other page's reference pointing at the original, unmodified object. This guarantees a marked page never finishes Apply still referencing the pre-redaction original object, while an unmarked page that happens to share the resource keeps rendering exactly as it did before. - Annotation, link, and field removal. Delete, in full, every annotation dictionary (comments,
stamps, highlights, freetext, links) and every AcroForm field/widget whose rectangle intersects a
mark. This includes the field's value, its appearance streams, and its entry in the AcroForm's
field-hierarchy array. A partially-overlapping annotation is removed entirely, never clipped — an
annotation clipped to look redacted but still holding its full un-redacted contents in its
dictionary is exactly the incremental-save-style leak this algorithm exists to prevent. Because the
whole annotation or field dictionary is deleted, its appearance streams (
/AP/N,/AP/D,/AP/R) are deleted with it as a matter of course — no separate appearance-stream scrub is needed for objects removed at this step. An appearance stream that survives, because it belongs to a field or annotation whose rectangle does not intersect any mark, is left untouched here; marked text rendered inside a surviving appearance stream (for example, a form field's own displayed value) is handled by the same glyph-purge mechanism as step 1 and is specified as an edge case in 9.1.13. Every annotation and field object reference removed here is recorded for use by step 4. - Structure-tree scrub. Walk the tagged-PDF structure hierarchy rooted at
/StructTreeRoot. For every structure element: (a) remove any kid that is a marked-content reference to a sequence recorded as removed in step 1; (b) remove any kid that is an object reference (/OBJR) to an annotation, form field, or image XObject recorded as removed in step 2 or step 3; (c) independently of (a) and (b), if the element's own/ActualTextor/Altentry contains, as a substring, any of the exact strings removed in step 1, clear that entry to an empty string./ActualTextand/Altexist specifically so assistive technology can read text a sighted user would see rendered on the page, and they hold that text verbatim and independently of the content stream — an unscrubbed entry is functionally identical to leaving the original text in the file, invisible to a sighted reviewer checking the rendered page but fully readable by a screen reader or by anyone who parses the structure tree directly; (d) after (a)–(c), any structure element left with zero kids and no independent content is itself removed from its parent's kid array, and this removal cascades upward — a parent left with zero kids after its child is removed is likewise removed — so the output structure tree contains no empty node that exists only as a naming artifact of a redacted region. The document's/ParentTree(the numbered map from MCID back to structure element) is rebuilt from scratch against the surviving tree rather than patched in place, since a stale/ParentTreeentry pointing at a deleted structure element would itself be a residual pointer into removed content. - Page thumbnail purge. Every page dictionary's
/Thumbentry — a pre-rendered raster thumbnail of the page as it looked before this Apply — is deleted outright on every page that had at least one mark applied. A stale thumbnail is a complete raster of the original, pre-redaction page and is exactly as recoverable as the page content itself, so there is no partial-redaction of a thumbnail, only removal; a thumbnail is never regenerated from the sanitized content, since regenerating one would add cost with no benefit the product needs. Because a/Thumbobject, like any other indirect object, may be stored either as a classic, directly-addressed object or compressed inside a cross-reference-stream-era object stream (/Type /ObjStm), this step operates on the fully-resolved object graph used throughout this algorithm — object streams are transparently decompressed as part of ordinary object resolution — so a thumbnail packed into an object stream is found and removed exactly as reliably as one stored as a classic indirect object. - Page-piece dictionary purge. Every
/PieceInfodictionary — at the document catalog level and on every individual page dictionary, whether or not that specific page carries a mark — is deleted unconditionally on every successful Apply. Authoring applications (Acrobat plug-ins, InDesign's PDF export, and similar tools) use/PieceInfoas free-form private storage, and in practice it has been observed to retain a cached copy of pre-edit page content or other original application state that has nothing to do with the page's current, visible appearance. The field's contents are opaque and application-specific and serve no function once the file leaves the authoring application, so there is no legitimate use of it worth preserving through a redaction apply; it is removed from every page and the catalog regardless of which pages were marked, not conditionally scrubbed. - Optional content group (layer) removal. For every optional content group (OCG) referenced only
by content now fully removed by steps 1–3, delete the OCG's membership dictionary and its entry in
the document's
/OCProperties. An OCG that still has surviving, non-redacted content is kept. - Orphan-object purge and font re-subsetting. Walk the full object graph from the document
catalog and delete every indirect object no longer reachable — the content-stream fragments,
annotation dictionaries, image XObjects, structure elements,
/ParentTreeentries,/Thumbobjects,/PieceInfodictionaries, and optional-content-group membership dictionaries removed in steps 1–7, plus any object that only existed to support them. For every font used on an affected page, compute the set of character codes still actually drawn anywhere in the surviving content streams (document-wide, not just on the affected page, since PDFs commonly share one font object across pages), and rebuild the font's embedded program (FontFile/FontFile2/FontFile3) as a fresh subset containing only the surviving glyphs. Removed characters are not merely hidden from the/Encoding— their outlines are physically absent from the rebuilt glyph table, so they cannot be recovered by re-parsing the font program. - Metadata, embedded file, and JavaScript stripping. Remove the document's XMP metadata packet
and
/Infodictionary (both are regenerated fresh with onlyProducerset), every embedded file stream (/EmbeddedFilesname tree and any/Filespecreferencing one), every JavaScript action (/Names/JavaScripttree, document-level/OpenActionif it is a JavaScript action, and any per-annotation/AAJavaScript action), every named destination that resolves into content removed by steps 1–3 or 7, and, unconditionally on every successful redaction apply, the entire/XFAkey if present. An XFA data island duplicates form field values in a separate XML stream that most viewers ignore in favor of the AcroForm representation but that Adobe Acrobat and some server-side text extractors will still read; since step 1–3 surgery operates on the AcroForm/page-content representation, a stale XFA island could otherwise retain removed text invisibly, so it is always dropped rather than conditionally scrubbed. - Full rewrite — never an incremental save. The sanitized object graph is serialized from scratch
as a brand-new PDF file: a fresh object numbering, a fresh single cross-reference section, and a
fresh trailer. An incremental save is never used for a redaction apply, under any circumstance.
An incremental save appends a new cross-reference section on top of the existing file bytes and
leaves everything before that append point physically present on disk — this is the precise
mechanism behind the well-known public redaction failures in which a black box was visible in a
viewer but the "removed" text was still extractable by opening the file in a text editor or an older
PDF tool that reads the original, pre-append object: the box was added, the text underneath never
left. This tool's redaction path has no code path that can produce an incremental update; the
serializer used for Apply is a distinct function from the serializer used for ordinary saves in
other tools, specifically so that a future change to another tool's save behavior cannot silently
reintroduce this failure mode into redaction. This full rewrite also resolves a subtler input case:
if the file being redacted already carried more than one revision before this tool opened it — a
trailer with a
/Prevkey chaining back to one or more earlier cross-reference sections, the ordinary result of a prior incremental save by some other tool — the object graph this algorithm builds is, as with any correct PDF parse, resolved from the current (latest) trailer, following/Prevonly to find objects the latest revision does not itself redefine. None of the earlier revisions' bytes — which could include an object holding pre-redaction content that the latest revision superseded but a/Prevwalk could still recover — are carried into the output: the fresh, single-revision serialization this step produces has no/Prevkey anywhere in its trailer, because there is only ever the one revision this step just wrote. An input/Prevchain of any length is therefore fully collapsed by the ordinary operation of this step, not by any special-cased handling. - Draw the redaction box last. Only after steps 1–10 have removed the underlying content is the
opaque fill rectangle (and optional
fillLabeltext) added, as brand-new content-stream operators on the now-sanitized page. Drawing it last guarantees the box can never be the only thing separating a viewer from content that is, in fact, still present underneath — because nothing is underneath. - Verification pass. Before the output is accepted as the final result: (a) re-extract all text
from every affected page of the freshly-serialized output and assert that none of the exact strings
removed in step 1 are present anywhere in the output — including inside object streams, inside the
font's
/ToUnicodeCMap, and inside any string object anywhere in the file, not only on the visible page; (b) re-read every surviving/ActualTextand/Altentry in the output's structure tree and assert none contains, as a substring, any of the exact strings removed in step 1; (c) re-render every marked region at 300 DPI and assert every pixel matches the configured fill color within a tolerance of 2 levels per channel (to allow for JPEG-style re-encoding rounding); (d) assert the output file contains exactly one cross-reference section, exactly one trailer, and no/Prevkey anywhere in that trailer; (e) assert no page dictionary anywhere in the output — not only the pages that carried a mark — contains a/Thumbkey; (f) assert neither the document catalog nor any page dictionary in the output contains a/PieceInfokey. If any of (a) through (f) fails, the entire operation is discarded per 9.1.9.
9.1.8 Redaction Verification Report #
On success, a downloadable report (JSON, with a rendered HTML/PDF summary view in the app) is produced and written to the job's local history entry:
{
"documentName": "Merger-Agreement-Draft.pdf",
"appliedAt": "2026-08-19T14:03:11Z",
"marksApplied": 14,
"pages": [
{
"page": 3,
"glyphRunsRemoved": 22,
"imagesModified": 1,
"imagesRemoved": 0,
"annotationsRemoved": 2,
"type3FallbackUsed": false,
"structureTreeEntriesScrubbed": 3,
"thumbnailRemoved": true,
"pixelVerificationPassed": true,
"textVerificationPassed": true,
"structureTreeVerificationPassed": true
}
],
"documentLevel": {
"metadataStripped": true,
"javaScriptRemoved": 0,
"embeddedFilesRemoved": 0,
"xfaRemoved": false,
"fontsResubsetted": 3,
"structureTreeElementsRemoved": 1,
"pieceInfoRemoved": true,
"prevChainCollapsed": false,
"crossReferenceSectionsInOutput": 1
},
"overallResult": "pass"
}structureTreeEntriesScrubbed (per page) counts /ActualText//Alt entries cleared and marked-content
kid references removed for that page's structure elements; structureTreeElementsRemoved (document
level) counts whole structure elements deleted by the cascade in step 4 of 9.1.7. thumbnailRemoved is
true whenever that page carried a /Thumb entry prior to Apply and it was removed, false when there
was none to remove (a page can pass this check by having had nothing to do). pieceInfoRemoved is true
only when the input actually contained a /PieceInfo dictionary that was removed, false when there was
none. prevChainCollapsed is true only when the input trailer actually contained a /Prev key that
was collapsed away; the example above shows false because that particular input had no prior revision
to collapse. In every case, whether the field reads true or false, the guarantee is identical: none
of these four artifacts exist in the output.
Every field is a fact about what the algorithm actually did to this specific file, not a generic disclaimer.
9.1.9 Failure behavior #
If step 12 of 9.1.7 fails for any page or for the document as a whole, the whole operation is discarded:
no output file is produced or downloadable, the original file in OPFS is never touched (the algorithm
always writes to a new scratch artifact and only promotes it to "the" result after verification passes,
so there is nothing to roll back), and the app shows a failure screen: "Redaction could not be verified.
Your original file was not modified." with the failing page numbers (where applicable — the thumbnail,
piece-info, and cross-reference//Prev checks are document- or page-level structural facts rather than
per-page content facts) and a failure reason drawn from the report: residual_text_found (check a),
residual_structure_text_found (check b), pixel_mismatch (check c), multiple_xref_sections (check
d — also covers a surviving /Prev key), thumbnail_present (check e), or piece_info_present (check
f). The failure screen also shows a Retry button and an Export diagnostic bundle button that
downloads the verification report JSON (no document content) for a support request. The tool never ships
a partially-redacted file under any circumstance.
9.1.10 What redaction does not protect against #
Stated plainly in the app UI (a permanent, dismissible-but-reappearing notice on the redact tool page) and here:
- A page image that visually encodes the information elsewhere on the page or in the document outside the marked region — redaction only removes what is inside a mark.
- A page that visually encodes the information as an image with no text layer. Search-and-redact (9.1.4) only finds matches in the text actually extracted from a page; a scanned or image-only page has no such text, so a search pass silently finds nothing on that page even if the sensitive content is plainly visible on it. Marking such a page requires one of the manual selection modes in 9.1.3 (draw box, select image, or select whole page) — the tool does not warn separately when a page has no text layer, so a workflow that relies solely on search-and-redact can miss an image-only page entirely.
- The document's filename, if the filename itself contains sensitive information; the tool suggests renaming the file separately.
- Any copy of the file already sent, printed, or posted before redaction was applied — redaction changes the file in front of the user, not copies already in someone else's hands.
- The same content existing in a copy the user has already distributed. Applying a redaction changes only the bytes of the file open in this session; it has no way to locate, recall, reach into, or update any other copy of the document already emailed, uploaded, printed, or stored elsewhere, even a copy that is otherwise identical to the one just redacted.
- Information that can be inferred from surrounding, unredacted context, or from a version of the document stored elsewhere (an email attachment sent earlier, a cloud-sync history, a printed copy).
9.1.11 UI flow and states #
Idle (no file) → Loaded (viewer + toolbar, Processing Location Indicator "On your device") →
Marking (a selection tool is active; ends when the user releases the pointer or completes a keyboard
selection) → Reviewing marks (marks panel open, list can be edited/deleted) → Searching (search
panel open) → Reviewing matches (match review list, per 9.1.4) → Ready to apply (≥1 mark exists,
Apply button enabled) → Confirming (dialog from 9.1.5) → Applying (progress bar with the job-state
vocabulary from Section 4.7 — running, stage strings "Removing content," "Rebuilding fonts,"
"Verifying") → Verified — succeeded (download screen: redacted PDF + Verification Report) or
Verification failed (failure screen per 9.1.9).
9.1.12 Keyboard and screen-reader behavior #
- Box-draw mode is reachable via the toolbar or the
Rkey. Because a pointer drag has no direct keyboard equivalent, a keyboard placement mode is offered: arrow keys move a text-run-snapped caret; Enter sets the selection anchor; further arrow-key presses (optionally with Shift, mirroring standard text-selection semantics) extend the selection by character, word, or line; a second Enter confirms the mark; Escape cancels. Screen readers announce mode entry ("Redaction placement mode. Use arrow keys to move, Enter to start a selection, Enter again to confirm.") and each extension step ("Selection now covers 14 characters on line 3."). - "Redact this page" and "Redact this image" are ordinary focusable, Tab-reachable buttons activated with Enter or Space.
- The marks panel is a list of focusable items; Delete removes the focused mark and a live region
(
aria-live="polite") announces "Mark removed. 13 marks remaining." - The match review list supports Up/Down to move focus and Space to toggle accept/reject on the focused row; "Accept all"/"Reject all" are ordinary buttons.
- The confirmation dialog traps focus, places initial focus on the checkbox, associates the
irreversibility copy via
aria-describedby, and returns focus to the Apply button on cancel/Escape.
9.1.13 Edge cases #
- Rotated pages: mark coordinates are transformed through the page's
/Rotatevalue before intersection testing, so a box drawn on-screen always maps to the correct unrotated content-stream coordinates. - Mixed page sizes/orientations within one document: each page's marks are evaluated against that page's own geometry independently.
- Vertical writing mode (CJK vertical text): glyph bounding-box intersection uses the actual glyph advance direction reported by the font's writing-mode flag, not an assumed horizontal advance.
- Text inside a form field's appearance stream: appearance streams (
/AP/N) are scanned and glyph-purged using the same algorithm as page content, referenced rather than duplicated in Section 8's form tooling. - No extractable text under a mark (the page is a scanned image with no text layer): the algorithm
automatically falls back to pixel-only overwrite for that mark, records
textVerificationPassed: truetrivially (there was nothing to find) in the report, and does not prompt the user separately — the report'spixelVerificationPassedfield is what carries the meaningful result for that mark. - Overlapping marks: unioned into a single removal region before step 1 runs, so no double processing or double-counted report figures.
- A file already opened with the wrong permissions/owner password restrictions: redaction proceeds regardless, because it operates on the user's own local copy of bytes already fully in their possession; permission bits are not a confidentiality control (9.5, 9.6) and are not consulted by this tool.
- An image XObject shared across multiple pages: pixel overwrite or deletion is scoped to the marked page via the clone-before-modify handling in step 2 of 9.1.7, so redacting on one page never silently leaves another, unmarked page still referencing the original object nor unexpectedly changes that other page's appearance.
- An input file that already carries a
/Prev-chained revision history (for example, a file previously saved incrementally by another tool before it reached this product): the full, single- revision rewrite in step 10 of 9.1.7 collapses this automatically. The output never contains a/Prevkey or more than one cross-reference section, regardless of how many revisions the input carried, and this is asserted directly by verification check (d) in step 12. - A structure element that mixes marked and unmarked content under the same parent (for example, a
/Sectelement wrapping both a redacted paragraph and an untouched one): only the kids that reference removed marked content or removed objects are pruned per step 4 of 9.1.7; the parent element and its surviving kids are kept, since they still describe real, unredacted content.
9.1.14 Public API operation #
Operation slug: redact. POST /v1/documents/{documentId}/redact, body containing a marks array
(page index plus rectangle in PDF user-space points, or a matches block describing a search
configuration identical in shape to the app's pattern-pack options) and the options of 9.1.6. Because
API calls always run server-side (Section 6) and the server uses the identical engine artifact as the
browser (Section 3), the output is byte-identical to what the app would have produced locally given
the same marks and the same deterministicTimestamp. The response is a Job resource (Section 4.7)
that, on succeeded, exposes resultDocumentId and a verificationReportUrl.
9.1.15 Acceptance criteria #
- Given a PDF containing the literal string
"555-12-3456"drawn via aTjoperator, marking and applying a redaction over its bounding box, extracting all text from the output finds the string nowhere — not on the page, not in any object stream, not in any font's/ToUnicodeCMap — and the Verification Report showsoverallResult: "pass"for that page. - The output file of any successful Apply contains exactly one cross-reference section, verified by
counting
startxrefoccurrences in the serialized bytes and confirming it is exactly one. - Given an image occupying the top half of a page with a mark covering only the left third of that image, the re-encoded image's pixels outside the marked region are unchanged (modulo re-encoding rounding) and the pixels inside are the configured fill color; the image XObject's byte length in the output differs from the input, proving re-encoding occurred rather than an overlay being drawn on top.
- Given a document with an embedded font subset containing glyphs used only inside redacted text, after Apply, the font program's glyph table no longer contains outlines for those specific characters, while glyphs for surviving characters remain intact.
- Running the "Credit card" pattern pack against a page containing one Luhn-valid 16-digit number and one same-length random 16-digit string that fails Luhn, the match review list surfaces only the Luhn-valid number.
- Given a tagged PDF whose
/Figurestructure element has an/Altattribute reading "Photo ID showing SSN 555-12-3456" over an image redacted by a mark covering that photo, after Apply, every surviving/ActualTextand/Altvalue in the output structure tree contains the string"555-12-3456"nowhere, and the Verification Report'sstructureTreeVerificationPassedfield for that page istrue. - Given a page with a
/Thumbentry present in the input and a mark applied on that page, after Apply the output page dictionary for that page has no/Thumbkey, and the report'sthumbnailRemovedfield for that page istrue. - Given a document with a
/PieceInfodictionary present at both the document catalog and on a page that carries no mark, after any successful Apply on that document, neither/PieceInfodictionary exists anywhere in the output, and the report'spieceInfoRemovedfield istrue. - Given an input file whose trailer contains a
/Prevkey pointing to an earlier cross-reference section that itself defines the original, pre-redaction version of a page object now redacted, after Apply, the output trailer contains no/Prevkey,startxrefoccurs exactly once in the serialized bytes, and re-extracting text from the complete output byte stream — not merely the current object graph — finds none of the redacted strings.
9.2 Watermark (client-side) #
9.2.1 Purpose, execution location, minimum plan #
Adds repeatable text or image marks (draft stamps, confidentiality notices, ownership marks) to page content. Client-side; minimum plan Free (not a guest tool).
9.2.2 Inputs and validation #
| Input | Validation |
|---|---|
| Watermark image (image mode) | PNG or JPEG, max 10 MB, decoded via the shared engine's libjpeg-turbo/libwebp codecs (Section 3). |
| Watermark text (text mode) | 1–200 characters. Empty string is rejected with watermark_text_required when text mode is selected. |
| Page range | A valid range expression against the document's actual page count, or odd/even. |
9.2.3 Options #
| Option | Type | Default | Notes |
|---|---|---|---|
type |
text | image |
text |
|
text |
string | empty (placeholder "e.g. CONFIDENTIAL") | Supports dynamic tokens, 9.2.4. |
font |
enum, bundled set | Helvetica (standard-14) | See 9.2.5. |
fontSize |
6–400 pt | 48 pt | |
color |
hex | #FF0000 |
|
opacity |
0–100 | 30 | |
rotation |
-180–180° | -45 | |
scale (image mode) |
1–500% of original pixel size | 100 | |
position |
9-point grid or custom |
center |
Same radio-group control described in 9.2.6. |
customX / customY (when position=custom) |
points from page origin | — | |
tiling |
boolean | false |
|
tileSpacing |
10–500 pt | 100 | Only relevant when tiling=true. |
layer |
front | behind |
front |
|
pageRange |
range string | odd | even | all |
all |
|
firstPageOnly |
boolean | false |
Overrides pageRange to page 1 only when set. |
9.2.4 Dynamic tokens #
| Token | Resolves to |
|---|---|
{{page}} |
The current page's 1-based number. |
{{pages}} |
Total page count of the document. |
{{filename}} |
The original filename without its extension. |
{{date}} |
The device's local date, formatted YYYY-MM-DD. |
{{time}} |
The device's local time, formatted 24-hour HH:mm. |
{{username}} |
The signed-in account's display name, or the workspace name for a Team-workspace document. |
Tokens may be combined freely with literal text, e.g. "Page {{page}} of {{pages}} — {{filename}}".
9.2.5 Fonts #
Text watermarks, page numbers (9.3), and Bates numbers (9.4) share one bundled font set, embedded at build time into the client bundle so no system-font dependency exists in the browser: the 14 standard PDF fonts (Helvetica, Helvetica-Bold, Helvetica-Oblique, Helvetica-BoldOblique, Times-Roman, Times-Bold, Times-Italic, Times-BoldItalic, Courier and its three variants, Symbol, ZapfDingbats), plus Noto Sans, Noto Sans CJK SC, and Noto Sans Arabic for Unicode coverage beyond Latin-1. The font picker groups these as "Standard" and "Extended (Unicode)."
9.2.6 Algorithm #
- Resolve all dynamic tokens in the text against the current document and, for
{{page}}, the specific target page. - Render the text or image to a content-stream fragment sized per
fontSize/scale. - Compute a placement matrix combining
position(orcustomX/customY),rotation, andscale. - If
tilingis true, compute a repeating grid across the page's CropBox usingtileSpacingas the period in both axes, generating one placement matrix per tile instance. - For each target page (per
pageRange/firstPageOnly): wrap the fragment inq/Qgraphics-state isolation operators and either prepend it to the page's content-stream array (layer=behind, painted before existing content) or append it (layer=front, painted after, and therefore visible on top of existing content). - Rewrite the file.
Because the watermark becomes ordinary page content at creation time, Flatten (9.7) has no additional effect on it — a watermark is never a separate annotation or interactive object to begin with.
Position grid control: nine buttons arranged in a 3×3 grid, implemented as a role="radiogroup" of
nine role="radio" buttons, navigable with arrow keys and selected with Enter/Space; this exact
control is reused unmodified for page numbers (9.3) and Bates numbering (9.4).
9.2.7 Removing a watermark #
The app can reliably undo a watermark it just added within the same local session, via the tool's Undo History, because it still holds a record of exactly which content-stream objects it inserted. Once the file has been closed, re-exported, or the browser's local history has been cleared, the applied watermark is ordinary page content indistinguishable from anything else on the page — the product does not attempt to auto-detect and remove watermarks from an arbitrary PDF (its own past output or anyone else's), because a heuristic detector (e.g., "low-opacity, repeated, diagonal text") is unreliable and would either miss real watermarks or delete legitimate content that happens to match the pattern. Removing an existing watermark from a file the app did not just create requires manually selecting the watermark's region with the Redact tool (9.1), which is destructive and permanent like any other redaction.
9.2.8 UI flow and states #
Idle → Loaded → Configuring (live preview canvas updates on every option change, debounced 150
ms) → Applying (progress bar; watermarking a typical document completes in under a second, so this
state is often not visibly separate from clicking Apply) → Done (download). No confirmation dialog —
watermarking is not treated as high-risk — except a passive warning banner when the computed tile
count for a tiling=true configuration exceeds 500 instances per page: "This may noticeably increase
file size."
9.2.9 Keyboard and screen-reader behavior #
All configuration controls are native, labeled form elements. The position grid uses the radiogroup
pattern of 9.2.6. The live preview canvas is not directly inspectable by assistive technology, so an
adjacent aria-live="polite" text region restates the current configuration in prose whenever it
changes, e.g. "Watermark preview: text 'CONFIDENTIAL', 30% opacity, rotated -45 degrees, tiled across
all pages."
9.2.10 Edge cases #
- Image watermark alpha transparency is preserved through placement.
- Text wider than the page:
Shrink to fitis on by default, reducingfontSizeuntil the text fits the page width minus a 36 pt margin; when off, text is clipped at the page edge. - Watermark on a rotated page: the placement matrix is composed with the page's own
/Rotatevalue. layer=behindunder fully opaque page-covering content is applied but may render invisible — this is expected and documented, not a bug; the tool does not attempt to detect page opacity.- Watermarking an encrypted file blocks with the unlock prompt (9.6) first.
9.2.11 Public API operation #
Operation slug: watermark. POST /v1/documents/{documentId}/watermark with the options of 9.2.3.
9.2.12 Acceptance criteria #
- On a 3-page document with
pageRange=odd, watermark content is present on pages 1 and 3 and absent from page 2. - With
tiling=trueandtileSpacing=50, the rendered output's watermark instances repeat horizontally with a period of 50 pt plus the glyph run width, within 1 pt tolerance. - On a 5-page document named
Q3-Report.pdf, with text"Page {{page}} of {{pages}} — {{filename}}", page 3's rendered watermark text is exactly"Page 3 of 5 — Q3-Report". - With
layer=behindon a page whose first content operator paints a fully opaque page-covering white rectangle, the watermark is not visible in a rendered raster of that page (verified by pixel sampling), confirming paint order.
9.3 Page numbers (client-side) #
9.3.1 Purpose, execution location, minimum plan #
Adds a running page-number stamp to a page range. Client-side; minimum plan Free (not a guest tool).
9.3.2 Options #
| Option | Type | Default | Notes |
|---|---|---|---|
position |
9-point grid | bottom-center |
Radiogroup control from 9.2.6. |
margin |
0–200 pt | 36 pt (0.5 in) | Distance from the relevant page edge(s). |
startPage |
1–page count | 1 | The page on which numbering visually begins. |
startNumber |
0 or greater | 1 | The value shown on startPage. |
format |
1 | i | I | a | A |
1 |
Arabic, lowercase roman, uppercase roman, lowercase letters, uppercase letters. |
prefix / suffix |
string, 0–20 chars each | empty | |
showTotal |
boolean | false |
Renders "Page X of Y" (composes with prefix/suffix as "{prefix}Page X of Y{suffix}"). |
font / fontSize / color |
see 9.2.5 | Helvetica, 10 pt, #000000 |
|
skipFirstPage |
boolean | false |
See distinction below. |
alternateOddEven |
boolean | false |
Mirrors position and margin horizontally between odd and even pages, for double-sided printing (odd pages numbered at the outer-right margin, even pages at the outer-left margin, when position is one of the bottom or top corners). |
skipFirstPage versus startPage: skipFirstPage hides the visual number on page 1 only, while
page 1 still occupies position 1 in the counting sequence — page 2 shows "2". startPage instead
excludes every page before it from the sequence entirely: with startPage=3, startNumber=1, pages 1–2
show nothing and page 3 shows "1". When showTotal is combined with startPage, Y is the count of
numbered pages only (total pages − startPage + 1), not the document's total page count.
Format i/I (roman numerals) uses standard subtractive notation, valid for 1–3999; a document with
more than 3999 numbered pages falls back silently to Arabic numerals for pages beyond that point, since
roman numerals have no standard representation past 3999. Format a/A wraps spreadsheet-column-style
after z/Z: 26 → z, 27 → aa, 28 → ab.
9.3.3 Algorithm #
For each page in the effective range: compute the display string per format/prefix/suffix/
showTotal; measure the glyph run's rendered width for center/right alignment; inject as a new,
front-layer content-stream text operator using the same q/Q-isolated append mechanism as 9.2.6,
positioned by margin from the grid cell's edge(s), mirrored per alternateOddEven if set. This tool
does not detect or remove page numbers already printed into the original page content — those are
ordinary page graphics/text to the engine, indistinguishable from any other content, so a pre-existing
number is not touched; removing one requires Redact (9.1) or the text-editing tool in Section 8. A
passive, non-blocking note in the options panel says so.
9.3.4 UI flow, keyboard/screen-reader behavior, edge cases #
Flow: Configuring (live preview) → Applying → Done. No confirmation dialog. Keyboard/SR: standard
labeled form controls; position grid reuses 9.2.6's pattern by reference.
Edge cases: an overlong prefix/suffix combined with showTotal on a narrow page auto-shrinks the font
down to a floor of 6 pt, then clips with an ellipsis if still too wide; mixed portrait/landscape pages
measure margins from each page's own MediaBox.
9.3.5 Public API operation #
Operation slug: page-numbers. POST /v1/documents/{documentId}/page-numbers.
9.3.6 Acceptance criteria #
- On a 10-page document with
format=1, startPage=1, startNumber=1, page 10 displays"10". - With
skipFirstPage=true, showTotal=trueon a 10-page document, page 1 has no number and page 2 shows"Page 2 of 10". - With
format=i, startNumber=1, page 4 displays"iv". - With
alternateOddEven=true, position=bottom-right (outer), margin=36, page 1 (odd) is numbered at the bottom-right and page 2 (even) at the bottom-left, both 36 pt from their respective outer edge.
9.4 Bates numbering (client-side) #
9.4.1 Purpose, execution location, minimum plan #
Sequential exhibit numbering for legal workflows, including continuous numbering across a batch of
files so a multi-file exhibit set numbers 1..N end to end. A single-file Bates job is Free-plan
eligible. A multi-file batch Bates job requires batch processing, which per the plan table in
Section 12.2 is Pro and above (Free and Guest do not have batch processing); this gate applies to
Bates numbering exactly as it applies to any other batch job.
9.4.2 Options #
| Option | Type | Default | Notes |
|---|---|---|---|
prefix |
string, 0–20 chars | empty | E.g. "SMITH-". |
suffix |
string, 0–20 chars | empty | |
startNumber |
1 or greater | 1 | |
digitPadding |
1–10 | 6 | E.g. padding 6 renders 1 as "000001". |
increment |
1 or greater | 1 | Added per numbered page. |
position |
9-point grid | bottom-right |
Radiogroup from 9.2.6. |
font / fontSize / color |
see 9.2.5 | Helvetica, 10 pt, #000000 |
|
perFilePrefixOverrides |
map of filename → prefix (batch mode only) | empty | Lets each file in a batch carry its own exhibit label (e.g., PLTF- vs DEF-) while the numeric counter keeps incrementing continuously across the whole set. |
There is no "restart numbering per file" toggle. This is intentional: Bates numbering exists specifically to produce one unbroken sequence across a set of documents, and a per-file restart option would defeat that purpose, so it is not offered. A user who wants independent numbering per file simply runs separate single-file jobs.
9.4.3 File ordering and the numbering manifest #
Batch file order determines numbering order. Default order is the user's upload/drop order; an explicit reorder list (the same accessible drag-and-drop list pattern specified in Section 16, keyboard reorderable) lets the user change it before submitting.
A CSV manifest is produced alongside the numbered files, columns exactly:
file_name,first_bates,last_bates,page_count
Complaint-Exhibit-A.pdf,ACME-000001,ACME-000010,10
Deposition-Transcript.pdf,ACME-000011,ACME-000015,5
Exhibit-B-Photos.pdf,ACME-000016,ACME-000035,20A zero-page input file is skipped for numbering purposes and appears in the manifest with page_count
of 0 and blank first_bates/last_bates.
9.4.4 Algorithm and idempotency #
Identical to the page-number injection mechanism in 9.3.3 (front-layer content-stream append), with the
display string computed as prefix + zeroPad(counter, digitPadding) + suffix, where counter starts
at startNumber and advances by increment for every numbered page across the whole ordered batch,
switching prefix per perFilePrefixOverrides when a file boundary is crossed without resetting the
counter. If counter exceeds what digitPadding can represent (e.g. padding 3 with a count reaching
1000), the digits simply expand beyond the configured width ("1000") rather than erroring or
truncating; the manifest and the on-screen summary flag this with a passive note.
Idempotency: running the identical batch configuration (same files, same order, same prefix/ suffix/start/padding/increment) against the same unstamped inputs a second time produces byte-identical Bates label text and an identical manifest. This is a statement about determinism, not safety: rerunning the job does not detect that the files were already stamped and will stamp them a second time if pointed at files that already carry Bates marks from a prior run, because a prior stamp is just page content like any other. If a selected file appears to already contain a Bates-style stamp (detected via the same passive, non-blocking heuristic note used for pre-existing page numbers in 9.3.3), the UI surfaces a note but does not block.
9.4.5 UI flow, keyboard/screen-reader behavior, edge cases #
Flow: File selection & reorder → Options (live preview of the first and last few stamps that will
be produced) → Applying (batch progress list per Section 13, one row per file) → Done (zip download
of all numbered files plus the manifest CSV download).
Keyboard/SR: file reorder list per Section 16's drag-and-drop pattern (reference, not restated); the manifest download button is labeled "Download numbering manifest (CSV)."
Edge cases: single-file batches behave identically to the single-file path; mixed page sizes within a file position each page independently per 9.3.3's mechanism.
9.4.6 Public API operation #
Operation slug: bates-numbering. Single file: POST /v1/documents/{documentId}/bates-numbering.
Batch: created through the batch endpoint in Section 13 with tool: "bates-numbering" and an ordered
document list; the completed batch response includes a manifest download URL.
9.4.7 Acceptance criteria #
- A batch of three files with page counts 10, 5, and 20,
prefix="ACME-",startNumber=1,digitPadding=6stamps file 1ACME-000001–ACME-000010, file 2ACME-000011–ACME-000015, file 3ACME-000016–ACME-000035, and the manifest reflects exactly these ranges. - A
perFilePrefixOverridesentry for file 2 ("DEF-") changes only its label prefix while the numeric counter continues unbroken from file 1's last value plusincrement. - Rerunning the identical batch configuration against the identical, still-unstamped input files produces byte-identical Bates label text and manifest values to the first run.
9.5 Protect / encrypt (client-side) #
9.5.1 Purpose, execution location, minimum plan #
Encrypts a PDF with AES-256 and sets usage permissions. Client-side; minimum plan Free.
Like Redact (9.1) and Unlock (9.6), Protect is on the hard exclusion list for the opt-in device-to-server fallback described in Section 6.3. If client-side processing cannot complete, the tool fails with a device-side remediation message rather than ever offering to upload the file — uploading would mean transmitting the very password, and the plaintext content that password will protect, that this tool exists to keep off the server.
9.5.2 Inputs and validation #
At least one of userPassword or ownerPassword must be non-empty, or the request is rejected with
protect_no_password_supplied (encrypting with neither would do nothing meaningful). Each password:
1–128 characters, UTF-8 input normalized to Unicode NFC before encoding per the ISO 32000-2 R6
requirement (UTF-16BE encoding of the normalized password). If ownerPassword is left blank while
permission restrictions are configured, the tool auto-generates a random 32-character owner password,
used only transiently during processing and never surfaced or stored, and the UI shows this notice:
"You haven't set an owner password. Permission restrictions will be applied, but since only your open
password protects this file, anyone who can open it can also remove these restrictions using common PDF
tools" (see 9.5.4). Encrypting a file that already carries an encryption dictionary is refused with
document_already_encrypted — the user must Unlock (9.6) first.
9.5.3 Permissions matrix #
| Permission | ISO 32000-2 bit | Default | Toggle exposed |
|---|---|---|---|
| Print (low resolution) | bit 3 | Allowed | Yes |
| Print (high resolution) | bit 12 | Allowed | Yes |
| Copy / extract text and images | bit 5 | Allowed | Yes |
| Modify document | bit 4 | Allowed | Yes |
| Annotate / comment | bit 6 | Allowed | Yes |
| Fill form fields | bit 9 | Allowed | Yes |
| Extract for accessibility | bit 10 | Always allowed | No — always set to permitted, matching the accessibility-friendly convention recommended by the PDF specification; the product does not offer a way to disable screen-reader extraction |
| Assemble document (insert/delete/rotate pages) | bit 11 | Allowed | Yes |
Encryption uses AES-256 under revision R6 of the ISO 32000-2 encryption scheme — the modern, standardized AES-256 handling, not the earlier pre-standardization AESV3 variant some tools shipped before R6 was finalized and which is now considered broken in places; R6 is the only AES-256 mode this tool implements.
9.5.4 Password strength guidance and the forgotten-password statement #
Document-open passwords are unrelated to account passwords (Section 17's 12-character minimum and
Have I Been Pwned check apply only to account login passwords, never to document passwords). A
client-side strength meter (character-class and length heuristic, weak/fair/strong, no network
call) gives advisory guidance only — a minimum length of 8 characters is recommended in copy but not
enforced, since it is the user's own document and their own risk.
Owner-password permissions are advisory and are widely ignored by third-party PDF readers and libraries — many implementations open a file and grant full access as soon as the correct user password (or no user password at all) has been supplied, without checking the permission bits at all. The product states this plainly next to the permissions matrix, rather than implying the restrictions are enforced everywhere.
Forgotten-password statement, shown inline near the password field: "PDFWorks does not store document passwords anywhere. If you forget this password, the file cannot be opened by PDFWorks or any other PDF reader, and there is no recovery. Keep an unprotected copy somewhere safe if you might need one later."
9.5.5 Algorithm #
- Generate a fresh, random AES-256 file encryption key (CSPRNG) — a new key on every run, never reused from a prior protect operation on the same file.
- Derive user- and owner-password validation hashes per the ISO 32000-2 R6 key-derivation procedure (delegated to the shared engine's QPDF-based implementation, Section 3).
- Set the permission bit field per 9.5.3.
- Encrypt every string and stream object with a per-object key derived from the file key and the object's number/generation, per the specification's standard security handler.
- Write the encryption dictionary into the trailer and rewrite the file.
9.5.6 UI flow, keyboard/screen-reader behavior, edge cases #
Flow: Configuring (password fields with a show/hide toggle and the strength meter, a permissions
checklist, optional owner password) → Applying → Done. No special confirmation dialog beyond the
inline "we do not store this" notice — the operation is reversible by anyone holding the password
(9.6).
Keyboard/SR: password fields are type="password" with an accessible show/hide button
(aria-pressed); permissions are a fieldset/legend group of checkboxes; the strength meter's state
is exposed as live text ("Password strength: strong"), not only a color bar.
Edge cases: password length capped at 128 characters, validated before processing; leaving the open password blank while restricting permissions sets only an owner password (9.5.2); protecting twice in succession with different passwords each time produces a file openable only by the second password's freshly-generated key, never the first.
9.5.7 Public API operation #
Operation slug: protect. POST /v1/documents/{documentId}/protect.
9.5.8 Acceptance criteria #
- A file protected with a user password cannot be opened without that exact password and opens correctly when it is supplied.
- A file protected with the "copy" permission denied reports that permission bit as unset when inspected by any spec-compliant reader, once opened with the correct user password.
- Protecting the same source file twice in succession with two different passwords produces a second output openable only by the second password, confirming the encryption key is freshly regenerated each run rather than reused.
9.6 Unlock (client-side) #
9.6.1 Purpose, execution location, minimum plan #
Removes a password the user already knows, or lifts permission restrictions from a file that has no open password at all. Client-side; minimum plan Free. This tool does not crack, brute-force, or guess passwords, under any circumstance, in any tier of the product.
Like Redact (9.1) and Protect (9.5), Unlock is on the hard exclusion list for the opt-in device-to-server fallback described in Section 6.3. If client-side processing cannot complete, the tool fails with a device-side remediation message rather than ever offering to upload the file, for the same reason: the password required to complete the operation, and the decrypted content that results from it, must never leave the device.
9.6.2 The two flows #
The UI presents two distinct, separately-labeled actions rather than one ambiguous "unlock" button:
- Remove Password — for a file that requires a password to open at all. The user supplies the password; if correct, both the open-password protection and any permission restrictions are removed together (per the specification, correctly authenticating with the user password grants full access, so there is no way to keep the file locked-for-opening while only lifting permissions — the two cannot be separated once the user password is known).
- Remove Restrictions — for a file that already opens with no password prompt at all but carries a nonzero permissions-restricting encryption dictionary (an owner password was set, but no user password). No password is requested for this flow.
9.6.3 The owner-password-only decision #
The product supports Remove Restrictions without knowing the owner password, for a file that already opens freely. The reasoning, stated in the product's own documentation and here: if a file opens without any password, its content was never confidentially protected in the first place — only usage permissions (print, copy, and so on) were asserted, and those permissions are already advisory and widely ignored by other software (9.5.4). Lifting them locally, on a device where the user already has full read access to the content by definition (the file opened), does not defeat any confidentiality control. Requiring the (to the user, unknown) owner password in this case would add friction with no security benefit. The line the product draws, and will not cross, is: it never attempts to bypass a control that protects confidentiality — an open password or encryption the user cannot get past — it only ever lifts a control that restricts usage of content the user can already view in full.
Refusal copy, shown verbatim whenever "Remove Password" is attempted without the correct password:
"PDFWorks cannot remove a password you don't know. This tool only removes a password you already have — enter it above. We do not offer password recovery, brute-force, or cracking services, for this file or any file."
9.6.4 Abuse-prevention posture #
Purely client-side, a soft speed bump discourages casual scripted brute-forcing of the password field: after 5 consecutive failed attempts in one session, a 5-second cooldown is imposed before the next attempt is accepted. This is disclosed as exactly what it is — a UX deterrent, not a real security control, since it is trivially bypassed by reloading the page. When the equivalent flow runs server-side through the public API (Section 6), calls to this operation are additionally rate-limited at 20 requests per minute per API key — stricter than the platform default token-bucket rate in Section 14 — and every use of the owner-password-only "remove restrictions" path is logged as an observability event (Section 18) for abuse-pattern monitoring, not blocking.
9.6.5 Algorithm #
Decrypt all objects with the supplied key (Remove Password) or the file's own implicit empty-user- password (Remove Restrictions), using the shared engine's QPDF-based decryption. Strip the entire encryption dictionary from the trailer. Rewrite the file from scratch — a full rewrite, never an incremental save, for the same reason given in 9.1.7 step 10.
9.6.6 UI flow, keyboard/screen-reader behavior, edge cases #
Flow: Loaded — if the file requires a password, a focus-trapped password modal blocks the viewer
until a correct password is supplied or the user cancels; if the file opens freely but carries
permission restrictions, a banner offers "This file has usage restrictions (for example, printing or
copying may be disabled). Remove restrictions?" → Confirming (for Remove Restrictions, a lightweight
confirm; for Remove Password, entering the correct password is itself the confirmation) → Applying →
Done.
Keyboard/SR: the password modal is a focus-trapped dialog; Enter submits; an incorrect attempt is
announced via aria-live="assertive" ("Incorrect password"); the cooldown state is announced
("Please wait 5 seconds before trying again").
Edge cases: a wrong password on a file that is separately corrupted is distinguished by first
attempting structural validation independent of any password — a file failing that check is reported as
file_corrupted, not incorrect_password, even if a password was also supplied and would have been
wrong.
9.6.7 Public API operation #
Operation slug: unlock. POST /v1/documents/{documentId}/unlock with body
{ "intent": "remove-password" | "remove-restrictions", "password": "string, required when intent is remove-password" }.
9.6.8 Acceptance criteria #
- Given a file encrypted with user password
"Correct1", calling unlock with"Correct1"returns a document that opens with no password and has no encryption dictionary. - Calling unlock on that same file with
"Wrong1"returns a422 processing_errorwith codeincorrect_password(per the error envelope in Section 14.6), and the original file is unchanged. - Given a file with no user password but the "copy" permission cleared, calling unlock with
intent: "remove-restrictions"and no password produces an output with every permission bit set to allowed and no encryption dictionary at all.
9.7 Flatten (client-side) #
9.7.1 Purpose, execution location, minimum plan #
Bakes interactive content — form field values, annotations, visible layers, and transparency groups — into permanent, non-interactive page content. Client-side; minimum plan Free.
9.7.2 Options #
| Option | Type | Default | Notes |
|---|---|---|---|
formFields |
boolean | true |
Field appearance streams become page content; the field disappears from any form-fill or form-building view (Section 8). |
annotations |
boolean | true |
Markup annotations (highlights, comments, stamps, freetext, shapes — Section 8) become page content. Comment thread text/replies have no home once the annotation object is gone. |
layers |
boolean | false |
Each optional-content group's currently visible content merges into base page content; a hidden OCG's content is discarded entirely, not merged — this is a distinctly more destructive case, called out separately in the confirmation dialog. |
transparencyGroups |
boolean | true |
Soft masks and blend groups are rasterized or flattened to equivalent opaque-composited operators, preserving appearance without requiring a transparency-capable renderer downstream. |
pageRange |
range string | all |
all |
|
exportCommentsFirst |
boolean | false, only relevant when annotations=true and the document has at least one comment-bearing annotation |
Downloads a CSV of comment text/author/page before flattening discards the live annotation objects. |
9.7.3 What becomes uneditable #
Once flattened, the corresponding content is no longer reachable by the form-fill or form-building tools, the annotation tools, or the layer-visibility control in Section 8/Section 7 respectively — those sections own the "before" state and are referenced rather than restated here.
9.7.4 The warning #
A standard (non-typed-confirmation) dialog: "Flatten will make the selected elements permanent and non-interactive. This does not delete anything you can currently see, but it cannot be undone once you leave this session. Continue?" with Cancel and Continue; default focus on Cancel, per the destructive- dialog convention in Section 16. This is deliberately lighter-weight than Redaction's confirmation (9.1.5) because Flatten does not remove visible information — it removes interactivity.
9.7.5 Interaction with signature appearance streams #
A self-signature placed with the self-sign tool in Section 8 is itself a widget/annotation appearance stream; flattening a document containing one bakes the visible signature mark into page content, which is the desired end state for a "download and share" use of a self-signed document. This manual, client- side Flatten tool is not what runs automatically when an e-signature envelope completes — envelope completion has its own dedicated, server-side flatten step described in Section 10, built on the same shared engine (Section 3) with all four toggles above enabled by default; the two flows share one implementation but are triggered independently.
9.7.6 Algorithm #
For each enabled category, on each page in pageRange: (1) enumerate the category's objects on that
page; (2) for each, resolve its current appearance stream (/AP/N, selecting the entry matching the
object's /AS value for stateful widgets like checkboxes); (3) transform the appearance stream's
content into the page's coordinate space using the object's placement matrix; (4) append the result as
new page-content operators; (5) delete the original interactive object (the widget/annotation
dictionary entry, the OCG's membership if wholly hidden-and-discarded, or the transparency group
dictionary); (6) rewrite the file from scratch — full rewrite, never incremental, per 9.1.7 step 10.
9.7.7 UI flow, keyboard/screen-reader behavior, edge cases #
Flow: Configuring (four checkboxes, page range, optional comment export) → Confirming (9.7.4) →
Applying → Done.
Keyboard/SR: the four toggles sit in one fieldset/legend; the dialog is focus-trapped with default
focus on Cancel per 9.7.4.
Edge cases: flattening with no matching interactive elements in scope is a no-op that still reports success with the note "No interactive elements found to flatten"; an unfilled required form field simply flattens to nothing visible (its border disappears along with the widget) — the tool does not enforce form completeness, which is a fill-forms concern in Section 8.
9.7.8 Public API operation #
Operation slug: flatten. POST /v1/documents/{documentId}/flatten with body
{ "formFields": true, "annotations": true, "layers": false, "transparencyGroups": true, "pageRange": "all" }.
9.7.9 Acceptance criteria #
- A text field containing
"Jane Doe", flattened withformFields=true, produces output where extracting text at that page position returns"Jane Doe"as ordinary page text, and no AcroForm field dictionary remains referencing that widget. - A hidden optional-content group's text, with
layers=true, does not appear anywhere in the extracted text of the output — confirming discard, not merge, for hidden layers. - Flattening with all four toggles
falsereturns a document that renders pixel-identical to the input, confirming the toggles are true independent no-ops when unset.
9.8 OCR (server-side) #
9.8.1 Purpose, execution location, minimum plan #
Produces a searchable PDF, plain text, or hOCR from a scanned or image-based document. Server-side
(the worker-media container of Section 3), because OCR is not on the client-side tool list. Minimum
plan Free, counting against the 2-server-side-task daily cap in Section 12.2; unlimited on Pro/Team;
metered on the API per the per-page pricing in Section 12.6.
9.8.2 Pipeline #
- Upload and validate: magic-byte and structural validation per Section 17; the file is encrypted at rest with a per-job data key per Section 6.
- Page rasterization at the chosen DPI, using the shared engine's rendering path (Section 3), native in the worker rather than through the WASM artifact.
- Deskew: a Hough-transform-based skew-angle estimate, correcting up to ±15°.
- Rotation detection: 0°/90°/180°/270° orientation and script detection (OSD), one of Tesseract's built-in passes.
- Despeckle: median-filter noise removal, gated by the
despeckletoggle. - Background removal / binarization: adaptive thresholding, automatically choosing Otsu (uniform background) or Sauvola (uneven lighting/scanned-book background) based on the page's histogram variance.
- Layout analysis: Tesseract page segmentation, automatic full-page mode by default (equivalent to Tesseract's PSM 3).
- Recognition: Tesseract's LSTM engine, run per the selected language(s) (multiple languages may
be combined in one pass, e.g.
eng+fra). - Confidence scoring: per-word confidence (0–100) aggregated to a per-page mean.
- Invisible text layer placement: recognized words are drawn with PDF text-rendering mode 3
(invisible), positioned and sized from Tesseract's word bounding boxes, over the untouched original
rasterized image, which remains the visible page content. The invisible glyphs use the bundled Noto
Sans font set (9.2.5) so the underlying character mapping (
/ToUnicode) is correct even though the glyphs are never painted. - Optional image optimization: the visible background image is recompressed — downsampled to the working DPI if the source raster was higher, JPEG-quality-tuned per the settings in 9.9.5.
- PDF/A-adjacent output settings: the OCR font subset is embedded, a generic sRGB output intent is attached, and only the bundled fonts are used — producing output compatible with common archival expectations. Formal PDF/A certification and validation are not offered by this tool.
- Searchable-PDF assembly (or plain-text / hOCR assembly for those output modes) and, if the user edited any low-confidence page in the review view (9.8.5), a final re-assembly pass writes the corrected text back into that page's invisible text layer before output.
9.8.3 Options #
| Option | Type | Default | Notes |
|---|---|---|---|
languages |
array of language codes | ["eng"] |
Multi-select; see the shipped language list below. |
mode |
force | skip-existing-text | redo |
skip-existing-text |
force OCRs every page regardless of an existing text layer; skip-existing-text OCRs only pages with no extractable text; redo discards any existing invisible text layer and re-OCRs every page. |
outputFormat |
searchable-pdf | text | hocr |
searchable-pdf |
|
deskew |
boolean | true |
|
rotationCorrection |
boolean | true |
|
despeckle |
boolean | false |
|
dpi |
72 | 150 | 300 | 600 |
300 |
Higher DPI improves accuracy on small print at a runtime and cost premium; 600 is the maximum offered. |
Shipped language packs: eng English, fra French, deu German, spa Spanish, ita Italian,
por Portuguese, nld Dutch, swe Swedish, dan Danish, nob Norwegian, fin Finnish, pol Polish,
ces Czech, ell Greek, rus Russian, ukr Ukrainian, tur Turkish, ara Arabic, heb Hebrew,
hin Hindi, tha Thai, vie Vietnamese, ind Indonesian, jpn Japanese, kor Korean, chi_sim
Chinese (Simplified), chi_tra Chinese (Traditional).
9.8.4 Confidence reporting and low-confidence review #
Every completed job returns a per-page mean confidence score. Pages scoring below 80 (the default threshold, not user-configurable) are flagged in a low-confidence review view: a side-by-side of the original page image and the recognized text, editable per page. Saved edits are written back into that page's invisible text layer in the final re-assembly step (9.8.2 step 13) and into the plain-text/hOCR outputs if those were requested; re-downloading reflects the corrected text.
9.8.5 Unsupported and limited capabilities #
- Handwriting recognition is not supported. Tesseract's LSTM models are trained for machine print; handwriting requires fundamentally different model architectures that are not shipped. The UI states this plainly rather than silently producing garbage output for handwritten pages.
- Table structure is not reconstructed by OCR. OCR produces flat, positioned words and, in hOCR output, paragraph/line bounding boxes — no cell/row/column structure. Structured table extraction is a PDF→XLSX conversion concern (9.9.4), not an OCR concern.
9.8.6 Throughput, timeouts, and cost drivers #
Typical throughput on the worker-media container's allocated cores at DPI 300, single language:
approximately 0.4 seconds per page end to end (rasterization plus recognition), i.e. roughly 2.5
pages/second, with pages within a job processed in parallel up to the container's core count. Timeout
budget: max(3 minutes, 0.6 seconds × page count), hard ceiling 20 minutes on the standard queue lane
and 40 minutes on the priority lane (Section 13). Page-count ceiling: 2,000 pages per job on
Free/Pro/Team (in addition to the file-size caps of Section 12.2); 5,000 pages on the metered API tier.
A job exceeding the ceiling is rejected before processing begins with ocr_page_limit_exceeded.
Cost drivers, in order of impact: page count (roughly linear), number of languages selected (each
additional language adds a proportional recognition-time cost), DPI above 300 (adds meaningful
recognition time for diminishing accuracy return on typical printed text), and output format
(searchable-pdf costs the most, since it re-embeds the background image alongside the text layer;
text and hocr are cheaper, with no image re-assembly).
9.8.7 UI flow and states #
Upload (Processing Location Indicator shows "On our servers" per Section 16) → Options → queued
→ running with stage strings ("Rasterizing," "Recognizing text — page 12 of 40," "Assembling
output") per the job vocabulary in Section 4.7 → succeeded (results screen: per-page confidence
table, low-confidence flags, download buttons per requested format) → optional Reviewing low- confidence pages → re-download after edits, or failed (error per Section 14.6, with codes including
ocr_timeout, ocr_page_limit_exceeded, ocr_unsupported_language).
If the source file is encrypted, the app blocks with the Unlock flow (9.6) before any upload occurs. Per the canonical rule that a document password is never transmitted to the server (Section 6.9), the user decrypts the file locally first; only after the user gives explicit consent to upload are the decrypted bytes sent for OCR processing, and the password itself never leaves the device.
9.8.8 Keyboard and screen-reader behavior #
The language multi-select is an accessible combobox with checkboxes. The progress view's stage changes
are announced via aria-live="polite", throttled to at most one announcement every 5 seconds to avoid
flooding a screen reader during fast per-page progress updates. The low-confidence review view supports
full keyboard text editing per flagged page, with Tab/Shift+Tab moving between flagged pages.
9.8.9 Edge cases #
Mixed-orientation pages are corrected independently per page. A document mixing machine-text pages with
scanned pages, run under mode=skip-existing-text, OCRs only the scanned pages. A fully blank page
yields near-zero confidence, is flagged low-confidence, and produces no text layer — this is not an
error. Right-to-left languages produce an invisible text layer whose glyph order follows Tesseract's own
reading-order output directly, with no additional reordering applied.
9.8.10 Public API operation #
Operation slug: ocr. POST /v1/documents/{documentId}/ocr with the options of 9.8.3.
9.8.11 Acceptance criteria #
- A scanned 10-page English document processed at
dpi=300, outputFormat=searchable-pdfproduces an output whose page 5 yields non-empty extracted text with a reported confidence between 0 and 100, while page 5's visible raster remains the documented downsampling of the input, not a different image. - A document mixing 3 machine-text pages and 7 scanned pages, run with
mode=skip-existing-text, leaves the 3 machine-text pages' original text-object count unchanged (no duplicate invisible layer added) while the 7 scanned pages gain one. outputFormat=texton a 5-page document returns a text file with exactly 5 page-delimited sections.- A job whose page count exceeds the plan's ceiling is rejected with
ocr_page_limit_exceededbefore any page is processed.
9.9 Office and HTML conversion (server-side) #
9.9.1 Purpose, execution location, minimum plan #
Converts between PDF and DOCX/XLSX/PPTX, and between PDF and HTML (including URL capture). Server-side unconditionally — Office and HTML conversions are the only conversions that are server-side by default; image conversions (PDF↔JPG/PNG) remain client-side per the rule in Section 6 and are never contradicted here. Minimum plan Free, counting against the 2-server-side-task daily cap in Section 12.2; unlimited Pro/Team; metered on the API.
For any direction whose source document is an encrypted PDF, the app blocks with the Unlock flow (9.6) before any upload occurs, exactly as OCR does (9.8.7). Per the canonical rule that a document password is never transmitted to the server (Section 6.9), the user decrypts the file locally first; only after the user gives explicit consent to upload are the decrypted bytes sent for conversion.
9.9.2 Toolchain by direction #
| Direction | Toolchain |
|---|---|
| PDF → DOCX | Shared engine (Section 3) layout and text extraction → a Python OOXML writer in the worker-office container, which reconstructs paragraphs, runs, images, and detected tables. |
| PDF → XLSX | Shared engine layout extraction → table-structure heuristics (9.9.4) → a Python OOXML writer producing one worksheet per page by default. |
| PDF → PPTX | Shared engine per-page rendering plus text/image block extraction → a Python OOXML writer producing one slide per page. |
| PDF → HTML | Shared engine layout extraction → an absolute-positioned HTML/CSS exporter, or a best-effort semantic exporter when that mode is selected (9.9.4). |
| DOCX → PDF | LibreOffice headless (soffice --headless --convert-to pdf). |
| XLSX → PDF | LibreOffice headless. |
| PPTX → PDF | LibreOffice headless. |
| HTML → PDF | Playwright's bundled Chromium in headless mode (page.pdf()) — reusing the same dependency already approved for end-to-end testing (Section 4), rather than introducing a second browser engine. |
9.9.3 Fonts #
Bundled font set, shared across every direction in this subsection: the 14 standard PDF fonts (9.2.5); LibreOffice's bundled Liberation family (Liberation Sans/Serif/Mono, metric-compatible with Arial/Times New Roman/Courier New) used automatically for the LibreOffice-driven directions; Carlito and Caladea (metric-compatible with Calibri and Cambria); and the Noto Sans / Noto Sans CJK SC / Noto Sans Arabic / Noto Sans Hebrew set for Unicode coverage.
| Referenced font with no embedded program in the source | Substituted with |
|---|---|
| Arial, Helvetica | Liberation Sans |
| Times New Roman, Times | Liberation Serif |
| Courier New, Courier | Liberation Mono |
| Calibri | Carlito |
| Cambria | Caladea |
| Any other unmapped Latin font | Liberation Sans |
| Any CJK reference with no embedded program | Noto Sans CJK SC |
| Any Arabic or Hebrew reference with no embedded program | Noto Sans Arabic / Noto Sans Hebrew respectively |
An embedded font program present in the source document is always used in preference to substitution; the table above applies only when no embedded program exists and the referenced family is not available on the conversion worker.
Image downsampling: output images default to a maximum of 200 DPI effective resolution for photographic raster content exceeding that, configurable per job to 150/200/300/"no downsampling." Photographic images are re-encoded as JPEG at quality 85 by default; images detected as having fewer than 256 distinct colors (an icon/diagram heuristic) are kept as lossless PNG.
9.9.4 Per-direction fidelity and known limitations #
PDF → DOCX. High fidelity for single-column, standard-font text; medium for multi-column or complex layouts; a page whose layout-confidence score falls below the reconstruction threshold is converted as a single embedded full-page image instead of reconstructed text, so the output is never silently wrong, only visually static for that page. Known limitations: unruled tables may merge or split columns incorrectly; footnotes and endnotes are inlined as ordinary paragraphs, with their numbering preserved as literal text rather than a live footnote object; headers and footers are detected heuristically by y-position clustering repeated across pages and may misfire on irregular margins; unlicensed-for-extraction embedded fonts fall back to the substitution table in 9.9.3.
PDF → XLSX. Table reconstruction, in order:
- Detect ruling lines (horizontal/vertical strokes) and use them directly as row/column boundaries where present ("ruled" tables).
- Where no ruling lines exist, cluster text runs on a line into column bins using a gap threshold: a horizontal gap wider than twice the median inter-word spacing on that line is treated as a column boundary ("unruled"/whitespace-aligned tables).
- Cluster rows by y-coordinate proximity across the page.
- Where a ruling line is absent across an otherwise-implied boundary, infer a merged cell and map it to an Excel merged-cell range.
- Cell text that matches a numeric grammar (with optional thousands separators or a currency symbol) is written as a numeric Excel cell type rather than a text string, so downstream formulas work against it.
Fidelity: high for ruled tables, medium for unruled tables, low for free-form text pages (each such page falls back to one column, one row per line of text). Known limitations: no formula reconstruction (PDFs never contain formulas, only values); charts and images are placed as best-effort floating objects; a table that continues across a page break is not automatically merged into one logical table — each page's portion lands on its own worksheet/section and the user merges manually; cell styling (bold, font size, borders) is approximated from the source, borders only where a ruling line was actually detected.
PDF → PPTX. Each detected text block becomes an editable text box at its original coordinates; each image becomes an image placeholder at its original coordinates. A page with more than 150 distinct vector-drawing operators is treated as graphically dense and rendered as a single full-page background image, with only its text boxes remaining editable on top — this threshold keeps output usable rather than producing hundreds of tiny, individually-unusable shapes. Fidelity: high visually, medium-to-low for editability on dense pages. Known limitations: no animations or transitions are added (none existed in the source); speaker notes are never populated; slide masters and themes are generic, not reconstructed from the source design.
DOCX → PDF. High fidelity for standard business documents; medium for documents relying on SmartArt or complex field codes, which LibreOffice renders via its own approximation. Known limitations: SmartArt may render simplified; embedded OLE objects (e.g., an embedded spreadsheet range) render as a static image; macros are never executed and are silently dropped; tracked changes render as-accepted by default (a toggle renders as-shown-with-markup instead, default off).
XLSX → PDF. High fidelity for sheets with a defined print area; medium otherwise, since LibreOffice's automatic pagination may split a wide table awkwardly across pages — "Fit to 1 page wide" scaling is suggested by default to reduce this. Known limitations: pivot tables render their last-cached view, not a recalculation; charts render as static images; hidden rows, columns, and sheets are excluded by default (a toggle includes them, default off); sheets exceeding 50 sheets or 10,000 rows per sheet exceed the size limit below and must be split before conversion.
PPTX → PDF. High fidelity for standard layouts and embedded/bundled fonts; medium where a referenced font is neither embedded nor in the bundled substitution set. Known limitations: animations/transitions collapse to their final static state, one PDF page per slide; embedded video or audio is replaced by its poster-frame image; speaker notes are excluded from the default output (a toggle appends them as extra pages after the slides, default off).
HTML → PDF. Two input modes:
- URL: the target is fetched from an isolated worker process behind an egress allowlist proxy per
Section 17. Before navigation, the resolved IP is validated against private, link-local, loopback, and
cloud-metadata address ranges and rejected if it matches any of them — this check runs before any
request reaches the target, not as a response-time filter. Up to 5 redirects are followed, each
hop's destination re-validated the same way. Non-
http(s)schemes are refused outright. - Uploaded HTML: a single file or a zip bundle (max 25 MB, max 50 files) of HTML plus relative assets.
This mode runs with no network egress at all — a stricter posture than URL mode, for defense in
depth — so any remote asset reference (e.g.
<img src="https://...">) that is not part of the uploaded bundle and reachable by a relative path simply does not load.
Common render controls: wait-for strategy load (default), networkidle (no network activity for 500
ms, capped at 10 s), domcontentloaded, or an explicit CSS selector via waitForSelector; whichever is
chosen is bounded by the 45-second wait budget in the limits table below, after which rendering proceeds with whatever is on
screen and the job result carries waitConditionTimedOut: true rather than failing outright, since a
partial render is usually still useful. Viewport: default 1280×1024, width 320–3840 px configurable,
height auto or fixed. Page size: Letter (default), A4, Legal, A3, A5, Tabloid, or custom width/height in
inches or millimeters. Margins: default 0.4 in on all sides, configurable per side. Header/footer
templates: HTML snippets supporting the placeholder classes pageNumber, totalPages, date, title,
url — the underlying render engine's own native placeholder classes, reused rather than reinvented.
Print CSS: @media print rules are honored by default; preferCSSPageSize (off by default) lets the
page's own @page CSS rule override the configured page size. JavaScript execution: enabled by default,
capped at a 15-second script-settle budget within the overall wait-for timeout; the tool never accepts
user-supplied JavaScript to inject into the page, closing an obvious injection vector.
Fidelity: high for standard responsive pages with print-friendly or absent print stylesheets; medium- to-low for pages that depend on user interaction (hover, modal reveal) to show content, since there is no real user. Known limitations: authenticated/paywalled pages are not supported (no cookie/session injection); infinite-scroll pages capture only what loaded within the wait-for budget; WebGL/canvas- animation-heavy pages may render blank or a single frame.
PDF → HTML. Each text run becomes an absolutely-positioned element (style="position:absolute; left:…; top:…; font:…"), each image an <img>, output as either a single self-contained file (assets
inlined as data URIs) or an HTML file plus an assets folder. Default mode prioritizes visual exactness
over document structure — "a picture of the page in HTML clothing," not a reflowable article. A
best-effort semantic mode (off by default) instead emits <p>, <h1>–<h6>, and <table> tags
where structure is confidently detected, at the cost of visual exactness; multi-column reading order is
only reliably correct in semantic mode, since visual mode does not need reading order at all. Link
annotations become real <a href> elements in both modes; AcroForm field reconstruction into real
<input> elements is semantic-mode only.
9.9.5 Scanned-input handling #
A page is classified scanned if its extractable text covers less than 2% of the page's visible
area, or it has zero extractable text runs and at least one image XObject covering more than 80% of the
page. If any page of a PDF submitted to any direction in this subsection is classified scanned, the UI
surfaces, before the job starts: "This document appears to contain scanned pages with no selectable
text. Converting it as-is will lose that page's text content in the output. Run OCR first?" with a
one-click Run OCR, then convert action that automatically chains an OCR job (9.8, default settings,
outputFormat=searchable-pdf) into the requested conversion, and a Convert without OCR action for
users who intentionally want an image-only result (for example, placing a scanned page as a picture on
a PPTX slide). This is a suggestion; nothing is forced.
9.9.6 Size, page limits, and timeout budgets #
| Direction | Max input size | Max pages/slides/sheets | Timeout budget |
|---|---|---|---|
| PDF → DOCX | plan file-size cap (Section 12.2) | 1,000 pages | max(90s, 1.2s × pages), ceiling 15 min standard / 25 min priority |
| PDF → XLSX | plan cap | 1,000 pages | Same formula as PDF → DOCX |
| PDF → PPTX | plan cap | 500 pages | max(90s, 1.5s × pages), ceiling 15 min / 25 min |
| PDF → HTML | plan cap | 1,000 pages | max(60s, 1.0s × pages), ceiling 10 min / 20 min |
| DOCX → PDF | plan cap | 1,000 pages | max(60s, 0.8s × pages), ceiling 10 min / 20 min |
| XLSX → PDF | plan cap | 50 sheets, 10,000 rows/sheet | Flat 10 min standard / 20 min priority |
| PPTX → PDF | plan cap | 500 slides | max(60s, 1.0s × slides), ceiling 10 min / 20 min |
| HTML → PDF (URL) | 25 MB downloaded | single page/document | 45 s wait budget plus 60 s render ceiling, 105 s hard cap |
| HTML → PDF (upload) | 25 MB bundle, 50 files | n/a | Flat 60 s hard cap |
Queue lanes (standard/priority) and the underlying timeout mechanism are defined in Sections 3 and
13 and are not restated here.
9.9.7 The fidelity-expectation table shown before commit #
Before the user clicks the convert action, the UI surfaces the row of this table matching the selected direction, so expectations are set before any file is uploaded:
| Conversion | Text | Layout | Images | Tables | Editability |
|---|---|---|---|---|---|
| PDF → DOCX | High | Medium | High | Medium | High |
| PDF → XLSX | High | Medium | Medium | Medium (ruled) / Low (unruled) | High |
| PDF → PPTX | High | High (visual) | High | Low | Low–Medium |
| PDF → HTML | High | High (visual mode) | High | Medium | Medium (semantic mode only) |
| DOCX → PDF | High | High | High | High | n/a — PDF output |
| XLSX → PDF | High | Medium | High | High | n/a |
| PPTX → PDF | High | High | High | n/a | n/a |
| HTML → PDF | High | High | Medium–High | High | n/a |
9.9.8 UI flow and states #
Upload / URL entry (Processing Location Indicator "On our servers") → scanned-input check (9.9.5, may
insert an OCR-first offer) → fidelity table shown (9.9.7) → queued → running with stage strings
("Uploading," "Rendering," "Reconstructing layout," "Assembling output") → succeeded (download) or
failed (error per Section 14.6, with codes including conversion_page_limit_exceeded,
conversion_timeout, ssrf_target_rejected, unsupported_source_format).
9.9.9 Keyboard and screen-reader behavior #
Direction selection is a standard select/radiogroup. The scanned-input offer is a dialog with two
clearly labeled, independently focusable buttons ("Run OCR, then convert" / "Convert without OCR"), no
default-selected destructive action. The fidelity table is rendered as a real HTML <table> with
<caption> and scoped headers, not an image, so its content is available to a screen reader before
the user commits.
9.9.10 Edge cases #
A URL that redirects to a private address after passing the initial check is caught by the per-hop
re-validation in 9.9.4. An uploaded HTML bundle referencing an asset outside the bundle silently omits
that asset rather than failing the whole job. A DOCX containing a macro is converted with the macro
dropped and no error raised (macros never execute). An XLSX sheet exceeding the row/sheet ceiling in
9.9.6 is rejected before processing with conversion_page_limit_exceeded, naming the offending sheet.
9.9.11 Public API operation #
Operation slug: convert. POST /v1/documents/{documentId}/convert with body
{ "from": "pdf", "to": "docx", "options": { ... } }. For URL-sourced HTML → PDF, where no source
document exists yet, a convenience endpoint is used instead: POST /v1/conversions/html-to-pdf with
body { "url": "https://example-client-site.com/report", "options": { ... } }; both return a Job
resource (Section 4.7) that, on succeeded, exposes a resultDocumentId.
9.9.12 Acceptance criteria #
- A single-column, single-font, image-free 20-page PDF converted to DOCX and back to PDF (DOCX → PDF) reproduces the same line count per page within a 2-line tolerance, and every extractable text substring from the original is present somewhere in the round-tripped output.
- A PDF page containing a ruled 5-column, 10-row table converted to XLSX produces a worksheet with exactly 5 populated columns and 10 populated rows in the corresponding range, with numeric-looking cells typed as numbers rather than text.
- An HTML → PDF request for a URL that resolves to a private (RFC 1918) address is rejected before any
navigation occurs, with
ssrf_target_rejected, rather than producing a blank or error-page render. - A 3-slide PPTX converted to PDF produces exactly 3 pages, and with the "include speaker notes" toggle off, no speaker-note text appears anywhere in the extracted text of the output.
- A scanned (image-only) PDF submitted for PDF → DOCX without accepting the OCR-first suggestion produces a DOCX whose only content on that page is an embedded image, with zero extractable text for that page, confirming the tool never fabricates OCR text the user did not request.
10. E-Signature System #
This section is the canonical specification for the e-signature system: requesting signatures from other people, the signer's experience, the audit trail that gives a completed envelope its evidentiary weight, and the public verification endpoint. On-device self-signing — drawing, typing, or uploading a signature image onto a document the user is not sending anywhere — is a client-side tool specified in Section 8.5 and is not respecified here. Everything in this section runs server-side, per the processing-location rules in Section 6, and displays the "On our servers" Processing Location Indicator (contract in Section 6.2, visual specification in Section 16).
10.1 Scope and legal framing #
What is offered. PDFWorks e-signature produces an ESIGN Act (United States), UETA, and eIDAS simple and advanced electronic signature. Every signer's identity is established by a verified-email link plus, where required, a one-time passcode sent to that same email address (Section 10.4). Every action a signer takes — opening the envelope, consenting to sign electronically, completing each field, applying a signature — is captured as a timestamped, hashed, tamper-evident audit event (Section 10.5). On completion, PDFWorks generates a Certificate of Completion that lists every event and lets any third party verify, without an account, that a completed document has not been altered since signing (Section 10.5.6).
What "tamper-evident" means, and against whom. The hash chain behind that claim (Section 10.5) makes tampering evident after the fact, not impossible — and those are different guarantees aimed at different attackers. Against an outside party with no special access — someone who intercepts a link, someone who tries to alter a downloaded PDF, an accidental bug in the application code — the chain is a strong guarantee: altering, inserting, deleting, or reordering a single event breaks the chain from that point forward, and anyone can verify this independently against the exported event set (Section 10.5.3). Against an operator with direct production-database write access, or anyone who obtains that access without authorization, a hash chain computed and stored entirely inside a database the operator controls is not, on its own, sufficient: in principle a full, internally-consistent rewrite of an envelope's chain could be produced, and no comparison against the database alone would catch it. This document does not pretend otherwise. To close that gap, every day's audit events are anchored outside PDFWorks' control entirely — a published, externally-held checkpoint that a wholesale database rewrite cannot retroactively match (Section 10.5.9). A verifier who trusts PDFWorks' own systems needs only the chain check in Section 10.5.3; a verifier who does not — who is specifically concerned about operator-level or compelled tampering — checks an envelope's events against the externally published anchor rather than against anything PDFWorks' own systems report.
What is not offered, stated plainly here and repeated in the product UI and the public documentation:
- No PKI-based digital signature. The product never writes a PDF
/Sigsignature dictionary, never applies a/ByteRangecryptographic digest over the document bytes, and never binds a signature to an X.509 certificate. - No certificate authority trust chain. The product does not participate in and does not present itself as compliant with Adobe Approved Trust List (AATL) or the EU Trusted Lists (EUTL).
- No qualified electronic signature (QES) under eIDAS. A QES requires a qualified certificate issued by a qualified trust service provider after in-person or video identity proofing; PDFWorks does not perform that proofing and does not issue that certificate.
- No in-person identity verification and no knowledge-based authentication (KBA — the "what was your first car" style of identity quiz). Identity is established solely by control of the recipient email address and, when enabled, an email-delivered one-time passcode.
What this means for enforceability. In the large majority of contract disputes in ESIGN/UETA jurisdictions, a document is not challenged on cryptographic grounds — it is challenged on whether the party actually agreed to the terms and whether the record of that agreement can be trusted. Courts weigh the audit trail: who was sent what, whether they consented to sign electronically, what they clicked, when, from where, and whether the resulting document can be shown to be unaltered since that moment. That is precisely what this system is built to produce, and it produces a strong one: a hash-chained, independently verifiable event log tied to a document hash at every step and, from the first full day after each event, checkable against a publicly anchored checkpoint outside PDFWorks' own control (Section 10.5.9), generated under a written Electronic Record and Signature Disclosure the signer affirmatively accepted before touching a single field (Section 10.4.3). A PKI digital signature and an AATL/EUTL trust chain add long-term cryptographic non-repudiation and are the right tool for scenarios that specifically demand them — but they are not what carries most disputes, and their absence does not weaken the product's suitability for the overwhelming majority of business documents (offer letters, NDAs, vendor agreements, consent forms, order confirmations).
When you need something this product does not offer. If a specific regulator, counterparty, or jurisdiction requires a qualified electronic signature, a PKI-based digital signature with a trust-list certificate, notarization, or in-person/video identity proofing (for example, certain EU public-sector filings, some real-estate transfer deeds, or certain notarized affidavits), this product is not the right tool for that specific document and the sender should use a qualified trust service provider instead. The product surfaces this as a plain-language notice (Section 10.3.1) at envelope creation and in the public documentation; it never implies broader legal force than it has.
10.2 Domain model #
The e-signature domain is built from seven entities, all defined at the column level in Section 5:
envelope, document (a specialization of the document metadata row shared with the rest of the
product), signer (a row of type recipient, keyed to an envelope), field, audit event,
reminder, and template. This subsection defines the behavior of those entities — their
state machines and the rules that govern transitions — without redefining their columns.
10.2.1 Entity summary #
| Entity | Public ID prefix | Owned by | Cardinality |
|---|---|---|---|
| Envelope | env_ |
Workspace or user | 1 envelope has many documents, signers, fields, audit events |
| Document (in-envelope) | doc_ |
Envelope | 1..N per envelope, ordered |
| Signer / recipient | (no separate public ID; addressed by envelope.id + recipientId, a UUIDv7 not separately prefixed) |
Envelope | 1..N per envelope |
| Field | (addressed by fieldId within an envelope) |
Document + recipient | 0..N per document |
| Audit event | evt_ |
Envelope | append-only, unbounded |
| Reminder | (system-scheduled, no public ID; visible via the audit trail as reminder.sent events) |
Envelope | 0..N |
| Template | tpl_ |
Workspace or user | reusable, referenced by envelopes created from it |
A recipient role is one of exactly three values, stored as text with a CHECK constraint per the
enum convention in Section 4: signer (must complete assigned fields and apply at least one
signature or initials field), approver (must review and approve the document but has no fields to
complete; approval is itself an audit event, field.completed is never emitted for an approver), and
cc (receives the completed document by email on envelope.completed; never receives a signing
link, never appears in routing order, never blocks completion).
10.2.2 The envelope state machine #
┌────────────────────────────────────────────────────────┐
▼ │
draft ──send──> sent ──first signer notified──> in_progress ──all signers/approvers done──> completed
│ │ │
│ │ ├──sender voids──> voided
│ ├──sender voids──> voided ├──any signer declines──> declined
│ ├──expiry timer fires──> expired └──expiry timer fires──> expired
│
└──sender deletes draft (hard delete, no audit trail exists yet)| From | To | Trigger | Who can trigger | Notes |
|---|---|---|---|---|
| — | draft |
Envelope created | Sender | No audit events yet; nothing has been sent |
draft |
(deleted) | Sender deletes the draft | Sender | Hard delete; no envelope.* audit events exist for a draft, so there is nothing to retain |
draft |
sent |
Sender clicks Send | Sender | Emits envelope.created then envelope.sent; triggers routing (Section 10.3.3) |
sent |
in_progress |
The first recipient in routing order opens or is notified | System | Emits email.delivered / signer.viewed for that recipient |
sent |
voided |
Sender voids before anyone acts | Sender only | Requires a void reason (Section 10.3.5); emits envelope.voided |
sent |
expired |
Expiry timestamp reached with zero signer activity | System (janitor) | Emits envelope.expired |
in_progress |
in_progress |
A signer/approver completes their part but others remain | System | No envelope-level transition; per-signer state advances (Section 10.2.3) |
in_progress |
completed |
Every signer and approver reaches a terminal success state | System | Emits envelope.completed; triggers certificate generation, flattening, and completion emails (Section 10.5.6, 10.6) |
in_progress |
voided |
Sender voids mid-flight | Sender only | Requires a void reason; already-signed fields remain in the audit trail as evidence of what happened before voiding; emits envelope.voided; all pending signing links are invalidated immediately |
in_progress |
declined |
Any signer or approver declines | The declining signer/approver | Terminal for the whole envelope — sequential and parallel routing both stop immediately; emits signer.declined then envelope.declined; already-collected signatures remain visible in the audit trail but the envelope never reaches completed and no flattened output is produced |
in_progress |
expired |
Expiry timestamp reached before all parties finish | System (janitor) | Emits envelope.expired; all pending signing links are invalidated |
draft, sent, in_progress, and completed are the four states a normal, successful envelope
passes through in order. voided, declined, and expired are terminal exception states reachable
only from sent or in_progress. There is no transition out of completed, voided, declined, or
expired — an envelope that needs a change after any of these states is reached is superseded by a
new envelope, never mutated (see correct-and-resend semantics, Section 10.3.6).
10.2.3 The signer state machine #
Each signer (and each approver) on an envelope has its own state, independent of the others except where sequential routing gates notification (Section 10.3.3).
pending ──notified──> notified ──opens link──> viewed ──accepts disclosure──> consented
│
completes fields, applies signature
▼
in_progress ──last field/signature──> signedBranches available from most non-terminal states: declined (from viewed, consented, or
in_progress — a signer can decline any time after opening the link and before completing), bounced
(from notified — the invitation email hard-bounced), delegated (from notified, viewed, or
consented — the signer forwards the request to someone else via "I am not the right person," see
below).
| From | To | Trigger | Notes |
|---|---|---|---|
| — | pending |
Signer added to a sequential-routing envelope, waiting for their turn | No email sent yet |
pending |
notified |
Their turn arrives (sequential) or the envelope is sent (parallel) | Invitation email sent, email.delivered recorded on provider confirmation |
notified |
viewed |
Signer opens the tokenized link | signer.viewed recorded with IP, user agent, geo-IP country |
notified |
bounced |
Invitation email hard-bounces | Sender is notified in the management view (Section 10.3.5); envelope does not advance for this signer until the sender corrects the address and resends to that recipient only |
viewed |
consented |
Signer accepts the Electronic Record and Signature Disclosure | signer.consented recorded; this is a hard gate — no field can be touched before this transition (Section 10.4.3) |
viewed |
declined |
Signer declines before consenting | signer.declined recorded with the reason; envelope moves to declined |
viewed |
delegated |
Signer uses "This isn't for me" | Original signer's state becomes delegated (terminal for them); sender is notified and must add a replacement recipient and resend to that position in the routing order; no signature or field values from the delegating signer exist because none could be entered pre-consent |
consented |
in_progress |
Signer completes at least one required field | — |
consented |
declined |
Signer declines after consenting, before completing | signer.declined recorded |
in_progress |
in_progress |
Signer completes additional fields | Each emits field.completed |
in_progress |
signed |
Signer completes the last required field, including at least one signature or initials field for role signer |
signer.signed recorded; this is terminal success for this signer |
in_progress |
declined |
Signer declines mid-completion | signer.declined recorded; any field values already entered remain in the audit trail as a record of what was attempted, but no signature is finalized |
signed, declined, bounced (if never corrected before envelope expiry), and delegated are
terminal for an individual signer. bounced and delegated are recoverable for the envelope only
by the sender taking a corrective action (resend to a corrected address, or add a replacement
recipient) — the original signer row itself never leaves its terminal state; a correction creates a
new recipient row.
An approver follows the identical machine through consented, but "completing fields" is
replaced by a single approve/reject action: in_progress → signed is relabeled approved in the
UI (the underlying stored state value is still signed for schema simplicity, per the shared-enum
rule in Section 4) and declined is relabeled rejected in the UI. A cc recipient has no state
machine at all — a CC row transitions directly from not-yet-notified to notified when
envelope.completed fires, and that is its only recorded event.
10.3 Sender flow #
10.3.1 Starting an envelope #
The sender starts from New Signature Request, reachable from the dashboard and from any document already in the workspace. Two entry points:
- Upload a document. Accepts PDF directly; accepts DOCX/XLSX/PPTX by routing through the Office-conversion pipeline (Section 9) before the envelope is created, so every envelope document is a PDF by the time fields are placed. Maximum size and page count follow the sender's plan limits (Section 10.9).
- Pick an existing document from the workspace's document list (subject to the same retention window as any other document — see Section 10.8) or start from a template (Section 10.7).
Multiple documents may be added to one envelope; they are concatenated in the order shown for the
purpose of field placement and signing, but each retains its own doc_ identity and its own hash
in the audit trail, so the certificate of completion can attest to each document individually.
Immediately below the upload control, the sender sees the fixed legal-framing notice from Section 10.1, rendered verbatim:
This document will be signed using electronic signature with verified-email identity and a tamper-evident audit trail. It is not a digital signature backed by a certificate authority and is not suitable for documents that require a qualified electronic signature, notarization, or government-issued digital ID verification.
This notice is shown once per envelope creation and does not require a separate click-through; it is informational, not a consent gate (the consent gate is the signer-side disclosure in Section 10.4.3).
10.3.2 Adding recipients #
Each recipient row collects:
| Field | Type | Required | Validation |
|---|---|---|---|
| Full name | text | Yes | 1–200 characters, trimmed, no HTML |
| text | Yes | RFC 5322 syntax check plus MX-record lookup at add time (soft warning, not a hard block, if the lookup fails or times out) | |
| Role | enum | Yes | signer | approver | cc; default signer |
| Routing order | integer | Yes | 1-based; see 10.3.3 |
| Recipient color | assigned automatically | — | One of eight palette colors from the design system (Section 16), assigned in add order and cycling after 8, used to color-code that recipient's fields on the canvas |
| Private message to this recipient (optional) | text | No | Up to 1,000 characters, appended to that recipient's invitation email only |
| Authentication step-up (optional) | enum | No | none (default) | email_otp — see 10.4.3 for when OTP is required regardless of this setting |
A recipient list must contain at least one signer or approver. Duplicate email addresses are
permitted only if the roles or routing positions differ (for example, the same person signing in two
different capacities); the UI warns but does not block. The sender cannot add themselves with role
cc silently — CC-ing the sender is allowed but the UI labels it explicitly ("You will receive a copy
when everyone finishes") to avoid an accidental extra signing link to the sender's own inbox.
Routing modes, selected once per envelope:
- Sequential. Every recipient has a distinct order number 1..N. Recipient n+1 is not notified
until recipient n reaches a terminal success state (
signedfor a signer,approvedfor an approver). Accrecipient never occupies a routing slot. - Parallel. All recipients share order number 1 and are notified simultaneously. The envelope completes when all of them reach a terminal success state, in any order.
- Mixed. Recipients are assigned into ordered groups; recipients within a group are notified in parallel, and the next group is not notified until every member of the current group reaches a terminal success state.
Worked example — mixed routing on a vendor contract with four recipients:
| Recipient | Role | Group (order) | Notified when |
|---|---|---|---|
| Priya Nair (Legal, internal approver) | approver |
1 | Immediately on send |
| Marcus Webb (Finance, internal approver) | approver |
1 | Immediately on send, in parallel with Priya |
| Dana Okafor (Vendor signatory) | signer |
2 | After both Priya and Marcus reach approved |
| ops-archive@ (internal CC) | cc |
— | On envelope.completed, after Dana signs |
This is stored as routingOrder: 1 for Priya and Marcus and routingOrder: 2 for Dana; the CC row
has no routingOrder value. The engine that advances the envelope groups signers by routingOrder
value and treats equal values as a parallel group, which is how sequential (each recipient a distinct
integer), parallel (every recipient the same integer), and mixed (some repeated, some distinct) are
represented with one column rather than three separate routing types.
10.3.3 Placing fields #
Fields are placed on a page canvas rendered from the uploaded PDF (using the same pdfcore rendering
path described in Section 3, so the on-screen preview is pixel-faithful to the final document). The
sender drags a field type from a palette onto the page; the field snaps to a 4px grid and to the
edges of nearby fields and text blocks detected on the page (text-block detection reuses the
extraction path already built for the fill-forms and edit-text tools in Section 8). Fields can be
resized by drag handles, nudged with arrow keys (1px per press, 10px with Shift held, satisfying the
keyboard-operability bar in Section 16), and deleted with Delete/Backspace when focused. Every field is
rendered in its assigned recipient's palette color with a small role badge, so a multi-signer document
is visually legible without opening a legend.
Field types and their full property set:
| Field type | Recipient-fillable value | Properties | Notes |
|---|---|---|---|
| Signature | Drawn, typed, or uploaded mark (Section 10.4.5) | required (default true), label (shown as placeholder text, default "Signature"), tooltip (optional help text) |
At least one per signer recipient is required for the envelope to be sendable |
| Initials | Drawn, typed, or uploaded mark, smaller canvas than Signature | required, label (default "Initials"), tooltip |
Reuses the same adopted mark as Signature within one envelope (10.4.6) |
| Date signed | Auto-populated, not editable by the signer | format (default MMMM D, YYYY, e.g. "August 19, 2026"), timezone (default the signer's browser timezone, falls back to UTC if unavailable) |
Always required; value is the server timestamp of the signer.signed event, not client clock |
| Full name | Text, pre-filled from the recipient's name and editable | required (default true), maxLength (default 200) |
— |
| Text, pre-filled from the recipient's email, read-only | — | Always non-editable; exists as a placeable field so it can appear printed on the page | |
| Title | Text | required (default false), maxLength (default 200), placeholder (default "Job title") |
— |
| Company | Text | required (default false), maxLength (default 200), placeholder (default "Company") |
— |
| Free text | Text, signer-entered | required, maxLength (default 500, max 5,000), validation (none | email | phone | number | regex with a sender-supplied pattern), placeholder, defaultValue |
Validation runs client-side on blur and again server-side on submit |
| Checkbox | Boolean | required (an unchecked required checkbox blocks completion), label, defaultChecked (default false) |
— |
| Radio group | Single choice from a sender-defined option list | required, options (2–20 strings), defaultOption (optional) |
Options rendered as a vertical group anchored at the field's placement |
| Dropdown | Single choice from a sender-defined option list | required, options (2–100 strings), defaultOption (optional) |
Used instead of Radio when the option count is large |
| Attachment request | Signer uploads a supporting file (e.g., a photo ID or a W-9) | required, label, acceptedTypes (default application/pdf,image/jpeg,image/png), maxSizeMb (default 10, max 25) |
The uploaded file is stored as an envelope attachment, encrypted at rest identically to the envelope document, and included in the completed-envelope download; it is never OCR'd or otherwise processed |
Every field also carries documentId (which page it lives on), page (1-based), xPct/yPct (the
top-left corner of the field's bounding box, expressed as a fraction of the page's width and height
respectively — 0.0 at the page's top-left corner, 1.0 at its bottom-right corner — matching the
x_pct/y_pct storage format defined in Section 5), widthPct/heightPct (the field's width and
height as the same page-relative fraction), and recipientId. Fields cannot
overlap another field belonging to a different recipient (the canvas blocks the drop and shows an
inline error); fields belonging to the same recipient may overlap only if one is a checkbox or
radio option nested inside a group.
The field's domain shape — the data every field carries, independent of how it travels over the wire —
is defined once in packages/contracts and used identically by the web app, the signer portal, and
the API layer:
interface EnvelopeField {
id: string; // fieldId, UUIDv7
documentId: string; // doc_...
recipientId: string;
page: number; // 1-based
xPct: number; // fraction of page width, 0.0-1.0, origin top-left (Section 5)
yPct: number; // fraction of page height, 0.0-1.0, origin top-left (Section 5)
widthPct: number; // fraction of page width
heightPct: number; // fraction of page height
type:
| "signature" | "initials" | "date_signed" | "full_name" | "email"
| "title" | "company" | "free_text" | "checkbox" | "radio_group"
| "dropdown" | "attachment";
required: boolean;
label: string | null;
tooltip: string | null;
defaultValue: string | boolean | null;
validation: "none" | "email" | "phone" | "number" | "regex" | null;
validationPattern: string | null; // present only when validation === "regex"
options: string[] | null; // present only for radio_group / dropdown
maxLength: number | null; // free_text only
acceptedTypes: string | null; // attachment only
maxSizeMb: number | null; // attachment only
}This is the field's internal domain shape, not the wire contract: creating, listing, and updating
fields follows the standard resource request/response contract, payload casing, and idempotency rules
in Section 14.5 and Section 14.8. Placing a field submits this shape (minus id, which the server
assigns) as an idempotent create against the envelope's field collection, and a successful response
returns the field with its server-assigned id. A validation failure — for example, a field placed on
a page number that does not exist in the document — returns the standard error envelope (Section 14.6)
with type: "invalid_request_error", code: "field_page_out_of_range", param: "page".
Preview-as-recipient mode. Before sending, the sender can switch the canvas into a read-only simulation of each recipient's signing session in turn — same field navigation, same order, same required-field indicators — without generating any audit events or sending any email. This is the sender's only way to verify field placement end-to-end before committing, and it is strongly recommended by an inline hint the first three times a workspace uses the product.
Message and settings, set once per envelope:
| Setting | Default | Range/notes |
|---|---|---|
| Subject line override | {{senderName}} sent you "{{documentTitle}}" to sign |
Sender may override; 5–150 characters |
| Message body | Empty | Up to 2,000 characters, shown in the invitation email above the Review Document button |
| Expiry | 14 days from send | Configurable 1–30 days (Section 10.8); envelope auto-expires if not completed by this date |
| Reminder cadence | Day 3, day 7, day 12 relative to send, only to recipients not yet in a terminal state | Sender may disable reminders entirely or set a custom cadence of up to 5 reminder days, each strictly between send and expiry |
This table is the canonical definition of the envelope's message and scheduling settings, including reminder cadence. The scheduled job that dispatches reminders on this cadence runs as part of the batch and job orchestration process in Section 13, which schedules against these values rather than restating them.
10.3.4 Sending #
Send is disabled until validation passes: at least one signer or approver, every signer has at least
one signature field, no two recipients share an identical (email, routing position) pair unless roles
differ, every required field is placed on a page that exists in the final document set, and the
sender's plan has remaining envelope quota for the current billing period (Section 10.9). Sending is a
state-changing action on the envelope resource, following the action and idempotency contract in
Section 14.5 and Section 14.8 — the sender issues an idempotent send request against the envelope,
carrying no body. On a successful call, the envelope transitions draft → sent (Section 10.2.2),
the first recipient group is notified per the routing rules in 10.3.3, and the response returns the
envelope's current state: its id, status, routingMode, expiresAt, and, per recipient, that
recipient's recipientId, role, state, and routingOrder.
A send attempted while validation fails returns the standard 422 processing_error envelope (Section
14.6) with code: "envelope_not_sendable" and a details array — an extension of the standard
envelope permitted by Section 14.6 for multi-cause failures — carrying one entry per unmet condition
(for example, missing_signature_field naming the affected recipientId, or envelope_quota_exceeded
naming the plan and remaining count), so the sender-facing UI can highlight every unmet condition at
once rather than one validation error per click.
10.3.5 Sender management view #
For any envelope not in draft, the sender sees:
- Status per recipient: current signer state (Section 10.2.3) rendered in plain language ("Viewed 2 hours ago," "Waiting on Priya Nair," "Signed August 18, 2026 at 3:42 PM UTC"), with a timeline of that recipient's audit events on expand.
- Resend: available for any recipient in
notified,viewed,bounced, orconsented(not yetsigned/declined/delegated). Resend issues a fresh invitation email and a fresh signing token (Section 10.4.1); it does not create a new audit trail branch, it appends areminder.sentevent (manual resend uses the same event type as a scheduled reminder, distinguished by atrigger: "manual"field in the event payload). - Edit-before-anyone-signs: while the envelope is
sentand no recipient has passedviewed(i.e., no one has opened their link yet), the sender may edit recipients, fields, routing, message, and expiry in place — this re-uses the existing envelope and does not create a new one. The instant any recipient reachesviewed, all edit controls are disabled and replaced with Void and correct (10.3.6); this boundary exists so that no one ever signs a document that was silently changed after they started looking at it. - Void with a reason: available from
sentorin_progress. Requires a free-text reason (10–500 characters) which is stored on theenvelope.voidedaudit event and shown to any recipient who still has the link open. Voiding invalidates every outstanding signing token immediately; a voided envelope cannot be un-voided. - Download current state: at any point after send, the sender can download a PDF snapshot of the
document as filled so far, watermarked diagonally with "NOT YET COMPLETE — MISSING SIGNATURES" in
50% gray, plus a plain-text export of the audit trail so far. This is never the flattened,
certificate-bearing final artifact — that only exists after
envelope.completed.
10.3.6 Correct-and-resend semantics #
Because a sent envelope becomes immutable the moment any recipient views it (10.3.5), fixing a
mistake discovered after that point is not an edit — it is a new envelope. Void and correct
performs, in one step: voids the current envelope with the system-supplied reason "Corrected and
resent by sender," creates a new draft envelope pre-populated from the voided one (same documents,
same recipients, same fields, same routing, same settings) with the sender's edits applied, and links
the two by storing the voided envelope's ID as supersedesEnvelopeId on the new one. The new envelope
gets a new env_ ID and its own independent audit trail from envelope.created forward; the old
envelope's audit trail is preserved untouched and continues to be retrievable for as long as the
retention rules in Section 10.8 keep it. Any signatures already collected on the voided envelope are
not carried forward — the new envelope starts every recipient at pending/notified fresh, because a
signature is only valid against the exact document and field set the signer saw, and that set has
just changed.
10.4 Signer experience #
Signers never create a PDFWorks account. Every step described in this subsection is reachable from a single emailed link with no login, no password, and no app install — this is a deliberate, unconditional product decision, not a default that can be turned off per workspace.
10.4.1 The invitation email and the tokenized link #
The invitation email (full copy in Section 10.6.1) contains one primary call to action, Review
Document, linking to https://sign.pdfworks.io/e/{token}.
Token format. 32 bytes of CSPRNG entropy, base32-Crockford encoded (the same alphabet used for public IDs elsewhere, per Section 4's ID convention), giving 256 bits of entropy — computationally unguessable and consistent with the API key entropy standard set in Section 17. The token is stored server-side only as a SHA-256 hash, identical in spirit to the API-key storage rule in Section 17: the token itself is high-entropy random, so a slow KDF adds nothing.
Scope. A token is scoped to exactly one (envelope, recipient) pair. It cannot be used to view or act on any other recipient's fields within the same envelope, and it cannot be reused across envelopes even for the same person. A signer with two envelopes to sign for the same counterparty receives two distinct emails with two distinct tokens.
Expiry. A token is valid until the earlier of the envelope's expiry date (10.3.4) or 30 days after issuance, refreshed to a new token and a new 30-day window on every resend (10.3.5). An expired token returns a plain, branded "This link has expired — ask the sender for a new one" page; it never reveals whether the underlying envelope still exists.
Reuse from a different IP. The token is not IP-bound. A signer opening the same link from a different device, network, or location than their first visit is permitted — this matters in practice (a signer starts on a work laptop, finishes on a phone) — but every IP address, user agent, and geo-IP country seen for that token is recorded as its own set of fields on the relevant audit events (10.5.1), so the full access pattern is part of the permanent record even though it is never used to block access.
Single-use decision. The link is not single-use in the sense of expiring after one open — a
signer routinely needs to leave and come back (save-and-finish-later, 10.4.9). It is single-use in
the stronger sense that matters legally: once the recipient reaches signed or declined, the token
is immediately invalidated for any further state-changing action; re-opening a post-terminal link
shows a read-only "You already completed this" or "You already declined this" confirmation page with
no editable fields, so nothing can be altered after the fact.
10.4.2 The consent gate #
Immediately after opening a valid, non-terminal link — before the document or any field is rendered — the signer sees the Electronic Record and Signature Disclosure. This is a fixed, full-screen step, not a modal the signer can dismiss by clicking outside it. It presents:
- A summary of what electronic signature means and the signer's right to receive a paper copy or withdraw consent (the standard content required by the ESIGN Act's consumer-consent provisions), rendered from a versioned disclosure template maintained by PDFWorks.
- The document's title and the sender's identity (name and email).
- Two controls: I agree to sign electronically (primary) and I decline (secondary, plain-text link style, not a button, to avoid steering).
Accepting emits signer.consented and advances the signer state machine from viewed to consented
(10.2.3); the document and field navigation UI unlock only at this point — no field, no signature
canvas, and no document text beyond the disclosure itself is rendered before consent. Declining
prompts for a one-line optional reason, emits signer.declined, moves the envelope to declined
(10.2.2), invalidates the token for further action, and shows the signer a confirmation that the
sender has been notified. There is no field-level consent step later in the flow — this single gate
covers the entire session, as is standard practice and sufficient under ESIGN/UETA.
10.4.3 Email OTP verification #
A 6-digit numeric one-time passcode, sent to the recipient's own email address (never SMS, since no phone number is collected), is required in exactly two circumstances:
- Always, immediately after consenting and before the first field can be touched, for any
recipient whose
roleissigner— this is the default step-up applied to every real signature, independent of the per-recipientauthentication step-upsetting in 10.3.2 (that setting exists for approvers, who do not get OTP by default, and can be opted in per-approver by the sender). - Whenever the sender explicitly sets
authentication step-up: email_otpon a recipient of roleapprover.
OTP mechanics: 6 digits, generated with a CSPRNG, valid for 10 minutes, a maximum of 5
attempts before the code is invalidated and a fresh one must be requested, and a resend throttle of
one request per 60 seconds capped at 5 resends per hour per token. Exceeding the attempt limit does
not lock the signer out permanently — it invalidates only that code and requires a fresh send, which
resets the attempt counter. Every OTP request and every verification attempt (success or failure) is
recorded as part of the signer.consented event's payload extension (10.5.1), including the number of
attempts used, but never the code value itself.
10.4.4 Guided field navigation #
Once consented (and OTP-verified where required), the signer sees the document rendered full-fidelity
in the browser with their own fields highlighted in their assigned recipient color; other recipients'
fields, if any are visible on a shared document, are shown de-emphasized and are not interactive. A
persistent progress indicator ("3 of 7 fields complete") and a Next control jump the viewport and
focus to the next incomplete required field in placement order (top-to-bottom, left-to-right within a
page, then page order). Optional fields are visually distinguished (lighter border, "optional" caption)
and are skipped by Next but remain directly clickable. Next and the equivalent keyboard
shortcut (Tab moves to the next field's control; Enter on a signature/initials field opens its
capture modal) are the primary way the entire flow is operable without a pointer, satisfying the
accessibility requirement in 10.4.10.
Each field value is persisted with a signer-scoped request against the token, not against a logged-in
session — the token itself is the credential (10.4.1), following the resource-update contract in
Section 14.5 with the token substituting for the standard bearer credential in the authorization
header. The request carries the field's value; a successful response confirms the field's completed
state and the count of fields remaining for that signer.
A free-text field whose validation is email, phone, number, or regex (10.3.3) is checked
against that rule server-side on every update, in addition to the client-side check on blur; a failure
returns the standard error envelope (Section 14.6) with type: "invalid_request_error", code: "field_validation_failed", param: "value", and the field is not marked complete, so Next does
not advance past it.
10.4.5 Signature capture #
Three capture methods, available for both Signature and Initials fields:
| Method | Mechanism | Limits |
|---|---|---|
| Draw | Pointer/touch/stylus input captured as an SVG path on an HTML canvas | Canvas minimum 300×100 CSS pixels; stroke smoothing applied; an all-flat (no movement) stroke is rejected client-side with "That doesn't look like a signature — try drawing it again" |
| Type | Signer types their name; rendered in one of four bundled cursive/script typefaces bundled with the product (no external font loading, so the capture works offline-tolerant and consistently across signer devices) | 1–100 characters; the signer picks their preferred face from the four before adopting |
| Upload | Signer uploads an image of a handwritten signature | JPEG or PNG, max 5 MB, max 2000×2000 px; background is not auto-removed (the sender-facing render simply places the image as-is, so signers are told in the UI to upload a signature on a white or transparent background) |
Whichever method is used, the result is rasterized to a PNG with a transparent background at capture time and that raster is what gets placed into the field and, later, flattened into the final document (10.5.7) — this normalizes all three input methods to one storage format.
10.4.6 Adopt-once, reuse-within-envelope #
The first time a signer completes a Signature field, they are shown an Adopt Your Signature step that also lets them set their Initials the same way (draw/type/upload, independently — a signer's initials need not visually derive from their signature). The adopted signature and initials are cached in the browser session (not persisted server-side beyond the field values themselves, and never linked to any other envelope) and auto-applied to every subsequent Signature or Initials field for that recipient within the same envelope, with a Change control on each instance if the signer wants a different mark for a particular field. This adoption is scoped strictly to one envelope; a signer returning for a different envelope adopts fresh, since there is no signer account to persist it to.
10.4.7 Save-and-finish-later #
Field values are saved to the server as each field is completed (not only at the end), so a signer can close the tab at any point after consenting and resume later from the same link without losing progress. There is no explicit "Save" action to click — saving is automatic and the UI shows a small "Saved" confirmation after each field. Resuming re-enters guided navigation at the first incomplete required field.
10.4.8 Decline #
Reachable at any point after consent from a persistent "I can't sign this" link in the page footer.
Prompts for an optional reason (0–500 characters), confirms once ("This will notify the sender and end
this signing session — you can't undo this"), then emits signer.declined, moves the envelope to
declined (10.2.2), and invalidates the token.
10.4.9 Mobile signing #
The signing route is fully responsive and is a first-class target, not a degraded fallback: touch targets are a minimum 44×44 CSS pixels, the Draw canvas responds to touch and stylus identically to mouse/pointer input, the document viewer supports pinch-zoom and double-tap-to-fit, and the guided Next control is pinned to the bottom of the viewport on narrow screens so it is always reachable with a thumb. The flow requires no app install; it is the same responsive web route at every screen size, consistent with the PWA-first architecture in Section 15.
10.4.10 Accessibility of the signing flow #
Full keyboard and screen-reader operability of the entire signing flow, end to end, is a legal accessibility requirement — many organizations sending signature requests are themselves bound by accessibility law for anything they ask their customers or employees to interact with — and is treated with the same seriousness as the rest of the product's WCAG 2.2 AA bar (Section 16). Specifics:
- Every field has a programmatic label (
aria-labelor an associated<label>) that states the field type and, for required fields, that it is required.- The consent gate, the OTP entry, the signature capture modal, and the decline-confirmation dialog are all implemented as proper modal dialogs with focus trapped inside, an accessible name, and focus returned to the triggering control on close.
- The progress indicator ("3 of 7 fields complete") is a live region (
aria-live="polite") that announces updates as fields are completed, without moving keyboard focus. - The Draw signature canvas is paired with an equally-supported Type alternative reachable by keyboard alone (Tab to the method switcher, arrow keys to choose Draw/Type/Upload, Enter to activate) — a screen-reader user is never forced through the canvas.
- Color is never the only signal: required-vs-optional and per-recipient field ownership are also conveyed by text and icon, not by the palette color alone.
- A full NVDA and VoiceOver manual pass over the signing flow is part of the per-release testing process described in Section 19.
10.4.11 Completion screen #
On the signer's own final field, the UI shows a completion screen immediately (their state is now
signed) even if other recipients still have work to do: a confirmation message, a summary of what
was signed and when (in the signer's local timezone, computed client-side from the UTC server
timestamp), and a Download a copy button. The download available at this point is the same
not-yet-complete snapshot described in 10.3.5 (watermarked, since the envelope isn't completed yet)
unless this signer happened to be the last one, in which case the flattened final document with the
certificate of completion (10.5.6–10.5.7) is offered directly.
10.5 The audit trail and tamper evidence #
The audit trail is the product's core evidentiary artifact (Section 10.1). This subsection specifies the full event catalogue, the hash chain that makes the trail tamper-evident against an outside attacker or accidental corruption, the daily external anchor that extends that guarantee to cover operator-level tampering (10.1 states exactly what each layer does and does not prove), the Certificate of Completion, and the public verification endpoint.
10.5.1 Event catalogue #
Every audit event is an append-only row: id (evt_), envelopeId, type, occurredAt
(timestamptz, server clock, per 10.5.4), payload (jsonb), documentHash (the SHA-256 of the
envelope's current document bytes at the moment of the event, per 10.5.3), eventHash (this event's
own hash), prevEventHash (the prior event's eventHash, or a fixed 32-byte zero value for the very
first event on an envelope). The full type list, with the payload each carries beyond the common
fields listed above:
| Event type | Emitted when | Payload fields (in addition to the common fields) |
|---|---|---|
envelope.created |
Envelope moves draft → sent (creation moment) |
senderUserId, senderName, senderEmail, documentTitles (array), recipientCount |
envelope.sent |
Immediately after envelope.created, once the first notification is dispatched |
routingMode (sequential | parallel | mixed), expiresAt |
email.delivered |
Email provider confirms delivery of an invitation, reminder, or notification | recipientId, emailType (invitation | reminder | completion | decline_notice | void_notice | expiry_warning | expiry_notice), messageId (provider's ID) |
email.bounced |
Email provider reports a hard bounce | recipientId, emailType, bounceType (hard | soft), providerReason |
signer.viewed |
Recipient opens the tokenized link for the first time in a session | recipientId, ipAddress, userAgent, geoCountry |
signer.consented |
Recipient accepts the Electronic Record and Signature Disclosure | recipientId, ipAddress, userAgent, disclosureVersion, otpVerified (boolean), otpAttempts (integer, omitted if OTP was not required) |
field.completed |
Recipient completes a single field | recipientId, fieldId, fieldType, valueHash (SHA-256 of the entered value — the value itself is not stored in the audit event; the current value lives on the field row and is subject to the same retention as the document) |
signer.signed |
Recipient's last required field is completed and role is signer (or approval is given for approver) |
recipientId, ipAddress, userAgent, signatureMethod (draw | type | upload), fieldsCompletedCount |
signer.declined |
Recipient declines, at any allowed point | recipientId, reason (nullable), declinedAtStep (consent | otp | field_completion) |
reminder.sent |
Scheduled or manual reminder dispatched | recipientId, trigger (scheduled | manual), dayNumber (nullable for manual) |
envelope.completed |
Every signer/approver reaches a terminal success state | finalDocumentHash, chainRootHash (the eventHash of the last event before this one), certificateId |
envelope.voided |
Sender voids | voidedByUserId, reason, precedingState (sent | in_progress) |
envelope.declined |
Any signer/approver declines | decliningRecipientId |
envelope.expired |
Expiry timer fires with the envelope not yet completed |
lastState (sent | in_progress) |
Every event is visible to the sender in the management view (10.3.5). Signers see only events pertaining to themselves plus the coarse envelope-level milestones (sent, completed) needed to understand the overall status; they never see other signers' IP addresses or user agents in the UI, though those fields remain in the underlying record used to build the certificate and the verification endpoint.
10.5.2 Canonical serialization before hashing #
Before any payload is hashed — whether to compute eventHash, documentHash, or valueHash — it is
reduced to a canonical byte sequence using these rules, applied in order:
- JSON key ordering. Object keys are sorted by their UTF-16 code unit values (JavaScript's default string comparison), recursively, at every nesting level. Arrays preserve their original order — only object keys are reordered.
- Unicode normalization. Every string value is normalized to NFC before serialization.
- Number formatting. Integers are serialized with no leading zeros and no fractional part.
Money-like values do not appear in audit payloads (there are none in this domain) so no decimal
formatting rule is needed beyond standard JSON number syntax; there is exactly one representation
per number and it is JavaScript's
Number.prototype.toString()output. - Excluded fields.
idand the event's owneventHashare never included in the bytes that get hashed to produce that sameeventHash(a field cannot be a preimage of its own hash).documentHashandprevEventHashare always included — they are inputs, not outputs, of the hash being computed. - Whitespace. No insignificant whitespace: the canonical form is
JSON.stringifywith no indentation, separators,and:with no trailing space. - Encoding. The canonical JSON string is encoded as UTF-8 bytes before hashing.
This exact procedure (key-sort, NFC-normalize, no-whitespace, UTF-8) is implemented once, in
packages/contracts, as canonicalize(value: unknown): Uint8Array, and is the only code path in the
codebase permitted to produce bytes that get fed to SHA-256. No other serialization ever appears in
a hash computation.
10.5.3 The hash chain #
For event n on a given envelope (1-indexed, in strict occurredAt order):
documentHash[n] = SHA256( current document bytes at the moment event n occurs )
eventHash[n] = SHA256( canonicalize({
envelopeId: envelope.id,
type: event.type,
occurredAt: event.occurredAt, // ISO-8601 UTC, millisecond precision
payload: event.payload,
documentHash: documentHash[n],
prevEventHash: eventHash[n-1] // eventHash[0] := 32 zero bytes, hex "00"×32
}) )documentHash[n] is computed against the document bytes as they exist in storage at that instant —
for every event type except field.completed with a field type that mutates visible content (none
do; field values are stored as structured data and are not burned into the PDF until flattening,
10.5.7) the document bytes are unchanged from the previous event, so documentHash[n] is usually
identical to documentHash[n-1] until the flattening step that produces envelope.completed, at
which point it changes to the hash of the final, flattened, certificate-appended PDF.
Verification algorithm (this is exactly what the public verification endpoint in 10.5.8 runs, and exactly what any third party can reimplement independently from the exported audit trail):
function verifyChain(events: AuditEvent[]): { valid: boolean; brokenAtIndex: number | null } {
let expectedPrev = ZERO_HASH; // 32 zero bytes
for (let i = 0; i < events.length; i++) {
const e = events[i];
if (e.prevEventHash !== expectedPrev) {
return { valid: false, brokenAtIndex: i };
}
const recomputed = sha256(canonicalize({
envelopeId: e.envelopeId,
type: e.type,
occurredAt: e.occurredAt,
payload: e.payload,
documentHash: e.documentHash,
prevEventHash: e.prevEventHash,
}));
if (recomputed !== e.eventHash) {
return { valid: false, brokenAtIndex: i };
}
expectedPrev = e.eventHash;
}
return { valid: true, brokenAtIndex: null };
}A chain is valid if and only if every event's stored eventHash matches its recomputed hash and every
event's prevEventHash matches the immediately preceding event's eventHash. A single altered,
inserted, deleted, or reordered event breaks the chain at that point and every event after it, which
is the intended tamper signal — partial tampering cannot be made to look like only the tampered event
is wrong.
verifyChain proves internal consistency: that the stored sequence of events has not been altered
relative to itself. It is computed entirely from data PDFWorks' own database returns, so it cannot by
itself rule out a wholesale, internally-consistent rewrite by someone with direct write access to that
database — the boundary stated plainly in 10.1. Closing that gap requires checking the chain's events
against a checkpoint published outside PDFWorks' control, which is what the daily anchor in 10.5.9
provides.
10.5.4 Clock discipline #
Every occurredAt timestamp is assigned by the API server at the moment it processes the triggering
request or job step — never by the signer's or sender's browser clock, which is untrusted and
routinely wrong or deliberately spoofable. Application servers run NTP-disciplined clocks (standard
cloud-provider time sync, verified by the infrastructure health checks in Section 20) so that
timestamps are trustworthy to within a few milliseconds of true UTC and, more importantly, are
internally consistent across every event in a chain regardless of which server instance handled which
request. Client-supplied timestamps are accepted nowhere in the audit trail; where the UI displays a
time in the signer's or sender's local timezone (10.4.11), that conversion happens client-side purely
for display and never touches the stored value.
10.5.5 The document hash at each event #
documentHash is stored on every single event, not only at completion, so that the trail can answer
"what did the document look like when this specific thing happened" even mid-flight — for example, if
a sender used the (now-locked-out-once-viewed) edit window in 10.3.5, the sequence of documentHash
values proves exactly which document version existed at each recipient's viewing and signing moment.
10.5.6 The Certificate of Completion #
Generated the instant an envelope reaches completed (10.2.2), before flattening (10.5.7). It is one
or more additional PDF pages appended to the end of the signed document. Full field-by-field
specification:
| Field | Source |
|---|---|
| Title | Fixed: "Certificate of Completion" |
| Envelope ID | env_... |
| Document title(s) | From the envelope's document set |
| Completion date/time | envelope.completed event's occurredAt, rendered in UTC with an explicit "UTC" label |
| Signer table | One row per recipient of role signer/approver: name, email, role, IP address (first and last seen), signed/approved timestamp |
| Event timeline | Every audit event, in order, with type and timestamp, rendered as a compact table |
| Final document hash | finalDocumentHash from the envelope.completed payload, SHA-256, hex |
| Chain root hash | chainRootHash from the same payload — the eventHash of the last event before completion, i.e., the value an independent verifier reduces the whole chain to |
| Daily anchor | The UTC day of envelope.completed's occurredAt, plus a note that the anchor covering that day publishes within 24 hours at a fixed, independent public location (Section 10.5.9). The anchor value itself is not yet known at certificate-generation time — the certificate never bakes in a value that does not exist yet — and is checked live at the verification URL below once published |
| QR code | Encodes the short verification URL (below) |
| Verification URL (short form, also printed as text under the QR code) | https://sign.pdfworks.io/verify/{envelopeId} |
Rendered example of the certificate page, laid out as it appears in the generated PDF:
┌──────────────────────────────────────────────────────────────────────────┐
│ Certificate of Completion │
│ │
│ Envelope: env_01K7Y8H4QZ9X3R2P6M0N7C5T1J │
│ Document: Vendor Services Agreement — Northwind Logistics │
│ Completed: August 19, 2026, 4:12:07 PM UTC │
│ │
│ Signers │
│ ───────────────────────────────────────────────────────────────────── │
│ Dana Okafor <dana@northwindlogistics.com> Signer │
│ IP 203.0.113.44 (first) → 203.0.113.44 (last) │
│ Signed August 19, 2026, 4:11:52 PM UTC │
│ Priya Nair <priya@pdfworks-customer.example> Approver │
│ IP 198.51.100.7 │
│ Approved August 18, 2026, 10:03:15 AM UTC │
│ Marcus Webb <marcus@pdfworks-customer.example> Approver │
│ IP 198.51.100.19 │
│ Approved August 18, 2026, 11:47:02 AM UTC │
│ │
│ Event Timeline │
│ ───────────────────────────────────────────────────────────────────── │
│ Aug 18, 2026 9:00:01 AM UTC envelope.created │
│ Aug 18, 2026 9:00:02 AM UTC envelope.sent │
│ Aug 18, 2026 9:00:04 AM UTC email.delivered (Priya Nair) │
│ Aug 18, 2026 9:00:04 AM UTC email.delivered (Marcus Webb) │
│ Aug 18, 2026 9:58:40 AM UTC signer.viewed (Priya Nair) │
│ Aug 18, 2026 9:59:10 AM UTC signer.consented (Priya Nair) │
│ Aug 18, 2026 10:03:15 AM UTC signer.signed (Priya Nair) │
│ Aug 18, 2026 11:40:22 AM UTC signer.viewed (Marcus Webb) │
│ Aug 18, 2026 11:46:30 AM UTC signer.consented (Marcus Webb) │
│ Aug 18, 2026 11:47:02 AM UTC signer.signed (Marcus Webb) │
│ Aug 18, 2026 11:47:03 AM UTC email.delivered (Dana Okafor) │
│ Aug 19, 2026 4:08:51 PM UTC signer.viewed (Dana Okafor) │
│ Aug 19, 2026 4:09:20 PM UTC signer.consented (Dana Okafor) │
│ Aug 19, 2026 4:11:52 PM UTC signer.signed (Dana Okafor) │
│ Aug 19, 2026 4:12:07 PM UTC envelope.completed │
│ │
│ Final document hash (SHA-256) │
│ 4f8a2c9e1d7b6053a8e4f1c2b9d6073a5e8f1c4b7a0d3e6f9c2b5a8d1e4f7a2 │
│ │
│ Chain root hash (SHA-256) │
│ 9b3e6d0c7a4f1e8b5c2a9f6d3e0b7a4c1f8e5b2a9d6c3f0e7b4a1d8c5f2e9b6 │
│ │
│ Daily anchor (external, outside PDFWorks' control) │
│ Publishes within 24 hours at │
│ transparency.pdfworks.io/anchors/2026-08-19 — check independently, or │
│ re-verify current status any time at the link below │
│ │
│ ┌────────────┐ │
│ │ ▓▓ ▓ ▓▓ │ Verify this document at │
│ │ ▓ ▓▓ ▓ │ sign.pdfworks.io/verify/env_01K7Y8H4QZ9X3R2P6M0N7C5T1J │
│ │ ▓▓ ▓ ▓▓ │ │
│ └────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘The daily-anchor line intentionally carries no hash at generation time — 10.5.9 explains why the system-wide anchor for a given day cannot exist until the day is over — so this line is a pointer to where a verifier checks the externally published checkpoint once it exists, not a value the reader is asked to trust from the certificate page alone.
10.5.7 Appending the certificate and flattening #
Order of operations at completion, executed as one server-side job on the esign queue (Section 3.9):
- Compute
finalDocumentHashover the document bytes as they stand with every field value applied but not yet flattened. - Render the Certificate of Completion page(s) from the template in 10.5.6 and append them to the document.
- Flatten: every filled field (text, checkbox, radio, dropdown) and every signature/initials mark is burned into the page content stream as regular, non-editable content, and the underlying AcroForm field definitions are removed. This uses the same flatten primitive specified for the general-purpose Flatten tool in Section 8, applied automatically rather than by user action.
- Recompute the document hash of the now-flattened, certificate-appended file; this final hash is
what
finalDocumentHashin theenvelope.completedaudit event and the certificate page both record — they are the same value by construction, and that equality is exactly what the verification endpoint checks. - Store the result as the envelope's completed artifact, subject to the 30-day post-completion download window (Section 10.8).
10.5.8 Public verification endpoint #
GET https://sign.pdfworks.io/verify/{envelopeId} — and its programmatic form,
POST https://api.pdfworks.io/v1/esign/verify — let anyone, without authentication, confirm that a
document they hold matches what PDFWorks recorded as the final output of a given envelope.
Inputs (the human-facing page accepts either; the API accepts either in one request body):
- Upload a PDF file (the page computes its SHA-256 client-side over the raw bytes before upload — the page never needs to retain the file, only its hash, though the human-facing flow accepts the upload for convenience and computes the hash server-side from it).
- Or paste a SHA-256 hash directly.
POST /v1/esign/verify
Content-Type: application/json
{ "envelopeId": "env_01K7Y8H4QZ9X3R2P6M0N7C5T1J", "documentHash": "4f8a2c9e1d7b6053a8e4f1c2b9d6073a5e8f1c4b7a0d3e6f9c2b5a8d1e4f7a2" }200 OK
{
"result": "MATCH",
"envelopeId": "env_01K7Y8H4QZ9X3R2P6M0N7C5T1J",
"completedAt": "2026-08-19T16:12:07.000Z",
"chainValid": true,
"anchor": {
"date": "2026-08-19",
"status": "pending",
"root": null,
"url": "https://transparency.pdfworks.io/anchors/2026-08-19"
}
}Three outcomes:
| Outcome | Meaning | HTTP status |
|---|---|---|
MATCH |
The submitted hash equals the stored finalDocumentHash for a completed envelope, and verifyChain (10.5.3) returns valid: true for that envelope's full event set |
200 |
ALTERED |
The envelope exists and is completed, but the submitted hash does not match finalDocumentHash, or the stored chain itself fails verifyChain (which would itself indicate server-side tampering, logged and alerted as a P0 security incident) |
200 (this is a successful check that reveals a real mismatch, not an error) |
UNKNOWN |
No envelope exists with that ID, or the envelope exists but never reached completed (still in progress, voided, declined, or expired) |
200 |
The anchor field is a separate guarantee from chainValid, and a caller who cares about
operator-level tamper resistance should not skip it. chainValid reflects verifyChain (10.5.3) run
against PDFWorks' own stored events — a check for internal consistency only. anchor.status is one of
pending (completion happened less than 24 hours ago, before that UTC day's anchor has published),
published (the day's root has been published externally per 10.5.9 and is returned here as a
convenience copy), or unavailable (publication failed and has not yet succeeded on retry — this is
surfaced, never hidden). A caller who trusts PDFWorks' own infrastructure can stop at chainValid. A
caller who does not — who is specifically checking for a rewrite an operator could have performed —
fetches anchor.root independently from anchor.url (or from a previously saved daily digest email,
per 10.5.9) rather than trusting this response, and compares it against a Merkle root recomputed from
the envelope's own event export using the inclusion-proof method in 10.5.9; this endpoint's
anchor.root field is a convenience copy for that comparison, not the source of truth for it.
What it does and does not reveal to an unauthenticated caller. The response for every outcome is
limited to: the outcome itself, the envelope ID, the completion timestamp (only for MATCH/ALTERED,
since those imply completion occurred), the chain-validity boolean, and the anchor status described
above. It never returns document content, signer names, signer emails, IP addresses, or any audit event
payload — a bare "does this hash match, and is it independently checkable" answer is what the guarantee
requires, and nothing about the parties involved is disclosed to a caller who does not already hold the
document or a link to the certificate. Full detail (the signer table, the timeline) is available only
from the certificate page embedded in the document itself or to the authenticated sender in the
management view.
Rate limiting. Because this endpoint is intentionally unauthenticated, it uses a stricter,
IP-scoped token bucket than the standard authenticated rate limits in Section 14: 20 requests per
minute per IP, 200 per hour per IP, with the standard RateLimit-* response headers and Retry-After
on 429 defined in Section 14. Repeated UNKNOWN responses from the same IP in a short window are
treated as an enumeration signal and trigger a temporary, longer cooldown (15 minutes) on top of the
standard limiter, logged for the abuse-monitoring process in Section 10.9.
10.5.9 Daily anchoring — the external tamper-evidence layer #
This subsection is what closes the gap stated in 10.1: a hash chain stored solely inside a database PDFWorks operates is not, on its own, evidence against PDFWorks itself. Every day, that day's audit events are checkpointed and the checkpoint is published somewhere PDFWorks does not unilaterally control, so that a wholesale rewrite of the database cannot be made to agree with copies that already exist outside it.
The anchoring job. At 00:10 UTC daily, a scheduled job on the janitor queue (Section 3.9)
collects every audit event system-wide, across every envelope regardless of workspace, whose
occurredAt fell within the previous full UTC day. Events are ordered deterministically — by
envelopeId, then by occurredAt, then by id as a final tiebreaker — and their eventHash values
(10.5.3) are combined into a binary Merkle tree; the resulting root is that day's anchorRoot. The job
records one audit_anchor row per day: date (the anchored UTC day), anchorRoot (SHA-256, hex, 64
characters), eventCount, status (pending | published | publication_failed), publishedAt,
and publicationReceipt (jsonb, the confirmation detail from each publication target below).
Where it is published, and why this is enough. anchorRoot is published to two independent
locations, neither of which PDFWorks can silently rewrite after the fact:
- A public transparency log, at
https://transparency.pdfworks.io, an append-only Merkle log structured the same way a Certificate Transparency log is (RFC 6962-style): PDFWorks can add new entries but cannot alter or remove a past one without the change being visible in the log's own append-only structure. The full log is additionally mirrored, once per day after publication, as a commit to a public, read-only Git repository, so a copy of every past entry exists in a form PDFWorks does not exclusively hold and cannot rewrite without leaving evidence in that repository's own commit history. - The workspace-owner daily digest. Every workspace that completed at least one envelope on the
anchored day receives an email containing that day's
anchorRootand the count of events it covers. This gives the party who actually cares about a specific day's integrity — the workspace owner — an independent, timestamped copy sitting in their own inbox, outside PDFWorks' systems entirely, with no dependency on remembering to check a separate website.
Two cheap, independently held copies are enough for the threat this defends against. The threat is not an anonymous outsider (the hash chain in 10.5.3 already handles that) — it is an operator, or someone who has obtained an operator's level of access, rewriting history and hoping no one has an independent copy to check against. Defeating that requires only that some copy exist outside the rewriter's reach, not a large distributed network: a public, independently mirrored log plus a paper trail already sitting in every affected customer's inbox already means a rewrite would have to also suppress a public log entry and silently un-send emails already delivered — both outside an operator's unilateral control by construction. A heavier scheme such as blockchain anchoring would raise the bar further only against an adversary willing and able to compromise or collude with the transparency log's own operation, which is not the threat model this product needs to defend against; it would add cost, external dependency risk, and complexity without closing a gap that remains open after the two-location scheme above.
Publication failure handling. If publication to the transparency log fails (network error, the log
service is unavailable), the job retries with the backoff schedule owned by Section 13.3.5, hourly,
until 06:00 UTC the same day. If it still has not succeeded by then, the row's status is set to
publication_failed, a P1 alert fires to the on-call rotation (Section 18), and the daily digest email
still ships on schedule carrying anchorRoot with a note that transparency-log publication is delayed
— the digest is not blocked on the log. Publication continues retrying hourly until it succeeds, at
which point status becomes published and publishedAt is set to the actual publication time. A
digest email that fails to send to one specific workspace owner (a bounce) does not affect the
transparency-log entry, which stands as the anchor of record for that day regardless; that owner can
still retrieve their own envelopes' anchor status from the verification endpoint (10.5.8) at any time.
Verifying an event against its day's anchor. Because the tree is built over every event
system-wide, a verifier who holds only one envelope's export cannot recompute the full day's root
without every other envelope's event hashes, which would leak unrelated customers' data. Instead,
alongside anchorRoot the job retains, per event, the sibling hashes along that event's path to the
root (a standard Merkle inclusion proof), retained for as long as the event itself is retained (Section
10.8). The verification endpoint (10.5.8) returns this proof for any event belonging to a completed
envelope on request. A verifier checks an event against its day's anchor without trusting PDFWorks' own
claim about the anchor value by:
function verifyEventAgainstAnchor(
event: AuditEvent,
inclusionProof: string[], // sibling hashes, leaf to root
publishedAnchorRoot: string, // fetched from transparency.pdfworks.io directly, not from PDFWorks' API
): boolean {
let node = event.eventHash;
for (const sibling of inclusionProof) {
node = sha256(orderedConcat(node, sibling)); // canonical left/right ordering per the proof's own index bit
}
return node === publishedAnchorRoot;
}This returns true only if the event's hash genuinely contributed to the root PDFWorks published
externally on the day in question — a value the verifier fetched independently, not one PDFWorks
supplied as part of the same response being checked. This is the check that survives an operator who
controls PDFWorks' entire database: rewriting an event after the fact changes its eventHash, which no
longer produces the externally published anchorRoot via any inclusion proof, and the mismatch is
detectable by anyone who kept a copy of that day's anchor — including, deliberately, every workspace
owner who received the digest email that day.
10.6 Notifications #
All e-signature email is transactional (not marketing) and is sent through the product's email
provider (Resend, per the version table) using React Email templates rendered server-side. Every
email in the flow is specified below with its subject line, preheader (the short summary text shown
by mail clients next to the subject), and full body copy. {{placeholders}} are filled per-send;
{{expiryDate}} and similar dates are always rendered in UTC with an explicit "UTC" suffix to avoid
ambiguity across signer timezones.
10.6.1 Invitation #
Subject: {{senderName}} sent you "{{documentTitle}}" to sign
Preheader: Review and sign — takes about {{estimatedMinutes}} minutes
{{senderName}} has sent you a document to review and sign.
Document: {{documentTitle}}
From: {{senderName}} <{{senderEmail}}>
{{#if messageBody}}
Message: "{{messageBody}}"
{{/if}}
[ Review Document ] → https://sign.pdfworks.io/e/{{token}}
This link expires {{expiryDate}}. No account or password is required — clicking the
button above takes you straight to the document.
This document will be signed electronically. You'll be asked to agree to sign
electronically before you can view or fill in anything.
If you weren't expecting this email, you can safely ignore it, or report it:
https://pdfworks.io/report-abuse?envelope={{envelopeId}}
—
PDFWorks Signature | This is a transactional email related to a document sent to you for signature.10.6.2 Reminder #
Subject: Reminder: {{senderName}} is waiting for your signature on "{{documentTitle}}"
Preheader: Still needs your signature — {{daysRemaining}} days left
This is a reminder that {{senderName}} is waiting for you to sign "{{documentTitle}}".
[ Review Document ] → https://sign.pdfworks.io/e/{{token}}
This request expires {{expiryDate}}.
—
PDFWorks Signature | This is a transactional email related to a document sent to you for signature.10.6.3 Completion (to every recipient, with attachment) #
Subject: Completed: "{{documentTitle}}" has been signed by everyone
Preheader: All signatures collected — your copy is attached
Everyone has signed "{{documentTitle}}". A copy of the fully signed document,
including the certificate of completion, is attached to this email.
[ View Online ] → https://sign.pdfworks.io/e/{{token}}/completed
This document is also available for download for {{downloadWindowDays}} days at
the link above.
—
PDFWorks Signature | This is a transactional email related to a document sent to you for signature.The completed PDF (flattened, certificate appended, per 10.5.7) is attached directly for recipients of
role signer, approver, and cc. If the file exceeds 25 MB, the attachment is omitted and the body
copy substitutes "Your document is ready — download it using the link above" in place of the
attachment reference, since most mail providers reject attachments beyond that size.
10.6.4 Decline notice (to sender) #
Subject: {{decliningRecipientName}} declined to sign "{{documentTitle}}"
Preheader: Signature request stopped — action needed
{{decliningRecipientName}} ({{decliningRecipientEmail}}) has declined to sign
"{{documentTitle}}".
{{#if reason}}
Reason given: "{{reason}}"
{{/if}}
This signature request has stopped. No further recipients will be notified. You
can review what happened and start a new request from your dashboard.
[ View Envelope ] → https://app.pdfworks.io/esign/{{envelopeId}}
—
PDFWorks Signature | This is a transactional email related to a document you sent for signature.10.6.5 Void notice (to any recipient with an outstanding link) #
Subject: "{{documentTitle}}" has been canceled by the sender
Preheader: No action needed — this request is no longer active
{{senderName}} has canceled the request to sign "{{documentTitle}}". No further
action is needed from you, and your previous link is no longer active.
{{#if reason}}
Reason given: "{{reason}}"
{{/if}}
—
PDFWorks Signature | This is a transactional email related to a document sent to you for signature.10.6.6 Expiry warning (48 hours before expiry, to any non-terminal recipient) #
Subject: Expiring soon: "{{documentTitle}}" needs your signature by {{expiryDate}}
Preheader: Only 2 days left to sign
"{{documentTitle}}" from {{senderName}} will expire on {{expiryDate}}. After that,
this link will no longer work and {{senderName}} will need to send a new request.
[ Review Document ] → https://sign.pdfworks.io/e/{{token}}
—
PDFWorks Signature | This is a transactional email related to a document sent to you for signature.10.6.7 Expiry notice (to sender, on expiry) #
Subject: "{{documentTitle}}" expired before everyone signed
Preheader: {{completedCount}} of {{totalCount}} signed before the deadline
Your signature request for "{{documentTitle}}" expired on {{expiryDate}} before
everyone finished. {{completedCount}} of {{totalCount}} recipients signed.
[ View Envelope ] → https://app.pdfworks.io/esign/{{envelopeId}}
You can start a new request with the same document and recipients from your
dashboard.
—
PDFWorks Signature | This is a transactional email related to a document you sent for signature.10.6.8 Sending domain and authentication #
E-signature mail is sent from notify@sign.pdfworks.io (a subdomain distinct from marketing mail,
which originates from pdfworks.io itself, so a spam or deliverability event on one never affects the
other). The sending domain publishes:
- SPF: a
TXTrecord authorizing the email provider's sending infrastructure as a permitted sender forsign.pdfworks.io. - DKIM: provider-generated key pairs, published as
CNAMErecords per the provider's DNS instructions, signing every outbound message. - DMARC:
p=quarantineat launch, withrua/rufaggregate and forensic reports directed to an internally monitored address, escalating top=rejectonce 30 days of aggregate reports show no legitimate mail failing alignment.
10.6.9 Bounce and complaint handling #
The email provider posts delivery events (delivered, bounced, complained) to a webhook consumed on the
webhook queue (Section 3.9). A hard bounce on an invitation or reminder immediately records
email.bounced on the audit trail and moves that recipient's state to bounced (10.2.3); the
sender's management view (10.3.5) surfaces it prominently as "Email undeliverable — check the
address" with a one-click correct-and-resend for that recipient only. A soft bounce is retried by
the provider's own retry policy and does not change signer state unless it eventually hard-bounces. A
spam complaint (recipient marks the mail as spam) immediately suppresses further sends to that
address across the whole product — not just that envelope — and is logged for the deliverability
review in Section 18.
10.6.10 Deliverability practices #
No marketing content, tracking pixels for engagement analytics, or promotional links ever appear in e-signature mail — every email in 10.6.1–10.6.7 contains exactly one class of call to action (sign, view, or acknowledge). Sending volume per domain is monitored against the provider's reputation metrics; a workspace whose recipients generate an abnormal bounce or complaint rate is throttled and flagged for the abuse review in 10.9 rather than being allowed to silently damage the shared sending domain's reputation.
10.6.11 The unsubscribe question #
Transactional signature email is not subject to a general unsubscribe link, and this is a deliberate, justified decision rather than an oversight: CAN-SPAM and equivalent regimes exempt transactional messages sent to complete a transaction the recipient is already a party to, and a signer who could "unsubscribe" from an invitation they need to act on would be unable to complete a document they may be contractually obligated to sign. Every email nonetheless carries a functional path out of unwanted contact that does not depend on a marketing unsubscribe mechanism: the abuse-report link in 10.6.1 (so a recipient who believes they were added in error, or who is being harassed with repeat requests, has an immediate escalation path independent of the sender), and the spam-complaint suppression in 10.6.9, which is honored even though the message class is transactional.
10.7 Templates #
10.7.1 Reusable envelope templates #
A template (tpl_) captures everything about an envelope except the actual recipient identities: the
document(s), the full field layout (type, position, size, properties, and which placeholder role
each field belongs to rather than a real recipient), the routing mode and per-role routing order, the
message body pattern, and the expiry/reminder settings. Creating a template from an existing envelope
or draft replaces every concrete recipient with a placeholder role (Role 1, Role 2, ... renameable
to something like "Client" or "Internal Approver") that a real recipient is bound to at send time.
Sending from a template is the same flow as 10.3 with recipients pre-seeded from the placeholder roles
— the sender fills in a real name and email for each role, everything else (fields, routing, settings)
carries over untouched, and the resulting envelope is a fully independent envelope with no further
link back to the template beyond a createdFromTemplateId reference used for the sender's own
reporting.
Team-shared templates. On the Team plan (Section 12.2), a template can be marked
visibility: workspace, making it usable by any workspace member with send permission, versus the
default visibility: private (creator only). Editing a shared template only affects envelopes sent
from it afterward — envelopes already sent or in draft from a prior template version are unaffected,
since a template's field layout is copied into the envelope at creation time, not referenced live.
10.7.2 Bulk send from CSV #
Bulk send takes one template and a CSV of recipients and creates one independent envelope per row,
each with its own env_ ID and its own audit trail from the start.
CSV schema (header row required, columns matched by name, not position):
| Column | Required | Notes |
|---|---|---|
role1Name, role1Email (repeated as role2Name/role2Email, etc., one pair per role defined on the template) |
Yes, for every role the template defines | Name 1–200 chars; email must pass RFC 5322 syntax |
expiryDays |
No | Overrides the template's default expiry for this row only; 1–30 |
message |
No | Overrides the template's message body for this row only; up to 2,000 characters |
Validation, per row, before any envelope is created:
- Every required role column is present and non-empty.
- Every email address passes syntax validation.
- No two roles in the same row share an identical email unless the template explicitly allows the same person in multiple roles (a template-level setting, default false).
expiryDays, if present, is an integer 1–30.- Total row count does not exceed the sender's plan's batch limit (Section 10.9, which references the batch-processing limits owned by Section 13).
Per-row failure report. Bulk send is all-or-nothing at the validation stage (a CSV with any invalid row is rejected wholesale with a downloadable annotated CSV marking every failing row and the specific reason, so the sender can fix and re-upload) but independent at the send stage — once validation passes, each row's envelope is created and sent independently, and a failure sending one row's envelope (for example, a transient email-provider error) does not block or roll back the others. The result is a summary:
{
"batchId": "bat_01K7Y9C3F6H2M8N4P1Q7R5T0X",
"totalRows": 240,
"envelopesCreated": 238,
"failed": [
{ "row": 57, "email": "not-a-real-address", "code": "invalid_request_error", "message": "Email address failed validation." },
{ "row": 189, "email": "j.kowalski@northgate-industrial.example", "code": "quota_error", "message": "Monthly envelope quota exceeded." }
]
}Each entry in failed uses the standard error envelope's type/code/message shape defined in
Section 14.6, embedded per-row rather than as a top-level response error, since the batch itself
succeeded even though some rows did not.
10.8 Retention exception #
Envelopes are the one deliberate exception to the product's default rule that any uploaded file is deleted within 24 hours (Section 6). That default rule cannot apply to e-signature, because a signer needs the document to still exist days or weeks after the sender uploads it, and destroying the source document before every signer has acted would make the feature nonfunctional by construction. The specific numbers, restated precisely here since this is their canonical home:
- An envelope's source and in-progress documents persist for the envelope's configured lifetime: a 14-day default expiry, configurable per envelope from 1 to 30 days, with 30 days as a hard ceiling the sender cannot exceed regardless of plan.
- Once an envelope reaches
completed, the flattened final document and its certificate of completion remain downloadable for 30 days after completion, after which the blob is hard-deleted exactly as any other server-side artifact is (Section 6). - The audit trail — every event, its hash chain, and the signer email/IP/user-agent captured on each event — is retained for 7 years from the envelope's completion (or, for an envelope that never completes, from the date it reaches whichever terminal state it does reach — voided, declined, or expired), independent of the source document's own deletion, because the trail, not the document bytes, is what a dispute years later actually needs, and the trail is deliberately designed to contain no document content, only metadata and hashes. Seven years is a hard ceiling, not a floor: it exceeds the statute of limitations for a written-contract claim in essentially every ESIGN/UETA and eIDAS jurisdiction this product targets, so retaining audit data past that point is a liability with no offsetting evidentiary benefit. At the 7-year boundary, the audit trail — including any archived export of it — is hard-deleted; an archived export is included in, and never extends, the 7-year window, and nothing about a given envelope's audit trail survives past it. Deleting an account before the 7-year boundary is reached anonymizes the personal fields in the still-active trail (email and IP addresses are replaced by their salted hashes) but neither shortens nor extends that boundary — the anonymized trail continues to exist, and continues to verify, until its own 7-year mark, because destroying it early would silently invalidate the evidentiary value of a signature a counterparty may still be relying on. What this deletion does not reach: the daily anchors published externally for that period (Section 10.5.9) contain no document content and no personal data — only Merkle roots over event hashes — and, being outside PDFWorks' own systems already, are unaffected by this or any other PDFWorks-side deletion process. Full detail on account-deletion anonymization lives in Section 17's privacy subsection; full detail on the general retention and deletion mechanics (data-key destruction, janitor sweep timing, and the archived-export destruction schedule) lives in Section 6.
10.9 Limits, quotas, and abuse #
Envelope quotas per plan are set out in the canonical plan table in Section 12.2 and are not
restated here. A quota check runs at send time (10.3.4), not at draft-creation time, so drafting freely
and hitting the limit only at Send is the intended behavior — a sender always knows exactly which send
consumed their last unit of quota. Exceeding quota returns the standard 402 quota_error envelope
(Section 14.6) with code: "envelope_quota_exceeded".
Structural limits, independent of plan:
| Limit | Value |
|---|---|
| Maximum recipients per envelope | 50 |
| Maximum fields per envelope | 500 |
| Maximum documents per envelope | 20 |
| Maximum document size | Follows the sender's plan file-size limit (Section 12.2) |
| Maximum page count per envelope (summed across documents) | 500 pages |
| Maximum attachment-request file size | 25 MB per attachment (10.3.3) |
Anti-abuse posture:
- Rate limits on sending. Beyond the monthly quota, sending itself is rate-limited per workspace to 20 envelope-sends per hour, using the same Redis token-bucket mechanism and response headers specified in Section 14, to blunt a compromised account being used for a mass-send burst even while comfortably inside monthly quota.
- New-account sending limits. An account or workspace less than 7 days old is capped at 5 envelope-sends in its first 24 hours regardless of plan, lifted automatically after the account ages past that window with no support ticket required. This specifically targets signup-and-abuse-quickly patterns without penalizing legitimate new customers beyond a short, automatic ramp period.
- Phishing-pattern detection on custom messages. The sender-supplied message body and subject override (10.3.4) are scanned against a maintained list of high-risk patterns (urgency language combined with a request for financial-account or credential-like free-text field labels, mismatched display-name-versus-domain patterns, known-bad URL patterns) before send; a match blocks the send with an actionable error rather than silently shadow-banning the sender, since false positives on legitimate legal or financial language are expected and must be correctable.
- Abuse report link in every recipient email. Present in the invitation email (10.6.1) and
reachable from every other recipient-facing email via the same footer pattern, linking to
https://pdfworks.io/report-abuse?envelope={{envelopeId}}, which does not require the reporter to have the signing link or any credential. - Takedown process. A submitted abuse report immediately freezes the reported envelope (no further recipient can view, consent, or sign; the sender's management view shows "Under review") and queues it for manual trust-and-safety review, targeted within 1 business day. A confirmed violation results in permanent envelope voiding, the reported workspace's sending capability suspended pending investigation, and, for repeat or severe violations, account termination under the product's terms of service. A report found to be unfounded unfreezes the envelope and it resumes exactly where it left off, since freezing never destroys any state.
10.10 Acceptance criteria #
- Creating a draft envelope and deleting it without sending produces zero rows in the audit-event table for that envelope, and the envelope itself is hard-deleted rather than soft-deleted.
- Sending an envelope with sequential routing and three signers notifies only the first signer; the
second and third signers' state remains
pendingand no invitation email is dispatched to them until the prior signer reachessigned. - Sending an envelope with parallel routing and three signers dispatches all three invitation emails
within the same send operation, and all three signer rows move to
notifiedtogether. - A mixed-routing envelope (two approvers in group 1, one signer in group 2, per the worked example in
10.3.2) does not notify the group-2 signer until both group-1 approvers reach
approved. - A signer who opens a valid link and clicks I decline before accepting the Electronic Record and
Signature Disclosure produces a
signer.declinedevent withdeclinedAtStep: "consent", moves the envelope todeclined, and no field-completion UI is ever rendered to them. - A signer cannot complete any field, including a non-signature text field, before their
signer.consentedevent exists — attempting to submit a field value via a direct API call prior to consent returns a409 conflict_error, not a silent no-op. - When email OTP is required (every
signerrecipient, per 10.4.3), entering an incorrect code five times invalidates that code and requires a fresh send; the sixth attempt against the invalidated code is rejected even if it is the correct value, and the signer's state does not advance pastconsenteduntil a subsequent correct verification succeeds. - An OTP code is rejected if submitted more than 10 minutes after it was issued, even if the value is correct.
- Recomputing every event's
eventHashandprevEventHashchain for a completed envelope, using the canonicalization rules in 10.5.2 and the algorithm in 10.5.3, returnsvalid: truefor an untampered envelope andvalid: falsewith the correctbrokenAtIndexwhen any single event's stored payload is mutated directly in the database out of band from the application. - The
finalDocumentHashrecorded on theenvelope.completedaudit event is byte-identical to the SHA-256 of the flattened, certificate-appended PDF actually stored as the envelope's completed artifact, and byte-identical to the hash printed in the certificate's "Final document hash" field. - The Certificate of Completion page appended to a completed envelope contains one row per
signer/approverrecipient with a non-empty IP address and a timestamp, and contains zero rows forccrecipients (who have no signing event to report). - Submitting a completed envelope's actual final PDF bytes to the public verification endpoint
returns
MATCH; submitting the same PDF with a single byte flipped anywhere in the file returnsALTERED; submitting a random, unrelated PDF or hash returnsUNKNOWNif no envelope with that hash exists, orALTEREDif it happens to reference a real envelope ID with a non-matching hash. - The verification endpoint's JSON response for every outcome (
MATCH,ALTERED,UNKNOWN) never contains a signer name, a signer email address, an IP address, or any field value, verified by schema assertion in the endpoint's test suite. - An envelope whose configured expiry timestamp passes with at least one recipient not yet in a
terminal state transitions to
expired, emitsenvelope.expired, and every outstanding signing token for that envelope is rejected by the token-validation middleware from that moment forward. - Voiding an in-progress envelope immediately invalidates every recipient's signing token — a
recipient whose browser tab is already open on a field-entry page and who attempts to submit a
field after the void receives a
409 conflict_errorand is redirected to the void-notice page on next load. - The entire signer flow — consent gate, OTP entry, field navigation, signature capture (via the Type method, which requires no pointer input), and decline — can be completed using only keyboard input, with every interactive control reachable in a logical tab order and every state change announced to a screen reader via the live region specified in 10.4.10, verified by an automated axe-core pass and a manual NVDA/VoiceOver pass per the process in Section 19.
- A bulk send from a CSV containing 240 valid rows and 2 invalid rows (one with a malformed email,
one that would exceed the sender's remaining monthly quota) creates exactly 238 envelopes and
returns a
failedarray with exactly those 2 rows, each carrying the correct errorcodefrom the catalogue in Section 23.1, embedded in the standard error envelope shape (Section 14.6). - A Team-plan workspace member without send permission cannot see a
visibility: workspacetemplate in the "send from template" picker if their workspace role lacks the send-envelope entitlement defined in Section 11, even though the template itself is workspace-visible. - The daily anchoring job (10.5.9), run against a UTC day with a known set of audit events across
multiple envelopes, produces an
anchorRootbyte-identical to a Merkle root independently recomputed over the same event hashes in the same deterministic order; the correspondingaudit_anchorrow'sstatusreachespublishedonly once both the transparency-log publication and the digest-email dispatch have been confirmed. - Querying the verification endpoint (10.5.8) for an envelope completed less than 24 hours ago
returns
anchor.status: "pending"andanchor.root: null; querying it again after that day's anchor has published returnsanchor.status: "published"with a non-nullanchor.rootmatching the value independently readable from the public transparency log. - Given a single audit event, its recorded Merkle inclusion proof, and the
anchorRootpublished externally for its day,verifyEventAgainstAnchor(10.5.9) returnstruefor the unaltered event andfalsewhen the event's stored payload is mutated directly in the database out of band from the application — even though such a mutation, made consistently, could still passverifyChain(10.5.3) run against the same tampered database, demonstrating that the two checks defend against different attackers and neither alone is sufficient against an operator with database access.
11. Accounts, Guest Access, Teams & Workspaces #
11.1 The account model #
There are exactly four account states, and every actor in the system is in exactly one of them at any moment:
| State | Has credentials? | Server-side records? | Can pay? | Belongs to a workspace? |
|---|---|---|---|---|
| Guest | No | No (see 11.5 — nothing about a guest is persisted server-side except an ephemeral daily counter) | No | No |
| Free | Yes (registered) | Yes | No | No, unless invited into someone else's workspace |
| Individual paid (Pro) | Yes | Yes | Yes, personally | No, unless invited into someone else's workspace |
| Workspace member | Yes | Yes | Only if role is owner or billing-only |
Yes, one or more workspaces |
These are not four different account types in the schema. A users row is a users row; "Free," "Pro," and "workspace member" are derived at read time from (a) the user's personal subscription row (Section 12.5) and (b) the user's rows in workspace_members. A single human can simultaneously hold a personal Pro subscription and be a member of two different Team workspaces — the product presents this as one account with a workspace switcher, never as separate logins.
The governing product principle: solo use is the primary path. PDFWorks is used every day by people who will never see a workspace — a paralegal redacting one contract, a student merging lecture PDFs, a contractor filling a form. Every workspace, billing-portal, and role-permission concept described in this section is additive: it exists only once an account opts into it, and its presence must never add friction, a step, a screen, or a decision to a user who has not opted in. Concretely, this rules out:
- A "personal or team?" choice at signup. Registration (11.2) never asks. A user who never creates or joins a workspace never sees the word "workspace."
- A workspace switcher rendered for accounts with zero workspace memberships. The switcher control does not exist in the DOM until
workspace_membershas at least one row for the signed-in user. - Routing a solo Pro subscriber's checkout, billing portal, or entitlement checks through any workspace-shaped code path.
resolveEntitlements()(12.3) takes the scope as an explicit argument; a solo user resolves against their personal subscription row directly, with zero joins throughworkspaces. - Any tool page, upload dialog, or export flow gaining an extra click ("select a workspace," "confirm owner," "choose sharing scope") because the codebase supports teams. If a flow needs a scope, it defaults silently to "personal" and only surfaces a picker when the user actually belongs to more than one workspace.
- Naming personal documents, jobs, or signature envelopes with any workspace-flavored language ("Owner: none," "Shared with: nobody"). The personal-scope UI never mentions sharing at all.
- A pricing page, onboarding email, or in-app upsell that frames Team as "the real product" and Pro as a stepping stone toward it. Pro is a complete, permanent destination for a solo professional; Team is a parallel destination for people who need collaboration, not a more-evolved version of the same thing.
- A mandatory "invite your team" step anywhere in onboarding. The invite-teammates prompt (Settings → Workspaces → "Invite people") is discoverable, never interstitial.
State transitions between account states (each transition is a single, reversible-or-not action; none of them ever collapse two of an existing account's states into one — a user who joins a workspace does not stop being personally on Free or Pro):
| From | To | Trigger | Reversible? |
|---|---|---|---|
| Guest | Free | Registration (11.2) | n/a — a new distinct account, the guest identity is never "upgraded in place" server-side because it never had server-side state to carry over (11.5.6 covers what does carry over: local OPFS/Dexie work) |
| Free | Pro | Stripe Checkout completes (12.5) | Yes — cancel and let the period lapse (12.2) |
| Pro | Free | Subscription lapses, dunning exhausts, or explicit cancellation reaches period end (12.5) | Yes — re-subscribe any time |
| Free or Pro user | Workspace member | Accepting an invitation (11.6) | Yes — leaving (11.6) removes the membership without touching the personal Free/Pro state at all |
| Workspace member | Sole owner with no other members |
Every other member leaves or is removed | The workspace itself still exists; the remaining owner's personal plan state is unaffected either way |
11.1.1 The account model, applied consistently #
The remainder of Section 11 specifies each of these transitions precisely. Nothing below introduces a fifth account state, a hidden intermediate status, or a workspace-only capability that leaks into the solo path — every subsection is read against the principle above, not independently of it.
11.2 Registration and authentication #
Credential types. Two ways to establish a password-based identity, one passwordless method, and optional social login:
- Email + password.
POST /internal/auth/register— handled by better-auth 1.7.x's email/password provider, backed by theusersandaccountstables it manages. - Magic link.
POST /internal/auth/magic-linksends a single-use sign-in link valid for 15 minutes; clicking it either logs in an existing user or completes registration for a new email with no password set (a password can be added later from Settings → Security). - Social login — enabled, exactly two providers: Google and Microsoft. Decision and justification: PDFWorks's buyer is disproportionately someone converting Office documents and signing contracts for work, and the overwhelming majority already hold a Google Workspace or Microsoft 365 account; supporting exactly these two removes signup friction for that audience without the maintenance and trust surface of an open-ended provider list. No Apple, GitHub, Facebook, or generic OIDC connector at launch. Whether social login is offered at all is a configurable choice, toggled by the customization decision in Section 1.1 — this section specifies the feature's behavior assuming that toggle is on. Social login is strictly personal OAuth: a strict alternative to email+password identity for an individual signing into their own account, not an enterprise identity federation feature. It does not touch, and never grants, SSO/SAML/SCIM-style organization-wide federation — enterprise SSO/SAML/SCIM remains explicitly out of scope for this product. OAuth 2.0 authorization-code flow with PKCE, handled by better-auth's social provider plugins; the returned verified email is trusted for email verification (11.2, next) — a social-login account never enters the unverified grace period because the identity provider has already verified the address.
Registration payload (email + password):
POST /internal/auth/register
{
"email": "dana.ruiz@example.com",
"password": "correct horse battery staple 9",
"displayName": "Dana Ruiz",
"timezone": "America/Denver"
}Password rules are defined once, in Section 17.2, and enforced identically here; this section does not restate the minimum length or the breach-check behavior. Validation is the shared Zod 4.x schema RegisterRequestSchema in packages/contracts, used unmodified by the web app's form (via @hookform/resolvers 5.x) and by the /internal/auth/register handler — the two never drift because they are the same object:
const RegisterRequestSchema = z.object({
email: z.string().email().max(254).transform(v => v.toLowerCase()),
password: PasswordSchema, // defined once in Section 17.2, imported here rather than redefined
displayName: z.string().min(1).max(80),
timezone: z.string().refine(isValidIanaTimeZone).default("UTC"),
});
const LoginRequestSchema = z.object({
email: z.string().email().max(254).transform(v => v.toLowerCase()),
password: z.string().min(1).max(256), // upper bound only — real strength rules already ran at registration
});On success: a users row is created (id = usr_ + base32-Crockford UUIDv7), email_verified_at is null, a session is issued immediately (a new account can use the product right away — see the grace period below), and a verification email is queued via Resend.
Core users schema (managed jointly by better-auth's own migrations and application migrations in packages/db; the columns below are the application-owned superset better-auth's tables are extended with):
CREATE TABLE users (
id uuid PRIMARY KEY, -- UUIDv7, rendered publicly as usr_<base32-crockford>
email text NOT NULL UNIQUE,
email_verified_at timestamptz,
display_name text NOT NULL,
avatar_url text,
timezone text NOT NULL DEFAULT 'UTC',
date_format text NOT NULL DEFAULT 'MM/DD/YYYY' CHECK (date_format IN ('MM/DD/YYYY','DD/MM/YYYY','YYYY-MM-DD')),
theme text NOT NULL DEFAULT 'system' CHECK (theme IN ('light','dark','system')),
default_tool_options jsonb NOT NULL DEFAULT '{}',
notification_preferences jsonb NOT NULL DEFAULT
'{"productEmail":true,"envelopeActivity":true,"usageAlerts":true,"marketingEmail":false}',
mfa_enabled boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE UNIQUE INDEX users_email_active_idx ON users (lower(email)) WHERE deleted_at IS NULL;Sessions are stored in better-auth's own sessions table, extended with an application-facing view that Settings → Security reads from:
-- better-auth-managed columns shown for completeness of the session-listing feature in this section
-- id, user_id, token_hash, ip_address, user_agent, created_at, expires_at, last_active_atThe session-listing endpoint (GET /internal/auth/sessions) parses user_agent with a UA-parsing library at read time (never stored pre-parsed, so a UA-parsing library upgrade never requires a backfill) into a display string such as "Chrome on macOS", and resolves ip_address to a city-level location via a local (non-third-party-calling) GeoIP database snapshot refreshed monthly — no per-request external lookup, consistent with the no-third-party-fingerprinting stance in Section 11.5.
Email verification.
- Verification email contains a link
https://app.pdfworks.io/verify-email?token=<opaque-256-bit-token>, token stored hashed (SHA-256) inemail_verification_tokenswith a 24-hour expiry and single use (consumed_atset on success; a second click returns410 { error: { type: "invalid_request_error", code: "verification_token_expired" } }per the error envelope in Section 14.6). - Resend throttling: one resend per 60 seconds per account, maximum 5 resends per rolling 24 hours; the 6th attempt returns
429 rate_limit_errorwithRetry-After. - Unverified-account grace period: 7 days from registration. During the grace period the account can use every client-side tool (Section 6.1) at Free-tier limits and can browse the product fully. What is blocked while unverified: every server-side tool (OCR, Office/HTML conversion), sending a signature envelope, inviting anyone to a workspace, creating an API key, and upgrading to a paid plan (Checkout is refused client-side and server-side with
403 { code: "email_not_verified" }) — a payment method must not be attached to an email the owner has not proven they control. After 7 days without verification, the account is downgraded to a read-only state: existing client-side work already saved locally is untouched (it never left the device), but no new tool run of any kind is permitted until the email is verified; a persistent, dismissible-per-session banner explains this with a one-click resend.
Login / logout.
POST /internal/auth/login— email + password, or the social/magic-link callback. Rate limited per email and per IP independently: 10 attempts / 15 minutes per email, 30 / 15 minutes per IP, both Redis token buckets (Section 4). Exceeding either returns429withRetry-Afterand does not reveal which bucket tripped.- Failed attempts do not reveal whether the email exists (
401 { code: "invalid_credentials" }for both "no such user" and "wrong password"). POST /internal/auth/logoutrevokes the current session server-side (better-auth session table row deleted) and clears the session cookie.
Session listing and per-device revoke. Settings → Security → Active sessions lists every non-expired session: approximate device/browser (parsed from the stored user agent), IP-derived city-level location, created-at, last-active-at, and a "This device" tag on the current session. Each row has a Revoke button (DELETE /internal/auth/sessions/{sessionId}) that deletes that session's row immediately; a "Log out everywhere else" bulk action revokes every session except the current one. Session mechanics (cookie flags, rolling/absolute lifetime) are defined once in Section 17.2.
GET /internal/auth/sessions
{
"data": [
{
"id": "ses_2q8nR7xK",
"device": "Chrome on macOS",
"location": "Denver, US",
"createdAt": "2026-08-10T14:02:11Z",
"lastActiveAt": "2026-08-19T09:14:03Z",
"isCurrent": true
},
{
"id": "ses_2q7mQ3vP",
"device": "Safari on iOS",
"location": "Denver, US",
"createdAt": "2026-08-05T08:41:00Z",
"lastActiveAt": "2026-08-18T21:30:44Z",
"isCurrent": false
}
]
}Which security state each action requires, consolidated so the individual flows above don't each have to restate it:
| Action | Requires active session | Requires verified email | Requires step-up (password/MFA re-entry) |
|---|---|---|---|
| Browse tool pages, run client-side tools | No | No | No |
| Run a server-side tool | Yes | Yes | No |
| Change password | Yes | No | Yes |
| Change email | Yes | No | Yes |
| Delete account | Yes | No | Yes |
| Enroll/regenerate MFA | Yes | No | Yes |
| Revoke a session | Yes | No | No |
| Create an API key | Yes | Yes | No |
| Create/join a workspace | Yes | Yes | No |
Password reset.
POST /internal/auth/password-reset/request { "email": "..." }
POST /internal/auth/password-reset/confirm { "token": "...", "newPassword": "..." }Always responds 202 regardless of whether the email exists (no account enumeration). The reset token is a 256-bit CSPRNG value, stored hashed, single use, and lifetime 1 hour. Confirming a reset revokes every other active session for the account (a password reset is treated as a possible compromise-recovery event) and sends a "Your password was changed" notification email to the account's address as a heads-up, not a confirmation step.
Email change.
POST /internal/account/email-change { "newEmail": "dana.new@example.com", "currentPassword": "..." }Requires re-entering the current password (or, for social-only accounts, re-running the OAuth flow) as a step-up check. Two emails are sent: a confirmation link to the new address (must be clicked within 24 hours to complete the change) and a notice to the old address ("Someone requested to change the email on this account to dana.new@example.com — if this wasn't you, reset your password immediately," with a direct link to password reset). The change only takes effect when the new-address link is clicked; the old address remains the account's email of record until then, and clicking the old-address notice never itself blocks the change (it is informational, not a veto), which correctly handles the case where the old inbox is no longer monitored.
Account deletion.
POST /internal/account/delete { "confirmEmail": "dana.ruiz@example.com", "currentPassword": "..." }Requires typing the account's own email as a confirmation string plus a step-up password/OAuth check. Deletion is soft (deleted_at set, per the delete policy in Section 5) with a 30-day grace period: the account is immediately logged out everywhere and can no longer sign in, but a "Restore my account" link mailed at deletion time reactivates it (clears deleted_at) if used inside the 30 days. After 30 days a hard-purge job (BullMQ janitor queue) runs:
| Destroyed permanently | Anonymized, not destroyed |
|---|---|
| Password hash, MFA secrets and recovery codes, session rows, personal document metadata rows, personal job history rows, API keys, avatar image blob | Signature-envelope audit-trail entries where this user was a signer or sender — email and IP fields are replaced by their salted SHA-256 hashes, per the e-signature retention exception in Section 10.8 and the audit mechanism in Section 10; the hash chain itself is untouched because invalidating it would retroactively break signature verification for every counterparty who relied on those envelopes |
If the deleted user is the sole owner of a workspace, deletion is blocked (409 { code: "sole_owner_must_transfer" }) until ownership is transferred (11.6) or the workspace is deleted.
Consolidated auth rate-limit table (every bucket is a Redis token bucket per the rate-limiting mechanism owned by Section 14.9; this table exists so every limit governing account access lives in one place rather than scattered across the prose above):
| Endpoint | Per-identity limit | Identity key | Response when exceeded |
|---|---|---|---|
POST /internal/auth/login |
10 / 15 min | 429, generic invalid_credentials-shaped body suppressed in favor of a plain rate-limit error |
|
POST /internal/auth/login |
30 / 15 min | IP | 429 |
POST /internal/auth/register |
5 / hour | IP | 429 |
POST /internal/auth/magic-link |
3 / 15 min | 429 |
|
POST /internal/auth/password-reset/request |
3 / 15 min | 202 returned regardless (no enumeration), but the email is not actually sent past the 3rd request in the window |
|
POST /internal/auth/mfa/verify |
8 / 15 min | mfaChallengeToken |
429, and the 9th attempt additionally invalidates the challenge token, forcing a fresh login |
POST /internal/account/email-change |
3 / day | user | 429 |
| Email verification resend (11.2) | 1 / 60 s, 5 / 24 h | user | 429 |
11.2.1 Magic-link mechanics, precisely. POST /internal/auth/magic-link { "email": "..." } always responds 202 (no account enumeration, same reasoning as password reset). If the email matches an existing verified account, the emailed link signs that account in directly. If it matches no account, clicking the link completes registration for a new account with email_verified_at set immediately (clicking a link delivered to the inbox is itself proof of control, so the separate verification step in 11.2 is skipped entirely for magic-link signups) and no password set — has_password: false on the account, surfaced in Settings → Security as "Add a password" so the account is not permanently locked into email-only access if the user wants a second factor path independent of their inbox. The link token is a 256-bit CSPRNG value, hashed at rest, single-use, 15-minute expiry; a second click after consumption or expiry returns the same generic 410 { code: "verification_token_expired" } used for other one-time tokens, and the user is prompted to request a new link rather than being told which specific reason it failed, again to avoid leaking account existence.
11.2.2 Social login mechanics. Both Google and Microsoft connect via better-auth's OAuth plugin using authorization-code-with-PKCE. On first callback for a given provider-subject pair, the flow checks for an existing users row with a matching verified email:
- Match found: the OAuth identity is linked to the existing account (a row in better-auth's
accountstable associatingprovider,provider_account_id, anduser_id) — this is what lets someone who originally registered with email+password later add "Sign in with Google" as a second path into the same account, and vice versa. - No match: a new
usersrow is created withemail_verified_atset from the provider's verified-email claim immediately (Google and Microsoft both assert email verification in their ID token, and that assertion is trusted — no separate verification email is sent).
If a provider's ID token claims an unverified email (rare, but Microsoft work/school accounts can be provisioned without a verification step by an organization admin), PDFWorks treats the resulting account exactly like a fresh email+password signup: email_verified_at stays null and the 7-day unverified grace period in 11.2 applies unchanged.
11.2.2.1 Provider scope and data requested. Both connections request the minimum OAuth scope needed to obtain a verified email and display name — openid email profile for Google, openid email profile User.Read for Microsoft (the latter is Microsoft's baseline sign-in scope, not a Graph API data-access grant; PDFWorks never requests calendar, mail, or file access from either provider, since none of it is used). No provider access or refresh token is retained after the initial sign-in exchange completes — PDFWorks authenticates the user once and then relies entirely on its own session (Section 17.2), never calling back into Google's or Microsoft's APIs on the user's behalf afterward, which is also why this is correctly described as social login rather than any form of account integration.
11.2.3 Account linking and conflicts. A given human can end up wanting to authenticate the same PDFWorks account three different ways over time (password, Google, Microsoft); this is supported, not treated as three accounts:
- Linking from a signed-in state. Settings → Security → "Connected accounts" lets a signed-in user connect Google and/or Microsoft to their existing account (
POST /internal/auth/social/link), or add a password to a social-only account (POST /internal/auth/password/set, requires no prior password since none exists — only a fresh OAuth re-auth as the step-up check). Each linked method appears with a "Remove" action, disabled (grayed out, not hidden) if it is the account's only remaining sign-in method — an account can never be left with zero ways to authenticate. - The email-match auto-link described above is the only case where linking happens without an explicit in-app action, and it is deliberately narrow: it only fires when the provider's own verified-email claim matches an already-
email_verified_at-set PDFWorks account. An unverified PDFWorks account is never auto-linked to an incoming OAuth callback, to prevent an attacker who has merely registered a matching-but-unverified email from hijacking a social login they don't otherwise control. - What is never supported: merging two already-distinct, already-used
usersrows (e.g., someone who registered separately with password and with Google before ever linking them) into one. This is treated as a support-assisted manual case, not a self-service flow, because it requires deciding which account's documents, subscription, and workspace memberships take precedence — a decision with real data consequences that a fully automated merge could get wrong silently.
11.3 Multi-factor authentication #
MFA is TOTP (RFC 6238) plus ten single-use recovery codes, per Section 17. This subsection specifies the enrollment and recovery mechanics.
Enrollment (POST /internal/auth/mfa/enroll):
- Server generates a 160-bit secret, stores it encrypted at rest (application-layer AES-256-GCM, key from the same KMS used for blob envelope keys in Section 3.5), and returns:The client renders
{ "otpauthUrl": "otpauth://totp/PDFWorks:dana.ruiz@example.com?secret=JBSWY3DPEHPK3PXP&issuer=PDFWorks&algorithm=SHA1&digits=6&period=30", "manualEntryKey": "JBSW Y3DP EHPK 3PXP", "qrPayload": "otpauth://totp/PDFWorks:dana.ruiz@example.com?secret=JBSWY3DPEHPK3PXP&issuer=PDFWorks&algorithm=SHA1&digits=6&period=30" }qrPayloadas a QR code locally (no image round-trip to the server) and showsmanualEntryKeyas the plain-text fallback for users who cannot scan. - The user submits one valid 6-digit code (
POST /internal/auth/mfa/confirm { "code": "482913" }) to prove the enrollment succeeded before it is activated. A ±1 time-step window (30 seconds either side) is accepted to tolerate clock drift. - On confirmation, the server generates ten single-use recovery codes (each a 10-character base32-Crockford string, e.g.
7K4M-2Q9X-VT), shows them exactly once, and stores only their SHA-256 hashes. The user must acknowledge "I have saved these codes" before the dialog can be dismissed.
Login with MFA: after a correct password, the response is 200 { "mfaRequired": true, "mfaChallengeToken": "..." } rather than a session; POST /internal/auth/mfa/verify { "mfaChallengeToken": "...", "code": "482913" } (or a recovery code in place of code) completes login. A used recovery code is marked consumed and cannot be reused; using a recovery code emits a "you have N recovery codes left" notice once N ≤ 3.
Regeneration: Settings → Security → "Regenerate recovery codes" requires a fresh TOTP or password step-up, invalidates all ten previous codes immediately, and issues ten new ones.
MFA schema:
CREATE TABLE mfa_totp_secrets (
user_id uuid PRIMARY KEY REFERENCES users(id),
secret_ciphertext bytea NOT NULL, -- AES-256-GCM, envelope-wrapped by the same KMS master key as blob storage (Section 3.5)
confirmed_at timestamptz, -- null until step 2 of enrollment succeeds; unconfirmed secrets are purged after 15 minutes
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE mfa_recovery_codes (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id),
code_hash text NOT NULL, -- SHA-256; codes are high-entropy random values, so — as with API keys in Section 17.2 — a slow KDF adds cost without adding security
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX mfa_recovery_codes_user_unconsumed_idx ON mfa_recovery_codes (user_id) WHERE consumed_at IS NULL;Exactly ten mfa_recovery_codes rows are inserted per enrollment (or regeneration, which first hard-deletes the previous ten and inserts ten fresh ones in the same transaction, so there is never a moment where old and new codes are simultaneously valid).
Recovery when both TOTP and all recovery codes are lost: there is no automated self-service bypass, by design — that would defeat the purpose of MFA. The account holder submits POST /internal/auth/mfa/recovery-request from a logged-out state, providing the account email and passing a fresh email-OTP challenge (a 6-digit code emailed to the account's verified address, 10-minute expiry). Passing that challenge does not restore access by itself; it opens a support case (visible to the user as a tracked ticket) with a mandatory 72-hour cooling-off period during which the account is flagged for manual review, and a member of the support team disables MFA on the account only after confirming the email-OTP challenge passed and no suspicious session activity occurred in the preceding 30 days. This trades convenience for the fact that an instant bypass is the single highest-value target for account takeover.
Workspace-wide MFA enforcement. A owner can set the workspace policy flag require_mfa = true (11.6). Effect, precisely: every existing member without MFA enrolled retains read access to everything they already have access to, but every write action (creating or editing a document server-side, sending an envelope, inviting a member, changing settings) is blocked with 403 { code: "mfa_required_by_workspace" } and the UI redirects the blocked action to the enrollment flow in-place — no session is revoked, no data is hidden, the member is simply walked through 11.3's enrollment before the write is retried. A member invited after the flag is set cannot complete workspace join (11.6) until MFA is enrolled, enforced at the invitation-acceptance step.
Turning enforcement off. An owner clearing requireMfa takes effect immediately and is purely permissive — no member's existing TOTP enrollment is removed (a member who enrolled to satisfy the policy is not forced to un-enroll, and typically leaves MFA on voluntarily), and no previously blocked write action needs to be retried; the next request simply passes the (now-disabled) check.
11.4 Profile and preferences #
GET/PATCH /internal/account/profile:
{
"displayName": "Dana Ruiz",
"avatarUrl": "https://app.pdfworks.io/cdn/avatars/usr_2q8n.../original.webp",
"timezone": "America/Denver",
"dateFormat": "MM/DD/YYYY",
"theme": "system",
"defaultToolOptions": {
"compress": { "quality": "balanced" },
"watermark": { "opacity": 0.35, "position": "center" },
"pageNumbers": { "format": "Page {n} of {N}", "position": "bottom-center" }
},
"signatureAssets": {
"typedFont": "caveat",
"drawnSignatureId": "sig_2q8p...",
"initialsId": "sig_2q8q..."
},
"notificationPreferences": {
"productEmail": true,
"envelopeActivity": true,
"usageAlerts": true,
"marketingEmail": false
}
}- Display name: 1–80 characters, any Unicode letter/mark/number/space/hyphen/apostrophe; rejected if it round-trips through a profanity/impersonation denylist used for public-facing signer names.
- Avatar: client-side crop-to-square + resize to 256×256 before upload (using the client-side image tools already specified in Section 6.1), uploaded as WebP, max 2 MB pre-crop source. Avatars are the one piece of "profile" content stored as a server blob rather than purely metadata; they follow ordinary server-side retention only in that a replaced avatar's old blob is hard-deleted, not the standard job-retention rule (Section 6), since it is not job output.
- Timezone: IANA identifier, auto-detected client-side (
Intl.DateTimeFormat().resolvedOptions().timeZone) at registration and editable afterward; this is the timezone used for the quota daily-window calculation in Section 12.4 and for all displayed timestamps. - Date format: enum
MM/DD/YYYY | DD/MM/YYYY | YYYY-MM-DD, display-only, never affects storage (all storage istimestamptzUTC per Section 5). - Signature and initials assets: created once via the self-sign tool (Section 6.1) or the e-signature signer experience (Section 10) and reused as defaults across both; stored as small vector/raster blobs owned by the user, referenced by
sig_IDs, never expire, deletable individually. A user may store at most 5 signature assets and 5 initials assets at a time (an arbitrary but generous ceiling — drawn variants, a typed fallback, an uploaded scan — past which creating a new one requires deleting an old one first, preventing unbounded blob accumulation from someone repeatedly experimenting with the draw tool); each asset is capped at 500 KB, well above what a cropped signature drawing or scan requires. - Notification preferences: four independent booleans;
envelopeActivityandusageAlertscannot both be silenced below a hard floor — a payment-failure notice (Section 12.5) and an MFA-enforcement notice (11.3) always send regardless of preference, since they are account-security-critical, not marketing. - Theme:
light | dark | system, canonical token behavior defined in Section 16.
Field-level validation (the shared packages/contracts Zod 4.x schema ProfileUpdateSchema, used identically by the settings form and the PATCH handler):
const ProfileUpdateSchema = z.object({
displayName: z.string().min(1).max(80).regex(DISPLAY_NAME_ALLOWED_CHARS),
timezone: z.string().refine(isValidIanaTimeZone, "Not a recognized IANA timezone identifier"),
dateFormat: z.enum(["MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD"]),
theme: z.enum(["light", "dark", "system"]),
defaultToolOptions: z.record(z.string(), z.unknown()).optional(),
notificationPreferences: z.object({
productEmail: z.boolean(),
envelopeActivity: z.boolean(),
usageAlerts: z.boolean(),
marketingEmail: z.boolean(),
}),
}).partial({ defaultToolOptions: true });A PATCH request is validated against this schema before touching the database; any field omitted from the request body leaves the stored value untouched (a PATCH, not a PUT — no field is silently reset to a default by a partial update).
Avatar moderation and CDN handling. An uploaded avatar is served from a content-hashed CDN path (/cdn/avatars/{userId}/{sha256-of-bytes}.webp) with a one-year immutable cache header, matching the content-hashing convention used for the WASM artifact in Section 3; replacing an avatar therefore always produces a new URL rather than mutating one in place, so cached copies elsewhere never go stale-but-wrong. Every uploaded avatar passes through the same magic-byte sniffing and structural validation applied to any other upload (Section 17.2) before being accepted — an avatar upload is a small, ordinary image upload, not a special-cased trust boundary.
Local-data controls. Settings → Privacy → "Local data on this device" shows, read from OPFS and Dexie directly in the browser (no server round trip): total OPFS bytes used, a table of Dexie-tracked entries (recent files, presets, draft annotations, queued batch descriptors) with name, size, and last-touched date, and two controls — "Clear local data" (wipes OPFS scratch space and Dexie metadata tables for this origin, per the mechanism in Section 3.7) and "Inspect", which expands the Dexie table list in place without leaving the settings page. Clearing local data never touches the server-side account, subscription, or job history.
11.5 Guest access #
Guests are anyone using the six basic tools (11.5.1) without signing in. Because those six tools are entirely client-side (Section 6.1), a guest never uploads a file to PDFWorks; the only thing PDFWorks tracks about a guest is a daily task count, for the sole purpose of enforcing the cap in Section 12.2's plan table.
11.5.1 The six guest tools #
merge, split, rotate, organize (reorder/delete pages), compress, and PDF ↔ JPG/PNG. Every other tool prompts an account-creation screen before the tool loads.
11.5.2 Daily cap #
5 tasks per device per day (Section 12.2). A "task" for this purpose is one completed run of any of the six tools (each file in a client-side batch-like multi-file merge still counts as the one task that produced the merged output — client-side operations are never metered per-file, only per invocation).
11.5.3 Device identification — specified precisely #
PDFWorks does not use a third-party fingerprinting library, canvas/WebGL fingerprinting, or any cross-site identifier. Identification is a first-party cookie paired with a narrow, privately-salted server-side hash, designed to be exactly strong enough to enforce a 5-per-day cap and no stronger:
Cookie. On first visit, the server sets
pdfworks_gid— a UUIDv7 value,HttpOnly,Secure,SameSite=Lax,Max-Age=15552000(180 days), first-party only, never sent cross-site. This is the primary device identity.Composite hash. On every guest-tool completion, the server independently computes
deviceHash = SHA-256(salt_today || ip_prefix || user_agent || accept_language)where:ip_prefixis the request IP truncated to a/24(IPv4) or/48(IPv6) — never the full address,user_agentandaccept_languageare the raw request headers,salt_todayis a random 32-byte value generated by a scheduled job (BullMQjanitorqueue) at00:00 UTCdaily and stored in aguest_saltstable keyed by UTC date, with rows purged after 2 days, giving the specified 48-hour TTL — a salt (and therefore every hash computed with it) is unrecoverable and unreproducible more than 48 hours after it was minted.
Salt-minting failure — fail closed, not open. If the
00:00 UTCminting job does not run (worker outage, deploy failure, queue backlog), PDFWorks does not fall back to an unsalted or predictable hash. The previous day'sguest_saltsrow is reused for at most 24 further hours beyond its normal validity window, a high-priority alert fires to on-call the moment the scheduled mint is detected more than 5 minutes late, and enforcement fails closed throughout: the 5-per-day cap in 11.5.2 continues without interruption for the entire outage — a missing salt is never treated as license to let guest usage through uncapped. If minting is still down once that additional 24 hours elapses (48 hours total since the salt was last minted), the stale row is not reused a third time; every guest request in that further window is instead folded into a single shareddevice_hashbucket, identical in effect to the shared-IP mitigation path in 11.5.7 — stricter, not looser, than normal operation. The data-layer constraint that bounds how long aguest_saltsrow may be reused is defined once, in Section 5, and is not restated here.Storage.
CREATE TABLE guest_salts (
day_bucket date PRIMARY KEY,
salt bytea NOT NULL -- 32 random bytes, generated once per UTC day by the janitor job
);
-- purged 48 hours after day_bucket by the same job that generates tomorrow's salt
CREATE TABLE guest_task_events (
cookie_id uuid NOT NULL,
device_hash text NOT NULL,
day_bucket date NOT NULL,
task_count int NOT NULL DEFAULT 0,
distinct_cookie_count int NOT NULL DEFAULT 0, -- maintained per device_hash, read by the shared-IP mitigation in 11.5.7
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (cookie_id, day_bucket)
);
CREATE INDEX guest_task_events_device_hash_idx ON guest_task_events (device_hash, day_bucket);No IP address, user agent string, or accept-language value is ever stored in full — only the irreversible hash. day_bucket is the UTC calendar date (guests have no known timezone, per the decision in Section 12.4). Both tables are hard-deleted 48 hours past their day_bucket by the same janitor-queue job that mints the next day's salt, giving the specified 48-hour TTL a single enforcement point rather than a per-row expiry check on every read.
4. What is stored, for how long: one row per device per UTC day holding only a counter, expiring (hard-deleted by the same janitor job) 48 hours after day_bucket. Nothing about a guest is ever joined to a users row unless the guest signs up (11.5.6).
5. Lawful basis: legitimate interest / strict technical necessity (GDPR Art. 6(1)(f) and ePrivacy Directive Art. 5(3)'s "strictly necessary for a service explicitly requested" exemption) — the cookie exists only to deliver the plan limit the product advertises, carries no advertising or cross-site purpose, and is therefore exempt from a cookie-consent banner. It is disclosed, not hidden.
6. Disclosure text (shown in the cookie policy linked from the site footer and, on first guest tool use, in a one-line dismissible notice): "To offer 5 free tasks per day without requiring an account, we set a small device cookie and a one-way scrambled fingerprint of your network and browser that we cannot reverse into your IP address. It's deleted automatically after 48 hours. See our Privacy Policy for details."
11.5.4 Communicating the cap before work starts #
Every guest-accessible tool page renders a small counter next to the Processing Location Indicator (Section 16), before the user drops a file: "4 of 5 free tasks left today". This is populated by GET /internal/guest/quota (reads the cookie, returns the current count) on page load — the cap is never a surprise sprung after work is done.
- At 1 remaining, the counter's copy becomes: "1 free task left today. [Create a free account] for unlimited access — it takes 30 seconds."
- At 0 remaining, the tool page still loads (curiosity and evaluation are not blocked) but the drop zone is replaced before any file is accepted with: "You've used today's 5 free tasks. [Create a free account] to keep going — no credit card required." — and a secondary, equally sized and equally styled "Not now" button that simply dismisses the message and lets the user keep browsing (it does not lead anywhere blocked; per the no-dark-pattern constraint below, declining must be exactly as easy as accepting).
11.5.5 Upgrade prompt design constraints #
Every guest-facing upgrade prompt in the product, not only the cap message, must satisfy: (a) the "create account" and "not now / dismiss" actions render at the same size, weight, and visual prominence — no color trick, no pre-checked box, no countdown timer, no fake scarcity; (b) dismissing never requires more than one click and is never hidden behind a secondary "are you sure" step; (c) no prompt claims data will be lost if the user doesn't upgrade — because, per 11.5.6, it never is.
11.5.6 In-progress work survives signup #
Because guest-tool output lives entirely in the browser's OPFS scratch space (Section 3.7) and never touched the server, signing up mid-session requires no file re-upload or transfer: the auth transition does not clear OPFS or Dexie, and any Dexie-tracked recent-file or preset entry created while a guest is retroactively attributed to the new usr_ ID the moment registration completes, so the user's just-finished or in-progress work is exactly where they left it, now saved to "Recents" under their new account.
11.5.7 Shared-IP / office-network false positives, and the mitigation #
A /24 IPv4 block plus identical browser/OS (common on a corporate network behind NAT, e.g. a law firm where twenty paralegals share one egress IP and a company-imaged browser) will produce the same device_hash for many real, distinct people. Because the cap is enforced primarily against cookie_id (11.5.3) — which is per-browser-profile, not per-network — ordinary shared-IP users are unaffected: each of the twenty paralegals has their own cookie and their own 5-a-day allowance. device_hash exists only as a secondary abuse signal: if a single device_hash accumulates more than 40 distinct cookie_id values in one day_bucket (a threshold sized well above any plausible single-office headcount, tuned to catch cookie-clearing scripts rather than shared offices), the next guest task from that hash is not blocked but is instead soft-gated behind the same equally-weighted upgrade prompt used at 0-remaining, one task earlier than the cookie alone would trigger it — nudging toward an account rather than hard-blocking a shared network.
Cookie-blocked browsers. Safari's Intelligent Tracking Prevention and any browser's private/incognito mode still permit a first-party, non-third-party pdfworks_gid cookie to be set and read within a single browsing session — ITP's restrictions target cross-site tracking, not first-party session cookies, so the primary mechanism is unaffected for a normal visit. The one real gap is a user who explicitly blocks all cookies at the browser level: in that case pdfworks_gid is never set, GET /internal/guest/quota receives no cookie, and the server falls back to device_hash alone as the enforcement key for that request — correctly degrading to the weaker, IP-block-shared identity described in 11.5.7 rather than either failing the request or granting unlimited access; a cookie-blocking visitor is simply pooled with everyone else sharing their device_hash for cap purposes, which is a conservative (stricter, not looser) fallback.
11.5.8 Worked walkthrough, end to end #
A visitor arrives at https://app.pdfworks.io/tools/merge with no cookie set.
- Server sets
pdfworks_gid = 018f2c1a-...and respondsGET /internal/guest/quota → { "used": 0, "remaining": 5 }. The page renders "5 of 5 free tasks left today" next to the On-your-device Processing Location Indicator (Section 16). - The visitor merges three PDFs. The client-side WASM engine (Section 3.6) does the work entirely in-browser; on completion the client calls
POST /internal/guest/task-complete { "tool": "merge" }, which incrementsguest_task_eventsfor today's(cookie_id, day_bucket)and the paralleldevice_hashrow, and returns the new remaining count. - The visitor repeats this four more times across other guest tools over the next hour. On the fifth completion the response carries
remaining: 0; the next page load's quota check renders the 0-remaining copy from 11.5.4 before any file can be dropped. - The visitor clicks "Create a free account," registers with email+password (11.2). Registration does not touch OPFS or Dexie at all — the five already-completed merges' output files, if still present in the browser's Recents list (a Dexie-tracked entry, not the guest counter), are immediately visible under the new account with no re-upload, per 11.5.6.
- The now-Free account is no longer subject to
guest_task_eventsat all going forward —resolveEntitlements(12.3) resolves against the Free plan row the instantAccountContext.scopebecomes"personal", and the guest device's Redis/Postgres rows simply age out on the existing 48-hour TTL with no explicit migration step.
11.6 Teams and workspaces #
Creating a workspace. Any signed-in, verified user can create a workspace (POST /internal/workspaces { "name": "Ruiz & Associates" }); the creator becomes its sole owner. Creating a workspace does not require a plan change by itself — the workspace exists at Free-equivalent limits until it (or the creator) subscribes to Team (Section 12.5); this lets a user set up a workspace and invite people before paying.
Roles. Exactly four: owner, admin, member, billing-only. A workspace always has at least one owner.
Core schema:
CREATE TABLE workspaces (
id uuid PRIMARY KEY, -- wsp_<base32-crockford>
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
stripe_customer_id text,
require_mfa boolean NOT NULL DEFAULT false,
restrict_server_side_tools boolean NOT NULL DEFAULT false,
job_history_retention_days int NOT NULL DEFAULT 90,
allowed_invitation_domains text[] NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE TABLE workspace_members (
workspace_id uuid NOT NULL REFERENCES workspaces(id),
user_id uuid NOT NULL REFERENCES users(id),
role text NOT NULL CHECK (role IN ('owner','admin','member','billing-only')),
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, user_id)
);
CREATE INDEX workspace_members_user_idx ON workspace_members (user_id);
CREATE TABLE workspace_invitations (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id),
email text NOT NULL,
role text NOT NULL CHECK (role IN ('admin','member','billing-only')), -- an invitation can never directly grant 'owner'
token_hash text NOT NULL,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','pending_seat','accepted','expired','revoked')),
invited_by uuid NOT NULL REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL, -- created_at + 7 days
accepted_at timestamptz
);
CREATE UNIQUE INDEX workspace_invitations_pending_email_idx
ON workspace_invitations (workspace_id, lower(email)) WHERE status IN ('pending','pending_seat');
CREATE TABLE templates (
id uuid PRIMARY KEY, -- tpl_<base32-crockford>
owner_type text NOT NULL CHECK (owner_type IN ('user','workspace')),
owner_id uuid NOT NULL,
created_by uuid NOT NULL REFERENCES users(id),
tool text NOT NULL, -- e.g. 'watermark', 'page-numbers'
name text NOT NULL,
options jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE INDEX templates_owner_idx ON templates (owner_type, owner_id) WHERE deleted_at IS NULL;An owner cannot directly appear as the role on a fresh invitation — the only way to create a second owner is the ownership-transfer endpoint below, which requires the recipient to already hold admin or member. This closes the obvious bypass of inviting a stranger straight into full control.
The workspace switcher. For an account with one or more workspace_members rows, the app shell renders a compact switcher (Section 16 owns its visual spec) listing "Personal" plus every workspace the user belongs to; selecting an entry scopes the active view's document list, tool defaults, and quota display to that scope. Server-side, the switcher is purely a client-side view-state concern — it changes which workspaceId query parameter subsequent /internal requests carry, never which credentials are attached, so switching scopes never triggers a re-login or a new session. Per the solo-first principle in 11.1, this control is entirely absent from the DOM (not merely hidden) for the majority of accounts that belong to zero workspaces.
11.6.1 Full permission matrix.
| Action | owner | admin | member | billing-only |
|---|---|---|---|---|
| View workspace's shared templates | Yes | Yes | Yes | No |
| Create / edit / delete a shared template | Yes | Yes | Yes (own templates); edit/delete others' — No | No |
| Send a signature envelope from workspace-owned senders | Yes | Yes | Yes | No |
| View the shared signature audit log (all workspace envelopes) | Yes | Yes | No (own envelopes only) | No |
| Use workspace server-side tool quota (OCR, conversion) | Yes | Yes | Yes | No |
| Invite a member | Yes | Yes | No | No |
| Change a member's role | Yes | Yes (cannot promote to owner) |
No | No |
| Remove a member | Yes | Yes (cannot remove an owner) |
No | No |
| Leave the workspace | Yes, only if another owner exists |
Yes | Yes | Yes |
| Transfer ownership | Yes (to any existing admin or member) |
No | No | No |
| View billing, invoices, payment method | Yes | No | No | Yes |
| Change plan / seat count | Yes | No | No | Yes |
| Update payment method | Yes | No | No | Yes |
| Manage workspace policy settings (below) | Yes | Yes | No | No |
| Create / revoke workspace-scoped API keys | Yes | Yes | No | No |
| Delete the workspace | Yes | No | No | No |
| View/edit own personal (non-workspace) documents | Yes | Yes | Yes | Yes |
billing-only is deliberately narrow: an accounts-payable contact who must be able to update a card and download invoices without ever seeing a client's document.
Invitations. POST /internal/workspaces/{workspaceId}/invitations { "email": "new.hire@example.com", "role": "member" }. Restricted to owner/admin. A token (256-bit CSPRNG, hashed at rest) is emailed with a link https://app.pdfworks.io/invitations/accept?token=..., lifetime 7 days, single use. Seat check at send time and again at accept time: if the workspace is at its paid seat count (Section 12.5), the invitation is created in a pending_seat state and the inviter is shown "This invitation will be sent once a seat is available — add a seat now?" with a one-click add-seat action; it is never silently dropped. If the workspace enforces an allowed-domain policy (below), the invited email's domain is checked at send time (403 { code: "domain_not_allowed" } if it fails) and re-checked at accept time in case the policy changed in the interim.
Concurrent invitations to the same address. workspace_invitations_pending_email_idx enforces at most one pending/pending_seat invitation per (workspace_id, lower(email)). The invitation endpoint is idempotent under a race: if two owner/admin actors submit an invitation for the same address within the same window, the handler attempts the insert and, on a unique-constraint violation against that index, re-reads the existing pending invitation for the address and returns it with 200 — the outstanding invitation, including who originally sent it and when — rather than surfacing the constraint violation as an error to the second admin. The second attempt never creates a duplicate token, never re-sends the invitation email, and never changes the invitation's role or expiry; only the first successful insert determines those values.
Joining. Clicking a valid invitation link: if the recipient is already signed in with the invited email, one click accepts; if signed out, they register or log in first (the invitation token survives the auth redirect via a signed short-lived cookie) and are returned to the acceptance screen. If the workspace has require_mfa = true (11.3), acceptance is gated on completing MFA enrollment before the workspace_members row is created.
Leaving. A member (any role except a workspace's only owner) can leave at any time from Settings → Workspaces. Their workspace_members row is deleted (hard delete — membership itself carries no legal-retention requirement, unlike the audit trail).
Removing a member, and what happens to their work. An owner/admin can remove any non-owner member. On removal: the member's workspace_members row is deleted immediately and their session-level access to workspace resources is revoked within one request cycle (the authorization check in 11.7 re-evaluates on every request, so there is no cache to bust). What happens to their contributions is decided by ownership, not by who touched the resource last:
- Personal documents and jobs the (now-former) member created were never workspace-owned; they remain theirs, untouched.
- Shared templates and workspace-owned signature envelopes they created while a member are owned by the workspace, not the individual (see 11.7's owner-predicate rule), so they are entirely unaffected by the member's removal and remain visible to the remaining
owner/admin/memberroles per the matrix above.
Transferring ownership. POST /internal/workspaces/{workspaceId}/transfer-ownership { "newOwnerUserId": "usr_..." }, restricted to the current owner, target must already be an admin or member. Requires the current owner's password/MFA step-up. The prior owner is demoted to admin (not removed) so continuity of billing contact isn't lost mid-transfer; a workspace can subsequently have multiple owners if a second transfer promotes someone else without demoting the first (transfer promotes the target to owner and demotes the source only — it is not a swap-exactly-one-owner operation, which allows a deliberate co-ownership period).
Seat management and mid-cycle proration. Adding a seat (inviting past the current paid seat count, or an owner proactively increasing seat count from Settings → Workspace → Seats) triggers an immediate Stripe subscription-item quantity increase; removing a seat cannot reduce the paid count below the current member count (11.7's test matrix includes this as a checked invariant). The complete billing and proration mechanics for both directions — what is charged, when, and how each appears on an invoice — are the single authoritative statement in Section 12.5; this section only states the workspace-facing trigger and the below-member-count guardrail.
Invitation email content (sent via Resend, React Email 1.x template):
Subject: You've been invited to Ruiz & Associates on PDFWorks
Dana Ruiz has invited you to join Ruiz & Associates on PDFWorks as a Member.
[Accept invitation] → https://app.pdfworks.io/invitations/accept?token=8f2b...
This invitation expires in 7 days. If you weren't expecting this, you can ignore this email.Worked seat-proration example. A Team workspace on the annual plan ($150.00/user/yr) has 4 seats, billed on a period that began 2026-06-01 and renews 2027-06-01 (365-day period). On 2026-08-19 (day 79 of the period, 286 days remaining) an owner adds a 5th seat to accept a pending invitation:
Prorated charge = ($150.00 / 365) × 286 days ≈ $117.53Stripe computes this automatically per the proration rule in Section 12.5 (proration_behavior: "create_prorations", the default) and adds it as a line item on the next invoice — PDFWorks never computes proration math itself; the figure above illustrates what the customer will see, not a value the application calculates independently. The new seat's entitlements (an additional workspace_members row eligible for invitation acceptance) are available the instant the subscription.update call succeeds, without waiting for the invoice to be issued or paid.
Shared templates. A template (a saved combination of tool + options — e.g., a watermark preset with the firm's logo and a fixed opacity, or a page-numbering scheme) created inside a workspace context is owned by the workspace (owner_type = 'workspace') and visible to every member per the matrix above; a template created in personal context is owned by the user and never appears inside any workspace. There is no "share this personal template to my workspace" conversion at launch — a member re-creates it once inside the workspace context if they want it shared, which is a one-tool-run cost, not a data-migration problem.
Concurrent template edits. Two members editing the same shared template within the same minute is resolved by last-write-wins on templates.updated_at, guarded by an optimistic-concurrency check: the PATCH request carries the updatedAt value the client last read, and a mismatch against the current row returns 409 conflict_error with the current server copy in the body so the client can show "this was changed by someone else — review and retry" rather than silently overwriting a colleague's edit.
Shared signature audit log. owner and admin see every envelope ever sent from the workspace (GET /internal/workspaces/{workspaceId}/envelopes), including ones sent by members who have since been removed (the envelope and its audit trail persist under workspace ownership per the e-signature retention exception in Section 10.8 regardless of sender membership status). A member sees only envelopes they personally sent. billing-only sees none — envelope content and metadata are outside its scope entirely.
Workspace-level defaults and policy settings. PATCH /internal/workspaces/{workspaceId}/policy, owner/admin only:
{
"requireMfa": true,
"restrictServerSideTools": false,
"jobHistoryRetentionDays": 30,
"allowedInvitationDomains": ["ruizlaw.com"]
}requireMfa— mechanics specified in 11.3.restrictServerSideTools— whentrue, every member (regardless of individual entitlement) is blocked from OCR, Office/HTML conversion, and sending signature envelopes workspace-wide; client-side tools are entirely unaffected, consistent with the client-side-is-always-available principle in Section 6. Intended for compliance-sensitive customers who want a contractual guarantee that no workspace file ever reaches a server.jobHistoryRetentionDays— may only be set to a value less than or equal to the plan's default retention (90 days for Team per Section 12.2); attempting to set it higher returns422 { code: "retention_exceeds_plan_limit" }. Lowering it takes effect on the next nightly retention sweep, not retroactively deleting anything mid-request.allowedInvitationDomains— an empty array means no restriction (any email may be invited); a non-empty array restricts both new invitations and, notably, does not retroactively remove existing members whose email domain no longer matches — it constrains future growth only.
Workspace deletion. owner-only, DELETE /internal/workspaces/{workspaceId}, requires typing the workspace's exact name as confirmation. Preconditions: blocked with 409 { code: "envelopes_in_flight" } if any workspace-owned envelope is in a non-terminal state (sent or in_progress — Section 10 owns the envelope state names); the owner must void those envelopes or wait for completion first, because deleting the workspace out from under signers mid-flow would break Section 10's ESIGN-defensibility guarantees. Once preconditions pass: the associated Stripe subscription is canceled immediately (Section 12.5's cancellation mechanics; no prorated refund is issued beyond the standing 14-day money-back window in 12.5), the workspaces row is soft-deleted, all workspace_members rows are deleted, and a 30-day recovery window applies (mirroring account deletion in 11.2) during which any former owner can restore it. After 30 days, the janitor job hard-deletes workspace-owned templates and hard-deletes workspace-owned envelope source/output blobs while preserving their audit-trail hash chains per the e-signature retention exception in Section 10.8 — exactly the same destroy-content/preserve-chain split used for individual account deletion.
Restoring a deleted workspace: POST /internal/workspaces/{workspaceId}/restore, callable only by a user who held the owner role at the moment of deletion (checked against a snapshot of the last-known workspace_members state taken at deletion time, since the live table has no rows to check against once deleted), and only inside the 30-day window. Restoration clears deleted_at, re-inserts every workspace_members row from that same snapshot, and — because the Stripe subscription was already canceled at deletion time — leaves billing in a lapsed state requiring a fresh Checkout session; restoring the workspace's data does not retroactively restore a canceled subscription, which is treated as a separate, ordinary re-subscription flow (12.5).
The billing-only role in practice. This role exists for the common case of an accounts-payable contact at a customer who must never see client documents — a paralegal firm's bookkeeper, for instance. Removing a billing-only member has no effect on any document, template, or envelope (they could never own or touch one), and — because billing-only cannot itself invite or remove anyone (11.6's matrix) — a workspace can never end up in a state where its only remaining accessible role is billing-only with no owner/admin able to manage it; an owner remains the only role that can appoint or remove a billing-only contact in the first place.
11.7 Authorization model #
The canonical rule. Every persisted resource in the system carries exactly one owner, expressed as two columns: owner_type ('user' | 'workspace') and owner_id. There is no resource with two simultaneous owners and no resource owned by neither. This single fact is what makes one authorization function correct for guest, personal, and workspace scopes alike.
| Resource | owner_type values it can take |
Grants read/write to |
|---|---|---|
documents (uploaded server-side file metadata) |
user, workspace |
Owner scope's members per role matrix |
jobs |
user, workspace |
Same as the document(s) it processes |
envelopes |
user, workspace |
11.6's audit-log visibility rule |
templates |
user, workspace |
11.6's shared-template rule |
api_keys |
user, workspace |
Key management matrix entry in 11.6 |
workspaces |
n/a (a workspace owns itself) | Role matrix in 11.6 |
Guests never appear in this table: because the six guest tools are entirely client-side (11.5), a guest never creates a row with owner_type = 'user' or 'workspace' — the only server state a guest touches is the ephemeral guest_task_events counter in 11.5.3, which is not an authorization-relevant resource (it has no read/write/delete verbs, only "increment").
The one authorization function.
type Scope =
| { kind: "personal"; userId: string }
| { kind: "workspace"; workspaceId: string; role: "owner" | "admin" | "member" | "billing-only" };
type Actor =
| { kind: "user"; userId: string; scopes: Scope[] } // a signed-in session; scopes = personal + every workspace membership
| { kind: "apiKey"; keyId: string; scope: Scope; grantedScopes: ApiScope[] }; // Section 14 owns ApiScope, e.g. "documents:write"
type ResourceRef = { type: "documents" | "jobs" | "envelopes" | "templates" | "apiKeys" | "workspaces";
ownerType: "user" | "workspace"; ownerId: string };
type Action = "read" | "write" | "delete";
function can(actor: Actor, action: Action, resource: ResourceRef): boolean {
const scope = actor.scopes ?? [actor.scope];
const matching = scope.find(s =>
(s.kind === "personal" && resource.ownerType === "user" && s.userId === resource.ownerId) ||
(s.kind === "workspace" && resource.ownerType === "workspace" && s.workspaceId === resource.ownerId)
);
if (!matching) return false;
const roleAllows = matching.kind === "personal"
? true // a user always has full read/write/delete over their own personal-scope resources
: ROLE_MATRIX[matching.role][resource.type][action]; // the table in 11.6, encoded as a lookup, not re-derived per call
if (!roleAllows) return false;
if (actor.kind === "apiKey") {
return actor.grantedScopes.includes(`${resource.type}:${action}` as ApiScope); // Section 14's key-scope model
}
return true;
}How API keys map onto this. An API key (Section 14) is never an independent principal — every key has exactly one owner_type/owner_id (a personal key on a Pro/API-plan account, or a workspace key created by an owner/admin per 11.6's matrix), and can() evaluates a key exactly as it would evaluate the human who could have created it, then additionally intersects with the key's own granted scopes. A key can never exceed the entitlements or authorization of its owning account — a key on a member-role workspace scope can never perform an owner-only action (deleting the workspace) no matter what scopes were granted to it, because ROLE_MATRIX denies it before the key-scope check is even reached.
Test matrix — proving no cross-tenant access is possible. These are executed as an automated integration-test suite (Vitest 4.x + a seeded database) on every change to can() or ROLE_MATRIX, per the ≥ 80% coverage bar in Section 19 applying specifically to this function:
| # | Actor | Resource | Action | Expected |
|---|---|---|---|---|
| 1 | User A, personal scope | User A's own document | read | Allow |
| 2 | User A, personal scope | User B's document | read | Deny (404, not 403 — see note below) |
| 3 | User A, member of Workspace W |
Workspace W's document | read | Allow |
| 4 | User A, no membership in Workspace W | Workspace W's document | read | Deny (404) |
| 5 | User A, member of W |
Workspace W's document | delete | Deny — member lacks delete on workspace documents created by others (403) |
| 6 | User A, owner of W |
Workspace W's document created by a different member | delete | Allow |
| 7 | User A, billing-only of W |
Workspace W's shared template | read | Deny (403) |
| 8 | API key owned by User A, scope documents:read only |
User A's own document, action write |
write | Deny — key lacks the write scope even though the owning user could write (403) |
| 9 | API key owned by Workspace W, created by an admin |
Another workspace W2's document | read | Deny (404) — cross-workspace, not merely cross-user |
| 10 | Former member (removed per 11.6) whose session is somehow replayed | Workspace W's document | read | Deny — membership row is gone, can() re-evaluates per request with no cache |
| 11 | Guest | Any server-side resource type | any | Deny unconditionally — guests hold no Actor capable of reaching can() at all; the route itself requires kind: "user" | "apiKey" |
| 12 | User A, admin of W |
Workspace W's policy settings (requireMfa, etc.) |
write | Allow |
| 13 | User A, member of W |
Workspace W's policy settings | write | Deny (403) |
| 14 | User A, owner of W, W is soft-deleted (deleted_at set, within the 30-day recovery window) |
Workspace W's document | read | Deny (404) — a soft-deleted workspace is invisible to can() exactly as if it did not exist; only the restore endpoint (11.6), which bypasses can() entirely and checks deleted_at IS NOT NULL plus prior ownership directly, can act on it |
Representative test implementation (Vitest 4.x, run against a seeded Postgres instance per the testing standards in Section 19):
import { describe, it, expect, beforeEach } from "vitest";
import { can } from "@pdfworks/contracts/authz";
import { seedUser, seedWorkspace, seedDocument } from "../fixtures";
describe("can() — cross-tenant isolation", () => {
it("denies User A read access to User B's personal document", async () => {
const userA = await seedUser();
const userB = await seedUser();
const doc = await seedDocument({ ownerType: "user", ownerId: userB.id });
const allowed = can(toActor(userA), "read", toResourceRef(doc));
expect(allowed).toBe(false);
});
it("denies a member-role actor from deleting another member's workspace document", async () => {
const workspace = await seedWorkspace();
const owner = await seedUser();
const member = await seedUser();
await workspace.addMember(owner.id, "owner");
await workspace.addMember(member.id, "member");
const doc = await seedDocument({ ownerType: "workspace", ownerId: workspace.id, createdBy: owner.id });
expect(can(toActor(member, workspace), "delete", toResourceRef(doc))).toBe(false);
expect(can(toActor(owner, workspace), "delete", toResourceRef(doc))).toBe(true);
});
});Note on row 2 and 4: a denied read on a resource that exists but is not owned by the actor returns 404 not_found_error, not 403 permission_error — existence of another tenant's resource ID is not disclosed. 403 is reserved for cases where the actor can see the resource exists (it is in their own scope) but lacks the specific permission, as in rows 5 and 7.
No caching of authorization decisions across requests. can() is evaluated fresh on every request against live workspace_members and ownership data — there is no session-embedded permission snapshot, no JWT claim listing role or workspace memberships, and no cache with a TTL to reason about. This is a deliberate cost/simplicity tradeoff: a role change, a removal, or an ownership transfer (11.6) takes effect on the very next request from the affected actor, with no propagation delay and no invalidation logic to get wrong, at the cost of one extra indexed lookup (workspace_members_user_idx, defined in 11.6's schema) per authorization check — a cost accepted because correctness of tenant isolation is worth more than shaving one indexed read.
12. Plans, Billing, Quotas & Entitlements #
12.1 Plan philosophy #
Five tiers, each answering a different question a visitor arrives with:
- Guest answers "let me try this right now" — zero commitment, the six tools in 11.5.1, entirely client-side, no account.
- Free answers "I want an account so my work is easier to find, and I occasionally need OCR or a conversion" — unlimited client-side tools (the privacy-first core of the product should never be rationed) plus a small taste of server-side capability.
- Pro at $9/mo answers "I do this often enough that two server-side tasks a day isn't enough" — the individual-professional tier, unlimited everything except workspace features.
- Team at $15/user/mo answers "more than one of us needs this, and we need to see each other's signature activity" — everything Pro has, per seat, plus the workspace layer in Section 11.6.
- API from $29/mo answers a different question entirely — "I want to call this from my own software," not "I want to click buttons." It is priced and metered independently of the human-facing tiers because every API operation is server-side by definition (Section 6.1) and its cost structure (compute, storage, OCR pages) is what must be recovered, not seats.
Which tier fits which visitor, used to drive the marketing site's plan-comparison copy owned by Section 21 (this table is the source data that section formats, not a restatement of it):
| Persona | Fits | Why |
|---|---|---|
| Someone with one PDF problem today and no recurring need | Guest | Zero friction, zero data collection beyond the anti-abuse counter in 11.5 |
| A student, hobbyist, or infrequent user who wants their recent files remembered | Free | Unlimited client-side, occasional OCR/conversion, no card required |
| A freelancer, consultant, or single practitioner who converts or OCRs documents weekly | Pro | Unlimited server-side at a fixed low price, no per-task anxiety |
| A firm, agency, or department where more than one person sends signature requests or needs to see each other's activity | Team | Seats, shared templates, the shared audit log in 11.6 |
| A SaaS product embedding PDF manipulation into its own workflow | API | No UI at all is relevant; billed on operations, not seats |
The consistent rule across all five: client-side capability is never the upsell. What is sold is server-side capacity, workspace collaboration, and API access — never "unlock more of the tools that already run entirely on your own device," because charging for compute the customer's own machine performed would contradict the privacy-first positioning in Section 1.1.
12.2 The canonical plan and limits table #
| Guest (no account) | Free | Pro — $9/mo | Team — $15/user/mo | API — usage-based, from $29/mo | |
|---|---|---|---|---|---|
| Client-side tools | 6 basic tools only¹ | All, unlimited | All, unlimited | All, unlimited | n/a — the API has no browser; every operation runs server-side per Section 6.1 |
| Daily task cap | 5 tasks / device / day | Unlimited client-side; 2 server-side tasks/day | Unlimited | Unlimited | Metered, no daily cap |
| Max file size | 25 MB | 25 MB | 1 GB | 1 GB | 1 GB |
| Batch processing | No | No | Yes, up to 100 files/job | Yes, up to 250 files/job | Yes, up to 1,000 files/job |
| Queue lane | standard | standard | priority | priority | priority |
| OCR | No | Yes (counts against the 2/day server-side cap) | Unlimited | Unlimited | Metered, Section 12.6 |
| Office/HTML conversion | No | Yes (counts against the 2/day server-side cap) | Unlimited | Unlimited | Metered, Section 12.6 |
| Signature requests (envelopes) | No | 3 envelopes/month | 100 envelopes/month | 500 envelopes/month/workspace | Metered, Section 12.6 |
| Job history retention | None | 7 days | 90 days | 90 days, workspace-wide | 90 days |
| Shared workspace, seats, shared templates, shared audit log | No | No | No | Yes | n/a |
| Public API access | No | No | No | No | Yes |
| Support | Docs only | Docs only | Email, 2 business days | Email, 1 business day | Email, 1 business day |
¹ The six guest tools, named explicitly: merge, split, rotate, organize (reorder/delete pages), compress, and PDF ↔ JPG/PNG. Every other tool requires at minimum a Free account. See Section 11.5 for the daily-cap mechanism and the no-dark-pattern upgrade-prompt rules.
Annual pricing — two months free on both seat-based tiers:
| Plan | Monthly | Annual | Effective monthly (annual) |
|---|---|---|---|
| Pro | $9.00/mo | $90.00/yr | $7.50/mo |
| Team | $15.00/user/mo | $150.00/user/yr | $12.50/user/mo |
API plans are usage-based and are not offered as annual commitments at launch — see 12.6 for the three API tiers and overage rates.
Downgrade and cancellation, stated once here and referenced elsewhere by number: access at the prior tier persists through the end of the current billing period. On expiry, the account reverts to Free limits; job history beyond 7 days becomes unreadable but is not deleted until 30 days after the downgrade (giving a window to re-upgrade without data loss); any envelope already in flight is allowed to run to completion regardless of tier, because Section 10's audit-trail guarantees do not degrade with billing status.
12.3 Entitlements as code #
The entitlement key list. A closed, versioned set — adding a key requires a schema change, never a free-text config value:
type Entitlements = {
"tools.clientSide.unlimited": boolean;
"tools.serverSide.dailyCap": number | null; // null = unlimited
"files.maxSizeBytes": number;
"batch.enabled": boolean;
"batch.maxFiles": number;
"queue.lane": "standard" | "priority";
"ocr.enabled": boolean;
"officeConversion.enabled": boolean;
"esign.envelopesPerMonth": number;
"jobHistory.retentionDays": number;
"workspace.enabled": boolean;
"api.access": boolean;
"support.tier": "docs" | "email-2bd" | "email-1bd";
};The resolver — a single typed function, one implementation shared by web, API, and workers via packages/contracts:
type AccountContext =
| { scope: "guest" }
| { scope: "personal"; userId: string; plan: "free" | "pro" }
| { scope: "workspace"; workspaceId: string; plan: "team"; policyOverrides: WorkspacePolicy };
function resolveEntitlements(ctx: AccountContext): Entitlements {
const base = PLAN_ENTITLEMENTS[ctx.scope === "guest" ? "guest" : ctx.plan]; // static table mirroring 12.2
if (ctx.scope !== "workspace") return base;
// Workspace policy settings (Section 11.6) may only tighten, never loosen, the plan's base entitlements.
return {
...base,
"jobHistory.retentionDays": Math.min(base["jobHistory.retentionDays"], ctx.policyOverrides.jobHistoryRetentionDays ?? base["jobHistory.retentionDays"]),
"ocr.enabled": base["ocr.enabled"] && !ctx.policyOverrides.restrictServerSideTools,
"officeConversion.enabled": base["officeConversion.enabled"] && !ctx.policyOverrides.restrictServerSideTools,
"esign.envelopesPerMonth": ctx.policyOverrides.restrictServerSideTools ? 0 : base["esign.envelopesPerMonth"],
};
}
function checkEntitlement<K extends keyof Entitlements>(
ctx: AccountContext,
key: K,
usage?: { current: number }
): { allowed: boolean; reason?: string; limit?: Entitlements[K]; remaining?: number } {
const entitlements = resolveEntitlements(ctx);
const limit = entitlements[key];
if (typeof limit === "boolean") {
return limit ? { allowed: true } : { allowed: false, reason: "feature_not_available", limit };
}
if (limit === null) return { allowed: true }; // unlimited
if (usage === undefined) return { allowed: true, limit }; // boolean-style presence check on a numeric key
const remaining = (limit as number) - usage.current;
return remaining > 0
? { allowed: true, limit: limit as number, remaining }
: { allowed: false, reason: "quota_exceeded", limit: limit as number, remaining: 0 };
}The static PLAN_ENTITLEMENTS table the resolver reads from — this is the literal, exhaustive encoding of the canonical plan table in 12.2 into the typed shape defined above, and it is the only place in the codebase where these numbers are allowed to be hard-coded (every call site reads through resolveEntitlements/checkEntitlement, never this constant directly):
const PLAN_ENTITLEMENTS: Record<"guest" | "free" | "pro" | "team", Entitlements> = {
guest: {
"tools.clientSide.unlimited": false, "tools.serverSide.dailyCap": 0,
"files.maxSizeBytes": 25 * 1024 * 1024, "batch.enabled": false, "batch.maxFiles": 0,
"queue.lane": "standard", "ocr.enabled": false, "officeConversion.enabled": false,
"esign.envelopesPerMonth": 0, "jobHistory.retentionDays": 0,
"workspace.enabled": false, "api.access": false, "support.tier": "docs",
},
free: {
"tools.clientSide.unlimited": true, "tools.serverSide.dailyCap": 2,
"files.maxSizeBytes": 25 * 1024 * 1024, "batch.enabled": false, "batch.maxFiles": 0,
"queue.lane": "standard", "ocr.enabled": true, "officeConversion.enabled": true,
"esign.envelopesPerMonth": 3, "jobHistory.retentionDays": 7,
"workspace.enabled": false, "api.access": false, "support.tier": "docs",
},
pro: {
"tools.clientSide.unlimited": true, "tools.serverSide.dailyCap": null,
"files.maxSizeBytes": 1024 * 1024 * 1024, "batch.enabled": true, "batch.maxFiles": 100,
"queue.lane": "priority", "ocr.enabled": true, "officeConversion.enabled": true,
"esign.envelopesPerMonth": 100, "jobHistory.retentionDays": 90,
"workspace.enabled": false, "api.access": false, "support.tier": "email-2bd",
},
team: {
"tools.clientSide.unlimited": true, "tools.serverSide.dailyCap": null,
"files.maxSizeBytes": 1024 * 1024 * 1024, "batch.enabled": true, "batch.maxFiles": 250,
"queue.lane": "priority", "ocr.enabled": true, "officeConversion.enabled": true,
"esign.envelopesPerMonth": 500, "jobHistory.retentionDays": 90,
"workspace.enabled": true, "api.access": false, "support.tier": "email-1bd",
},
};Entitlement key to tool/feature mapping, so a reader implementing any individual tool spec in Sections 7–10 knows exactly which key gates it:
| Entitlement key | Gates |
|---|---|
tools.serverSide.dailyCap |
Every server-side tool invocation: OCR (Section 9), Office/HTML conversion (Section 9), sending a signature envelope (Section 10) |
files.maxSizeBytes |
The upload-size check on every server-side tool and every client-side tool's file picker alike (Section 7) |
batch.enabled / batch.maxFiles |
The batch-processing entry point (Section 13) |
queue.lane |
Which BullMQ priority value a server-side job is enqueued with (Section 13) |
ocr.enabled |
The OCR tool specifically (Section 9) |
officeConversion.enabled |
PDF↔DOCX/XLSX/PPTX and HTML↔PDF specifically (Section 9) — never the client-side PDF↔JPG/PNG tools (Section 7), which are always unlimited per the client-side-is-never-the-upsell rule in 12.1 |
esign.envelopesPerMonth |
Envelope creation (Section 10); does not gate self-sign, which is client-side (Section 6.1) |
jobHistory.retentionDays |
How far back the job-history list (Section 13) queries |
workspace.enabled |
Every endpoint under /internal/workspaces/* (Section 11.6) |
api.access |
Whether an account may create an API key at all (Section 14) |
The API plan tiers (Starter/Growth/Scale) do not populate this table at all — an API key's admission decision runs entirely through the metering path in 12.6, not through checkEntitlement, because API usage is priced per operation rather than gated by a fixed boolean/numeric ceiling.
Call-site pattern, identical shape everywhere:
// apps/api route handler, before enqueueing an OCR job
const check = checkEntitlement(ctx, "ocr.enabled");
if (!check.allowed) {
return c.json({ error: { type: "permission_error", code: "feature_not_available",
message: "OCR is not available on your current plan.", docsUrl: "https://docs.pdfworks.io/errors/feature_not_available",
requestId: c.get("requestId") } }, 403);
}
const dailyCheck = checkEntitlement(ctx, "tools.serverSide.dailyCap", { current: usageToday });
if (!dailyCheck.allowed) {
return c.json({ error: { type: "quota_error", code: "daily_task_cap_exceeded",
message: "You've used today's 2 server-side tasks on the Free plan.", docsUrl: "https://docs.pdfworks.io/errors/daily_task_cap_exceeded",
requestId: c.get("requestId") } }, 402);
}The exact JSON shape follows the error envelope owned by Section 14.6; this section only fixes which code values entitlement failures use (feature_not_available, quota_exceeded, daily_task_cap_exceeded, file_too_large, batch_size_exceeded, envelope_quota_exceeded).
Where the check runs, and the trust rule. resolveEntitlements and checkEntitlement are pure functions in packages/contracts, imported unmodified by the Next.js app (for UX — disabling a button, rendering an upsell tooltip, showing "1 of 2 today") and by apps/api (for enforcement). The client-side check is never trusted. Every route handler in apps/api re-runs checkEntitlement against server-fetched usage counters before doing any work; a request that reaches the handler having bypassed or spoofed the client UI is rejected identically to one that respected it. This is stated once, here, as the canonical rule — no other section re-derives it.
What a denied check returns.
| Surface | Behavior |
|---|---|
| UI (button-level) | The action's trigger control renders disabled with a tooltip explaining why, plus an inline "Upgrade to Pro" link where a plan change would resolve it; no click reaches the server at all for a boolean-type denial the client already knows about |
| UI (race — server denies something the client thought was fine, e.g. stale cached entitlements) | A toast/dialog surfaces the server's message verbatim, with the same equally-weighted "Upgrade" / "Not now" pattern required in Section 11.5.5 |
| API | The JSON error envelope (Section 14.6) with type: "permission_error" (plan doesn't include the feature at all) or type: "quota_error" (feature included but the numeric limit is exhausted), HTTP 403 or 402 respectively |
12.4 Quota accounting #
What counts as one task. One accepted invocation of a tool — client-side or server-side — that reaches an execution state (client-side: the local job store's running state; server-side: the job row transitions to queued, defined once in the job state machine owned by Section 13). A batch job containing N files counts as N tasks, one per file, because that is what the plan table's per-day and per-month numbers are calibrated against; the batch job row itself is one row in job history regardless of N.
What does not count: thumbnail/preview generation, a validation-only request that never reaches queued (e.g., the client-side pre-flight that checks a PDF isn't corrupt before offering to process it), a canceled-before-queued request, re-downloading a previously completed result, and — per the refund rule directly below — a system-caused retry of the same logical task.
Increment and refund boundary. The counter increments the instant a job enters queued (job acceptance), not on completion — a task that is accepted and then fails still consumed capacity, because the system did the work of accepting and attempting it. Whether it is refunded depends on why it failed, and the boundary is precise:
| Failure reason | Refunded? | Examples |
|---|---|---|
system_error — the platform failed to do what it promised |
Yes, automatically, the instant the job reaches failed with this reason |
Worker crash, out-of-memory kill, wall-clock timeout not caused by pathological input, infrastructure fault (queue/storage unavailable), an internal bug |
user_error — the input or request was the problem |
No | Wrong password supplied to unlock/protect, a file that fails PDF structural validation mid-processing after passing the shallow pre-flight check, an unsupported feature in the source file (e.g. XFA form OCRmyPDF cannot flatten), exceeding a size/page limit that pre-flight didn't catch |
Rejected before queued (immediate 422/415) |
N/A — never incremented in the first place | Pre-flight validation failure, magic-byte mismatch, oversized upload rejected at the edge |
A refund credits the counter back within the same request cycle that marks the job failed, visible in the usage endpoint (12.6) within 60 seconds.
Enforcement implementation, in full:
async function admitTask(redis: Redis, ctx: AccountContext, jobUnits: number): Promise<{ admitted: boolean }> {
const key = quotaRedisKey(ctx); // e.g. quota:user:usr_2q8n...:2026-08-19
const check = checkEntitlement(ctx, "tools.serverSide.dailyCap", { current: Number(await redis.get(key) ?? 0) });
if (!check.allowed) return { admitted: false };
await redis.multi()
.incrby(key, jobUnits)
.expire(key, 26 * 60 * 60) // 26h so the key always outlives its own calendar day, including DST-shifted 25h days
.exec();
return { admitted: true };
}
async function refundTask(redis: Redis, ctx: AccountContext, jobUnits: number): Promise<void> {
const key = quotaRedisKey(ctx);
await redis.decrby(key, jobUnits); // never goes below 0 in practice: a refund always corresponds to a prior increment on the same key within the same day
}quotaRedisKey is the single function that encodes the per-actor-type day-bucket rule above — quota:user:{userId}:{YYYY-MM-DD in the user's timezone}, quota:workspace:{workspaceId}:{YYYY-MM-DD UTC} for workspace-scoped server-side quota, or quota:guest:{cookieId}:{YYYY-MM-DD UTC} — so no call site is ever tempted to compute a day bucket inline and drift from the rule.
Daily window definition — decided per actor type, because "a day" only has one honest meaning when a timezone is known:
- Authenticated users (Free/Pro/Team): a calendar day in the user's stored profile timezone (Section 11.4). This matches the user's own mental model of "today" and avoids the frustrating case of a cap resetting at 5pm local time because the server used UTC.
- Guests: a UTC calendar day, because no timezone is known for an unauthenticated device (Section 11.5.3 deliberately stores nothing that could infer one).
- API keys: a UTC calendar day — server-to-server traffic has no "local time" concept, and UTC keeps the metering window unambiguous for the daily rollup and for customers reconciling their own logs.
How batch jobs count: as established above, one unit per file for cap/quota purposes; for signature envelopes (which are not files but recipients-and-documents packages), one envelope creation is one unit against the monthly envelope entitlement in 12.2 regardless of signer count.
How API operations count: every accepted /v1 processing call is one operation against the API plan's included-operations pool (12.6); reads (GET) are never metered.
Durable usage schema:
CREATE TABLE quota_usage_daily (
scope_type text NOT NULL CHECK (scope_type IN ('user','workspace','apiKey')),
scope_id uuid NOT NULL,
day_bucket date NOT NULL,
tasks_used int NOT NULL DEFAULT 0,
tasks_refunded int NOT NULL DEFAULT 0,
server_side_used int NOT NULL DEFAULT 0, -- the subset of tasks_used that were server-side, for the 2/day Free cap
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (scope_type, scope_id, day_bucket)
);
CREATE INDEX quota_usage_daily_scope_idx ON quota_usage_daily (scope_type, scope_id);tasks_used - tasks_refunded is the number actually charged against a plan's daily or monthly figure; both raw columns are retained (rather than collapsing to one net counter) so that the internal metrics in Section 18 can separately track gross attempt volume and the system-error refund rate as an operational health signal — a spike in tasks_refunded is a leading indicator of a worker-fleet problem independent of any customer-visible complaint.
The daily rollup job. Real-time enforcement reads live Redis counters (quota:{scopeType}:{scopeId}:{dayBucket}, INCR on acceptance, DECR on a system-error refund, EXPIRE set to 26 hours so a counter always outlives its own day). A BullMQ janitor-queue repeatable job runs hourly (0 * * * *) and flushes each active Redis counter into a permanent quota_usage_daily Postgres table (scope_type, scope_id, day_bucket, tasks_used, refunded) for billing history, the in-app usage dashboard (12.6), and the 50/80/100% alerting job — Redis is the enforcement source of truth for "can this request proceed right now," Postgres is the durable record for everything that looks backward.
12.5 Stripe integration #
Products and prices.
| Product | Stripe object | Billing | Price |
|---|---|---|---|
| Pro (monthly) | prod_pdfworks_pro |
recurring, monthly | $9.00 |
| Pro (annual) | prod_pdfworks_pro |
recurring, yearly | $90.00 |
| Team (monthly, per seat) | prod_pdfworks_team |
recurring, monthly, quantity = seats | $15.00/seat |
| Team (annual, per seat) | prod_pdfworks_team |
recurring, yearly, quantity = seats | $150.00/seat |
| API Starter | prod_pdfworks_api_starter |
recurring monthly base + metered overage | $29.00 base, then $0.012/operation over 2,000 included |
| API Growth | prod_pdfworks_api_growth |
recurring monthly base + metered overage | $99.00 base, then $0.009/operation over 10,000 included |
| API Scale | prod_pdfworks_api_scale |
recurring monthly base + metered overage | $399.00 base, then $0.006/operation over 50,000 included |
| OCR page overage (any API tier) | prod_pdfworks_ocr_overage |
metered, Stripe Meter ocr_pages |
$0.004/page beyond plan inclusion |
Each metered price is attached to a Stripe Meter (api_operations, ocr_pages) with event_time_window: hour aggregation and default_aggregation: { formula: "sum" }.
Checkout session creation.
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: stripeCustomerId, // created on first checkout attempt, cached on the users/workspaces row
client_reference_id: scope.kind === "workspace" ? scope.workspaceId : scope.userId,
line_items: [{ price: priceIdFor(plan, billingCycle), quantity: scope.kind === "workspace" ? seatCount : 1 }],
subscription_data: (plan === "pro" || plan === "team")
? { trial_period_days: 14, metadata: { pdfworksScope: scope.kind, pdfworksScopeId: client_reference_id } }
: { metadata: { pdfworksScope: scope.kind, pdfworksScopeId: client_reference_id } },
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
success_url: "https://app.pdfworks.io/billing/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://app.pdfworks.io/billing/plans?canceled=1",
});Billing Portal. stripe.billingPortal.sessions.create({ customer: stripeCustomerId, return_url: "https://app.pdfworks.io/billing" }). The Portal configuration (stripe.billingPortal.configurations) is deliberately narrow: payment-method update, invoice history, and subscription cancellation are enabled; plan switching and seat-quantity changes are disabled in the Portal and only available through in-app flows, so entitlement recomputation (12.3) always happens synchronously with the app's own webhook handling rather than racing a Portal-initiated change the app finds out about only via webhook.
Complete webhook event list and handler behavior:
| Event | Handler behavior |
|---|---|
checkout.session.completed |
Create/link the subscriptions row to the scope from client_reference_id; send a welcome/receipt email |
customer.subscription.created |
Upsert subscriptions (plan, status, seats, current_period_end, trial_end); call resolveEntitlements to activate access |
customer.subscription.updated |
Upsert; diff plan/seat/status against the prior row to detect an upgrade, downgrade-scheduled, or seat change, and recompute entitlements |
customer.subscription.trial_will_end |
Email reminder 3 days before trial conversion |
customer.subscription.deleted |
Mark subscriptions.status = 'canceled'; if not already at Free/no-plan, downgrade entitlements immediately |
invoice.payment_succeeded |
Mark the invoice paid; if the account was mid-dunning or already downgraded, restore full entitlements immediately (12.7 covers this edge case explicitly) |
invoice.payment_failed |
Advance the dunning sequence below; update subscriptions.status = 'past_due' |
invoice.upcoming |
Send a renewal-notice email 7 days before a Team/Pro annual renewal only (monthly renewals are not pre-announced, to avoid inbox fatigue) |
customer.updated |
Sync billing email/address onto the local customer record |
payment_method.attached |
Update the default-payment-method flag shown in Settings → Billing |
charge.refunded |
Record the refund against the invoice's local history row |
radar.early_fraud_warning.created |
Flag the account for manual review; never auto-suspend on this signal alone |
Representative webhook payload (customer.subscription.updated, abbreviated to the fields the handler actually reads):
{
"id": "evt_1PqR7sDx8f2K9mNc",
"type": "customer.subscription.updated",
"data": {
"object": {
"id": "sub_1PqR6zDx8f2K9mNc",
"customer": "cus_QwErTy1234",
"status": "active",
"items": { "data": [{ "price": { "id": "price_team_annual" }, "quantity": 5 }] },
"current_period_end": 1798761600,
"trial_end": null,
"metadata": { "pdfworksScope": "workspace", "pdfworksScopeId": "6f2a9c1e-..." }
},
"previous_attributes": { "items": { "data": [{ "quantity": 4 }] } }
}
}The handler diffs items.data[0].quantity against previous_attributes to detect this was a seat-count change specifically (rather than a plan swap), and updates subscriptions.seats accordingly; metadata.pdfworksScopeId is what maps the Stripe object back to a workspaces.id without a secondary lookup table.
Signature verification. stripe.webhooks.constructEvent(rawBody, signatureHeader, webhookSigningSecret) against the raw request body (captured before any JSON body-parsing middleware runs) — an invalid signature returns 400 immediately with no processing. The handler acknowledges with 200 as soon as the event is durably recorded (next paragraph) and does the actual state change asynchronously via the webhook BullMQ queue, so Stripe's retry budget is never consumed by slow downstream work.
Idempotent handling and event deduplication.
CREATE TABLE stripe_webhook_events (
id text PRIMARY KEY, -- the Stripe event id, e.g. evt_1P...
event_type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
payload_hash text NOT NULL
);The webhook handler INSERT ... ON CONFLICT (id) DO NOTHING before enqueueing processing; if the insert affects zero rows, the event was already seen (Stripe redelivers on any non-2xx or timeout) and the handler returns 200 without re-enqueueing — this makes every handler above naturally idempotent without each one re-implementing dedup logic.
Local subscription read-model and reconciliation.
CREATE TABLE subscriptions (
id uuid PRIMARY KEY,
owner_type text NOT NULL CHECK (owner_type IN ('user','workspace')),
owner_id uuid NOT NULL,
stripe_customer_id text NOT NULL,
stripe_subscription_id text NOT NULL UNIQUE,
plan text NOT NULL CHECK (plan IN ('pro','team')),
status text NOT NULL, -- mirrors Stripe subscription status verbatim: trialing, active, past_due, canceled, unpaid
seats int NOT NULL DEFAULT 1,
current_period_end timestamptz NOT NULL,
trial_end timestamptz,
canceled_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);A janitor-queue job runs every 30 minutes and calls stripe.subscriptions.retrieve for any row whose updated_at is older than 36 hours (a webhook should have touched it well before then; this catches missed deliveries), overwriting the local row with Stripe's current truth. A "Sync billing status" button in Settings → Billing runs the same reconciliation function synchronously and is the escape hatch for a customer who sees a stale state and doesn't want to wait for the next sweep.
Stripe customer creation timing. A stripe.customers.create call is deferred until the first moment it is actually needed — the first Checkout session attempt — rather than at registration; a Free-plan account that never subscribes never has a Stripe customer object at all. The resulting stripe_customer_id is cached on the users row (personal scope) or the workspaces row (workspace scope) the instant it is created, so a returning customer's second Checkout session reuses the same customer object rather than creating a duplicate, which also keeps Stripe Tax's saved tax-ID and address association intact across subscription changes.
Trials — stated once, exactly here. Pro and Team each carry a 14-day free trial, card required at signup (decision: requiring a card up front, rather than a no-card trial, is the deliberate abuse control for a product whose server-side tools consume real compute — OCR and Office conversion are not free to run, and a no-card trial on those would be trivially farmable). The trial auto-converts to a paid subscription at day 14 unless canceled before then, at which point the card is never charged. One trial per person is enforced by uniqueness on verified email plus Stripe Radar's payment-method fingerprinting flagging repeat trial attempts for manual review. The API plans carry no trial — their low base price and small included-operation pool already function as a low-commitment entry point, and metered infrastructure cost makes a card-free trial an unacceptable abuse surface.
Proration on upgrade. Default Stripe proration (proration_behavior: "create_prorations") — the customer is charged the prorated difference immediately on the upgrade invoice, and entitlements activate the moment customer.subscription.updated confirms the new price, not on the next billing cycle.
Downgrade at period end. A downgrade (Pro → Free, or a Team seat-price change) is scheduled via a Stripe subscription schedule that switches the price at current_period_end, with no proration credit — the customer keeps the higher tier's entitlements until the period genuinely ends, then customer.subscription.updated fires when the schedule's phase change lands and entitlements are recomputed at that moment, matching the downgrade behavior already stated in 12.2.
Seat changes mid-cycle (Team) — the canonical proration rule. This paragraph is the single authoritative statement of how a Team workspace's billing responds to a seat-count change mid-cycle; every other section that discusses adding or removing a seat (including 11.6) defers to this paragraph rather than restating its mechanics.
- Adding a seat mid-cycle. The moment a seat is added — whether an
ownerproactively increases seat count from Settings → Workspace → Seats, or a pending invitation is accepted past the current paid seat count and theownerapproves adding a seat to admit it (11.6) — the application immediately callsstripe.subscriptions.updateto increase the subscription item'squantityby one, using Stripe's default proration behavior (proration_behavior: "create_prorations"). Stripe computes a prorated charge for the remainder of the current billing period (days remaining in the period ÷ total days in the period × the per-seat price) and adds it as its own distinct line item on the next invoice — it is never billed as an immediate, separate charge outside the normal invoicing cycle, and it is never folded silently into the recurring per-seat line item, so the customer can see exactly what the mid-cycle addition cost. The new seat's entitlements (an additionalworkspace_membersrow eligible for invitation acceptance) become available the instant thesubscription.updatecall succeeds, without waiting for that invoice to be issued or paid. - Removing a seat mid-cycle. Decreasing seat count schedules the subscription item's
quantityto decrease at the next renewal, never immediately. Stripe issues no proration credit and no refund for the unused remainder of the removed seat's current period — the seat remains billed at the current rate and remains available (an existing member can continue occupying it) throughcurrent_period_end. The invoice generated at that renewal simply reflects the lowerquantityat the normal per-seat rate; no separate "seat removed" line item appears, because nothing is being credited. The API layer refuses to accept a targetquantitybelow the workspace's currentworkspace_membersrow count (409 { code: "seats_below_member_count" }) until members are removed first, so a seat reduction can never be scheduled out from under an occupying member.
Dunning — the failed-payment sequence, day by day. Stated once, exactly here. Triggered by the first invoice.payment_failed on a subscription, using Stripe's Smart Retries schedule as the retry mechanism and the following communication cadence layered on top:
| Day | Event | Action |
|---|---|---|
| 0 | invoice.payment_failed (1st attempt) |
Email "Your payment didn't go through" + persistent in-app banner; full plan access continues — a dunning grace period, not an immediate downgrade, because a single failed card is often transient |
| 3 | Stripe retry #2 fails | Email #2, same content, escalated subject line |
| 7 | Stripe retry #3 fails | Email #3: "Your account will be downgraded to Free in 7 days if payment isn't updated"; banner escalates to a blocking modal specifically on server-side tool actions (client-side tools remain fully usable throughout dunning, per the Section 6 principle that client-side capability never degrades) |
| 10 | Stripe retry #4 fails | Email #4, "final notice, 4 days remaining" |
| 14 | Retries exhausted | Subscription transitions to canceled (customer.subscription.deleted fires); entitlements downgrade to Free immediately per 12.2's downgrade rule; email #5 confirms the downgrade and links directly to Billing to re-subscribe |
Involuntary churn. A churn_reason field (voluntary | involuntary) is set on the subscriptions row at cancellation time: voluntary when the customer explicitly canceled (Billing Portal or in-app), involuntary when cancellation resulted from dunning exhaustion. This split feeds the internal metrics reported in Section 18 and is not customer-visible.
Cancellation. Self-service, via Billing Portal or the in-app "Cancel plan" action: sets cancel_at_period_end: true. Access continues at full entitlement through current_period_end. Reactivation before period end is one click (cancel_at_period_end: false), charges nothing extra, and simply un-schedules the cancellation. Reactivation after the period has fully lapsed (account already on Free) is a normal new Checkout session and — because the account already consumed its trial — starts immediately as a paid subscription with no new trial.
Refund policy — stated once, exactly here. A 14-day money-back guarantee applies to a customer's first-ever paid charge on a given plan family (Pro or Team): a self-service "Request a refund" button is shown in Billing only within 14 days of that first charge, triggers an immediate full refund (stripe.refunds.create) and immediate cancellation (no waiting for period end), logged with reason: "requested_by_customer". Outside that 14-day window, renewal charges are non-refundable for the partial remainder of a period — the customer instead cancels and retains access through period end, per the cancellation flow above. Refund requests outside the guarantee window (e.g., a billing dispute) are handled manually by support and issued at their discretion via the same stripe.refunds.create call, logged with a free-text reason.
Tax handling. Stripe Tax is enabled on every Checkout session and every subscription (automatic_tax: { enabled: true }); business customers can supply a tax ID (tax_id_collection: { enabled: true }) to support EU B2B reverse-charge treatment. Stripe determines nexus, rate, and tax type (VAT/GST/US sales tax) automatically from the billing address; tax is itemized on both the Checkout confirmation and the resulting invoice.
Invoices. Every invoice is Stripe-hosted; invoice.finalized triggers a Resend email containing the hosted invoice PDF link. Settings → Billing → Invoices mirrors the list in-app (GET /internal/billing/invoices, cursor-paginated per the pagination model owned by Section 14.7) for convenience, but the PDF itself is always served from Stripe's hosted URL, never re-hosted by PDFWorks.
12.6 API metering and billing #
The three tiers, restated for completeness of this section (the authoritative numbers live in the table in 12.5):
| Tier | Base price | Included operations/mo | Overage rate | OCR page overage |
|---|---|---|---|---|
| Starter | $29/mo | 2,000 | $0.012/operation | $0.004/page |
| Growth | $99/mo | 10,000 | $0.009/operation | $0.004/page |
| Scale | $399/mo | 50,000 | $0.006/operation | $0.004/page |
What counts as a billable operation. Any accepted /v1 call that reaches queued for actual document processing (merge, split, OCR, conversion, envelope creation, etc.) — the same acceptance-based counting rule as 12.4, including the identical refund boundary (a system_error failure is not billed; a user_error failure is). Read-only calls (GET status/list/download, webhook management) are never metered. A replayed request sharing an already-seen Idempotency-Key (Section 14.8) is never billed a second time — it returns the stored original response without re-entering the metering path at all.
OCR pages meter separately. An OCR job is charged as one base operation (covering job orchestration) plus one ocr_pages meter unit per page actually OCR'd, reported as two separate Stripe Meter events from the same job. A 40-page scanned contract therefore consumes 1 operation + 40 OCR-page units.
Stripe Meters usage reporting and its idempotency.
await stripe.billing.meterEvents.create({
event_name: "api_operations",
payload: { value: "1", stripe_customer_id: customerId },
identifier: idempotencyKey, // the request's own Idempotency-Key header — guarantees exactly-once metering even if the client retries
});Meter events are not sent synchronously inline with the API response; they are pushed onto a Redis stream and drained by a webhook-queue worker every 60 seconds in batches, respecting Stripe Meters' ingestion rate limits. The Redis stream entry ID is retained as a secondary dedup key on the consumer side in case a batch is redelivered after a worker crash mid-flush.
Billing-relevant columns on api_keys (the security-relevant columns — the pk_live_/pk_test_ prefix, the stored SHA-256 hash, per-key IP allowlist, and per-key scopes — are defined once, canonically, in Section 17.2; this section owns only the columns that drive metering and spend control):
ALTER TABLE api_keys
ADD COLUMN owner_type text NOT NULL CHECK (owner_type IN ('user','workspace')),
ADD COLUMN owner_id uuid NOT NULL,
ADD COLUMN tier text NOT NULL CHECK (tier IN ('starter','growth','scale')),
ADD COLUMN spend_cap_cents int, -- null = uncapped
ADD COLUMN stripe_subscription_item_id text NOT NULL; -- the metered price's subscription item, for meterEvents.createUsage response shape (GET /v1/usage?period=current), a bare object per the single-resource rule owned by Section 14:
{
"period": { "start": "2026-08-01T00:00:00Z", "end": "2026-08-31T23:59:59Z" },
"tier": "growth",
"includedOperations": 10000,
"operationsUsed": 7412,
"operationsRemaining": 2588,
"ocrPagesUsed": 1230,
"estimatedOverageCents": 0,
"spendCapCents": 5000,
"spendUsedCents": 4998
}Per-key spend cap and the 402 it produces. api_keys.spend_cap_cents — nullable, defaults to null (no cap). Before admitting a metered call, the request handler checks a Redis counter spend:{keyId}:{stripePeriodStart} (maintained by the same drain worker that pushes meter events, incremented pessimistically at request time and reconciled against Stripe's authoritative usage on the next hourly sync): if the operation about to run would push the running total past the cap, the call is rejected before any processing begins:
{
"error": {
"type": "quota_error",
"code": "spend_cap_exceeded",
"message": "This API key has a $50.00 spend cap for the current billing period, which has been reached.",
"param": null,
"docsUrl": "https://docs.pdfworks.io/errors/spend_cap_exceeded",
"requestId": "req_01K7Y9Q2R7ZC3M"
}
}HTTP 402, per the mapping owned by Section 14.6.
Admission check, in full:
async function admitApiOperation(redis: Redis, key: ApiKeyRecord, estimatedCents: number): Promise<AdmitResult> {
if (key.spendCapCents == null) return { admitted: true };
const spendKey = `spend:${key.id}:${key.currentPeriodStart}`;
const current = Number(await redis.get(spendKey) ?? 0);
if (current + estimatedCents > key.spendCapCents) {
return { admitted: false, code: "spend_cap_exceeded" };
}
await redis.incrby(spendKey, estimatedCents);
await redis.expireat(spendKey, key.currentPeriodEndUnix + 3600); // survives one hour past period end for the reconciliation sweep
return { admitted: true };
}Both personal and workspace-owned API keys default spend_cap_cents to null (uncapped) at creation — an explicit opt-in ceiling, not an opt-out one, on the reasoning that a surprise cap silently throttling a production integration is a worse failure mode for most customers than a surprise bill, and the usage-alert thresholds below give ample warning before either occurs. A workspace-owned key's cap, once set, is visible to and editable only by owner/admin roles (11.6's matrix), consistent with billing controls generally.
estimatedCents uses the key's tier overage rate as an upper-bound estimate at admission time (e.g., a single non-OCR operation on Growth is estimated at $0.009 even though it may fall within the included pool and ultimately cost $0.00) — the pessimistic estimate is intentional: it is corrected downward, never upward, by the hourly reconciliation sync against Stripe's authoritative meter totals, so the spend cap can only ever be more conservative than actual spend, never less, closing the race between two concurrent requests both checking a stale counter.
Meter-event batching failure handling. If the webhook-queue drain worker's meterEvents.create call to Stripe fails (network fault, Stripe-side 5xx), the batch remains on the Redis stream un-acknowledged and is retried by the next drain cycle (60 seconds later) with the same batch of Redis stream entry IDs — since identifier on each event is the original request's Idempotency-Key, a retried batch that partially succeeded on a prior attempt is safe to resend in full; Stripe Meters treats a duplicate identifier as a no-op rather than double-counting.
Usage dashboards. GET /v1/usage?period=current (public API) and an in-app "API Usage" page under Settings → API, both backed by quota_usage_daily (12.4) joined against the key's tier: operations by day (TanStack Table on the web app, plain JSON via the API), running cost against the included pool, and a projected month-end spend extrapolated linearly from the current day's run rate.
Billing alerts at 50/80/100 percent. The hourly rollup job (12.4) compares each API key's cumulative-this-period operation count against its tier's included-operations figure; crossing 50%, 80%, or 100% triggers one Resend email and one in-app notification per threshold, gated by a usage_alerts_sent (api_key_id, threshold, period_start) uniqueness constraint so a threshold is never announced twice in the same billing period even if the rollup job re-evaluates it every hour.
12.7 Failure and edge cases #
A subscription lapses mid-job. A job already queued or running when a subscription transitions to past_due or canceled is allowed to run to completion — the quota unit was already consumed at acceptance time (12.4), and killing in-flight work would waste the compute already spent without recovering anything. No new job is accepted once entitlements have downgraded; the next request re-evaluates checkEntitlement fresh.
A workspace loses seats below its member count. This is prevented proactively wherever possible (12.5's 409 seats_below_member_count on a direct seat reduction), but can still occur via an external Stripe-side change (e.g., a manual adjustment in the Stripe Dashboard during support intervention) — the reconciliation job (12.5) detects seats < member_count on sync and does not remove anyone automatically; instead it flags the workspace seats_deficient = true, blocks new invitations and new seat-consuming actions, and surfaces a persistent banner to every owner/admin until seats are increased or members are removed to close the gap. Existing members' day-to-day access is unaffected while deficient.
A downgrade would exceed the new plan's retention. Handled identically whether the trigger is a plan downgrade or a workspace policy tightening (11.6): job history beyond the new, lower retention window becomes unreadable in the UI and API immediately (a GET on an out-of-window job returns 404 not_found_error, indistinguishable from true absence, since it will genuinely be gone soon) but the underlying row is not physically deleted until 30 days after the downgrade takes effect — the same grace figure used in 12.2, applied consistently whether the cause is billing or workspace policy.
A payment succeeds after access was already downgraded. Because dunning (12.5) only downgrades after day 14, and Stripe can still deliver a very late invoice.payment_succeeded for a retry that happened just past that boundary (or a customer manually pays an overdue invoice from the Billing Portal after downgrade), the invoice.payment_succeeded handler unconditionally re-checks entitlements and restores full access immediately, in the same handler invocation — there is no separate "welcome back" flow and no waiting for the next billing cycle. The account is notified by email that access has been restored.
A user's profile timezone changes mid-day, shifting the quota window. Editing timezone in Settings (11.4) never retroactively re-buckets already-recorded quota_usage_daily rows or the live Redis counter for the day in progress — the current day's counter, keyed by the timezone in effect when it was created, runs to its original boundary; the new timezone takes effect starting the next Redis key it creates. This avoids the pathological case of a user gaming the daily cap by repeatedly nudging their timezone forward, since a change never opens a new window early.
An API key's spend cap is reached mid-batch job. A batch job (Section 13) that has already been accepted (queued, all N files' quota units and their corresponding meter events reserved at acceptance per 12.4/12.6) is never partially aborted because a later, unrelated request would have exceeded the spend cap — the cap check runs once, at acceptance, against the batch's full projected cost; a batch that passes admission runs to completion even if intervening API calls from the same key push cumulative period spend over the nominal cap in between, and the next new request after the batch is what is actually rejected. This keeps a single job's outcome deterministic once accepted, rather than depending on unrelated concurrent traffic.
A Team workspace's plan lapses to Free while requireMfa is still set. The workspace policy row itself is untouched by a billing downgrade (workspaces.require_mfa is independent of subscriptions.status) — but workspace.enabled in the resolved entitlements becomes false the moment the workspace has no active team subscription (12.3's resolver), which removes workspace-scoped access for every member entirely, MFA enforcement included, until the workspace re-subscribes. The policy setting is preserved, not reset, so it resumes exactly as configured the moment billing is restored — the member-facing effect described in 11.3 simply has no workspace left to apply to in the interim.
12.8 Acceptance criteria #
- A guest who completes 5 tasks on the six client-side tools within one UTC calendar day and attempts a 6th sees the exact 0-remaining copy specified in Section 11.5.4 and the task does not execute.
- A Free-plan user attempting a 3rd server-side task within one calendar day in their profile timezone receives
402withtype: "quota_error",code: "daily_task_cap_exceeded". - Completing Stripe Checkout for a Free-to-Pro upgrade results in full Pro entitlements being active — verified by a subsequent unrestricted OCR call succeeding — within one webhook-processing cycle of
customer.subscription.created, with no re-login required. - A Pro subscription canceled with
cancel_at_period_end: truecontinues to pass every Pro-tiercheckEntitlementcall untilcurrent_period_end, then reverts to Free-tier results within the same request cycle that processes the period-end schedule transition. - Two
POST /v1/documents/convertrequests carrying the identicalIdempotency-Keywithin the replay window owned by Section 14.8 produce exactly onequeuedjob, one billed operation, and oneapi_operationsStripe Meter event. - A job whose terminal state is
failedwithfailure_reason: "system_error"shows the consumed quota unit restored inGET /v1/usagewithin 60 seconds of the failure. - A job whose terminal state is
failedwithfailure_reason: "user_error"shows no quota restoration. - An attempt to reduce a Team workspace's Stripe subscription
quantitybelow its currentworkspace_membersrow count is rejected with409 { code: "seats_below_member_count" }and the row count is unchanged. - Setting a Team workspace's
requireMfapolicy totrueimmediately blocks every write action from an unenrolled member with403 { code: "mfa_required_by_workspace" }, without deleting or invalidating that member's existing session. - An API key with
spend_cap_cents = 5000accepts an operation that brings cumulative period spend to $49.98 and rejects the following operation that would exceed $50.00, returning402 { code: "spend_cap_exceeded" }. - A refund requested through the self-service control within 14 days of a first Pro or Team charge completes the Stripe refund and revokes paid entitlements within the same request cycle, with no manual support step involved.
- No request shape available to an unauthenticated guest actor can create a row with
owner_type IN ('user','workspace')indocuments,jobs,envelopes,templates, orapi_keys— enforced by the authorization test matrix in Section 11.7 and re-run on every change to thecan()function. - Deleting a user account anonymizes that user's email and IP fields across every signature-envelope audit-trail entry they appear in within the same request, while the envelope's hash chain remains independently verifiable through the verification mechanism defined in Section 10.
- A workspace deletion attempt while any workspace-owned envelope is in a non-terminal state is rejected with
409 { code: "envelopes_in_flight" }, and succeeds once every such envelope is voided or completed. - Linking a Google account to an existing password-based account, then removing the password, is rejected with
403 { code: "cannot_remove_last_credential" }when Google is the only remaining sign-in method, and succeeds once a second method exists. - A
Idempotency-Keyreused within its replay window (Section 14.8) against aPOST /v1/documents/convertrequest carrying a materially different body (a differentdocumentId) returns409 { code: "idempotency_key_reuse" }per the shared idempotency rule, rather than either silently replaying the first response or double-processing. - Trial-to-paid conversion on day 14 of a Pro trial, with a valid card on file, charges the card and transitions
subscriptions.statusfromtrialingtoactivewithout any customer action, verified against the exact webhook sequence in this section. - A Free-plan user's client-side tool usage — merge, split, compress, and every other tool in Section 6.1's client-side list — is never blocked, throttled, or counted against any numeric cap, at any volume, confirmed by an automated test that runs 1,000 consecutive client-side operations against a single Free account and asserts zero
quota_errorresponses.
These eighteen criteria are the minimum bar; the automated suite implementing Section 19's coverage requirement for entitlement and quota logic is expected to exceed this list substantially, particularly around the boundary conditions called out throughout 12.4 and 12.7.
13. Batch Processing & Job Orchestration #
13.1 The job abstraction #
PDFWorks has exactly one job model. A job is anything that starts, runs for a nonzero amount of time, and ends in one of four terminal states. This holds whether the work happens in a browser tab, inside a worker container, as a member of a batch, or as the direct result of a public API call. There is no second vocabulary anywhere in the product for "task," "operation in progress," or "process" — everything that is not instantaneous is a job, and every job obeys the state machine in this subsection. Every other section that refers to job state, progress, or lifecycle refers back to this definition rather than restating it.
13.1.1 The state machine (canonical) #
queued → running → succeeded
queued → running → failed
queued → canceled
running → canceled
running → expired (wall-clock timeout)Terminal states: succeeded, failed, canceled, expired. No other states exist, and no state is skipped — a job that finishes always passes through running first, even if the observable duration in running is a handful of milliseconds. Progress is an integer 0–100 plus an optional stage string (e.g. "extracting text", "rendering page 42 of 210"). Client-side jobs use these exact five state names and the identical transition table in their local store, so the UI vocabulary never forks between what happens on-device and what happens on a server.
| From | To | Trigger |
|---|---|---|
queued |
running |
A worker (client-side worker-pool slot or server-side BullMQ worker) picks the job up |
running |
succeeded |
The operation completes and produces a valid result |
running |
failed |
The operation raises an error it cannot recover from, or exhausts its retry budget (Section 13.3) |
queued |
canceled |
The user or an API caller cancels before a worker claims the job |
running |
canceled |
The user or an API caller cancels while a worker holds the job; the worker is signaled to stop at the next safe checkpoint |
running |
expired |
The job exceeds its per-tool wall-clock timeout (Section 13.3.8) without reaching a terminal state |
Any transition not in this table is invalid and is rejected by the state-transition guard (both the client-side store and the server-side job service enforce it — a job that is already succeeded cannot be re-queued, and one that is canceled cannot later report succeeded). A failed or expired job may spawn a new job with a fresh ID as a retry; the original job's terminal state is never mutated.
13.1.2 One model, four flavors #
| Flavor | Where it runs | Triggered by | Persisted in |
|---|---|---|---|
| Client-side job | The browser tab, inside the worker pool (Section 3.7) | A user acting on a client-side tool in the app | Dexie (local_jobs table, metadata only) plus in-memory Zustand store for the live session |
| Server-side job | A worker container (worker-media or worker-office) |
A user acting on a server-side tool in the app, an OCR/conversion request, or an e-signature action | The jobs row (owned by Section 5) plus the corresponding BullMQ job |
| Batch job | Coordinates one or more child jobs of either flavor | A user submitting a batch (Section 13.4) | A batches row (owned by Section 5) referencing N child job rows/local entries |
| API job | Always server-side (Section 6.1: the public API has no browser, so it has no client-side execution path) | POST /v1/tools/{tool} or POST /v1/batches (Section 14) |
Identical row shape to a server-side job; the only difference is that it was created by an API key rather than a session |
A job's public representation carries a location field with value client or server, populated once at creation and never mutated — the Processing Location Indicator (Section 16) reads this field directly, and a mismatch between the indicator and this field is treated as a defect (Section 6.2). The over-the-wire job object (returned by the app's internal API and the public API alike) uses this shape:
interface JobRepresentation {
id: string; // "job_" + base32-Crockford UUIDv7
type: ToolSlug | "convert" | "envelope-processing";
status: "queued" | "running" | "succeeded" | "failed" | "canceled" | "expired";
progress: number; // 0-100
stage: string | null;
location: "client" | "server";
batchId: string | null; // "bat_..." if this job is a batch member
createdAt: string; // ISO-8601 UTC
startedAt: string | null;
finishedAt: string | null;
attempts: number; // server-side jobs only; always 1 for client-side
input: Record<string, unknown>;
output: Record<string, unknown> | null;
error: { type: string; code: string; message: string } | null;
}ToolSlug is the fixed enum of 21 values defined in Section 14.5.5: merge, split, organize, rotate, crop, compress, pdf-to-image, image-to-pdf, watermark, page-numbers, bates, protect, unlock, flatten, redact, metadata, repair, ocr, convert, forms-fill, forms-extract.
Because pdfcore is one engine compiled once and run on two hosts (Section 3.6), every tool that is client-side by default in the app remains executable server-side when invoked through the public API — the API has no browser to run WASM in, so Section 6.1's blanket rule ("every operation invoked through the public REST API runs server-side") applies uniformly to all 21 tools, not only the six that are server-side-by-default in the app. The worker fleet therefore hosts the complete tool catalog, not just OCR and Office/HTML conversion.
13.2 Client-side job execution #
13.2.1 The local queue #
Each browser tab owns one client-side job queue, implemented as a Zustand store (useJobQueueStore) backed by a Dexie table local_jobs for anything that must survive a reload. The Dexie row holds only metadata — id, tool, status, progress, stage, createdAt, finishedAt, error, and OPFS handle names for input/output artifacts — never file bytes (Section 3.7). A job's actual file data lives exclusively in OPFS for the lifetime of the job.
13.2.2 Concurrency #
The worker pool is sized clamp(navigator.hardwareConcurrency - 1, 2, 4) (Section 3.7). The local queue schedules jobs onto free pool slots in FIFO order by default, with one exception: a single multi-file batch (Section 13.4) is allowed to occupy every free slot simultaneously (its items race each other), while unrelated single-tool jobs queue behind whatever is already dispatched. If the pool is saturated, newly submitted jobs sit in queued and the UI shows their position in the local queue.
13.2.3 Progress reporting #
Each worker posts { jobId, progress, stage } messages back to the main thread over the Comlink RPC channel (Section 3.7) at least once per page processed, or at least every 250 ms during a single-pass operation, whichever is more frequent. The main thread reduces these into the Zustand store; every subscribed UI component (the per-tool progress bar, the batch table row, the global job tray) re-renders from the same store, so no two surfaces can show contradictory progress for the same job.
13.2.4 Cancellation #
Cancellation is cooperative, not preemptive — WASM workers cannot be forcibly killed mid-instruction without tearing down the whole worker (which would also kill any other job sharing that pool slot's lifetime, since pool workers are reused). The main thread posts a cancel message over Comlink; the pdfcore binding checks a cancellation flag at the start of every page-level loop iteration (the finest grain at which a check is cheap) and returns a distinguished Canceled result the moment it observes the flag set. Worst-case latency between requesting cancellation and the job reaching canceled is therefore bounded by the time to process one page, which the UI states as "canceling…" during the gap rather than claiming instant cancellation. If a worker does not acknowledge cancellation within 5 seconds (indicating it is wedged), the pool terminates and replaces that worker; the job is marked canceled regardless, and any partial output is discarded.
13.2.5 Persistence across a page reload #
On reload, the store rehydrates from the Dexie local_jobs table. Jobs that were succeeded, failed, canceled, or expired at the time of reload keep their recorded state and are shown with their result if the OPFS artifact still exists (subject to the 24-hour purge in Section 3.7). Jobs that were running at the moment the page unloaded cannot be resumed — there is no persisted WASM execution state — so on rehydration any job found in running is transitioned to failed with error.code = "interrupted" and error.message = "Processing was interrupted when the page was closed or reloaded." A batch (Section 13.4) is the one exception with a better story: because each item in a batch is an independent job, only the in-flight item(s) at reload time are marked failed this way; items that had already reached succeeded keep their results, and items still queued resume normally when the tab reopens and the queue restarts.
13.2.6 Recovery after a crash #
A browser or tab crash leaves no unload event to react to. On the next time the app loads (in any tab), a startup sweep scans local_jobs for rows still marked running whose updatedAt is more than 60 seconds old (a job legitimately running for under 60 seconds without a fresh reload could still be alive in another tab, so the sweep is conservative) and applies the same interrupted transition described in Section 13.2.5. This sweep runs once per app load, is idempotent, and is the client-side analogue of the janitor sweeps in Section 13.7.
13.2.7 Multi-tab coordination #
Dexie's local_jobs table and the OPFS scratch area are shared across every tab of the app open in the same browser profile, but each tab owns its own worker pool and its own in-memory Zustand store — a job dispatched in Tab A does not execute in Tab B. To avoid two tabs racing to "recover" (Section 13.2.6) or double-process the same row, every tab acquires a Web Locks API lock (navigator.locks.request("pdfworks-startup-sweep", ...)) before running the startup sweep; a tab that loses the race simply skips the sweep for that load, trusting the tab that won it. While a job is actively running, its owning tab renews a heartbeat on the Dexie row (updatedAt) every 5 seconds specifically so that the 60-second staleness threshold in Section 13.2.6 correctly distinguishes "still running in some other open tab" from "abandoned." Opening the same batch or job list in two tabs simultaneously is fully supported and safe — both read the same Dexie rows and OPFS handles, and only the owning tab's worker pool ever writes to a given job's row, so there is no write-write conflict to resolve.
13.2.8 OPFS quota exhaustion #
If a write to OPFS fails because the browser's storage quota is exhausted (QuotaExceededError), the in-progress job is transitioned to failed with error.code = "device_storage_full" and a message directing the user to the "Clear local data" control (Section 3.7) or to use the server-side fallback offer (Section 6.3) instead. A quota failure never silently drops output — the job either produces a complete artifact or fails cleanly, matching the redaction tool's own "never ship a partial result" principle (Section 9).
13.3 Server-side job execution #
13.3.1 Queues #
Six BullMQ 6.x queues on Redis 8.x, matching the fixed list in Section 3.9:
| Queue | Carries | Why it is its own queue |
|---|---|---|
ocr |
The ocr tool |
Tesseract's runtime is an order of magnitude longer than any other tool and highly variable by page count; isolating it prevents it from head-of-line-blocking fast operations and lets it autoscale independently |
convert |
Every other pdfcore-based tool operation (all 20 remaining tool slugs, including the convert from/to Office and HTML transcodes) |
pdfcore is one engine; grouping its operations in one queue keeps worker images and scaling policy uniform. Office/HTML transcodes route to worker-office; every other tool in this queue routes to worker-media — the queue is one logical unit with two consumer container types selected by job.data.tool |
esign |
Envelope-lifecycle processing: document flattening on completion, certificate-of-completion generation, OTP dispatch, reminder/expiry side effects triggered outside the janitor's own scheduling loop | Legally time-sensitive; never subject to the degradation ladder (Section 13.6.5) |
batch |
Batch parent orchestration only — never file processing itself | Keeps the (cheap, high-fan-out) act of splitting a batch into child jobs off the queues that do actual pdfcore/OCR/Office work |
webhook |
Outbound webhook delivery attempts (Section 14.10) | Independent scaling; a slow customer endpoint must never back up document processing |
janitor |
The nine scheduled maintenance jobs (Section 13.7) | Cron-triggered, not user-triggered |
13.3.2 Priority lanes #
Two lanes, priority (Pro, Team, and API-plan jobs) and standard (Free-plan jobs), implemented as a BullMQ job priority value on a shared queue — not as separate queues. BullMQ dequeues the lowest priority-number job first among ready jobs. priority-lane jobs are enqueued with priority 1; standard-lane jobs with priority 10. A Pro job submitted after a Free job is therefore dequeued first — it "overtakes" the Free job — the moment a worker slot frees up, because BullMQ re-evaluates the ready set on every dequeue rather than preserving strict FIFO across priority classes.
Strict priority alone can starve the standard lane indefinitely under sustained priority-lane load, so a fairness floor applies: each worker reserves at least one in every five concurrency slots (20%, rounded up) for the oldest standard-lane job currently queued, regardless of how many priority-lane jobs are waiting. This is enforced in the worker's dispatch loop (not expressible as a native BullMQ option): before pulling the globally-highest-priority job, the worker checks whether it has dispatched four consecutive priority-lane jobs on this reservation cycle; if so, its next pick is forced to the oldest standard-lane job regardless of priority number.
13.3.3 Concurrency, visibility timeout, heartbeats, stalled detection #
| Queue | Concurrency / worker replica | Visibility timeout (lockDuration) |
Stalled-check interval | maxStalledCount |
|---|---|---|---|---|
ocr |
2 | 15 min | 30 s | 1 |
convert |
6 | 5 min | 30 s | 1 |
esign |
8 | 2 min | 30 s | 1 |
batch |
20 | 1 min | 30 s | 1 |
webhook |
50 | 30 s | 15 s | 2 |
janitor |
2 | 10 min | 60 s | 1 |
A worker renews its lock (job.extendLock) every third of the visibility timeout while actively processing (e.g. every 5 minutes for ocr), piggy-backed on the same progress-reporting call so a single round-trip serves both purposes. If a worker process dies or its container is killed without releasing the lock, the lock expires at lockDuration and BullMQ's stalled-job checker (running at the interval above) reclaims the job. maxStalledCount is how many times a given job may stall before BullMQ marks it failed outright regardless of remaining attempts — set to 1 everywhere except webhook, where transient network blips on the delivery worker's own infrastructure make a second chance worthwhile before counting against the customer-facing retry schedule in Section 14.10.6.
13.3.4 Retry policy #
Exponential backoff with full jitter, computed as:
function nextRetryDelayMs(attemptNumber: number): number {
const base = 2_000; // 2 seconds
const max = 300_000; // 5 minutes
const exp = Math.min(max, base * 2 ** (attemptNumber - 1));
return Math.floor(exp * (0.5 + Math.random() * 0.5)); // jitter in [0.5x, 1.0x]
}attemptNumber is 1-indexed on the retry (i.e. the delay before the second attempt uses attemptNumber = 1). This is configured as a BullMQ backoff: { type: "custom" } strategy shared by every queue.
13.3.5 Maximum attempts per queue #
| Queue | Max attempts | Rationale |
|---|---|---|
ocr |
3 | Expensive; more attempts rarely change the outcome for a genuinely bad scan |
convert |
3 | Same reasoning across all pdfcore/Office operations |
esign |
5 | Cheap, and a transient failure here should not require the sender to re-send |
batch |
1 | The batch parent orchestration step is idempotent to re-run manually (Section 13.7) but is not auto-retried; its children retry individually per their own queue's policy |
webhook |
7 | Matches the customer-facing retry schedule in Section 14.10.6 exactly |
janitor |
3 | Scheduled jobs are safe to retry; a fourth failure pages on-call (Section 18) |
This table is the single source of truth for every queue's maximum attempt count; no other section restates these figures. The webhook row's value of 7 is not an independent choice — it is derived from, and must always equal, the number of entries in the customer-facing retry schedule in Section 14.10.6, since that schedule's seven intervals correspond one-to-one with this queue's seven attempts.
13.3.6 Dead-letter queue and operator runbook #
When a job exhausts its max attempts, a QueueEvents failed listener checks attemptsMade >= opts.attempts; if so, the job's data, error, tool, queue name, account ID, and failedAt timestamp are written to a Postgres dead_letter_jobs table (the row does not include raw file bytes — only the documentId/batchId references needed to reprocess) and the corresponding customer-facing job row is left failed (the customer is never blocked on operator triage). Dead-letter entries are surfaced in an internal operator console with two actions:
- Requeue — re-submits the original job data as a brand-new BullMQ job with a fresh attempt counter, keeping the same customer-facing
job_ID mapping updated to point at the new attempt. Used when the failure was caused by an infrastructure issue that has since been fixed (e.g. a worker image bug, a transient S3 outage). - Void — marks the dead-letter row
resolved_no_action, permanently accepting the failure. Used when the failure is inherent to the input (e.g. a genuinely corrupt file the repair tool also cannot fix). The customer already received thejob.failedwebhook and UI error; voiding does not re-notify them.
The runbook: (a) triage new dead-letter rows within 1 business day (paged automatically if the table grows by more than 20 rows in an hour, per Section 18); (b) group by error.code before acting — a spike in one code usually indicates one root cause; (c) prefer Requeue only after confirming the underlying cause is fixed, to avoid a requeue storm re-failing identically; (d) any dead-letter row untouched for 30 days is auto-voided daily by the dead-letter-auto-void-sweep janitor job (Section 13.7) so the table does not grow unbounded.
13.3.7 Poison-message handling #
A "poison" job is one that crashes its worker process outright (a native-code fault in pdfcore, OCRmyPDF, or LibreOffice) rather than raising a catchable application error. BullMQ observes this identically to any other stall — the lock expires because no heartbeat arrives — and the stalled-job checker retries it. Left unchecked, a poison job would crash a worker on every attempt, burning the full attempt budget on wall-clock alone. To prevent a crash-loop, each job carries an internal consecutiveStalls counter incremented on every stall event; on the second stall the job is force-failed immediately (bypassing any remaining attempts) with error.code = "worker_crash", and the input document's row is flagged known_poison = true. Any subsequent job submitted against a known_poison document is rejected pre-dispatch with 422 processing_error / code: "known_poison_input" rather than being handed to a worker again, until an operator clears the flag after investigation.
13.3.8 Per-job wall-clock timeouts by tool #
Enforced both as the job's expired transition trigger (Section 13.1.1) and as a hard container-level timeout (Section 3.5) so a wedged native process cannot outlive its budget:
| Tool(s) | Timeout |
|---|---|
merge, split, organize, rotate, crop, watermark, page-numbers, bates, protect, unlock, flatten, metadata, forms-fill, forms-extract |
60 s |
compress |
120 s |
redact |
90 s |
repair |
120 s |
pdf-to-image, image-to-pdf |
90 s |
convert (Office ⇄ PDF, HTML ⇄ PDF, one unified timeout regardless of direction or format) |
5 min |
ocr |
20 min (scales with page count; hard ceiling regardless) |
esign flatten/certificate step |
60 s |
This table is exhaustive and is the single source of truth for every job's wall-clock timeout: it covers all 21 values of ToolSlug (Section 13.1.1) — every tool specified in Sections 7, 8, and 9 — plus the one non-tool server-side step (esign flatten/certificate generation) that also runs as a job. No other section states a timeout figure; a section that needs one cites this table by number rather than restating a value, and if a new tool is ever added, its row is added here first.
A job that hits its timeout transitions running → expired, is not retried automatically (an expired job is terminal), and its consumed quota (Section 12) is refunded since no usable output was produced.
13.3.9 Enqueuing a job — the concrete shape #
Every server-side job is enqueued with the same option shape, parameterized per queue from the tables above:
import { Queue } from "bullmq";
const convertQueue = new Queue("convert", { connection: redisConnection });
await convertQueue.add(
"compress", // BullMQ job name = tool slug
{
jobId: "job_01K7Y3M8H6QY6V9N2X4R7T1B3C", // our public job_ ID, stored in job.data
accountId: "wsp_01K7Y1A0B1C2D3E4F5G6H7J8K",
tool: "compress",
input: { documentId: "doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P" },
options: { level: "medium" },
lane: "priority",
consecutiveStalls: 0,
},
{
jobId: "job_01K7Y3M8H6QY6V9N2X4R7T1B3C", // BullMQ's own dedupe key; also our public ID
priority: 1, // 1 = priority lane, 10 = standard lane (13.3.2)
attempts: 3, // convert queue max attempts (13.3.5)
backoff: { type: "custom" }, // 13.3.4
removeOnComplete: { age: 3600 }, // BullMQ bookkeeping only; the jobs row is the source of truth
removeOnFail: { age: 86_400 },
},
);The jobId passed to BullMQ intentionally matches our own public job_ ID rather than letting BullMQ generate one, so every log line, trace span (Section 18), and dead-letter row (Section 13.3.6) can be correlated by a single identifier with no translation table in between.
13.3.10 A worked timeout-and-retry sequence #
To make Sections 13.3.4 through 13.3.8 concrete together: an ocr job on a 400-page scanned document is dispatched with its full retry budget (attempts: 3, Section 13.3.5) and its wall-clock ceiling (20 minutes, Section 13.3.8). Attempt 1 starts, renews its lock every 5 minutes as it works through the document, but a transient out-of-memory condition in the worker container kills the process at the 11-minute mark without releasing the lock cleanly. The lock expires at the 15-minute lockDuration (Section 13.3.3); the stalled-job checker (running every 30 seconds) reclaims it, increments consecutiveStalls to 1, and — because 1 < 2 (Section 13.3.7) — allows attempt 2 to proceed on a fresh worker. Attempt 2 completes successfully in 9 minutes, well inside its wall-clock ceiling, and the job reaches succeeded having used 2 of its available attempts; the customer never sees the intermediate failure, only the final result and (if they registered one) a single job.succeeded webhook.
13.4 Batch processing #
13.4.1 Creating a batch #
A user selects between 2 and the plan's per-job file cap (Section 12.2) and chooses one tool to apply. Options may be shared (one settings panel applied identically to every file — the default, and the only mode for tools like merge where the "files" collectively form one operation) or per-file (each file gets its own settings panel — offered for tools where per-document values are meaningful, such as protect's password or watermark's text). The UI enforces the file-count cap client-side before submission and again server-side on POST /v1/batches (Section 14.5.6), returning 422 processing_error / code: "batch_size_exceeded" if violated. Batch creation itself is unavailable on Free (Section 12.2); the app hides the batch entry point entirely rather than showing it disabled.
13.4.2 The mixed client/server batch #
A batch is classified file-by-file before any processing starts, using the same client/server split as every other tool invocation (Section 6): if the chosen tool is client-side-by-default (Section 6.1) and every file is small enough to process locally, that file's item runs on-device; if the tool is server-side-by-default (Section 6.1), every item in the batch runs server-side regardless of file size.
The interesting case is a client-side tool applied to a batch where some files exceed a size/complexity threshold the local engine flags as risky (the same signal that drives the "Finish this on our servers instead" offer for a single file, Section 6.3) while others do not. In that case:
- The app pre-classifies every file into Local or Needs upload before any processing begins.
- A consent dialog is shown once, before the batch starts, stating the split plainly: "12 of 15 files will be processed on your device. 3 files are large enough that we recommend processing them on our servers instead — they'll be uploaded, encrypted, and deleted within 24 hours. [Process the 3 on our servers] [Skip those 3, process only the 12 locally]". Each of the two buttons is a distinct explicit action; there is no default/pre-selected choice and no auto-proceeding after a timeout.
- Each file's row in the batch table shows its own Processing Location Indicator (Section 16) from the moment classification completes, before the user consents to anything.
- If the user chooses "Skip those 3," the skipped files are marked
skipped_by_userin the per-item result table (Section 13.4.4) — notfailed— and never leave the device. - If the user consents to upload, those files become ordinary server-side batch items from that point on, sharing the batch's single
bat_ID with the locally-processed items so the results table, ZIP, and manifest are unified.
Silent upload of any file the user did not explicitly consent to is a P0 defect, per Section 6.3.
13.4.3 Ordering guarantees #
Items are submitted, and always reported back, in the order the user selected the files, indexed 0..N-1. Execution order is not guaranteed to match submission order — items dispatch concurrently up to the client-side worker-pool size (Section 13.2.2) or the account's server-side concurrency cap (Section 13.6.2) and complete whenever they complete. The per-item result table, the ZIP archive, and the manifest CSV are always assembled by index, never by completion timestamp, so re-running an identical batch twice produces identically ordered artifacts even if the two runs' items finished in a different sequence.
13.4.4 Partial failure semantics #
Default: continue on error. One item failing does not stop the rest of the batch — this is the default because the common case is a large batch with one or two malformed inputs, and stopping the other 98 files to protect the user from a failure they can simply inspect afterward is a worse experience than finishing everything and reporting failures inline. A "Stop on first error" toggle is available in the batch creation panel (and as "onError": "fail_fast" in the API body, Section 14.5.6) for the minority of workflows where partial output is actively unwanted (e.g. a Bates-numbered production set, Section 13.4.6, where a gap breaks the numbering contract downstream). With fail_fast, the first item to fail cancels every item still queued (transitioning them to canceled, not failed) and leaves already-succeeded items' output available.
Per-item result table (returned by GET /v1/batches/{id}, Section 14.5.6, and rendered identically in the app):
| Field | Type | Meaning |
|---|---|---|
index |
integer | Position in the original submission order |
inputFilename |
string | Original filename as uploaded/selected |
jobId |
string | null | The job_ ID for this item, null only for skipped_by_user |
status |
enum | queued | running | succeeded | failed | canceled | expired | skipped_by_user |
outputDocumentId |
string | null | doc_ ID of the result, present only when status = succeeded |
error |
object | null | The same { type, code, message } shape as a job error |
pages |
integer | null | Page count of the output, when known |
sizeBytes |
integer | null | Output size, when known |
durationMs |
integer | null | Wall-clock processing time for this item |
Retry a single failed item: POST /v1/batches/{id}/items/{index}/retry (app: a "Retry" button on the failed row) creates a brand-new job for that index only, using the same tool and options as the original submission, and updates that row in place; it does not touch any other item's state or re-trigger batch-level completion notifications unless this retry is the item that finally completes the batch.
Cancel the whole batch: POST /v1/batches/{id}/cancel transitions every queued item to canceled and signals every running item to cancel cooperatively per Section 13.2.4/13.3 semantics; already-succeeded or already-failed items keep their state. The batch itself reaches canceled once no item remains queued or running.
13.4.5 Downloadable results #
Results are available two ways, both listed on the batch summary screen and both exposed via the API (GET /v1/batches/{id} returns downloadUrls: { zip, manifest } and each item's own outputDocumentId for individual retrieval):
- Individual files — each succeeded item's output can be downloaded on its own via the standard signed-URL mechanism (Section 14.4.5), useful when only a few files in a large batch are wanted.
- ZIP archive — all succeeded items bundled together. Named
pdfworks-batch-{last8ofBatId}-{YYYYMMDD}.zip, e.g.pdfworks-batch-9n2x4r7t-20260819.zip. Internal structure:
pdfworks-batch-9n2x4r7t-20260819.zip
├── manifest.csv
├── 000-quarterly-report.pdf
├── 001-vendor-agreement.pdf
├── 003-invoice-scan.pdf
└── ...Each output filename is the zero-padded original index, a hyphen, then the original input filename with its extension replaced by the tool's actual output extension (e.g. an .docx input to convert becomes 002-contract.pdf). Failed, canceled, expired, and skipped items are omitted from the ZIP's file list but still appear as rows in manifest.csv so the CSV is the single complete record of the batch.
manifest.csv columns: index, inputFilename, outputFilename, status, errorCode, pages, sizeBytes, durationMs. outputFilename and errorCode are empty strings for rows where they do not apply (a succeeded row has no errorCode; a failed row has no outputFilename).
13.4.6 Cross-file continuity: Bates numbering across a batch #
Bates numbering (Section 9.4) requires a single monotonically increasing counter across every page of every file in submission order — a guarantee that is easy to break under the concurrent, index-order-only guarantee of Section 13.4.3. This is resolved with a pre-flight pass: before any file is dispatched, the batch orchestrator (batch queue, Section 13.3.1) opens each input just far enough to read its page count (no rendering, no full parse) and computes each item's starting Bates number as the running sum of preceding items' page counts plus the batch's configured start value. These per-item starting numbers are written into each child job's options.bates.startNumber before dispatch, so every item's Bates numbering is fully determined at submission time and correct regardless of the order in which items actually finish. A Bates batch therefore defaults to onError: "fail_fast" (overridable) because a gap left by a continue on error failure would leave a hole in an otherwise-contiguous legal numbering sequence — the UI surfaces this as a pre-checked, explained default rather than a hidden behavior.
13.4.7 A worked batch, end to end #
An API caller submits 3 scanned contracts for OCR:
POST /v1/batches
{
"tool": "ocr",
"documentIds": [
"doc_01K7Y6A1B2C3D4E5F6G7H8J9K0",
"doc_01K7Y6B2C3D4E5F6G7H8J9K0L1",
"doc_01K7Y6C3D4E5F6G7H8J9K0L1M2"
],
"options": { "languages": ["eng"] },
"onError": "continue"
}The response is 202 Accepted with the batch in queued, one child job_ ID pre-allocated per index:
{
"id": "bat_01K7Y6D4E5F6G7H8J9K0L1M2N3",
"tool": "ocr",
"status": "queued",
"itemCount": 3,
"onError": "continue",
"createdAt": "2026-08-19T16:00:00.000Z"
}Two of the three documents are unusually large scans and take 14 and 17 minutes respectively; the third is a short 3-page contract and finishes in 40 seconds. Because ordering is guaranteed only for reporting, not execution (Section 13.4.3), the third item reaches succeeded first even though it is index 2. A subsequent GET /v1/batches/bat_01K7Y6D4E5F6G7H8J9K0L1M2N3 while the other two are still running returns:
{
"id": "bat_01K7Y6D4E5F6G7H8J9K0L1M2N3",
"status": "running",
"items": [
{ "index": 0, "inputFilename": "contract-a.pdf", "jobId": "job_01K7Y6E5F6G7H8J9K0L1M2N3O4", "status": "running" },
{ "index": 1, "inputFilename": "contract-b.pdf", "jobId": "job_01K7Y6F6G7H8J9K0L1M2N3O4P5", "status": "running" },
{ "index": 2, "inputFilename": "contract-c.pdf", "jobId": "job_01K7Y6G7H8J9K0L1M2N3O4P5Q6", "status": "succeeded", "outputDocumentId": "doc_01K7Y6H8J9K0L1M2N3O4P5Q6R7", "pages": 3, "durationMs": 39820 }
],
"downloadUrls": { "zip": null, "manifest": null }
}downloadUrls remain null until the batch itself reaches a terminal state (every item terminal), at which point they are populated with the ZIP and manifest signed URLs described in Section 13.4.5, and a single batch.completed webhook (Section 14.10.2) fires exactly once.
13.4.8 Subscription lapse and quota exhaustion mid-batch #
A batch can outlive the billing state it was submitted under: a Team workspace's payment can fail and exhaust dunning retries (Section 12), or a Free-plan daily task cap or a metered API plan's quota can be crossed by the batch's own items, all while some of the batch's items are still queued or running. The rule is the same regardless of which of these triggers it:
- In-flight items complete. Any item already
running— already dispatched to a worker and consuming compute — is allowed to finish normally and reaches whatever terminal state its own processing produces (succeeded,failed, orexpired). Canceling work that is already paid for in compute time, moments before it would have produced a usable result, wastes the resource without benefiting anyone; the batch orchestrator does not preempt running work for a billing-state change. - Queued items are canceled with a specific code. Every item still
queued— not yet claimed by a worker — is immediately transitioned tocanceledrather than being dispatched, witherror: { "type": "quota_error", "code": "subscription_lapsed", "message": "This item was not processed because the workspace's subscription lapsed while the batch was running." }recorded on that item's row. If the trigger was a numeric quota ceiling rather than a lapsed subscription, the same handling applies withcode: "plan_limit_exceeded"and a message naming the specific limit (Section 12) — both codes share thequota_errortype and its402status (Section 14.6), keeping one consistent HTTP status for every "you cannot spend more until you act" condition regardless of the precise reason. Either code is what distinguishes a quota-caused cancellation from an ordinary user-initiated one (POST /v1/batches/{id}/cancel, Section 13.4.4) in the per-item result table andmanifest.csv— both are reported asstatus: "canceled", but only the quota case carries a populatederror. - No attempt budget is spent. Because a quota-canceled item was never dispatched to a worker, it does not consume any of its tool's retry-attempt budget (Section 13.3.5); a subsequent manual retry (below) starts with a full budget.
- The user is told exactly which items ran. The final
GET /v1/batches/{id}response and the batch's terminalbatch.completedwebhook (Section 14.10.2) carry the complete per-item table with each item's true outcome — succeeded, failed, or quota-canceled — so nothing is ambiguous after the fact. The app's batch summary screen additionally surfaces a one-line banner in this situation: "7 of 20 files were processed before your plan's quota was reached. The remaining 13 were not started. [Upgrade plan] [View the 7 results]" — built from the same per-item table rather than a separate code path. - Resumption is manual, not automatic. Once the subscription is restored or the quota resets at the next billing period (Section 12), quota-canceled items are not automatically re-submitted — a workspace that fixed its payment method should not be surprised by a burst of jobs it did not explicitly ask to run again. The existing single-item retry endpoint,
POST /v1/batches/{id}/items/{index}/retry(Section 13.4.4), creates a fresh job for each quota-canceled index exactly as it would for any other failed or canceled item, so recovery uses a path the user already understands.
13.5 Progress and notification #
13.5.1 The progress model #
Every job, of every flavor, reports progress as an integer 0–100 plus an optional stage string, per the canonical job state machine (Section 13.1.1). A batch's aggregate progress, shown on the batch summary row, is the unweighted mean of its items' individual progress values (an item not yet dispatched counts as 0; a terminal item counts as 100 regardless of whether it succeeded or failed, since "processing has finished for this item" is what the aggregate communicates).
13.5.2 How progress reaches the browser #
Server-Sent Events (SSE), with polling as the fallback. SSE is chosen over a raw polling-only design because job counts per active session are small (a handful at a time), the connection is long-lived and one-directional (matching SSE's model exactly, unlike WebSockets which would be over-provisioned for a server-to-client-only stream), and it rides plain HTTP so it survives typical corporate proxies and load balancers without special-casing — where it does not survive (a minority of restrictive proxies strip long-lived streaming responses), the app degrades to polling automatically rather than failing.
The app's internal API exposes GET /internal/jobs/{jobId}/events (session-cookie authenticated, not part of the public /v1 surface owned by Section 14) which upgrades to text/event-stream and emits:
id: 42
event: progress
data: {"jobId":"job_01K7Y3M8H6QY6V9N2X4R7T1B3C","progress":63,"stage":"rendering page 132 of 210"}
id: 43
event: completed
data: {"jobId":"job_01K7Y3M8H6QY6V9N2X4R7T1B3C","status":"succeeded","outputDocumentId":"doc_01K7Y3N0P2QZQXK5V7M3T9YQ0P"}Each event carries a monotonically increasing id scoped to the job. The server buffers the last 100 events per job in Redis with a TTL matching that job's own record TTL, so a reconnecting client can catch up rather than miss updates emitted while disconnected.
If the browser's EventSource reports an error and does not recover within 5 seconds (covering both "proxy killed the stream" and "server hiccup"), the client falls back to polling GET /v1/jobs/{jobId} (the public, documented endpoint, Section 14.5.7) every 2 seconds, backing off multiplicatively to a 10-second ceiling if the job has shown no progress change across three consecutive polls. Batch progress uses the same SSE-first, polling-fallback pattern via GET /internal/batches/{batchId}/events.
13.5.3 Reconnect behavior #
On reconnect, the browser's native EventSource automatically sends the last received event's id in a Last-Event-ID header; the server replays every buffered event with a higher id than that before resuming live streaming, so no progress update is silently lost across a brief network blip, only delayed.
13.5.4 Tab-close behavior #
Server-side jobs are entirely unaffected by the tab closing — they run to completion in the worker fleet regardless, and their result is retrievable the next time the user opens the app or via the completion email (Section 13.5.6). Client-side jobs are not so lucky: the computation lives in the tab's own worker pool, so closing the tab terminates it. Per Section 13.2.5, a job that was running at close time is recovered as failed/interrupted on the next load; a batch's already-succeeded items are unaffected and only the interrupted item(s) need to be retried.
13.5.5 Browser notifications #
The first time any job (client- or server-side) is expected to run longer than 30 seconds, the app requests Notification API permission with an explanatory pre-prompt (never the bare browser permission dialog with no context, to avoid a reflexive "block"). Once granted, a native OS notification fires when a job reaches any terminal state while the tab is not focused, titled with the tool name and a one-line result summary, and clicking it focuses the tab and scrolls to that job.
13.5.6 Email on completion for long jobs #
An email is sent via Resend when a server-side job or a batch reaches a terminal state, if the job's expected or actual duration exceeded 2 minutes, and the account has this notification enabled (default on for batches and for e-signature envelope events, default on but user-toggleable in account settings for everything else). The email template is selected by job type (job-completed-single, job-completed-batch, job-failed) and links directly to the result inside the app; it never contains the file itself. Client-side jobs never trigger an email — the server has no visibility into them.
13.6 Capacity, backpressure, and fairness #
13.6.1 Queue depth limits #
| Queue | Waiting-job depth limit | On limit reached |
|---|---|---|
ocr |
3,000 | New submissions rejected, 429 rate_limit_error / code: "queue_full" |
convert |
5,000 | Same |
esign |
2,000 | Same |
batch |
500 (parent orchestration jobs, not child items) | Same |
webhook |
Unbounded (delivery attempts are cheap and time-boxed by Section 14.10.6) | N/A |
janitor |
N/A (singleton scheduled jobs; never queued in volume) | N/A |
A rejected submission's 429 body includes Retry-After computed from the current oldest-waiting-job age for that queue, giving the caller a realistic (not arbitrary) wait estimate. The app translates the same response into a "We're at capacity right now — try again in about a minute" banner rather than a raw error.
13.6.2 Per-account concurrency caps #
To stop one customer's large batch from starving every other customer's single-file job, each account is capped on simultaneously running (not queued) server-side jobs, enforced by a Redis-backed counter (INCR/EXPIRE semaphore keyed by account) checked at job start: if the account is at its cap, the job is placed back with a short delay (2 seconds, not counted against its retry-attempt budget) rather than being marked failed.
| Plan | Concurrent running jobs |
|---|---|
| Free | 1 |
| Pro | 3 |
| Team | 10 (workspace-wide, shared across every seat) |
| API — Starter | 5 |
| API — Growth | 20 |
| API — Scale | 50 |
13.6.3 Autoscaling triggers #
Worker replica counts (both worker-media and worker-office) scale on queue depth and wait-time signals; the mechanics of applying these triggers (the autoscaler implementation) belong to Section 20 — this subsection defines only the thresholds that drive it:
- Scale up by 2 replicas (capped at 20) when a queue's depth exceeds 200, or its oldest waiting job's age exceeds 45 seconds, sustained across two consecutive 30-second samples.
- Scale down by 1 replica (floored at 2) when a queue's depth stays under 20 and its oldest waiting job's age stays under 5 seconds for 10 consecutive minutes.
13.6.4 The degradation ladder #
Levels are evaluated per queue against that queue's depth limit (Section 13.6.1) as a percentage:
| Level | Trigger | Behavior |
|---|---|---|
| L0 — Normal | < 60% of depth limit | Full service, no user-visible signal |
| L1 — Elevated | 60–80% | status.pdfworks.io shows an "increased processing times" notice; autoscaler runs at maximum scale-up rate; no submission restrictions |
| L2 — Backpressure | 80–95% | New standard-lane (Free) submissions show a pre-submit dialog with an estimated wait before the user confirms; priority-lane (Pro/Team/API) submissions are unaffected |
| L3 — Protection | 95–100% | New standard-lane submissions are rejected (429 / queue_full); already-queued standard jobs continue draining; priority-lane continues normally |
| L4 — Emergency | Depth limit reached, or the L3 wait-time SLA is breached for 5+ consecutive minutes | New submissions of any lane rejected for the affected queue, except esign (never throttled — legally time-sensitive per Section 13.3.1) and janitor; an incident opens automatically and on-call is paged (Section 18) |
The ladder is evaluated independently per queue — ocr reaching L3 does not affect convert's level.
13.6.5 esign is never throttled #
The esign queue is excluded from every level of the ladder above L0's normal operation. Signature requests have externally-visible legal deadlines (Section 6, Section 10) that the product does not get to renegotiate because of internal load; if esign genuinely saturates, it autoscales more aggressively rather than shedding load (its autoscale-up ceiling is 40 replicas rather than the general 20).
13.6.6 A worked ladder transition #
The ocr queue's depth limit is 3,000 (Section 13.6.1). A marketing promotion drives a surge of Free-plan signups who immediately try the OCR tool; depth climbs past 1,800 (60%) and the status page shows the L1 notice while the autoscaler adds replicas at its maximum rate. Depth keeps climbing faster than autoscaling can absorb it, crosses 2,400 (80%): every new Free-plan OCR submission now shows "Estimated wait: about 6 minutes — continue?" before it is accepted, while Pro/Team/API submissions continue instantly. Depth reaches 2,850 (95%): new Free-plan submissions are rejected outright with 429 / queue_full and a Retry-After reflecting the oldest waiting job's age; Pro/Team/API traffic is still unaffected. Depth hits 3,000: every new submission to ocr, including priority-lane ones, is rejected, an incident opens automatically, and on-call is paged per Section 18 — except any in-flight esign certificate generation, which was never routed through ocr in the first place and is unaffected regardless. As replicas catch up and depth falls back under each threshold, the ladder relaxes level by level in reverse, never skipping a level on the way down, so submissions resume in the same staged order they were restricted.
13.7 The janitor #
Nine scheduled jobs, each run on the janitor BullMQ queue by a cron-style repeatable job definition, each idempotent by design so a missed tick, a re-run, or two overlapping ticks (guarded by SELECT ... FOR UPDATE SKIP LOCKED on the rows each sweep claims) never double-processes or corrupts state.
| # | Job | Cadence | Work | Idempotency mechanism |
|---|---|---|---|---|
| 1 | key-shredding-sweep |
Every 5 min | Finds blobs eligible for expiry (2 hours past their job's terminal state, or 24 hours past upload, whichever is sooner — Section 6) whose wrapped data key has not yet been destroyed; destroys the wrapped key (sets the column to NULL), which cryptographically shreds the object immediately even before bytes are removed; enqueues the byte-deletion step |
WHERE wrapped_key IS NOT NULL AND expires_at < now(); setting wrapped_key = NULL is a no-op if already NULL, so a re-run is harmless |
| 2 | blob-byte-deletion-sweep |
Every 15 min (safety net; the normal path is triggered immediately by step 1 and by explicit DELETE /v1/documents/{id} calls, targeting the 60-second SLA in Section 6) |
Deletes S3 object bytes for every blob whose key has already been shredded but whose bytes are still present | Deleting an already-absent S3 key is a no-op (S3 DeleteObject is idempotent); bytes_deleted_at is set once and checked before re-issuing the delete |
| 3 | orphan-sweep |
Every 6 hours | Diffs the blob storage bucket's object listing against the jobs/documents rows; any object older than 25 hours (past even the 24-hour hard ceiling plus margin) with no corresponding DB row is deleted. Also issues an S3 AbortMultipartUpload for every multipart upload session (Section 14.4.7) whose sessionExpiresAt is more than 1 hour in the past and was never finalized, releasing its uploaded-but-unassembled parts |
A pure delete-if-orphaned pass; re-running it finds nothing to do the second time. Aborting an already-aborted or already-completed multipart upload is a no-op |
| 4 | usage-rollup |
Hourly | Aggregates raw per-operation usage events into daily and monthly usage_summary rows (Section 12) used for entitlement checks and reported to Stripe Meters for overage billing |
INSERT ... ON CONFLICT (account_id, date, metric) DO UPDATE recomputed from source events, not incremented, so a re-run converges to the same total rather than double-counting |
| 5 | webhook-retry-sweep |
Every 5 min | Safety net: finds webhook deliveries whose computed next-retry time (Section 14.10.6) has passed but which are still pending — covering the case where a scheduled BullMQ delayed job was lost |
Checks each delivery's current status immediately before resending; a delivery already delivered or exhausted since the sweep started is skipped |
| 6 | envelope-expiry-and-reminders |
Hourly | Sends reminder emails for envelopes still pending at day 3, 7, and 12 of their lifetime (Section 10); expires envelopes past their configured expiry (default 14 days, max 30) by transitioning them to expired and recording the envelope.expired audit event (Section 10) |
Per-cadence-step reminder_sent_at timestamps prevent a duplicate send for the same milestone; expiry checks the envelope's current status first and is a no-op if it is already terminal |
| 7 | session-cleanup |
Daily | Deletes expired better-auth sessions (absolute maximum age 90 days, Section 17) and expired signer OTP records | DELETE ... WHERE expires_at < now(), safe to re-run |
| 8 | partition-maintenance |
Weekly | Creates next month's time-based partitions for high-volume append-only tables (audit events, usage events) ahead of need, and archives/drops partitions past their retention window (7 years for audit events, per Section 6) | CREATE TABLE IF NOT EXISTS ... PARTITION OF ...; dropping an already-dropped partition is a no-op guarded by an existence check |
| 9 | dead-letter-auto-void-sweep |
Daily | Finds dead_letter_jobs rows (Section 13.3.6) whose failedAt is more than 30 days in the past and which are still unresolved (neither requeued nor manually voided); marks each resolved_no_action with resolution: "auto_voided_stale" so the table does not grow unbounded. The customer already received their job.failed webhook and UI error at the time of the original failure, so this auto-void does not re-notify them, exactly as a manual Void does not (Section 13.3.6) |
WHERE resolved_at IS NULL AND failed_at < now() - interval '30 days'; a row already resolved (manually or by a prior run of this sweep) no longer matches the predicate, so a re-run finds nothing to do |
Every janitor job emits a structured completion log line (rows affected, duration, errors) per the logging convention in Section 4, and a run that raises an exception retries per the janitor queue's policy (Section 13.3.5) before paging on-call on the third consecutive failure (Section 18).
14. Public REST API & Webhooks #
14.1 API principles #
The base URL is https://api.pdfworks.io/v1. Every resource path begins with the version segment; there is no unversioned path.
Versioning policy: the major version in the URL (v1) is the only version signal — there is no header-based versioning. Within v1, changes are additive-only: new endpoints, new optional request fields, new response fields, and new webhook event types may appear at any time without notice and without a version bump, and a well-behaved client (one that ignores unknown JSON fields, which every generated SDK does by construction) is unaffected by them. A genuinely breaking change — removing a field, changing a field's type or meaning, removing an endpoint, changing default behavior — requires a new major version (v2). v1 is guaranteed supported for at least 12 months after v2 reaches general availability. During that overlap, every v1 response carries a Sunset header (RFC 8594) once a retirement date is set, and the deprecation is additionally announced by email to every API-plan account, on the docs site, and on the status page at least 12 months ahead of the sunset date.
Stability guarantee: a code value in the error catalogue (Section 14.6) is never repurposed to mean something else; a webhook event type is never repurposed; a resource's id prefix is never repurposed. Removing something from the catalogue still requires the v2 process above — the additive-only guarantee is a floor, not a promise that nothing ever changes.
14.2 Authentication #
Every request (other than GET /v1/health and the unauthenticated signer-verification endpoint owned by Section 10) carries:
Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2bTest/live split. Keys are prefixed pk_live_ or pk_test_. A test key operates against the account's isolated test-mode data partition (every resource created with a test key is tagged livemode: false and is invisible to a live key and vice versa), runs the identical pdfcore engine and identical Office/OCR toolchain as live mode (the byte-identical guarantee in Section 3.6 makes no distinction between modes — a test-mode result is not a mock), never counts against the account's real plan quota (test mode has its own generous, non-billed counters), never triggers a real Stripe charge, and never sends a real email to a signer — e-signature actions in test mode always deliver to a fixed internal testing sink regardless of the recipient address supplied, so a developer cannot accidentally email a real person while testing.
Scopes. Each key is granted one or more scopes at creation; a request whose target requires a scope the key lacks fails closed.
| Scope | Grants |
|---|---|
documents:read |
GET /v1/documents/{id} |
documents:write |
POST /v1/documents, POST /v1/uploads, DELETE /v1/documents/{id} |
tools:execute |
POST /v1/tools/{tool} |
batches:read |
GET /v1/batches, GET /v1/batches/{id} |
batches:write |
POST /v1/batches, POST /v1/batches/{id}/cancel, POST /v1/batches/{id}/items/{index}/retry |
jobs:read |
GET /v1/jobs, GET /v1/jobs/{id} |
jobs:write |
POST /v1/jobs/{id}/cancel |
envelopes:read |
GET /v1/envelopes, GET /v1/envelopes/{id}, .../audit-trail, .../certificate, .../documents/{docId} |
envelopes:write |
POST /v1/envelopes, .../send, .../void, .../remind |
templates:read / templates:write |
Template CRUD |
usage:read |
GET /v1/usage |
account:read |
GET /v1/account |
webhooks:read / webhooks:write |
Webhook endpoint and delivery management (Section 14.10.1) |
A newly created key defaults to every scope; scopes can be narrowed at creation or by rotating to a new, narrower key (keys are immutable once issued — see rotation below).
IP allowlists. Each key optionally carries a list of CIDR blocks; an empty list allows any source IP. The allowlist check runs before signature/auth validation completes so a disallowed IP never learns whether the key itself was otherwise valid.
Key rotation without downtime. There is no in-place "change the secret" operation, because the key is the secret. Rotation is: create a new key with the same scopes (POST /v1/account/keys), update the caller's configuration to use it, confirm traffic has shifted (via lastUsedAt on the old key), then revoke the old key (DELETE /v1/account/keys/{keyId}). Both keys are simultaneously valid during the transition, so there is no window with zero working credentials.
Every auth failure mode:
| Condition | Status | type |
code |
|---|---|---|---|
No Authorization header |
401 | authentication_error |
missing_api_key |
Header present but not Bearer <token> shape, or key does not match pk_(live|test)_[A-Za-z0-9]{32} |
401 | authentication_error |
invalid_api_key_format |
| Well-formed key, but unknown or revoked | 401 | authentication_error |
invalid_api_key |
| Valid key, source IP not in its allowlist | 403 | permission_error |
ip_not_allowed |
| Valid key, missing required scope for this endpoint | 403 | permission_error |
insufficient_scope |
| Valid key, per-key spend cap already reached (Section 12.6) | 402 | quota_error |
spend_cap_reached |
A worked test-mode call. The only visible difference between a test and live request is the key prefix and the livemode field echoed back — the processing itself is identical:
curl -X POST https://api.pdfworks.io/v1/tools/compress \
-H "Authorization: Bearer pk_test_3mK9QpZx2vD8rT1sL6nJ3wE0aH5cY2bF" \
-H "Idempotency-Key: 6b7c8d9e-0f1a-2b3c-4d5e-6f7a8b9c0d1e" \
-H "Content-Type: application/json" \
-d '{"documentId": "doc_01K7Y8A3B4C5D6E7F8G9H0J1K2", "options": {"level": "medium"}}'{
"id": "job_01K7Y8B4C5D6E7F8G9H0J1K2L3",
"type": "compress",
"status": "queued",
"livemode": false,
"progress": 0,
"stage": null,
"location": "server",
"batchId": null,
"createdAt": "2026-08-19T16:45:00.000Z"
}A test-mode job's resulting webhook deliveries (Section 14.10) also carry "livemode": false, so a single registered endpoint can safely distinguish and route test traffic away from any code path that has real-world side effects (e.g. notifying a customer).
14.3 The synchronous versus asynchronous model #
Every GET and every account/usage/template read is synchronous — it returns the requested representation inline in the response body with no job involved. Every operation that runs a tool is asynchronous, with no exception. POST /v1/tools/{tool} and POST /v1/batches always return 202 Accepted with a job (or batch) representation rather than an inline result, even for operations that would typically finish in well under a second. This is a deliberate consistency choice: server-side timing is not guaranteed at any given instant (queue depth varies, Section 13.6), and a client integrating against the API should write one code path — submit, then observe completion via a webhook or a poll — rather than two paths branching on how fast a particular tool happens to be.
The 202 Accepted shape:
{
"id": "job_01K7Y3M8H6QY6V9N2X4R7T1B3C",
"type": "compress",
"status": "queued",
"progress": 0,
"stage": null,
"location": "server",
"batchId": null,
"createdAt": "2026-08-19T14:02:11.000Z",
"startedAt": null,
"finishedAt": null,
"attempts": 1,
"input": { "documentId": "doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P" },
"output": null,
"error": null,
"links": { "self": "https://api.pdfworks.io/v1/jobs/job_01K7Y3M8H6QY6V9N2X4R7T1B3C" }
}A resource in this response, like every other resource the API returns, is never wrapped in an outer key named after its type — the response body is the resource.
Polling: GET /v1/jobs/{jobId} returns the same shape with status, progress, stage, output, and error reflecting current state (Section 14.5.7). Polling is fully supported and never rate-limited more strictly than any other read endpoint (Section 14.9), but it is not the recommended integration pattern.
Webhooks are the preferred completion signal (Section 14.10): a caller that registers a webhook endpoint and reacts to job.succeeded / job.failed / batch.completed avoids polling entirely. The job and batch objects returned inline at submission time are sufficient to correlate a later webhook event back to the request that triggered it.
14.4 File handling #
14.4.1 Direct multipart upload #
POST /v1/documents with Content-Type: multipart/form-data, field name file. Suitable for files up to 25 MB; above that, the presigned flow (Section 14.4.2) avoids holding a large request body in the API process's memory, and above 25 MB it also switches to multipart part-by-part transfer so an interruption only costs one part's worth of re-upload (Section 14.4.7).
curl -X POST https://api.pdfworks.io/v1/documents \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b" \
-H "Idempotency-Key: 8f14e45f-ceea-467e-9b83-1a2c3d4e5f60" \
-F "file=@quarterly-report.pdf;type=application/pdf"{
"id": "doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P",
"filename": "quarterly-report.pdf",
"contentType": "application/pdf",
"sizeBytes": 4831201,
"pages": 42,
"createdAt": "2026-08-19T14:00:02.000Z",
"expiresAt": "2026-08-20T14:00:02.000Z"
}14.4.2 Presigned upload for large files #
POST /v1/uploads requests an upload slot; the shape of the response depends on whether the declared sizeBytes is above or at-or-below the single-part threshold of 25 MB. At or below the threshold, the response is a single presigned PUT URL, identical to the mechanism this section has always used. Above the threshold — the common case for anything nearing the 1 GB plan ceiling (Section 12.2) — the response is an S3 multipart upload session: the file is split into fixed 16 MiB parts, each uploaded independently through its own short-lived presigned URL, so a failure partway through only costs one part's worth of re-transfer rather than the whole file, and no single request holds the API process's memory or a single long-lived signed URL for the full transfer.
Requesting the slot:
curl -X POST https://api.pdfworks.io/v1/uploads \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b" \
-H "Idempotency-Key: 2b6c9e10-4a3f-4c7d-9e21-6f8a0b1c2d3e" \
-H "Content-Type: application/json" \
-d '{"filename": "annual-report.pdf", "contentType": "application/pdf", "sizeBytes": 187342011}'Because 187342011 bytes is above the 25 MB threshold, the response describes a multipart session rather than a single URL:
{
"documentId": "doc_01K7Y2P8R4TZ0YM6W8N4V0ZR1Q",
"uploadMode": "multipart",
"uploadId": "mpu_9c2f1a7e4b3d6082",
"partSizeBytes": 16777216,
"partCount": 12,
"sessionExpiresAt": "2026-08-20T14:00:02.000Z"
}uploadId is an opaque handle over the underlying S3 multipart upload; sessionExpiresAt is 24 hours from creation — the outer bound on the whole transfer, independent of any individual part's own URL lifetime (below). A sizeBytes at or under 25 MB instead gets a single presigned PUT URL directly, with no part concept at all:
{
"documentId": "doc_01K7Y2N9R4TZ0YM6W8N4V0ZR2S",
"uploadMode": "single",
"uploadUrl": "https://pdfworks-uploads.s3.us-east-1.amazonaws.com/staging/doc_01K7Y2N9R4TZ0YM6W8N4V0ZR2S?X-Amz-Signature=...",
"method": "PUT",
"headers": { "Content-Type": "application/pdf" },
"expiresAt": "2026-08-19T14:15:02.000Z"
}Uploading a part. For a multipart session, the client requests one presigned URL per part, uploads it, and repeats:
curl -X POST https://api.pdfworks.io/v1/uploads/doc_01K7Y2P8R4TZ0YM6W8N4V0ZR1Q/parts/1 \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b"{ "partNumber": 1, "uploadUrl": "https://pdfworks-uploads.s3.us-east-1.amazonaws.com/staging/...&partNumber=1&uploadId=...", "expiresAt": "2026-08-19T14:15:02.000Z" }curl -X PUT "https://pdfworks-uploads.s3.us-east-1.amazonaws.com/staging/...&partNumber=1&uploadId=..." \
--data-binary @part-1.binS3 returns an ETag header for the part on success; the client records { partNumber, eTag } for every part as it completes. The client repeats the request-a-part-URL / PUT / record-the-ETag cycle for partNumber 2 through 12. Once every part has been uploaded, finalize supplies the full ordered list of all 12:
curl -X POST https://api.pdfworks.io/v1/documents/doc_01K7Y2P8R4TZ0YM6W8N4V0ZR1Q/finalize \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b" \
-H "Content-Type: application/json" \
-d '{
"parts": [
{"partNumber": 1, "eTag": "\"9bb58f26192e4ba00f01e2e7b136bbd8\""},
{"partNumber": 2, "eTag": "\"c2a6d9f4108b3aa751d0eaa22e0c9b47\""},
{"partNumber": 3, "eTag": "\"4e1a0d8c6f2b9e3d7a5c1f0b8e4d2a6c\""},
{"partNumber": 12, "eTag": "\"7f3d9a1e5c8b0f4a2d6e9c3b7f1a5d8e\""}
]
}'(Parts 4 through 11 follow the identical {"partNumber": N, "eTag": "..."} shape and are omitted here only for brevity — the real request body includes all 12.)
Finalize calls S3's CompleteMultipartUpload with the supplied part list (single-shot uploads finalize exactly as before, with no body, via HeadObject), verifies the assembled object's total size matches the value declared at session creation, and performs the same magic-byte and structural validation applied to a direct upload (Section 17); on success it returns the same document representation shown in Section 14.4.1. Supplying a part list that omits a part the session expects to exist returns 422 processing_error / code: "incomplete_multipart_upload", param: "parts", naming the missing part number in the message, rather than silently assembling a truncated file. If the single-shot upload URL's 15-minute window (expiresAt) has passed with no object present, finalize returns 404 not_found_error / code: "upload_not_found"; if a multipart session's 24-hour sessionExpiresAt has passed, finalize returns 409 conflict_error / code: "upload_session_expired" and the caller must start over with a fresh POST /v1/uploads call (Section 14.4.7 covers what happens to the abandoned session's already-uploaded parts).
Both upload paths carry no Authorization: Bearer header on the actual byte transfer to S3-compatible storage at all — the signature embedded in each presigned URL is the only credential needed, scoped to exactly one object (or, for a part, one partNumber within one uploadId) and time-boxed by its own expiresAt.
14.4.3 Remote URL as input #
Any tool or document-creation body may supply {"source": {"type": "url", "url": "https://example-client.com/contracts/msa-draft.pdf"}} instead of a documentId, causing the server to fetch the file itself. Because this makes the API process an outbound HTTP client on the caller's behalf, it is subject to the SSRF controls defined in full in Section 17: only https:// URLs are fetched, DNS resolution is restricted to public routable addresses (no RFC 1918, loopback, link-local, or cloud metadata-endpoint ranges), redirects are followed at most 3 times with the same restriction re-checked at every hop, the fetch times out at 10 seconds, and the download is size-capped to the caller's plan file-size ceiling with the connection dropped the instant that cap is exceeded rather than after the fact.
14.4.4 Reusing an uploaded document by ID #
A documentId returned by POST /v1/documents, POST /v1/uploads + finalize, or as a tool's output.documentId may be passed into any subsequent tool call (including as one of several documentIds for merge or image-to-pdf) as long as it has not expired or been deleted. Referencing an expired or deleted document returns 404 not_found_error / code: "document_not_found".
14.4.5 Output retrieval #
A succeeded job's output includes a short-lived signed download URL:
"output": {
"documentId": "doc_01K7Y3N0P2QZQXK5V7M3T9YQ0P",
"downloadUrl": "https://pdfworks-outputs.s3.us-east-1.amazonaws.com/doc_01K7Y3N0P2QZQXK5V7M3T9YQ0P?X-Amz-Signature=...",
"expiresAt": "2026-08-19T14:20:11.000Z",
"sizeBytes": 1882004,
"pages": 42
}The signed URL is valid for 15 minutes from the moment it is issued. GET /v1/documents/{id} mints a fresh signed URL on every call (it does not re-run any processing) so a client that needs to download later than the original 15-minute window simply re-fetches the document representation.
14.4.6 Deletion #
DELETE /v1/documents/{id} returns 204 No Content on success and shreds the document per the retention rule in Section 6: the wrapped data key is destroyed within the request itself, and the underlying bytes are removed within 60 seconds. Deleting a document that is currently referenced by a running job does not cancel the job; it fails the job's eventual output retrieval instead, since the input no longer exists once the job tries to read it — callers should cancel the job first (POST /v1/jobs/{id}/cancel) if early deletion is intended.
14.4.7 Interrupted transfers and resumption #
Section 12.2's 1 GB file-size ceiling means a transfer can run long enough on a slow or mobile connection that it gets cut off mid-way. Both upload paths in Section 14.4.2 — the public API's presigned flow and the app's own browser-side direct upload — share the same interruption story, described once here.
Detecting an incomplete transfer. For a multipart session, each part is its own bounded unit of work: the client considers a part's PUT failed if it returns a non-2xx status, the connection drops before a response arrives, or no response arrives within 30 seconds of the request starting. None of these end the overall upload — only that one part is retried. For a single-shot upload (a file at or under the 25 MB threshold), the same three conditions on the one PUT mean the whole transfer failed, since there is only one part to retry.
Per-part retry. A failed part is retried up to 5 times with exponential backoff (2 s, 4 s, 8 s, 16 s, 32 s) before the client surfaces an error to the user. If a retry attempt happens more than 15 minutes after the part's presigned URL was issued, the client requests a fresh one first (POST /v1/uploads/{documentId}/parts/{partNumber}, Section 14.4.2) rather than retrying against an expired signature — a part-level URL's lifetime is 15 minutes, matching the single-shot upload URL's lifetime, and is independent of the overall 24-hour session window (sessionExpiresAt) that bounds the multipart upload as a whole.
Resuming after a client-side interruption (tab closed, process killed, network dropped for longer than the retry budget above can absorb). Because S3 durably acknowledges each part independently the moment its PUT succeeds, nothing already uploaded needs to be resent. On restart, the client calls GET /v1/uploads/{documentId} to recover session state:
{ "documentId": "doc_01K7Y2P8R4TZ0YM6W8N4V0ZR1Q", "uploadId": "mpu_9c2f1a7e4b3d6082", "partSizeBytes": 16777216, "partCount": 12, "partsReceived": [1, 2, 3, 4, 5, 6, 7], "sessionExpiresAt": "2026-08-20T14:00:02.000Z" }The client diffs partsReceived against partCount, requests fresh presigned URLs only for the missing part numbers (8 through 12 here), uploads those, and finalizes — the first seven parts are never re-transferred. If sessionExpiresAt has already passed by the time the client resumes, GET /v1/uploads/{documentId} returns 409 conflict_error / code: "upload_session_expired" and the client must start a brand-new session (POST /v1/uploads) and re-upload every part; there is no way to extend an expired session's lifetime, by design, so that an abandoned session cannot be kept alive indefinitely.
Server-side cleanup of abandoned sessions. A multipart session that is never finalized within its 24-hour sessionExpiresAt window is abandoned storage: its already-uploaded parts occupy space in the staging bucket with nothing to reference them once the session can no longer be completed. The orphan-sweep janitor job (Section 13.7) is extended to cover this case: on each of its 6-hourly runs, it also issues an S3 AbortMultipartUpload call for every uploadId whose sessionExpiresAt is more than 1 hour in the past and which was never finalized, which releases the storage held by that session's uploaded-but-never-assembled parts. This reuses the existing sweep rather than adding another janitor job, and it is idempotent for the same reason the rest of that job is: aborting an already-aborted or already-completed multipart upload is a no-op recognized and skipped by the sweep.
The browser-side direct upload path. The app's internal API (/internal/uploads, session-cookie authenticated, Section 13.5.2's internal-surface pattern) exposes the identical contract described in Section 14.4.2 and above — session creation with the same 25 MB single/multipart split, per-part presigned URL issuance, GET .../uploads/{documentId} status polling, and finalize — used whenever the app itself needs to move a file to the server rather than a public-API caller: most commonly the opt-in "Finish this on our servers instead" fallback for an oversized client-side job (Section 6.3). The browser's uploader implements the same per-part retry and partsReceived-driven resume logic as any other client of this API; the only differences are the authentication method (session cookie instead of pk_live_/pk_test_) and that upload progress is surfaced through the app's own job progress UI (Section 13.5) rather than left for an external caller to poll on its own.
14.5 The complete endpoint catalog #
Every endpoint below shares the conventions locked once and referenced everywhere: the error envelope (Section 14.6), cursor pagination for list endpoints (Section 14.7), the Idempotency-Key requirement on creating/spending POSTs (Section 14.8), and token-bucket rate limiting (Section 14.9). Path and body parameter tables list only what is specific to that endpoint.
14.5.1 Documents #
| Method & path | Purpose | Scope | Rate-limit class | Idempotent |
|---|---|---|---|---|
POST /v1/documents |
Direct multipart upload (14.4.1) | documents:write |
W | Yes — header required |
GET /v1/documents/{id} |
Fetch metadata and a fresh signed download URL | documents:read |
R | Yes (read) |
DELETE /v1/documents/{id} |
Immediate shred (14.4.6) | documents:write |
W | Yes — safe to repeat, second call returns 404 |
POST /v1/uploads |
Request a presigned upload slot (14.4.2) | documents:write |
W | Yes — header required |
POST /v1/documents/{id}/finalize |
Confirm a presigned upload landed (14.4.2) | documents:write |
W | Yes — safe to repeat once finalized |
Errors specific to this group: 404 not_found_error / document_not_found; 400 invalid_request_error / file_too_large (param: "file", with the plan's byte ceiling in the message); 400 invalid_request_error / unsupported_file_type; 422 processing_error / corrupt_pdf_structure or malformed_upload.
14.5.2 Tools — common contract #
All 21 tools share one request/response contract, differing only in input shape and options fields (catalogued in Section 14.5.5). Every call:
POST /v1/tools/{tool}, scopetools:execute, rate-limit class X,Idempotency-Keyrequired.- Returns
202 Acceptedwith the job shape from Section 14.3, always — never an inline result. - Common errors beyond the endpoint-specific ones below:
404 document_not_found;402 quota_error/plan_limit_exceeded(daily server-side task cap or monthly envelope/operation cap reached, Section 12.2);402 quota_error/spend_cap_reached(API-plan per-key cap, Section 12.6);422 processing_error/corrupt_pdf_structure;422 processing_error/known_poison_input(Section 13.3.8);429 rate_limit_error/queue_full(Section 13.6.1).
Design decision — one path per tool, not one shared endpoint with a tool body field: POST /v1/tools/{tool} (a distinct URL per tool) rather than a single POST /v1/tools with the tool named in the body. This keeps rate-limit accounting, OpenAPI operation IDs, and generated SDK method names (client.tools.compress(...), client.tools.ocr(...)) one-to-one with the product's tool catalog, and it lets Section 14.9's per-endpoint rate-limit classing apply per-tool in the future without a body-level routing layer.
curl -X POST https://api.pdfworks.io/v1/tools/merge \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b" \
-H "Idempotency-Key: c4f1a9e2-5b6d-4e8f-a1c3-7d9e0f2a4b6c" \
-H "Content-Type: application/json" \
-d '{
"documentIds": ["doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P", "doc_01K7Y2P8R4TZ0YM6W8N4V0ZR1Q"]
}'curl -X POST https://api.pdfworks.io/v1/tools/ocr \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b" \
-H "Idempotency-Key: 91d2c8b4-3f6a-4e1d-8c5b-2a4e6f8a0c1e" \
-H "Content-Type: application/json" \
-d '{
"documentId": "doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P",
"options": { "languages": ["eng"], "forceOcr": false }
}'curl -X POST https://api.pdfworks.io/v1/tools/convert \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b" \
-H "Idempotency-Key: 5e7f9a1b-2c4d-4e6f-8a0b-1c3d5e7f9a0b" \
-H "Content-Type: application/json" \
-d '{
"documentId": "doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P",
"options": { "from": "pdf", "to": "docx" }
}'curl -X POST https://api.pdfworks.io/v1/tools/redact \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b" \
-H "Idempotency-Key: 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d" \
-H "Content-Type: application/json" \
-d '{
"documentId": "doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P",
"options": { "regions": [{"page": 1, "x": 72, "y": 640, "width": 220, "height": 18}] }
}'A redaction job's success response includes the Redaction Verification Report defined in Section 9.1 inline on the job's output: "output": { "documentId": "...", "downloadUrl": "...", "verificationReport": { "pages": [...], "passed": true } }. If verification fails, the job reaches failed with error.code = "redaction_verification_failed" and no output is produced, per Section 9.1.
curl -X POST https://api.pdfworks.io/v1/tools/forms-fill \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b" \
-H "Idempotency-Key: 1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f" \
-H "Content-Type: application/json" \
-d '{
"documentId": "doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P",
"options": {
"fields": {
"applicant_name": "Jordan Lee",
"date_of_birth": "1990-04-12",
"agrees_to_terms": true
}
}
}'A fields key that does not match a field name present in the document's AcroForm dictionary is not an error by itself — it is silently ignored, mirroring how a paper form is unaffected by writing in a margin — but the response's output includes a fieldsApplied count so a caller can detect a total mismatch (e.g. 0 applied against a non-empty fields object) and treat that as a signal to double-check field names via forms-extract (Section 14.5.4) on a blank copy of the same document first.
14.5.3 image-to-pdf and pdf-to-image are not part of convert #
Per the locked rule that image conversions are always their own execution path, distinct from Office/HTML transcoding (Section 6.1), pdf-to-image and image-to-pdf are dedicated tool slugs with their own paths (POST /v1/tools/pdf-to-image, POST /v1/tools/image-to-pdf) rather than values of convert's from/to pair. convert's from/to is restricted to exactly four pairs: pdf → docx, pdf → xlsx, pdf → pptx, docx|xlsx|pptx → pdf, html → pdf, pdf → html. Any other pair returns 400 invalid_request_error / code: "unsupported_conversion_pair", param: "options.to".
14.5.4 forms-extract returns JSON, not a PDF #
Every other tool's output.documentId points at a PDF (or, for pdf-to-image, a ZIP of images per page — Section 7). forms-extract is the one exception: its job's output is { "fields": [{ "name": "signature_date", "type": "date", "value": "2026-08-19" }, ...] } with no documentId at all, since there is no output document to retrieve.
14.5.5 The 21 tools #
| Slug | Input | Key options fields |
|---|---|---|
merge |
documentIds: string[] (2–100, order = merge order) |
— |
split |
documentId |
mode: "ranges" | "everyPage" | "everyNPages", ranges?: [{from, to}], n?: number |
organize |
documentId |
pageOrder: number[], deletedPages?: number[], insertBlankAt?: [{position, count}] |
rotate |
documentId |
rotations: [{page, degrees}] (degrees ∈ 90, 180, 270) or {all: degrees} |
crop |
documentId |
box: {top, right, bottom, left} (points), pages: "all" | number[] |
compress |
documentId |
level: "low" | "medium" | "high" |
pdf-to-image |
documentId |
format: "jpg" | "png", dpi: number (72–300, default 150), pages: "all" | number[] |
image-to-pdf |
documentIds: string[] |
pageSize: "a4" | "letter" | "auto" |
watermark |
documentId |
type: "text" | "image", text?, imageDocumentId?, opacity: number (0–1), position, rotationDegrees |
page-numbers |
documentId |
format: string (e.g. "Page {n} of {total}"), position, startAt: number |
bates |
documentId (or documentIds for batch continuity, Section 13.4.6) |
prefix: string, startNumber: number, digits: number, position |
protect |
documentId |
userPassword: string, ownerPassword?, permissions: {print, copy, modify} |
unlock |
documentId |
password: string |
flatten |
documentId |
— |
redact |
documentId |
regions: [{page, x, y, width, height}], patterns?: string[] (auto-detect, e.g. "ssn", "email") |
metadata |
documentId |
set?: {title, author, subject, keywords}, remove?: boolean |
repair |
documentId |
— |
ocr |
documentId |
languages: string[], forceOcr: boolean |
convert |
documentId |
from, to (Section 14.5.3) |
forms-fill |
documentId |
fields: Record<string, string | boolean> |
forms-extract |
documentId |
— (see 14.5.4 for output shape) |
Wall-clock timeouts for all 21 tools are defined once, in Section 13.3.8, which is the single source of truth; they are not restated in this table.
protect's userPassword and unlock's password are never logged, never stored beyond the job's own encrypted payload, and are redacted by the Pino redact-path list (Section 4) if a request body is ever included in a log line for debugging.
14.5.6 Batches #
| Method & path | Purpose | Scope |
|---|---|---|
POST /v1/batches |
Create a batch: { "tool": "compress", "documentIds": [...], "options": {...} | "perFileOptions": [{...}], "onError": "continue" | "fail_fast" } |
batches:write |
GET /v1/batches/{id} |
Batch status, per-item result table (Section 13.4.4), downloadUrls |
batches:read |
POST /v1/batches/{id}/cancel |
Cancel the whole batch (Section 13.4.4) | batches:write |
POST /v1/batches/{id}/items/{index}/retry |
Retry a single failed item (Section 13.4.4) | batches:write |
GET /v1/batches |
List batches, cursor-paginated | batches:read |
onError defaults to "continue". Batch-specific errors: 422 processing_error / batch_size_exceeded (param: "documentIds", message states the plan's per-job file cap from Section 12.2); 403 permission_error / batch_not_available_on_plan (Free-plan key or session, Section 12.2). See Section 13.4.8 for the subscription_lapsed / plan_limit_exceeded per-item cancellation behavior when a workspace's plan status changes while a batch is still running.
14.5.7 Jobs #
| Method & path | Purpose | Scope |
|---|---|---|
GET /v1/jobs |
List jobs, cursor-paginated, filterable by ?status= and ?type= |
jobs:read |
GET /v1/jobs/{id} |
Fetch a single job (Section 14.3) | jobs:read |
POST /v1/jobs/{id}/cancel |
Cancel (Section 13.1.1) | jobs:write |
Canceling a job already in a terminal state returns 409 conflict_error / code: "job_already_terminal" rather than silently succeeding, so a caller's cancel race is always observable.
curl "https://api.pdfworks.io/v1/jobs?status=running&limit=2" \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b"{
"data": [
{
"id": "job_01K7Y6E5F6G7H8J9K0L1M2N3O4",
"type": "ocr",
"status": "running",
"progress": 62,
"stage": "recognizing page 9 of 14",
"location": "server",
"batchId": "bat_01K7Y6D4E5F6G7H8J9K0L1M2N3",
"createdAt": "2026-08-19T16:00:00.000Z",
"startedAt": "2026-08-19T16:00:04.000Z",
"finishedAt": null,
"attempts": 1,
"input": { "documentId": "doc_01K7Y6A1B2C3D4E5F6G7H8J9K0" },
"output": null,
"error": null
}
],
"pagination": { "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTE5VDE2OjAwOjAwWiIsImlkIjoiam9iXzAxSzdZNkU1RjZHN0g4SjlLMEwxTTJOM080In0", "hasMore": true }
}14.5.8 Envelopes (e-signature) #
| Method & path | Purpose | Scope |
|---|---|---|
POST /v1/envelopes |
Create an envelope in draft: documents, signer roles/routing, field placement (Section 10) | envelopes:write |
GET /v1/envelopes |
List, cursor-paginated | envelopes:read |
GET /v1/envelopes/{id} |
Fetch status, signer states | envelopes:read |
POST /v1/envelopes/{id}/send |
Transition draft → sent; dispatches the first-signer notification | envelopes:write |
POST /v1/envelopes/{id}/void |
Cancel a sent, not-yet-completed envelope; records envelope.voided |
envelopes:write |
POST /v1/envelopes/{id}/remind |
Trigger an out-of-cadence reminder to signers who have not yet signed | envelopes:write |
GET /v1/envelopes/{id}/audit-trail |
The full hash-chained event list (Section 10) | envelopes:read |
GET /v1/envelopes/{id}/certificate |
The Certificate of Completion PDF (only once completed) |
envelopes:read |
GET /v1/envelopes/{id}/documents/{docId} |
Retrieve a source or completed document within the envelope | envelopes:read |
curl -X POST https://api.pdfworks.io/v1/envelopes \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b" \
-H "Idempotency-Key: 7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f" \
-H "Content-Type: application/json" \
-d '{
"documentIds": ["doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P"],
"signers": [
{ "email": "counterparty@example-client.com", "role": "signer", "order": 1 },
{ "email": "cfo@internal-company.com", "role": "approver", "order": 2 }
],
"fields": [
{ "type": "signature", "signerIndex": 0, "page": 3, "x": 400, "y": 120 },
{ "type": "date-signed", "signerIndex": 0, "page": 3, "x": 400, "y": 90 }
],
"expiresInDays": 14
}'{
"id": "env_01K7Y4A2B3C4D5E6F7G8H9J0K1",
"status": "draft",
"documentIds": ["doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P"],
"signers": [
{ "email": "counterparty@example-client.com", "role": "signer", "order": 1, "status": "pending" },
{ "email": "cfo@internal-company.com", "role": "approver", "order": 2, "status": "pending" }
],
"createdAt": "2026-08-19T14:05:00.000Z",
"expiresAt": "2026-09-02T14:05:00.000Z"
}Envelope-specific errors: 402 quota_error / plan_limit_exceeded (monthly envelope cap, Section 12.2); 409 conflict_error / envelope_already_sent (calling send twice); 409 conflict_error / envelope_not_completed (fetching a certificate before completion); 404 not_found_error / envelope_not_found.
14.5.9 Templates #
| Method & path | Purpose | Scope |
|---|---|---|
POST /v1/templates |
Save an envelope's document/field/signer-role layout for reuse | templates:write |
GET /v1/templates |
List, cursor-paginated | templates:read |
GET /v1/templates/{id} |
Fetch | templates:read |
PATCH /v1/templates/{id} |
Update | templates:write |
DELETE /v1/templates/{id} |
Soft-delete (Section 4) | templates:write |
POST /v1/envelopes with {"templateId": "tpl_..."} |
Create an envelope from a template, supplying only signer emails | envelopes:write |
Template resources are prefixed tpl_ (extending the base32-Crockford UUIDv7 ID scheme, Section 4, to a resource type not enumerated in that list by name but following its exact format). Creating a template captures everything about an envelope except the signer identities themselves:
curl -X POST https://api.pdfworks.io/v1/templates \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b" \
-H "Idempotency-Key: 3f4a5b6c-7d8e-9f0a-1b2c-3d4e5f6a7b8c" \
-H "Content-Type: application/json" \
-d '{
"name": "Standard NDA",
"documentIds": ["doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P"],
"signerRoles": [
{ "roleName": "Recipient", "order": 1 },
{ "roleName": "Countersigner", "order": 2 }
],
"fields": [
{ "type": "signature", "roleName": "Recipient", "page": 4, "x": 400, "y": 120 },
{ "type": "date-signed", "roleName": "Recipient", "page": 4, "x": 400, "y": 90 },
{ "type": "signature", "roleName": "Countersigner", "page": 4, "x": 400, "y": 220 }
]
}'{
"id": "tpl_01K7Y7A2B3C4D5E6F7G8H9J0K1",
"name": "Standard NDA",
"documentIds": ["doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P"],
"signerRoles": [
{ "roleName": "Recipient", "order": 1 },
{ "roleName": "Countersigner", "order": 2 }
],
"createdAt": "2026-08-19T16:30:00.000Z"
}Creating an envelope from this template then only needs to bind an actual email address to each declared role:
curl -X POST https://api.pdfworks.io/v1/envelopes \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b" \
-H "Idempotency-Key: 4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d" \
-H "Content-Type: application/json" \
-d '{
"templateId": "tpl_01K7Y7A2B3C4D5E6F7G8H9J0K1",
"signers": [
{ "roleName": "Recipient", "email": "counterparty@example-client.com" },
{ "roleName": "Countersigner", "email": "cfo@internal-company.com" }
]
}'14.5.10 Usage, account, health #
| Method & path | Purpose | Scope | Auth |
|---|---|---|---|
GET /v1/usage |
Current billing-period operation counts by metric, against plan inclusions (Section 12.6) | usage:read |
Required |
GET /v1/account |
Workspace/plan/entitlement snapshot | account:read |
Required |
GET /v1/health |
Liveness: { "status": "ok", "version": "2026.08.19" } |
None | None — unauthenticated, rate-limit class H (flat 5 req/s per IP) |
curl https://api.pdfworks.io/v1/usage \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b"{
"plan": "growth",
"periodStart": "2026-08-01T00:00:00.000Z",
"periodEnd": "2026-09-01T00:00:00.000Z",
"metrics": {
"operations": { "included": 10000, "used": 6412, "overage": 0, "overageRate": "0.009" },
"ocrPages": { "included": 0, "used": 812, "overage": 812, "overageRate": "0.004" },
"envelopes": { "included": null, "used": 0, "overage": 0, "overageRate": null }
}
}envelopes.included: null reflects that e-signature volume on the API plan is billed under the same metered operations metric rather than a separate envelope cap — the field is present for shape consistency with the app-facing plan table (Section 12.2) but carries no limit for this metric on API plans.
curl https://api.pdfworks.io/v1/account \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b"{
"id": "wsp_01K7Y1A0B1C2D3E4F5G6H7J8K",
"name": "Acme Legal Operations",
"plan": "growth",
"livemode": true,
"entitlements": { "maxFileSizeBytes": 1073741824, "maxBatchSize": 1000, "concurrencyCap": 20 },
"spendCap": { "enabled": true, "limitCents": 50000, "currency": "usd" }
}14.5.11 Webhook management #
Full definition, including request/response bodies, in Section 14.10.1 (owned there since it is inseparable from the delivery model). Listed here for catalog completeness: POST /v1/webhooks, GET /v1/webhooks, GET /v1/webhooks/{id}, PATCH /v1/webhooks/{id}, DELETE /v1/webhooks/{id}, GET /v1/webhooks/{id}/deliveries, POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver.
14.6 The canonical error envelope #
Every non-2xx response, on every endpoint, without exception, has this exact shape:
{
"error": {
"type": "invalid_request_error",
"code": "file_too_large",
"message": "The uploaded file is 41.2 MB. Your plan allows 25 MB.",
"param": "file",
"docsUrl": "https://docs.pdfworks.io/errors/file_too_large",
"requestId": "req_01K7Y3M2QF8V6X"
}
}type is one of exactly nine values, each with a fixed HTTP status:
type |
HTTP status |
|---|---|
invalid_request_error |
400 |
authentication_error |
401 |
permission_error |
403 |
not_found_error |
404 |
conflict_error |
409 |
quota_error |
402 |
processing_error |
422 |
rate_limit_error |
429 |
api_error |
500 |
codeis a stable, snake_case, machine-matchable string drawn from the catalogue in Section 23.1. Codes are additive-only — a code already shipped is never redefined to mean something else, and a code is never removed, only potentially deprecated in favor of a more specific new one while continuing to work.paramnames the offending request field using dot-path notation for nested fields (e.g."options.dpi"); it isnullwhen the error is not attributable to one field (e.g.api_error).docsUrlalways resolves to a live page explaining the code and, where applicable, how to fix it; the URL pattern ishttps://docs.pdfworks.io/errors/{code}.requestIdis the samereq_-prefixed ID emitted on every response (success or failure) as theX-Request-Idheader and echoed in server logs (Section 4, Section 18) — the single value a support request or bug report needs to attach.- Additive-only guarantee: new top-level fields may be added to the
errorobject over time (still underv1, per Section 14.1); existing fields never change type or meaning; newcodevalues appear regularly as the product grows; the ninetypevalues are closed and do not grow — a new failure category is modeled as a newcodeunder the closest existingtype, not as a tenthtype.
Ten worked examples of the most common failures:
// 1. Idempotency-Key omitted on a creating/spending POST
{ "error": { "type": "invalid_request_error", "code": "idempotency_key_required", "message": "This endpoint requires an Idempotency-Key header.", "param": null, "docsUrl": "https://docs.pdfworks.io/errors/idempotency_key_required", "requestId": "req_01K7Y3M2QF8V6X" } }
// 2. Unknown or revoked API key
{ "error": { "type": "authentication_error", "code": "invalid_api_key", "message": "The provided API key was not recognized or has been revoked.", "param": null, "docsUrl": "https://docs.pdfworks.io/errors/invalid_api_key", "requestId": "req_01K7Y3M3RH9W7Y" } }
// 3. Key lacks the required scope
{ "error": { "type": "permission_error", "code": "insufficient_scope", "message": "This API key does not have the 'envelopes:write' scope.", "param": null, "docsUrl": "https://docs.pdfworks.io/errors/insufficient_scope", "requestId": "req_01K7Y3M4SJ0X8Z" } }
// 4. Document not found or expired
{ "error": { "type": "not_found_error", "code": "document_not_found", "message": "No document exists with ID doc_01K7Y2N5QJ8ZQXK5V7M3T9YQ0P, or it has expired.", "param": "documentId", "docsUrl": "https://docs.pdfworks.io/errors/document_not_found", "requestId": "req_01K7Y3M5TK1Y9A" } }
// 5. Same Idempotency-Key reused with a different request body
{ "error": { "type": "conflict_error", "code": "idempotency_key_reuse", "message": "This Idempotency-Key was already used with a different request body.", "param": null, "docsUrl": "https://docs.pdfworks.io/errors/idempotency_key_reuse", "requestId": "req_01K7Y3M6UL2Z0B" } }
// 6. Monthly plan quota exceeded
{ "error": { "type": "quota_error", "code": "plan_limit_exceeded", "message": "You have used all 100 envelopes included in your Pro plan this month.", "param": null, "docsUrl": "https://docs.pdfworks.io/errors/plan_limit_exceeded", "requestId": "req_01K7Y3M7VM3A1C" } }
// 7. Malformed or structurally invalid PDF
{ "error": { "type": "processing_error", "code": "corrupt_pdf_structure", "message": "The file's cross-reference table could not be parsed and automatic repair failed.", "param": "documentId", "docsUrl": "https://docs.pdfworks.io/errors/corrupt_pdf_structure", "requestId": "req_01K7Y3M8WN4B2D" } }
// 8. Token bucket exhausted
{ "error": { "type": "rate_limit_error", "code": "rate_limit_exceeded", "message": "Too many requests. Retry after the interval in the Retry-After header.", "param": null, "docsUrl": "https://docs.pdfworks.io/errors/rate_limit_exceeded", "requestId": "req_01K7Y3M9XO5C3E" } }
// 9. Queue at capacity
{ "error": { "type": "rate_limit_error", "code": "queue_full", "message": "The ocr processing queue is at capacity. Retry after the interval in the Retry-After header.", "param": null, "docsUrl": "https://docs.pdfworks.io/errors/queue_full", "requestId": "req_01K7Y3MA0P6D4F" } }
// 10. Unexpected internal failure
{ "error": { "type": "api_error", "code": "internal_error", "message": "An unexpected error occurred. Our team has been notified.", "param": null, "docsUrl": "https://docs.pdfworks.io/errors/internal_error", "requestId": "req_01K7Y3MB1Q7E5G" } }14.7 Pagination #
Every list endpoint (GET /v1/documents is intentionally absent from this list — documents are fetched individually by ID, never listed, since there is no product need to browse them; GET /v1/jobs, GET /v1/batches, GET /v1/envelopes, GET /v1/templates, GET /v1/webhooks, GET /v1/webhooks/{id}/deliveries all follow it) uses cursor pagination exclusively — there is no offset pagination anywhere in the API.
Request: ?limit=&cursor=. limit defaults to 25, maximum 100; a value above 100 is clamped rather than rejected (values above the max are a common integration mistake and clamping is friendlier than a 400 for something this harmless). cursor is opaque and omitted for the first page.
Cursor encoding: the cursor is base64url(JSON.stringify({ createdAt: string, id: string })) — the (createdAt, id) tuple of the last row on the current page, which is a stable keyset because createdAt (with UUIDv7's embedded timestamp making id itself monotonic within a millisecond, Section 4) is monotonically increasing and never reused. The server decodes it, validates both fields are present and well-typed, and returns 400 invalid_request_error / code: "invalid_cursor" if decoding or validation fails — a cursor is never silently ignored.
Response envelope:
{
"data": [ { "...": "resource" } ],
"pagination": { "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTE5VDEzOjAwOjAwWiIsImlkIjoiam9iXzAxSzdZM0...", "hasMore": true }
}nextCursor is null and hasMore is false on the last page.
Stability under concurrent writes: because pagination walks a keyset (WHERE (created_at, id) > (?, ?) ORDER BY created_at, id LIMIT ?) rather than skipping a row count, a row inserted after the caller began paging never causes a row to be skipped or duplicated across pages, and a row deleted between pages is simply absent from a later page rather than shifting every subsequent row's position — the classic offset-pagination failure mode (LIMIT/OFFSET re-numbering rows out from under an in-progress walk) cannot occur.
A worked two-page walk over a workspace with 30 envelopes and limit=25 (the default):
curl "https://api.pdfworks.io/v1/envelopes" \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b"returns 25 envelopes and "pagination": { "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTE5VDEyOjAwOjAwWiIsImlkIjoiZW52XzAxSzdZOEExQjJDM0Q0RTVGNkc3SDhKOUswIn0", "hasMore": true }. The caller then requests:
curl "https://api.pdfworks.io/v1/envelopes?cursor=eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTE5VDEyOjAwOjAwWiIsImlkIjoiZW52XzAxSzdZOEExQjJDM0Q0RTVGNkc3SDhKOUswIn0" \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b"and receives the remaining 5 envelopes with "hasMore": false. If three new envelopes were created between the two calls, they sort after the cursor's position (their createdAt is later than every row already seen) and appear on a subsequent page rather than being skipped or inserted into the middle of the walk already in progress.
14.8 Idempotency #
Header: Idempotency-Key, any caller-generated string up to 255 characters (a UUID is recommended but not required).
Required on: every POST that creates a resource or spends quota/money — concretely: POST /v1/documents, POST /v1/uploads, POST /v1/documents/{id}/finalize, every POST /v1/tools/{tool}, POST /v1/batches, POST /v1/envelopes, POST /v1/envelopes/{id}/send, POST /v1/envelopes/{id}/void, POST /v1/envelopes/{id}/remind, POST /v1/templates, POST /v1/webhooks. Omitting it on any of these returns 400 invalid_request_error / code: "idempotency_key_required" (worked example 1, Section 14.6). It is not required, and is ignored if sent, on any GET, PATCH, DELETE, or on POST /v1/jobs/{id}/cancel / POST /v1/batches/{id}/cancel / POST /v1/batches/{id}/items/{index}/retry — cancel and retry are naturally idempotent-in-effect operations already guarded by the state machine (Section 13.1.1) rather than by key replay.
24-hour window: a (apiKeyId, Idempotency-Key) pair is remembered for 24 hours from first use. A repeated request with the same key and an identical request body (compared by SHA-256 of the canonicalized JSON body) within that window returns the exact original response — same status code, same body, byte-for-byte — without re-executing anything (worked once, e.g. a POST /v1/tools/compress replay returns the same job object at whatever state it has since reached, not a new job). After 24 hours the key is forgotten and reusable for an unrelated request.
Conflicting body: the same key reused within the window with a different request body returns 409 conflict_error / code: "idempotency_key_reuse" (worked example 5, Section 14.6) — the original response is never overwritten and the second request never executes.
14.9 Rate limiting #
A Redis token bucket per (accountId, endpointClass) pair. Four classes:
| Class | Covers |
|---|---|
| R (read) | Every GET |
| W (write) | POST/PATCH/DELETE that is not a tool execution — documents, uploads, batches metadata mutation, envelopes, templates, webhooks |
| X (execute) | POST /v1/tools/{tool}, POST /v1/batches |
| H (health) | GET /v1/health only, unauthenticated, flat 5 req/s per source IP regardless of plan |
| API plan | Class R | Class W | Class X |
|---|---|---|---|
| Starter | 10 req/s, burst 30 | 5 req/s, burst 15 | 5 req/s, burst 10 |
| Growth | 25 req/s, burst 75 | 15 req/s, burst 40 | 15 req/s, burst 30 |
| Scale | 60 req/s, burst 150 | 40 req/s, burst 100 | 40 req/s, burst 80 |
Bucket capacity equals the burst figure; the bucket refills continuously (not in fixed windows, which avoids a thundering-herd at window boundaries) at the sustained req/s figure. Every response, success or failure, carries:
RateLimit-Limit: 15
RateLimit-Remaining: 11
RateLimit-Reset: 1755612345RateLimit-Reset is a Unix timestamp for when the bucket will next hold a full token if no further requests are made. A request against an empty bucket returns 429 with the error body from worked example 8 (Section 14.6) and:
Retry-After: 1computed as ceil(1 / sustainedRate) — the minimum wait until exactly one token is available. Burst policy: because capacity exceeds the sustained rate (roughly 2–3x across every plan and class), a caller that has been idle can issue a short burst well above its steady-state rate without being throttled, which comfortably covers a pagination loop fetching several pages back-to-back or a batch submission followed immediately by a few status polls.
A worked exhaustion. A Starter-plan key (class X: 5 req/s sustained, burst 10) fires 12 POST /v1/tools/compress calls in the same second from a retry loop that has no backoff of its own. The first 10 succeed immediately, draining the bucket to empty. The 11th and 12th receive:
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 5
RateLimit-Remaining: 0
RateLimit-Reset: 1755612346
Retry-After: 1{ "error": { "type": "rate_limit_error", "code": "rate_limit_exceeded", "message": "Too many requests. Retry after the interval in the Retry-After header.", "param": null, "docsUrl": "https://docs.pdfworks.io/errors/rate_limit_exceeded", "requestId": "req_01K7Y3M9XO5C3E" } }Waiting the 1 second indicated by Retry-After and retrying succeeds, since the bucket has refilled by exactly one token (the sustained rate) in that interval — a caller that honors Retry-After rather than retrying immediately never enters a retry storm against its own rate limit.
14.10 Webhooks #
14.10.1 Webhook management endpoints #
| Method & path | Purpose |
|---|---|
POST /v1/webhooks |
Register an endpoint: { "url": "https://...", "events": ["job.succeeded", "job.failed", ...] } (or ["*"] for every event type) |
GET /v1/webhooks |
List, cursor-paginated |
GET /v1/webhooks/{id} |
Fetch, including status: "enabled" | "failing" | "disabled", consecutiveFailures: number, and secret (shown only in the POST response, never again — matching the API-key show-once pattern, Section 17) |
PATCH /v1/webhooks/{id} |
Update url, events, or status |
DELETE /v1/webhooks/{id} |
Remove |
GET /v1/webhooks/{id}/deliveries |
Delivery log (14.10.7), cursor-paginated, filterable by ?status= |
POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver |
Manual redelivery (14.10.8) |
14.10.2 Event catalog #
Event type |
Fired when |
|---|---|
document.uploaded |
A document finishes upload and passes validation |
job.succeeded |
Any job (any tool) reaches succeeded |
job.failed |
Any job reaches failed |
job.canceled |
Any job reaches canceled |
job.expired |
Any job reaches expired |
batch.completed |
Every item in a batch has reached a terminal state |
envelope.sent |
An envelope transitions draft → sent |
envelope.viewed |
Any signer opens the signing session |
envelope.signer_completed |
One signer finishes all their fields |
envelope.completed |
Every required signer has completed |
envelope.declined |
A signer declines |
envelope.voided |
The sender voids the envelope |
envelope.expired |
The envelope's expiry date passes unsigned (Section 13.7) |
14.10.3 Payload shape #
{
"id": "evt_01K7Y5B3C4D5E6F7G8H9J0K1L2",
"type": "job.succeeded",
"createdAt": "2026-08-19T14:03:47.000Z",
"livemode": true,
"data": {
"object": {
"id": "job_01K7Y3M8H6QY6V9N2X4R7T1B3C",
"type": "compress",
"status": "succeeded",
"output": { "documentId": "doc_01K7Y3N0P2QZQXK5V7M3T9YQ0P", "downloadUrl": "https://...", "expiresAt": "2026-08-19T14:20:11.000Z" }
}
}
}A representative envelope.completed payload:
{
"id": "evt_01K7Y5C4D5E6F7G8H9J0K1L2M3",
"type": "envelope.completed",
"createdAt": "2026-08-19T15:10:02.000Z",
"livemode": true,
"data": {
"object": {
"id": "env_01K7Y4A2B3C4D5E6F7G8H9J0K1",
"status": "completed",
"completedAt": "2026-08-19T15:10:02.000Z",
"certificateUrl": "https://api.pdfworks.io/v1/envelopes/env_01K7Y4A2B3C4D5E6F7G8H9J0K1/certificate"
}
}
}And envelope.declined, fired the moment any signer declines rather than waiting for the whole envelope to reach a terminal state, so the sender can react immediately:
{
"id": "evt_01K7Y5D5E6F7G8H9J0K1L2M3N4",
"type": "envelope.declined",
"createdAt": "2026-08-19T15:12:30.000Z",
"livemode": true,
"data": {
"object": {
"id": "env_01K7Y4A2B3C4D5E6F7G8H9J0K1",
"status": "declined",
"declinedBy": { "email": "counterparty@example-client.com", "role": "signer" },
"declineReason": "Incorrect purchase amount on page 3."
}
}
}14.10.4 Delivery model #
At-least-once, POST to the registered url, Content-Type: application/json, one event per delivery. A 2xx response within 10 seconds counts as delivered; anything else (non-2xx, timeout, connection failure) is a delivery failure and enters the retry schedule.
14.10.5 Signature scheme #
Every delivery carries:
PDFWorks-Signature: t=1755612227,v1=5257a869e7bfbe86d956ac36f4a4b6e29b9de1ef23c69c0af10a4c1e7fa8b0a2v1 is HMAC-SHA256(secret, "{t}.{rawRequestBody}") hex-encoded, computed over the exact bytes sent (not a re-serialization). The receiver recomputes the same HMAC using its endpoint's current secret and compares in constant time; a mismatch means the payload was not sent by PDFWorks or was altered in transit. Replay protection: the receiver additionally rejects any request whose t is more than 5 minutes from the receiver's own clock, so a captured, valid signature cannot be replayed indefinitely — combined with tracking already-processed evt_ IDs (the payload's id field), a receiver gets full replay protection.
Node verification:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const provided = Buffer.from(parts.v1, "hex");
const expectedBuf = Buffer.from(expected, "hex");
return provided.length === expectedBuf.length && timingSafeEqual(provided, expectedBuf);
}Python verification:
import hmac
import hashlib
import time
def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > 300:
return False
signed_payload = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])14.10.6 Retry schedule #
Seven attempts total (matching the webhook queue's maxAttempts, Section 13.3.5), at these intervals from the prior attempt: immediate, 1 min, 5 min, 30 min, 2 hr, 6 hr, 24 hr — roughly 32 hours end to end. After the seventh failed attempt, the delivery is marked exhausted and no further attempts occur for that event; the event remains permanently visible in the delivery log.
14.10.7 Delivery log #
GET /v1/webhooks/{id}/deliveries lists every attempt (not just the latest) for every event sent to that endpoint: { id: "del_...", eventId: "evt_...", eventType, status: "pending" | "delivered" | "failed" | "exhausted", httpStatus, attemptNumber, attemptedAt, responseBody (truncated to 2 KB) }.
{
"data": [
{
"id": "del_01K7Y9C5D6E7F8G9H0J1K2L3M4",
"eventId": "evt_01K7Y5B3C4D5E6F7G8H9J0K1L2",
"eventType": "job.succeeded",
"status": "failed",
"httpStatus": 503,
"attemptNumber": 3,
"attemptedAt": "2026-08-19T14:13:47.000Z",
"responseBody": "upstream connect error or disconnect/reset before headers"
},
{
"id": "del_01K7Y9D6E7F8G9H0J1K2L3M4N5",
"eventId": "evt_01K7Y5B3C4D5E6F7G8H9J0K1L2",
"eventType": "job.succeeded",
"status": "delivered",
"httpStatus": 200,
"attemptNumber": 4,
"attemptedAt": "2026-08-19T14:33:47.000Z",
"responseBody": "{\"received\":true}"
}
],
"pagination": { "nextCursor": null, "hasMore": false }
}This example shows one event (evt_01K7Y5B3...) whose third attempt failed with a 503 and whose fourth attempt, 20 minutes later per the retry schedule (Section 14.10.6), succeeded — the log retains both rows rather than overwriting the failed attempt, so the full delivery history for that event is reconstructable from this endpoint alone.
14.10.8 Manual redelivery #
POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver re-sends the original event payload immediately as a new delivery attempt, independent of and without resetting the original attempt's retry schedule — useful after fixing a receiving endpoint that was down during the original 32-hour window.
curl -X POST https://api.pdfworks.io/v1/webhooks/whk_01K7Y9E7F8G9H0J1K2L3M4N5O6/deliveries/del_01K7Y9C5D6E7F8G9H0J1K2L3M4/redeliver \
-H "Authorization: Bearer pk_live_9f2K7QmZx4vD8rT1sL6nJ3wE0aH5cY2b"{
"id": "del_01K7Y9F8G9H0J1K2L3M4N5O6P7",
"eventId": "evt_01K7Y5B3C4D5E6F7G8H9J0K1L2",
"eventType": "job.succeeded",
"status": "pending",
"attemptNumber": 1,
"triggeredBy": "manual_redelivery",
"attemptedAt": "2026-08-19T17:00:00.000Z"
}The redelivered attempt is numbered 1 in its own right (it is a fresh delivery record, not attempt 5 of the original), and triggeredBy: "manual_redelivery" distinguishes it in the log from an automatic retry.
14.10.9 Secret rotation #
PATCH /v1/webhooks/{id} with {"rotateSecret": true} generates a new secret and returns it once in the response; for the next 24 hours, every delivery is signed twice — a second header PDFWorks-Signature-Previous: t=...,v1=... computed with the old secret accompanies the primary one — so the receiver can switch its verification to the new secret at its own pace within the window without dropping a single valid delivery. After 24 hours the old secret stops being accepted for signing.
14.10.10 Endpoint health: active → failing → disabled #
Every webhook endpoint carries a status of active, failing, or disabled (the values stored by the schema in Section 5.7), plus a consecutiveFailures counter that is never reset by time — only by a successful delivery or an operator/owner action, as described below. consecutiveFailures increments by exactly one each time an event (not an individual delivery attempt) reaches exhausted — i.e. all seven attempts in the retry schedule (Section 14.10.6) have failed for that event — and resets to 0 the instant any delivery to that endpoint succeeds (a 2xx within 10 seconds, Section 14.10.4), regardless of how many prior events had failed.
active → failing: the moment consecutiveFailures reaches 3. Nothing about delivery behavior changes while failing — every subsequent event is still queued and attempted on the full seven-attempt schedule exactly as it would be for an enabled endpoint; failing is a visibility state, not a throttling state. The one behavioral difference is notification: at the instant of the enabled → failing transition, the workspace owner is emailed once, with the endpoint URL, the count of recent consecutive failures, a link to the delivery log, and a note that no action is required yet but the endpoint will be automatically disabled if the pattern continues. This email is not repeated for every subsequent failure while still failing — only the single state-transition email, to avoid alert fatigue.
failing → disabled: the moment consecutiveFailures reaches 10, or the endpoint has been continuously failing for 14 days without a single successful delivery, whichever comes first (the counter continues accumulating from the same running total that crossed 3, it is not reset when entering failing). Once disabled, no further delivery attempts are made against the endpoint for any event, including ones already queued on the webhook BullMQ queue for it at the moment of disabling (those queued deliveries are marked exhausted immediately with responseBody: "endpoint disabled before delivery was attempted" rather than left pending indefinitely). The account owner is emailed the moment auto-disabling occurs, with a link to the delivery log and the re-enable action — this is a second, distinct email from the failing notification above.
Recovery: a successful delivery at any point while failing resets consecutiveFailures to 0 and flips status back to active automatically — no manual action needed, since a live success is proof the endpoint has recovered. There is no automatic recovery from disabled: once disabled, an endpoint stays disabled, and no further delivery attempts (hence no further chance to self-heal) occur until an operator or the account owner explicitly re-enables it via PATCH /v1/webhooks/{id} with {"status": "enabled"}, which also resets consecutiveFailures to 0 and resumes normal delivery for new events from that point forward (it does not retroactively retry events that were dropped while disabled — those remain permanently exhausted in the delivery log, and a manual POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver, Section 14.10.8, is the way to recover any specific one of them).
GET /v1/webhooks/{id} and GET /v1/webhooks always return the endpoint's current status and consecutiveFailures so a caller can monitor endpoint health without parsing the full delivery log.
14.11 SDKs and developer experience #
JavaScript/TypeScript SDK. Published to npm as @pdfworks/sdk, built from packages/sdk-js and versioned independently of the API's own major version (the SDK follows semver; a 2.x SDK release can still target /v1). Its surface mirrors the endpoint catalog one-to-one: client.documents.upload(...), client.tools.compress({ documentId, options }), client.tools.ocr(...), client.batches.create(...), client.envelopes.send(...), client.webhooks.verify(rawBody, header, secret) (implementing Section 14.10.5 so a consumer never hand-rolls signature verification), and a client.jobs.waitFor(jobId) convenience helper that internally uses the SSE-then-poll strategy from Section 13.5.2's internal equivalent — externally it simply polls GET /v1/jobs/{id} at a backoff schedule, since SSE is not part of the public surface. Every request/response type is generated from the same Zod schemas in packages/contracts that validate the API server-side (Section 4), so the SDK's types and the server's actual validation can never drift apart.
OpenAPI document. Generated at build time from packages/contracts' Zod schemas (via a Zod-to-OpenAPI transform), published at https://docs.pdfworks.io/openapi.json and regenerated on every deploy so it is never stale relative to the running API.
Docs site. docs.pdfworks.io renders the OpenAPI document as browsable reference (one page per endpoint, matching Section 14.5's grouping) plus hand-written guides; every code sample on the reference pages is runnable in-page against test mode (Section 14.2) using a visitor-scoped temporary test key, so a developer can execute a real request before writing any of their own code.
Postman collection. Auto-generated from the same OpenAPI document by a CI step and published as a one-click "Run in Postman" link on the docs site; regenerated on the same cadence as the OpenAPI document so it cannot drift either.
Sandbox/test mode. Fully covered in Section 14.2; the docs site's runnable examples and the Postman collection both default to a pk_test_ key.
The first ten minutes. The path a new developer follows, start to finish: (1) sign up and land on the API dashboard; (2) a pk_test_ key is generated automatically and shown once, with a one-click copy; (3) the dashboard's first screen is a pre-filled curl command identical in shape to the compress example in Section 14.5.2, using that key; (4) pasting it into a terminal returns a 202 with a job; (5) a second pre-filled command polls GET /v1/jobs/{id} until succeeded and shows the downloadUrl; (6) a "Switch to Node/Python/@pdfworks/sdk" toggle re-renders the same two steps as SDK code instead of curl; (7) an optional seventh step registers a webhook endpoint pointed at a disposable inspection URL (e.g. one the dashboard offers to generate) so the developer sees a real signed delivery land before writing any receiving code of their own. No step requires reading documentation outside the page the developer is already on.
14.12 Acceptance criteria #
- A job created via the app's client-side tools and a job created via
POST /v1/tools/{tool}for the identical operation both expose exactly the five terminal/non-terminal states from Section 13.1.1, and no other state string appears in either the local Dexie store or thejobsAPI representation. - Given a Free-plan (
standard-lane) job queued behind three Pro-plan (priority-lane) jobs, submitting a fourthpriority-lane job still results in thestandard-lane job being dequeued within one worker's fairness-reservation cycle (Section 13.3.2), never starved indefinitely. - A job that exceeds its per-tool wall-clock timeout (Section 13.3.8) transitions to
expired, is not automatically retried, and the account's consumed quota for that operation is refunded. - A worker process that crashes mid-job (simulated by killing the container) results in the job being retried at most once more before being force-failed with
error.code = "worker_crash", never looping indefinitely. - Submitting a batch of 20 files with
onError: "continue"where 3 files are deliberately malformed results in 17succeededitems, 3faileditems, and amanifest.csvlisting all 20 rows with correctstatusanderrorCodevalues. - The same 20-file batch submitted with
onError: "fail_fast"results in every item stillqueuedat the moment of the first failure transitioning tocanceled, and no item that started after the failure was detected reachingsucceeded. - A Bates-numbering batch across 5 files with known page counts produces a strictly contiguous, gap-free numbering sequence across every output file, verified by re-opening the concatenated outputs and asserting the last number of file N is exactly one less than the first number of file N+1.
- Closing the browser tab mid-way through a client-side job and reopening the app results in that job showing
status: "failed",error.code: "interrupted", while any already-succeededsibling items in the same batch retain their results. - Every
POST /v1/tools/{tool}call, for all 21 tool slugs, returns202 Acceptedwith ajobobject and never returns a 200 with an inline result. - Every field present in the canonical error envelope (Section 14.6) is present on every non-2xx response across every endpoint in Section 14.5, with
typealways one of the nine closed values. - Two identical requests to any endpoint requiring
Idempotency-Key, sent with the same key and an identical body within 24 hours, return byte-identical response bodies and never create two resources. - The same
Idempotency-Keysent twice with two different request bodies within the 24-hour window returns409 idempotency_key_reuseon the second call, and only the first request's side effects occur. - A list endpoint's cursor, decoded and re-submitted, resumes exactly where the prior page left off even if rows were inserted or deleted elsewhere in the result set between the two calls.
- Exhausting a plan's class-
Xtoken bucket results in a429whoseRetry-Afterheader value, when honored, is always sufficient for the very next request to succeed. - A webhook payload's
PDFWorks-Signatureheader verifies successfully using both the Node and Python snippets in Section 14.10.5 against the same raw body and secret, and a header with atmore than 5 minutes old fails verification in both. - Rotating a webhook endpoint's secret results in every delivery within the following 24 hours carrying both
PDFWorks-Signature(new secret) andPDFWorks-Signature-Previous(old secret), and verification against either succeeds during that window. - An endpoint whose
consecutiveFailuresreaches 3 is automatically set tostatus: "failing"with one owner-notification email sent, continues attempting deliveries normally, and returns tostatus: "enabled"with the counter reset the moment any delivery to it next succeeds; an endpoint whoseconsecutiveFailuresinstead reaches 10 is automatically set tostatus: "disabled", stops receiving delivery attempts, and triggers a second, distinct notification email to the account owner. - The
esignqueue continues accepting and processing new jobs even while every other queue is simultaneously at Section 13.6.4's L4 emergency level. - All nine janitor jobs (Section 13.7), when run twice in immediate succession against the same database state, produce identical end-state data on both runs — no row is double-processed, double-deleted, or double-billed.
15. Frontend Application Architecture #
15.1 Application shape #
The client application is a single Next.js 16.x App Router codebase (apps/web) that serves three distinct audiences from three route groups and three hostnames. Next.js route groups (parenthesized folders) do not appear in the URL; hostname routing is handled by middleware.ts, which inspects the Host header on every request and rewrites the request path into the matching route group before Next.js resolves it:
// apps/web/middleware.ts
const HOST_TO_GROUP: Record<string, string> = {
'pdfworks.io': '/_marketing',
'www.pdfworks.io': '/_marketing',
'app.pdfworks.io': '/_app',
'sign.pdfworks.io': '/_signer',
};
export function middleware(request: NextRequest) {
const host = request.headers.get('host')?.split(':')[0] ?? '';
const prefix = HOST_TO_GROUP[host];
if (!prefix) return NextResponse.next();
const url = request.nextUrl.clone();
url.pathname = `${prefix}${url.pathname}`;
return NextResponse.rewrite(url);
}
export const config = { matcher: ['/((?!_next|_marketing|_app|_signer).*)'] };The three route groups map 1:1 to the three placeholder hostnames from Section 1: (marketing) serves pdfworks.io, (app) serves app.pdfworks.io, (signer) serves sign.pdfworks.io. All three are built and deployed as one Next.js application; there is no separate signer deployment. This keeps the design system, the i18n catalog, and the build pipeline single-sourced while still letting each hostname carry its own cookie scope, its own Cross-Origin-Opener-Policy posture (Section 3.8 applies only to (app); the marketing and signer groups are not cross-origin-isolated because neither needs SharedArrayBuffer), and its own CDN cache policy.
Every tool has two pages by design, and this split is deliberate: a marketing landing page at pdfworks.io/tools/{slug} (SEO copy, screenshots, FAQ, a single "Use this tool free" call to action) and the working tool at app.pdfworks.io/tools/{slug} (the actual workbench described in Section 15.4). The landing page never embeds the workbench; it links to it. This keeps the cross-origin-isolated, WASM-heavy surface confined to app.pdfworks.io and keeps the marketing surface fast, cacheable, and crawlable without a service worker.
15.1.1 Complete route table #
Auth values used below: Public (no session, no caps beyond the guest limits in Section 11.5) · Public, gated at run (page renders for anyone; the primary action opens a signup/login prompt unless the tool is one of the six guest-tier tools) · Session (authenticated user required) · Session + role (authenticated user with a specific workspace role) · Signer token (no account; a single-use signed link token in the query string authorizes the request) · Session or none (renders for guests and authenticated users with different content).
Marketing (pdfworks.io), route group (marketing)
| Path | Rendering | Auth | Purpose |
|---|---|---|---|
/ |
Static | Public | Homepage: value proposition, tool grid preview, trust signals |
/pricing |
Static | Public | Plan comparison table (Section 12.2), links to Stripe Checkout |
/tools |
Static | Public | SEO directory of all 34 tools, category filters |
/tools/[tool] |
Static (ISR, 24h) | Public | Per-tool marketing landing page, one per slug in 15.1.2 |
/teams |
Static | Public | Team plan landing page |
/developers |
Static | Public | Public API landing page, links to docs.pdfworks.io |
/security |
Static | Public | Security and compliance posture (Section 17) |
/about |
Static | Public | Company page |
/blog |
Static (ISR, 1h) | Public | Blog index |
/blog/[slug] |
Static (ISR, 1h) | Public | Blog post |
/changelog |
Static (ISR, 1h) | Public | Release notes |
/legal/privacy |
Static | Public | Privacy policy |
/legal/terms |
Static | Public | Terms of service |
/legal/dpa |
Static | Public | Data Processing Addendum template (Section 17) |
/accessibility |
Static | Public | Accessibility statement (Section 16.6) |
/status |
Static | Public | Redirect to status.pdfworks.io |
Application (app.pdfworks.io), route group (app)
| Path | Rendering | Auth | Purpose |
|---|---|---|---|
/ |
Dynamic | Session or none | Dashboard: recent files, quick tool tiles, upgrade banner for Free/guest |
/tools |
Static shell, client data | Public | Full tool grid with search and category filters, Processing Location Indicator on every card |
/tools/[tool] |
Static shell, client workbench | Public, gated at run | Tool workbench, one route per slug in 15.1.2 |
/jobs |
Dynamic (SSR shell, CSR data) | Session | Job history: client-side and server-side jobs, filter by tool/date/status |
/jobs/[jobId] |
Dynamic | Session | Job detail: status, timing, error, download, redaction verification report where applicable (Section 9.1) |
/esign |
Dynamic | Session | Envelope list (sent and received-for-CC) |
/esign/new |
Dynamic, client-heavy | Session | Envelope composer: upload, place fields, add signers |
/esign/[envelopeId] |
Dynamic | Session | Envelope detail: signer status, audit trail, resend, void |
/esign/[envelopeId]/edit |
Dynamic, client-heavy | Session | Edit a draft envelope before sending |
/esign/templates |
Dynamic | Session + Team | Shared envelope templates (Team plan only) |
/settings/profile |
Dynamic | Session | Name, avatar, locale preference |
/settings/security |
Dynamic | Session | Password, MFA enrollment, session list (Section 17.2) |
/settings/notifications |
Dynamic | Session | Email notification preferences |
/billing |
Dynamic | Session | Current plan, usage meters, invoices, link to the Stripe Billing Portal (Section 3.4, Section 12) |
/workspace |
Dynamic | Session + Team | Workspace name, MFA enforcement toggle, EU residency toggle |
/workspace/members |
Dynamic | Session + owner/admin | Seat invite, role assignment, seat removal |
/workspace/templates |
Dynamic | Session + Team | Shared tool presets and envelope templates |
/workspace/audit-log |
Dynamic | Session + owner/admin | Shared workspace audit log (Section 11) |
/developers/api-keys |
Dynamic | Session + API plan | Create, scope, and revoke API keys (Section 17.4) |
/developers/webhooks |
Dynamic | Session + API plan | Webhook endpoint registration and delivery log (Section 14) |
/login |
Static shell, client form | Public | Sign in |
/signup |
Static shell, client form | Public | Create account |
/forgot-password |
Static shell, client form | Public | Request password reset |
/reset-password |
Dynamic (token) | Public with reset token | Set new password |
/verify-email |
Dynamic (token) | Public with verification token | Confirm email address |
/open-with |
Dynamic, client-heavy | Public | PWA file-handling launch target (Section 15.6), routes the opened file into the tool grid |
/account/delete |
Dynamic | Session | DSAR account deletion flow (Section 17) |
/offline |
Static | Public | Service-worker offline fallback shell (Section 15.6) |
Signer portal (sign.pdfworks.io), route group (signer)
| Path | Rendering | Auth | Purpose |
|---|---|---|---|
/[envelopeId] |
Dynamic | Signer token | Signing session: consent, field completion, signature capture |
/[envelopeId]/complete |
Dynamic | Signer token | Post-signature confirmation, download completed PDF and certificate |
/[envelopeId]/declined |
Dynamic | Signer token | Decline confirmation |
/verify |
Static shell, client-heavy | Public, unauthenticated | Certificate hash verification (upload a PDF or paste a hash), implements the mechanism in Section 10 |
15.1.2 Tool route slugs and the marketing/app relationship #
The 25 client-side tools (Section 1.1) and 9 server-side conversion tools (Section 1.2) each own exactly one slug. The same slug is reused unchanged on both hosts — there is no per-host slug variant, and the /tools/ segment is never dropped on either host. The general rule, with no exceptions: the marketing landing page is pdfworks.io/tools/{slug} and the app workbench is app.pdfworks.io/tools/{slug}. This table is the single source of truth for all 34 routes; nothing elsewhere in the product introduces a different slug or a different path shape for a tool.
| Slug | Tool | Location | Marketing route (pdfworks.io) |
App route (app.pdfworks.io) |
|---|---|---|---|---|
merge-pdf |
Merge | Client | /tools/merge-pdf |
/tools/merge-pdf |
split-pdf |
Split | Client | /tools/split-pdf |
/tools/split-pdf |
extract-pages |
Extract pages | Client | /tools/extract-pages |
/tools/extract-pages |
organize-pdf |
Organize / reorder | Client | /tools/organize-pdf |
/tools/organize-pdf |
rotate-pdf |
Rotate | Client | /tools/rotate-pdf |
/tools/rotate-pdf |
delete-pages |
Delete pages | Client | /tools/delete-pages |
/tools/delete-pages |
insert-pages |
Insert blank pages | Client | /tools/insert-pages |
/tools/insert-pages |
crop-pdf |
Crop | Client | /tools/crop-pdf |
/tools/crop-pdf |
compress-pdf |
Compress | Client | /tools/compress-pdf |
/tools/compress-pdf |
watermark-pdf |
Watermark (text + image) | Client | /tools/watermark-pdf |
/tools/watermark-pdf |
add-page-numbers |
Page numbers | Client | /tools/add-page-numbers |
/tools/add-page-numbers |
bates-numbering |
Bates numbering | Client | /tools/bates-numbering |
/tools/bates-numbering |
protect-pdf |
Protect (encrypt) | Client | /tools/protect-pdf |
/tools/protect-pdf |
unlock-pdf |
Unlock | Client | /tools/unlock-pdf |
/tools/unlock-pdf |
flatten-pdf |
Flatten | Client | /tools/flatten-pdf |
/tools/flatten-pdf |
redact-pdf |
Redact | Client | /tools/redact-pdf |
/tools/redact-pdf |
annotate-pdf |
Annotate & shapes | Client | /tools/annotate-pdf |
/tools/annotate-pdf |
edit-pdf |
Edit text & images | Client | /tools/edit-pdf |
/tools/edit-pdf |
fill-form |
Fill forms | Client | /tools/fill-form |
/tools/fill-form |
create-form |
Create fillable forms | Client | /tools/create-form |
/tools/create-form |
sign-pdf |
Self-sign | Client | /tools/sign-pdf |
/tools/sign-pdf |
pdf-to-image |
PDF → JPG/PNG | Client | /tools/pdf-to-image |
/tools/pdf-to-image |
image-to-pdf |
JPG/PNG → PDF | Client | /tools/image-to-pdf |
/tools/image-to-pdf |
repair-pdf |
Repair | Client | /tools/repair-pdf |
/tools/repair-pdf |
edit-metadata |
Edit metadata | Client | /tools/edit-metadata |
/tools/edit-metadata |
ocr-pdf |
OCR | Server | /tools/ocr-pdf |
/tools/ocr-pdf |
pdf-to-word |
PDF → DOCX | Server | /tools/pdf-to-word |
/tools/pdf-to-word |
pdf-to-excel |
PDF → XLSX | Server | /tools/pdf-to-excel |
/tools/pdf-to-excel |
pdf-to-powerpoint |
PDF → PPTX | Server | /tools/pdf-to-powerpoint |
/tools/pdf-to-powerpoint |
word-to-pdf |
DOCX → PDF | Server | /tools/word-to-pdf |
/tools/word-to-pdf |
excel-to-pdf |
XLSX → PDF | Server | /tools/excel-to-pdf |
/tools/excel-to-pdf |
powerpoint-to-pdf |
PPTX → PDF | Server | /tools/powerpoint-to-pdf |
/tools/powerpoint-to-pdf |
html-to-pdf |
HTML → PDF | Server | /tools/html-to-pdf |
/tools/html-to-pdf |
pdf-to-html |
PDF → HTML | Server | /tools/pdf-to-html |
/tools/pdf-to-html |
pdf-to-image exposes a format toggle (JPG or PNG) inside the options panel (Section 15.4) rather than splitting into two routes; both output formats share one client-side pipeline. Tool detail specifications live in Sections 7, 8, and 9; this section defines only how each tool is hosted, rendered, and wired into the shared shell.
Which host serves which page. The marketing route (pdfworks.io/tools/{slug}) is served by the (marketing) route group as a statically generated, ISR-revalidated (24h) page: SEO copy, screenshots, an FAQ block, and a single "Use this tool free" call to action. It never renders the workbench and never loads packages/pdfcore. The app route (app.pdfworks.io/tools/{slug}) is served by the (app) route group as the actual <ToolWorkbench> (Section 15.4): the file drop zone, the canvas, the options panel, and the job pipeline. It never renders marketing copy beyond the tool's name and a one-line description in its header.
What happens on direct arrival, per host. A user who lands on the marketing route directly (organic search, a shared link, a bookmark) sees the full SEO page and must click through to start using the tool — the marketing page's Auth value is plain Public and it takes no file input itself. A user who lands on the app route directly (a bookmarked tool, a link shared by another user, /open-with's PWA hand-off per Section 15.6) gets the working tool immediately: the app route's Auth value is Public, gated at run (Section 15.1.1), so the workbench renders and accepts a file for any of the six guest-tier tools, or for the other 28 tools renders with the primary action wired to open a signup/login prompt on first use rather than gating the whole page. Neither host requires the other to be visited first; both are independently complete entry points for the same slug.
Redirect and canonical relationship. There is no server-side redirect between the two hosts for a tool route — the relationship from marketing to app is a same-tab in-page link (the "Use this tool free" call to action), never an HTTP redirect, so a marketing page never issues a 301/302 to app.pdfworks.io. The two pages are treated as distinct URLs for search purposes, not canonical duplicates of each other: the marketing route is the one and only indexed, crawlable, canonical URL for a given tool (it carries a self-referencing <link rel="canonical"> and appears in /tools' sitemap); the app route carries <meta name="robots" content="noindex, follow"> in its <head> and is deliberately excluded from the sitemap, so search engines never index or rank the workbench page against its own marketing counterpart. This is the single answer downstream SEO work builds on: index the marketing route, link (never redirect) to the app route, and noindex the app route.
15.1.3 API and web slug namespaces #
The public API (Section 14.5) and the web routes above deliberately use two different slug vocabularies for the same 34 tools. This is a considered split, not an inconsistency, and the mapping between them is mechanical:
- API slugs (Section 14.5) are short, bare tool identifiers used as the
toolvalue in a job payload and in the public API's own path segments where applicable —merge,split,compress,redact,ocr, and so on. They carry no-pdfsuffix because every API call already operates in a PDF-only context (POST /v1/jobsalways takes a PDF or produces one), so the suffix would be redundant on every single call. - Web route slugs (Section 15.1.2 above) are suffixed —
merge-pdf,split-pdf,compress-pdf,redact-pdf— because a marketing URL is read by humans and by search engines outside any API context, where "merge" alone is ambiguous (merge what?) and "merge-pdf" is both self-describing and keyword-rich for search.
The generation rule. The web route slug is derived from the API slug by one of two mechanical transforms, chosen once per tool and then fixed forever in the lookup table below: (1) append -pdf — the default, used whenever the API slug does not already contain the word "pdf" (merge → merge-pdf, compress → compress-pdf, redact → redact-pdf); (2) use as-is — for the ten conversion tools whose API slug already names pdf on one side of the conversion (pdf-to-word, word-to-pdf, pdf-to-image, image-to-pdf, and so on), where appending a second -pdf would be redundant, so the web slug is identical to the API slug. Four tools (organize, page-numbers, edit, sign) fall under neither transform because their web slug was chosen independently for marketing and SEO clarity before the API slug was finalized; for those four the table below, not a transform function, is authoritative. The full mapping is generated once, at build time, from the tool registry (apps/web/lib/tools/registry.ts) into a lookup table consumed by both the web app's route generation (generateStaticParams, Section 15.2) and the marketing sitemap generator, and a build-time check fails if a new tool's slug pair is added to the registry without a corresponding row in this table, so the two vocabularies can never drift out of sync with each other.
| API slug (Section 14.5) | Web route slug (Section 15.1.2) |
|---|---|
merge |
merge-pdf |
split |
split-pdf |
extract-pages |
extract-pages |
organize |
organize-pdf |
rotate |
rotate-pdf |
delete-pages |
delete-pages |
insert-pages |
insert-pages |
crop |
crop-pdf |
compress |
compress-pdf |
watermark |
watermark-pdf |
page-numbers |
add-page-numbers |
bates-numbering |
bates-numbering |
protect |
protect-pdf |
unlock |
unlock-pdf |
flatten |
flatten-pdf |
redact |
redact-pdf |
annotate |
annotate-pdf |
edit |
edit-pdf |
fill-form |
fill-form |
create-form |
create-form |
sign |
sign-pdf |
pdf-to-image |
pdf-to-image |
image-to-pdf |
image-to-pdf |
repair |
repair-pdf |
edit-metadata |
edit-metadata |
ocr |
ocr-pdf |
pdf-to-word |
pdf-to-word |
pdf-to-excel |
pdf-to-excel |
pdf-to-powerpoint |
pdf-to-powerpoint |
word-to-pdf |
word-to-pdf |
excel-to-pdf |
excel-to-pdf |
powerpoint-to-pdf |
powerpoint-to-pdf |
html-to-pdf |
html-to-pdf |
pdf-to-html |
pdf-to-html |
15.1.4 Layout and provider composition #
Each route group has exactly one layout.tsx that mounts the providers that group's pages need, and no provider is mounted higher than the group that needs it — the root app/layout.tsx stays deliberately empty of anything beyond <html>/<body> and the font variables from Section 16.2, so the marketing group never pays for providers only the app group needs.
// apps/web/app/_app/layout.tsx (Server Component)
import { Providers } from '@/components/providers';
import { AppShell } from '@/components/app-shell';
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await getServerSession(); // reads the better-auth cookie, Section 17.2
return (
<Providers session={session}>
<AppShell session={session}>{children}</AppShell>
</Providers>
);
}// apps/web/components/providers.tsx
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './theme-provider';
import { LocaleProvider } from './locale-provider';
import { ToastProvider } from './toast-provider';
import { getQueryClient } from '@/lib/query-client';
export function Providers({ session, children }: { session: Session | null; children: React.ReactNode }) {
const queryClient = getQueryClient(); // one client per request on the server, one per tab on the client
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<LocaleProvider>
<ToastProvider>{children}</ToastProvider>
</LocaleProvider>
</ThemeProvider>
</QueryClientProvider>
);
}(marketing) mounts only ThemeProvider and LocaleProvider (a static marketing page has no server cache to manage and no toasts to queue). (signer) mounts ThemeProvider, LocaleProvider, and QueryClientProvider (the signing session polls envelope status) but never ToastProvider — signer-facing feedback uses inline banners only, since a signer has no persistent session in which a transient toast's history would matter. This asymmetry is intentional: a provider is added to a layout only when a component beneath that specific layout consumes it, verified by an ESLint rule that flags an imported context hook with no matching provider in scope.
15.2 Rendering strategy #
Default is Server Component. Every page.tsx and layout.tsx in (marketing) and (app) is a Server Component unless it has an explicit 'use client' directive. 'use client' is placed only at the narrowest leaf that actually needs one of: local state, effects, event handlers, or a browser-only API (WASM, Canvas, OPFS, IndexedDB/Dexie, navigator.*, drag-and-drop). It is never placed on a route's page.tsx or on a shared layout.tsx. The rule in one sentence: a page is a Server Component shell that mounts exactly one Client Component boundary; everything interactive lives below that boundary.
// apps/web/app/_app/tools/[tool]/page.tsx (Server Component, no 'use client')
import { ToolWorkbench } from '@/components/tool-workbench';
import { getToolDefinition } from '@/lib/tools/registry';
import { notFound } from 'next/navigation';
export default async function ToolPage({ params }: { params: Promise<{ tool: string }> }) {
const { tool } = await params;
const definition = getToolDefinition(tool);
if (!definition) notFound();
return <ToolWorkbench definition={definition} />;
}
export function generateStaticParams() {
return TOOL_SLUGS.map((tool) => ({ tool }));
}// apps/web/components/tool-workbench.tsx
'use client';
// Owns: the Zustand store (15.3), the Comlink worker pool (Section 3.7),
// OPFS/Dexie access, drag-and-drop, canvas rendering. Nothing above this
// boundary touches the browser.The WASM engine is client-only by construction. packages/pdfcore's TypeScript binding layer is imported exclusively from Client Components; it is never imported into a Server Component, a Route Handler, or middleware.ts in apps/web (the same artifact runs server-side, but only inside apps/api's Node process per Section 3.6 — the two hosts do not share a JavaScript module boundary, only the compiled .wasm artifact and its bindings package). A Server Component page renders the tool's static chrome (title, description, structured data) so the route is fully crawlable and has a paintable first frame before a single byte of JavaScript for the engine has loaded; the engine loads only after the Client Component boundary mounts.
Streaming and Suspense. Routes that need per-user server data (/jobs, /esign, /billing, /workspace/*) use <Suspense> around a Server Component that calls prefetchQuery against the internal API and hands a dehydrated cache to a HydrationBoundary; the surrounding shell (header, nav, page title) renders immediately and the data-bearing region streams in behind a skeleton (Section 16.4). Tool workbench routes do not stream server data — their state is local (Section 15.3) — so no server Suspense boundary is needed there; the equivalent "not ready yet" state is the WASM-loading shell described in Section 15.8.
Loading and error boundaries. Every route segment under (app) has a loading.tsx and an error.tsx:
loading.tsxrenders the same shell as the page with content regions replaced by skeleton components (Section 16.4) sized to match the eventual layout, so there is no layout shift on hydration.error.tsxis a Client Component (Next.js requires this) that renders the full-page error card from the taxonomy in Section 15.7, logs the error to Sentry with the route name andrequestIdif one is present, and offers a "Try again" button that calls thereset()function Next.js passes to error boundaries.- The root
(app)layout also has a top-levelglobal-error.tsxthat catches errors thrown by the root layout itself (whicherror.tsxcannot catch) and renders a minimal, dependency-free error page.
15.2.1 Suspense in practice #
The /jobs route illustrates the streaming pattern used by every data-bearing (app) route:
// apps/web/app/_app/jobs/page.tsx (Server Component)
import { Suspense } from 'react';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { queryKeys } from '@/lib/query-keys';
import { fetchJobs } from '@/lib/internal-api/jobs';
import { JobHistoryList } from '@/components/job-history-list';
import { JobHistorySkeleton } from '@/components/job-history-skeleton';
export default async function JobsPage() {
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
queryKey: queryKeys.jobs.list({ status: 'all' }),
queryFn: () => fetchJobs({ status: 'all' }),
});
return (
<main id="main-content">
<h1>Job history</h1>
<Suspense fallback={<JobHistorySkeleton />}>
<HydrationBoundary state={dehydrate(queryClient)}>
<JobHistoryList />
</HydrationBoundary>
</Suspense>
</main>
);
}<JobHistoryList> is a Client Component that calls useSuspenseQuery(queryKeys.jobs.list({ status: 'all' })); because the server already populated the cache with the same query key, the client-side call resolves instantly from the hydrated cache on first paint, and subsequent refetches (triggered by the polling rule in Section 15.3) hit the network normally. The header (<h1>Job history</h1>) and page chrome render immediately regardless of how long the prefetch takes, since only the list itself is wrapped in Suspense.
15.2.2 Degradation by browser capability #
The support matrix from Section 19 (last two major versions of Chrome, Edge, Firefox, Safari, plus iOS Safari and Chrome Android) is not uniform on cross-origin isolation and SharedArrayBuffer. The rendering layer checks crossOriginIsolated once at engine-initialization time and selects the multi-threaded or single-threaded pdfcore artifact (Section 3.6) accordingly; no other part of the rendering strategy branches on browser identity. The table below is the complete, current degradation surface and is kept in sync with the CI browser matrix in Section 19:
| Capability | Unavailable on | Effect |
|---|---|---|
SharedArrayBuffer / threaded WASM |
Any browser blocking cross-origin isolation via an extension or a credentialless-incompatible embed |
Single-threaded pdfcore artifact loads instead; correctness is identical, large-document operations (merge of many files, OCR-adjacent rasterization) take longer |
OffscreenCanvas transfer |
Legacy Android WebView contexts inside some OEM browsers | Main-thread ImageBitmap draw path (Section 15.5); functionally identical, lower scroll frame rate under heavy zoom |
File Handling API (launchQueue) |
All non-Chromium browsers, and Chromium browsers without the PWA installed | The /open-with route and manifest file_handlers entry (Section 15.6) simply have no OS-level entry point; in-app "Choose files" continues to work everywhere |
beforeinstallprompt |
Safari (desktop and iOS), Firefox | The custom install banner (Section 15.6) never fires; Safari users see platform-native "Add to Home Screen" instructions in the /tools page's install-hint banner instead |
15.3 State management #
Four kinds of state, one tool for each, never mixed:
| State kind | Tool | Scope | Example |
|---|---|---|---|
| Server cache | TanStack Query 5.x | Anything that originated from /internal or /v1 |
job list, envelope status, billing usage |
| URL | useSearchParams / next/navigation |
Anything the user should be able to bookmark or share | tool grid filter, job history page, active tab |
| Tool-local | Zustand 5.x store, one instance per open tool | The working document, selection, undo/redo, in-progress edits | the page organizer's page order, the redaction tool's marked regions |
| Global UI | React Context | Exactly two things: theme, locale | dark mode, active locale |
Server cache — TanStack Query. Query keys are arrays, most-specific segment last, and always start with the resource family so cache invalidation can target a whole family with a prefix match:
const queryKeys = {
jobs: {
all: ['jobs'] as const,
list: (filters: JobListFilters) => ['jobs', 'list', filters] as const,
detail: (jobId: string) => ['jobs', 'detail', jobId] as const,
},
envelopes: {
all: ['envelopes'] as const,
list: (filters: EnvelopeListFilters) => ['envelopes', 'list', filters] as const,
detail: (envelopeId: string) => ['envelopes', 'detail', envelopeId] as const,
},
billing: {
usage: ['billing', 'usage'] as const,
invoices: ['billing', 'invoices'] as const,
},
workspace: {
members: (workspaceId: string) => ['workspace', workspaceId, 'members'] as const,
},
};Invalidation rules: a mutation invalidates the narrowest key that is guaranteed to have changed, plus the parent list key if the mutation changes list membership (create, delete, status transition). Example: completing an envelope invalidates envelopes.detail(envelopeId) and envelopes.list (any filter), because the envelope now belongs in a different filtered view (e.g., "completed"). staleTime defaults to 30 seconds for list queries and 0 for detail queries that back an actively-polled job (Section 4.7's job state machine is polled via refetchInterval: (query) => isTerminal(query.state.data?.status) ? false : 2000). Mutations use optimistic updates only for reversible, low-risk actions (renaming a document, reordering a list); anything that spends quota or money (starting a job, sending an envelope) waits for the server response before updating the cache.
URL — searchParams. Shareable state never lives in Zustand or Context. The tool grid's search and category filter, the job history list's status/date filters, and the active tab on multi-tab pages (e.g., /settings sub-tabs) are all encoded as query parameters (?category=security&q=redact, ?status=failed&since=7d) and read with useSearchParams, so a copied URL reproduces the exact view.
Tool-local — Zustand. Every tool workbench mounts its own Zustand store instance (created with create() inside a useState initializer in the workbench root, not a module-level singleton, so that opening the same tool twice in two tabs never cross-contaminates). The store shape is standardized across all 34 tools; tools that don't need a field (e.g., selection for compress-pdf) simply leave it at its default:
interface PageModel {
id: string; // stable client-generated id, independent of page index
sourceDocId: string; // which input document this page came from (merge/organize need this)
sourcePageIndex: number; // 0-based index into the source document
rotation: 0 | 90 | 180 | 270;
width: number; // points, at 72 DPI
height: number;
thumbnailUrl: string | null; // objectURL backed by an OPFS-resident bitmap
}
interface DocumentHandle {
id: string; // uuidv7, matches the OPFS directory name
fileName: string;
byteLength: number;
opfsPath: string; // path within the origin-private root
loadedAt: string; // ISO 8601
}
interface ToolWorkbenchState {
// --- document ---
documents: DocumentHandle[];
pages: PageModel[]; // the working page order/state, independent of source order
selection: { pageIds: string[]; anchorPageId: string | null };
// --- history ---
undoStack: ToolWorkbenchPatch[];
redoStack: ToolWorkbenchPatch[];
isDirty: boolean;
// --- job ---
jobStatus: 'idle' | 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled' | 'expired';
jobProgress: number; // 0-100
jobStage: string | null;
jobError: { code: string; message: string } | null;
// --- actions ---
addDocuments: (files: File[]) => Promise<void>;
reorderPages: (pageIds: string[]) => void;
removePages: (pageIds: string[]) => void;
setSelection: (pageIds: string[]) => void;
applyPatch: (patch: ToolWorkbenchPatch) => void;
undo: () => void;
redo: () => void;
runJob: () => Promise<void>;
cancelJob: () => void;
reset: () => void;
}
type ToolWorkbenchPatch =
| { type: 'reorder'; before: string[]; after: string[] }
| { type: 'remove'; pages: PageModel[]; atIndex: number }
| { type: 'rotate'; pageId: string; before: PageModel['rotation']; after: PageModel['rotation'] }
| { type: 'insertBlank'; page: PageModel; atIndex: number };jobStatus uses exactly the vocabulary defined by the job state machine in Section 4.7, so the same status pill component (Section 16.4) renders identically whether the underlying job ran in a worker thread or on the server. The undo/redo stacks store inverse patches, not full document snapshots, capped at 100 entries; beyond that the oldest entry is dropped silently (undo history is a convenience, not a save file). isDirty is true whenever undoStack is non-empty and gates the "leave without saving" browser confirmation (beforeunload) and the result panel's "start over" affordance (Section 15.4).
Global UI — Context. ThemeProvider (light/dark/system, backed by localStorage for the preference and a data-theme attribute on <html>) and LocaleProvider (wraps next-intl's provider, Section 16.7) are the only two Context providers in the tree. Every other piece of cross-component state that looks like it wants Context (open/closed state of a dialog, form state, toast queue) is either local useState, a dedicated Zustand store (the toast queue is a small global Zustand store, since toasts are triggered from anywhere including non-visual code like a mutation's onError), or owned by the Radix primitive itself.
15.3.1 Reading server cache and Context in a component #
Two short examples that make the conventions above concrete. A TanStack Query hook wrapping the raw fetch, so components never call fetch directly against /internal:
// apps/web/lib/internal-api/jobs.ts
export function useJobList(filters: JobListFilters) {
return useQuery({
queryKey: queryKeys.jobs.list(filters),
queryFn: () => fetchJobs(filters),
staleTime: 30_000,
});
}
export function useJobDetail(jobId: string) {
return useQuery({
queryKey: queryKeys.jobs.detail(jobId),
queryFn: () => fetchJob(jobId),
refetchInterval: (query) => {
const status = query.state.data?.status;
const terminal = status === 'succeeded' || status === 'failed' || status === 'canceled' || status === 'expired';
return terminal ? false : 2000;
},
});
}And the theme Context, the simpler of the two global providers (locale follows the same shape, wrapping next-intl's own provider instead of a bespoke value):
// apps/web/components/theme-provider.tsx
'use client';
type Theme = 'light' | 'dark' | 'system';
const ThemeContext = createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme() ?? 'system');
const setTheme = useCallback((t: Theme) => {
setThemeState(t);
localStorage.setItem('pdfworks-theme', t);
document.documentElement.dataset.theme = resolveTheme(t);
}, []);
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}15.4 The tool page pattern #
Every one of the 34 tool routes renders inside one shared shell, <ToolWorkbench>, so that adding tool #35 never means designing a new page:
┌─────────────────────────────────────────────────────────────┐
│ Header: tool name · Processing Location Indicator (16.3) │
├───────────────┬─────────────────────────────┬────────────────┤
│ │ │ │
│ File Drop │ Preview Canvas │ Options Panel │
│ Zone (empty │ (Section 15.5) │ (tool-specific│
│ state) OR │ │ form) │
│ Page Grid │ │ │
│ (populated) │ │ │
│ │ │ │
├───────────────┴─────────────────────────────┴────────────────┤
│ Action Bar: secondary actions (left) · primary action (right)│
├─────────────────────────────────────────────────────────────┤
│ Result Panel (replaces the shell above once the job succeeds)│
└─────────────────────────────────────────────────────────────┘The tool module contract. A tool is a plain object implementing ToolModule; <ToolWorkbench> is generic over it and renders identically regardless of which tool is active:
interface ToolModule<TOptions extends Record<string, unknown>> {
slug: string;
location: 'client' | 'server';
acceptedMimeTypes: string[]; // e.g. ['application/pdf']
maxFiles: number; // 1 for split, unbounded (plan-capped) for merge
optionsSchema: z.ZodType<TOptions>; // from packages/contracts, shared with the API (Section 4)
defaultOptions: TOptions;
OptionsPanel: React.ComponentType<{
options: TOptions;
onChange: (next: TOptions) => void;
pages: PageModel[];
}>;
run: (input: { documents: DocumentHandle[]; pages: PageModel[]; options: TOptions }) => Promise<ToolRunResult>;
}
interface ToolRunResult {
outputs: { fileName: string; opfsPath: string; byteLength: number }[];
report?: { kind: string; opfsPath: string }; // e.g. the redaction verification report, Section 9.1
}Adding a new client-side tool is four steps: (1) define the Zod options schema in packages/contracts, shared with the server so the same validation runs on both hosts (Section 4); (2) write the OptionsPanel component; (3) write run, which calls into packages/pdfcore's bindings through the worker pool (Section 3.7); (4) register the module in apps/web/lib/tools/registry.ts and add the slug to TOOL_SLUGS for generateStaticParams. No route file, no shell component, and no result-handling code is ever touched. A server-side tool follows the same four steps except run posts to /v1/jobs instead of calling the worker pool, and the workbench polls job status via TanStack Query (Section 15.3) instead of awaiting a promise directly.
File-input component. <FileDropZone> is the empty-state entry point, shared by every tool:
- Drag and drop: a full-panel drop target; dragging over the window (not just the panel) highlights the panel via a
dragenter/dragleavecounter to avoid flicker from child-element boundary crossings. - Click: opens the native file picker (
<input type="file" multiple accept="application/pdf">,multipleomitted whenmaxFiles === 1). - Paste from clipboard: a document-level
pastelistener active only while the drop zone is mounted and focused-within, readingClipboardEvent.clipboardData.files. - Multiple files: enforced against
maxFiles; exceeding it truncates the selection to the firstmaxFilesfiles and shows a toast ("Only the first 10 files were added — this tool accepts up to 10 at once.") rather than rejecting the whole drop. - Per-file validation: each file is checked against
acceptedMimeTypesby magic-byte sniffing (never the browser-reportedContent-Type, consistent with Section 17's upload-validation rule) before it is added todocuments; a rejected file shows an inline error row with the reason ("invoice.exe" is not a PDF file) but does not block the other files in the same drop. - Reorder: once files are added, they render as a
<PageGrid>(Section 15.5, Section 16.4.2) supporting pointer drag-and-drop and the full keyboard-only reorder path specified in Section 16.6.
Result component. <ResultPanel> replaces the shell once jobStatus reaches succeeded:
- Download: a per-output download button; for a single output it is the primary action.
- Download all as ZIP: shown when
outputs.length > 1(split, extract, PDF-to-image on a multi-page document); the ZIP is assembled client-side for client-side tools (using a Web Worker to avoid blocking the main thread) and server-side (streamed) for server-side tools. - Continue to another tool: a "Continue in {tool}" picker restricted to tools compatible with the current output's MIME type, which hands the output document straight into the next tool's
documentsstate without a re-upload or re-download round trip. - Save to history: persists the job's metadata to the job history list (Section 13); for client-side jobs this writes a Dexie metadata row only (never the file bytes, per Section 3.7), and the underlying OPFS artifact is retained until the 24-hour janitor sweep.
- Start over: clears the workbench store (
reset()) and returns to the empty-state drop zone; guarded by a confirmation only ifisDirtyand the user has not yet downloaded a result.
15.4.1 A complete tool module example #
compress-pdf's options schema and panel, illustrating the contract end to end — the same Zod schema shared with apps/api per Section 4 so client and server validate identically:
// packages/contracts/tools/compress-pdf.ts
import { z } from 'zod';
export const compressPdfOptionsSchema = z.object({
quality: z.enum(['low', 'medium', 'high']).default('medium'),
downsampleImages: z.boolean().default(true),
targetDpi: z.number().int().min(72).max(300).default(150),
});
export type CompressPdfOptions = z.infer<typeof compressPdfOptionsSchema>;// apps/web/lib/tools/compress-pdf/options-panel.tsx
'use client';
export function CompressPdfOptionsPanel({ options, onChange }: ToolModule<CompressPdfOptions>['OptionsPanel']) {
const t = useTranslations('tools.compressPdf');
return (
<fieldset>
<legend>{t('optionsLabel')}</legend>
<RadioGroup
value={options.quality}
onValueChange={(quality) => onChange({ ...options, quality: quality as CompressPdfOptions['quality'] })}
>
<Radio value="low" label={t('qualityLow')} hint={t('qualityLowHint')} />
<Radio value="medium" label={t('qualityMedium')} hint={t('qualityMediumHint')} />
<Radio value="high" label={t('qualityHigh')} hint={t('qualityHighHint')} />
</RadioGroup>
<Switch
checked={options.downsampleImages}
onCheckedChange={(v) => onChange({ ...options, downsampleImages: v })}
label={t('downsampleImages')}
/>
</fieldset>
);
}// apps/web/lib/tools/compress-pdf/run.ts
export async function run({ documents, options }: { documents: DocumentHandle[]; options: CompressPdfOptions }) {
const engine = await getEngine(); // Comlink-wrapped worker pool, Section 3.7
const outputBytes = await engine.compress(documents[0].opfsPath, options);
const opfsPath = await writeToOpfsScratch(outputBytes);
return { outputs: [{ fileName: renameWithSuffix(documents[0].fileName, '-compressed'), opfsPath, byteLength: outputBytes.byteLength }] };
}// apps/web/lib/tools/registry.ts
export const compressPdf: ToolModule<CompressPdfOptions> = {
slug: 'compress-pdf',
location: 'client',
acceptedMimeTypes: ['application/pdf'],
maxFiles: 1,
optionsSchema: compressPdfOptionsSchema,
defaultOptions: compressPdfOptionsSchema.parse({}),
OptionsPanel: CompressPdfOptionsPanel,
run,
};A server-side tool's run differs only in its body — it calls postJob('/v1/jobs', { tool: 'ocr-pdf', documentId, options }) and returns once the job (Section 4.7) reaches a terminal state, polled through the same useJobDetail hook shown in Section 15.3.1 — everything else in the module (schema, panel, registry entry) follows the identical shape.
15.4.2 Server-fallback consent modal #
A client-side job can fail for device-memory reasons — a very large or very complex file exceeds what the browser's WASM heap can hold. When that happens, the opt-in server-fallback rule (Section 6.3) requires an explicit, informed click before any byte is uploaded; this subsection specifies the component that click happens through.
When it appears. <ServerFallbackConsentDialog> (a Radix Dialog per Section 16.4) mounts only when a client-side run() throws a memory-class error (detected via the engine's OutOfMemoryError signal or a WASM allocation failure surfaced through packages/pdfcore's bindings) for a tool that is not on the exclusion list. It is never rendered for Redact, Protect, Unlock, or Self-sign — those four tools are hard-excluded from the server-fallback path entirely (Section 6.3); a memory failure on one of them instead renders the plain processing_error result state from Section 15.7, with no fallback offered, because their execution model requires the file to stay client-side by design.
Copy. Title: "This file is too large for your device to finish." Body: "We can finish this on our servers instead. The file will be uploaded, encrypted, and deleted within 24 hours — see how this compares to on-device processing above." The Processing Location Indicator (Section 16.3) full variant is rendered directly above the two actions, already switched to its on-server state, so the last thing the user sees before choosing is exactly where the file would go, per the indicator's confirmation-dialog placement rule.
Two equally weighted actions. The dialog has exactly two buttons, both the secondary variant (Section 16.4.5) at identical width and identical visual weight — neither is styled as the dialog's dominant action, which is a deliberate, explicit exception to the "one obvious primary action" rule in Section 16.1, justified for the same reason the "never hide the free path" principle exists: accepting a change of processing location is a consequential, privacy-relevant choice, not a routine confirmation, so the UI must not visually nudge the user toward either answer.
- "Try again on this device" (decline): closes the dialog, returns the tool to its pre-run state with the original files and options intact, and does not upload anything. This is never rendered as a smaller, lower-contrast, or lower-position control than the accept action — the decline path is exactly as easy to find and click as the accept path.
- "Continue on our servers" (accept): closes the dialog and re-runs the operation via
POST /v1/jobswith the same options, transitioning the tool'sjobStatus(Section 15.3) toqueuedexactly as a native server-side tool would.
Dismissing the dialog by any other means (Esc, clicking the scrim, the dialog's close button) is treated identically to the decline action — there is no third, ambiguous outcome.
Component props:
interface ServerFallbackConsentDialogProps {
tool: string;
open: boolean;
onDecline: () => void;
onAccept: () => void;
}Analytics. Mounting the dialog fires server_fallback_offered; clicking "Continue on our servers" fires server_fallback_accepted (Section 15.9). There is no event for the decline path beyond the dialog simply closing — declining is not treated as a funnel drop-off requiring its own instrumentation, since staying on-device is the expected default outcome, not a failure.
15.5 The PDF viewer and page canvas #
Rendering pipeline. A page is rendered by asking packages/pdfcore (via the Comlink worker pool, Section 3.7) for an RGBA bitmap at a target device-pixel resolution; the bitmap is drawn to a <canvas> with putImageData (for bitmaps produced synchronously in the same worker) or transferred as an ImageBitmap (for bitmaps produced in a different worker than the one owning the canvas). The engine never draws directly to a DOM canvas — it only ever returns pixels — which keeps the render path identical between the browser and the server-side byte-equality tests referenced in Section 3.7.
Canvas vs. OffscreenCanvas. Each visible page has its <canvas> element's control transferred to a worker via canvas.transferControlToOffscreen() where the browser supports it (all four target desktop browsers in Section 19's support matrix; Safari has supported this since version 16.4). The worker owns the OffscreenCanvas, calls the engine, and draws directly — the main thread never touches pixels for that page. Where OffscreenCanvas transfer is unavailable (older WebViews inside the PWA install path on some Android OEM browsers), the fallback draws on the main thread from an ImageBitmap message; this is functionally identical, only slower under heavy scroll, and requires no separate code path in the workbench — only in the render-target abstraction inside packages/pdfcore's bindings.
Tile and page virtualization. For documents up to 200 pages, every page's <canvas> element exists in the DOM but only pages within the viewport ± 2 are rendered at full resolution; off-screen pages show their cached thumbnail bitmap (Section 15.5's thumbnail rail) scaled up as a placeholder. Above 200 pages (and unconditionally above 1,000 pages, which covers the 5,000-page documents named in Section 19's test corpus), the page list itself is windowed — only DOM nodes for pages within the viewport ± 5 exist at all, using fixed per-page height estimates seeded from the document's declared page size and corrected once each page's real thumbnail is measured, with content-visibility: auto on the scroll container to let the browser skip layout for anything outside the visible range as a second line of defense. At zoom levels above 150%, a single page is additionally split into 1024×1024 device-pixel tiles rendered independently, so scrolling a heavily zoomed page never requires re-rendering the whole page bitmap for a small viewport shift.
Zoom levels and fit modes. Discrete zoom steps: 25, 50, 75, 100, 125, 150, 200, 300, 400 percent, plus three fit modes computed against the current viewport: Fit width (default on load), Fit page (whole page visible), and Actual size (100%, aliased to the 100% step). Zoom and fit mode are tool-local state (not persisted across sessions) except that the last-used zoom preference is written to a Dexie metadata row per Section 3.7 and restored as the default for the next session.
Page navigation. A page-number input with <input type="number"> semantics (typing a number and pressing Enter jumps to that page), previous/next buttons, and keyboard shortcuts PageUp/PageDown and Home/End (first/last page) when the canvas region has focus. Scroll position and the page-number input stay synchronized via an IntersectionObserver watching each page's DOM node.
Text selection layer. A transparent DOM layer positioned absolutely over each page's canvas, populated with the engine's extracted text spans (each span a <span> positioned via transform: translate() and sized to its glyph run, with color: transparent and a ::selection style so the highlight is visible but the glyphs themselves are not double-rendered on top of the canvas bitmap). This layer is generated lazily, only for pages within the virtualization window, and is what makes text selection, copy, and the browser's native find-in-page (Ctrl/Cmd+F, intercepted and redirected to an in-app find bar, Section 16.4) possible without re-parsing the PDF on every interaction.
Annotation overlay layer. A second absolutely-positioned layer above the text layer, holding interactive elements: redaction region handles, annotation shapes, form field placement markers, and signature-field placeholders. It is implemented as SVG (not canvas) so individual shapes are addressable DOM nodes with their own ARIA roles, satisfying the keyboard-placement-mode requirement in Section 16.6. Editing an annotation never triggers a re-render of the page's bitmap; only the SVG layer repaints.
Thumbnail rail. A vertical strip (horizontal on narrow viewports, Section 16.5) of low-resolution page thumbnails (150px wide, generated once per page at 1x device pixel ratio and cached both in memory and in OPFS keyed by document id + page id + rotation, so re-opening the same document skips regeneration). Thumbnails render progressively, nearest-to-viewport first, and double as the drag handles for the page organizer described in Section 16.6.
Performance budgets. First page paints within 300 ms of the document finishing its initial parse (a partial parse sufficient to render page 1 begins before the rest of the document has streamed in, since the engine parses the cross-reference table and page tree incrementally). Scrolling maintains 60 fps on a mid-tier laptop for documents virtualized per the rule above. Thumbnail generation for a 200-page document completes within 4 seconds total, generated in the background at low priority (requestIdleCallback) so it never competes with an in-progress full-resolution render. A 5,000-page document reaches an interactive viewer (page 1 visible, thumbnail rail populated for the first screenful, page-number input functional) within 2 seconds; the remaining thumbnails continue generating in the background without blocking navigation.
15.5.1 Tile rendering, illustrated #
The render worker's tiling decision, simplified to its controlling logic (the actual bitmap production happens inside packages/pdfcore's bindings; this is the orchestration layer in apps/web):
// apps/web/lib/viewer/render-page.ts
const TILE_SIZE = 1024; // device pixels
const TILE_ZOOM_THRESHOLD = 1.5; // 150%
export async function renderPage(engine: PdfEngine, page: PageModel, zoom: number, canvas: OffscreenCanvas) {
const dpr = self.devicePixelRatio ?? 1;
const targetWidth = Math.round(page.width * zoom * dpr);
const targetHeight = Math.round(page.height * zoom * dpr);
if (zoom < TILE_ZOOM_THRESHOLD || (targetWidth <= TILE_SIZE && targetHeight <= TILE_SIZE)) {
const bitmap = await engine.renderPageBitmap(page.sourceDocId, page.sourcePageIndex, { width: targetWidth, height: targetHeight, rotation: page.rotation });
canvas.width = targetWidth;
canvas.height = targetHeight;
canvas.getContext('2d')!.transferFromImageBitmap(bitmap);
return;
}
// Above the threshold: render only the tiles intersecting the current viewport rect.
const visibleTiles = computeVisibleTiles(targetWidth, targetHeight, TILE_SIZE, getViewportRect());
for (const tile of visibleTiles) {
const bitmap = await engine.renderPageTile(page.sourceDocId, page.sourcePageIndex, tile, { rotation: page.rotation });
drawTile(canvas, tile, bitmap);
}
}15.5.2 Performance budgets by component #
| Component | Budget |
|---|---|
| First page paint | Within 300 ms of the initial incremental parse completing |
| Steady-state scroll | 60 fps within the virtualization window on a mid-tier laptop |
| Thumbnail generation, 200-page document | Under 4 s total, background priority |
| Interactive viewer, 5,000-page document | Under 2 s to page 1 visible + first screenful of thumbnails |
| Zoom step change | Under 150 ms to re-render the visible page(s) at the new zoom |
| Text layer generation per page | Under 50 ms, generated only for pages inside the virtualization window |
15.6 The PWA #
Manifest. apps/web/public/manifest.webmanifest, served only on app.pdfworks.io:
{
"name": "PDFWorks",
"short_name": "PDFWorks",
"description": "Edit, convert, and sign PDFs privately in your browser.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#0B0F14",
"background_color": "#0B0F14",
"categories": ["productivity", "business", "utilities"],
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"screenshots": [
{ "src": "/screenshots/tool-grid.png", "sizes": "1280x800", "type": "image/png", "form_factor": "wide" },
{ "src": "/screenshots/mobile-tool.png", "sizes": "750x1334", "type": "image/png", "form_factor": "narrow" }
],
"file_handlers": [
{
"action": "/open-with",
"accept": { "application/pdf": [".pdf"] }
}
]
}Service worker strategy per asset class, implemented with a hand-rolled service worker (no third-party SW framework, to keep the WASM caching logic auditable) registered only on app.pdfworks.io:
| Asset class | Strategy | Notes |
|---|---|---|
| App shell (root document, layout JS/CSS) | Stale-while-revalidate, versioned cache name | Cache name includes the build id so a deploy invalidates the whole shell cache atomically |
Static hashed assets (/_next/static/*) |
Cache-first, immutable | Filenames are content-hashed; a cache hit never needs revalidation |
The pdfcore WASM artifact |
Cache-first with integrity check, dedicated cache | Precached on install (see below); fetched with WebAssembly.instantiateStreaming, verified against the content hash embedded in the manifest before use |
Tool route documents (/tools/*) |
Network-first, cache fallback | Falls back to the cached shell plus /offline messaging if the network request fails |
/internal/* and /v1/* API calls |
Network-only | Never cached; TanStack Query owns freshness (Section 15.3) |
| Marketing pages | Not handled by this service worker | pdfworks.io has no service worker |
Precaching. On install, the service worker precaches the app shell and the WASM artifact (both the multi-threaded and single-threaded fallback builds from Section 3.6, since which one a given browser needs is only known once cross-origin isolation is checked at runtime). Precaching the ~6 MB Brotli-compressed artifact (Section 18.6's budget) happens once, on first visit, in the background, and does not block the install event from resolving.
Offline behavior. All 25 client-side tools listed in Section 1.1 work fully offline once the shell and WASM artifact are cached, because their entire execution happens on-device. The 9 server-side tools and the e-signature flow require a network round trip and cannot work offline; when the app detects it is offline (a navigator.onLine check backed by an active fetch health probe, since navigator.onLine alone is unreliable), those tool cards in the grid show a disabled state with the label "Needs a connection" and the tool route itself shows a full-width banner ("You're offline. This tool needs a connection — try one of the tools marked "On your device" instead.") in place of the drop zone. Client-side tools show no offline messaging at all beyond a small persistent "Offline" badge in the header, because nothing about their function changes.
Install prompt. The beforeinstallprompt event is captured and its prompt() call deferred; the custom install prompt (a banner, not a native browser dialog) is shown only after a user has completed at least two successful tool operations in the current or a prior session (tracked via a Dexie metadata counter) — never on a first visit, to avoid asking before the product has demonstrated value. If dismissed, it is not shown again for 30 days (also tracked in Dexie). If installed, it is never shown again.
Update detection and reload prompt. The service worker checks for an update on every visibilitychange to visible and additionally via a 30-minute periodic check while the tab is open. A new service worker is installed in the waiting state (it does not call skipWaiting() automatically) and the app shows a small, dismissible "Update available" toast with a "Reload" button. Clicking Reload sends a SKIP_WAITING message to the waiting worker, and the page reloads once controllerchange fires. The app never force-reloads a user out from under an in-progress edit; if isDirty is true in the active tool's Zustand store (Section 15.3), the toast copy changes to "Update available — will apply next time you start over or reload."
File-handling API. The file_handlers manifest entry registers PDFWorks as an available handler for .pdf files where the operating system and browser support the File Handling API (currently Chromium-based browsers on desktop after PWA install). Opening a PDF with PDFWorks selected as the handler launches /open-with, which receives the file via the launchQueue API, loads it into memory, and redirects the user to the tool grid with the file pre-attached, ready to drop into any client-side tool. This is opt-in at the OS file-association level; PDFWorks never requests to become the default PDF viewer automatically.
15.6.1 Service worker fetch handling #
// apps/web/public/sw.js (registered only on app.pdfworks.io)
const SHELL_CACHE = `shell-${BUILD_ID}`;
const WASM_CACHE = `wasm-${WASM_CONTENT_HASH}`;
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname.endsWith('.wasm')) {
event.respondWith(cacheFirstWithIntegrityCheck(event.request, WASM_CACHE));
return;
}
if (url.pathname.startsWith('/_next/static/')) {
event.respondWith(caches.match(event.request).then((hit) => hit ?? fetch(event.request)));
return;
}
if (url.pathname.startsWith('/v1/') || url.pathname.startsWith('/internal/')) {
return; // network-only: do not intercept
}
if (url.pathname.startsWith('/tools/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(SHELL_CACHE).then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request).then((hit) => hit ?? caches.match('/offline')))
);
}
});15.7 Error handling in the UI #
Error taxonomy and component mapping:
| Error type | Component | Retry affordance |
|---|---|---|
| Validation (bad input, before submission) | Inline field error, aria-describedby on the field |
User corrects the field; no retry button |
authentication_error |
Redirect to /login with a banner on return |
"Sign in" |
permission_error |
Banner (page-scoped) | None — "Contact your workspace admin" copy |
not_found_error |
Full-page error (404-style) | "Go to dashboard" |
conflict_error (e.g., idempotency key reuse) |
Toast | "Try again" re-submits with a fresh idempotency key |
rate_limit_error |
Banner with the Retry-After value surfaced as a countdown |
Auto-retries once the countdown reaches zero |
quota_error (402) |
Banner with an upgrade prompt (Section 16.4.4) | "Upgrade" — never a bare retry, since retrying without upgrading fails identically |
processing_error (422, engine or worker failure) |
Result-panel-shaped error state with the error message and, where available, the request id | "Try again"; after two consecutive failures, also offers "Report a problem" |
| Network error / offline | Banner (Section 15.6) | Auto-retries on online event |
| Unhandled client exception | Full-page error (error.tsx, Section 15.2) |
"Try again" calls reset(); "Reload page" as a fallback |
This taxonomy's type values are exactly the error envelope's type enum defined in Section 14.6; the UI never invents its own error type vocabulary, it only adds a presentation mapping on top of the server's.
Retry affordances never auto-retry a mutation that spends money or quota (starting a paid job, sending an envelope) without an explicit click, to avoid duplicate side effects; every such mutation is sent with the Idempotency-Key header (Section 14.8) precisely so a manual retry after a network error is always safe.
Error tracking. Every unhandled exception and every api_error (500) or processing_error (422) response is reported to Sentry 10.x with: the route name, the error type and code from the envelope, the requestId, a breadcrumb trail of the last 20 user actions (button clicks and navigations, by label only), and the browser/OS/viewport. Never reported: document content or filenames (the report includes a document id, never a filename or any extracted text), form field values, email addresses or IP addresses beyond what Sentry's own IP-scrubbing default already removes, and API keys or session tokens (redacted by a beforeSend hook that scans for the pk_live_/pk_test_ prefix pattern and any header matching Authorization or Cookie).
Copy rules. Every error message states three things in order: what happened, why (if known and useful), what to do next. Examples:
- "This file couldn't be compressed. It appears to be encrypted — unlock it first, then try again." (
processing_error,code: encrypted_document) - "The uploaded file is 41.2 MB. Your plan allows 25 MB. Upgrade to Pro for files up to 1 GB." (
quota_error,code: file_too_large, matching the envelope example in Section 4.1) - "We couldn't reach our servers. Check your connection — client-side tools like Merge and Split still work offline." (network error)
Error copy never blames the user ("Invalid file" is never used alone; it is always "This file isn't a valid PDF — the header is missing or corrupted"), never shows a raw stack trace or a bare HTTP status code to the end user, and always includes the requestId in a copyable form when the error originated server-side, so a support request can reference it.
15.7.1 Toast durations and stacking #
Toasts (Section 16.4) auto-dismiss on a duration keyed to severity, and pause their timer on hover or focus so a user reading one is never interrupted mid-read: info/success 4 s, warning 6 s, danger does not auto-dismiss (it requires an explicit dismiss click, since a danger toast typically communicates a failed action the user needs to consciously acknowledge). A maximum of 3 toasts stack at once, newest on top; a 4th queues and appears once the oldest of the 3 dismisses. Toasts never stack on top of a banner occupying the same corner region — the toast region's z-index (--z-toast, Section 16.2) is deliberately below the banner region so a page-level banner is never obscured by transient feedback.
15.8 Performance #
Budgets. The canonical performance budgets are defined in Section 18.6; the subset load-bearing for the frontend implementation is restated here because this section is where they are met (the full quality-bar list, including test coverage and availability targets, is out of this section's scope and stays owned by Section 18.6): LCP under 2.0 seconds on the tool grid over a simulated 4G profile; the tool shell interactive (drop zone accepting a file) before the WASM module has finished loading; first-byte-to-first-page-preview under 1.5 seconds for a 10 MB PDF on a mid-tier laptop; merging ten 5 MB files completes client-side in under 3 seconds; the initial JS bundle for a tool route stays under 200 KB gzipped, excluding the WASM artifact; the WASM artifact itself stays under 6 MB Brotli-compressed, served with a one-year immutable Cache-Control header under a content-hashed filename.
Code splitting boundaries. Each tool's OptionsPanel and run implementation are dynamically imported (next/dynamic, ssr: false) keyed by slug, so the initial tool-route bundle contains only the shared shell (<ToolWorkbench>, <FileDropZone>, <PageGrid>, <ResultPanel>) and not the code for the other 33 tools. packages/pdfcore's JS binding layer is its own chunk, loaded once and shared across every tool route via the browser's HTTP cache (not re-fetched per navigation, since the filename is content-hashed and immutable). Marketing-only dependencies (blog MDX rendering, the pricing page's comparison table) never enter the (app) bundle graph; each route group has its own dependency subtree enforced by an ESLint import-boundary rule in the shared packages/config preset.
Dynamic import rules. Anything used by fewer than half of tool routes is dynamically imported: the ZIP assembly worker (Section 15.4), the signature-drawing canvas (used only by sign-pdf and the signer portal), the OCR language picker, and any Radix Dialog/Drawer content that isn't shown on initial render (dialogs import their body content lazily; the trigger button is the only thing in the initial bundle).
Image strategy. Marketing pages use next/image with AVIF-first, WebP fallback, and explicit width/height to prevent layout shift. In-app document previews are never <img> elements — they are canvas bitmaps per Section 15.5 — so next/image is not used inside the tool workbench at all; this is a deliberate exception to "always use next/image," stated explicitly so it is never flagged as an inconsistency.
Font loading. Self-hosted via next/font/local (no runtime request to a third-party font host, which also keeps the CSP in Section 17 free of an external font-CDN allowance): "Inter" variable font for UI text, "JetBrains Mono" for code and API examples. Both use font-display: swap and next/font's automatic fallback-metric adjustment (which generates a fallback font-face with matching ascent-override/descent-override/size-adjust values) so the swap from system font to webfont causes no measurable cumulative layout shift.
WASM loading strategy. The artifact loads via WebAssembly.instantiateStreaming against the cached (or network) response, in parallel with the rest of the tool route's JS, not blocking it. The shell is interactive before the module lands: the drop zone accepts a file immediately, the file is staged into OPFS, and if the user clicks the primary action before the engine is ready, the action queues (shown as a "Preparing…" state on the primary button) and fires the instant the module's initialization promise resolves. This is the concrete mechanism behind the "shell interactive before WASM finishes loading" budget above.
Prefetching rules. next/link's default viewport-triggered prefetch is enabled for navigation between marketing pages and between tool grid entries, but the WASM artifact is never prefetched speculatively — only the tool route's JS chrome is prefetched on hover/viewport-intersection of a tool card; the multi-megabyte engine begins loading only once a tool route actually mounts, to avoid spending a visitor's bandwidth on tools they never open.
CI measurement. A Lighthouse CI run against the tool grid and three representative tool routes (merge-pdf, redact-pdf, ocr-pdf) enforces the LCP budget on every pull request, failing the build on regression. A bundle-size check (a CI step running size-limit against each tool route's client chunk) enforces the 200 KB budget per route. A dedicated CI assertion diffs the built WASM artifact's Brotli-compressed size against the 6 MB ceiling and fails the build if exceeded. These checks live in the CI pipeline described in Section 19; this section defines only what is measured and against which numbers.
15.8.1 CI budget enforcement, concretely #
// lighthouserc.json (excerpt)
{
"ci": {
"collect": { "url": ["https://app.pdfworks.io/tools", "https://app.pdfworks.io/tools/merge-pdf", "https://app.pdfworks.io/tools/redact-pdf"], "numberOfRuns": 3 },
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2000 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 204800 }]
}
}
}
}// packages/config/size-limit.json (excerpt, per tool route)
[
{ "name": "merge-pdf route", "path": ".next/static/chunks/app/_app/tools/merge-pdf/**/*.js", "limit": "200 KB" },
{ "name": "pdfcore wasm artifact", "path": "packages/pdfcore/dist/pdfcore.wasm.br", "limit": "6 MB" }
]Both run as required checks on every pull request touching apps/web or packages/pdfcore, wired into the pipeline described in Section 19; a failing budget blocks merge, it does not merely warn.
15.9 Analytics and instrumentation #
What is measured: page views (route-level, no query-string parameters that could carry a filename or token), tool lifecycle events (started, completed, failed — see the taxonomy below), signup and checkout funnel events, PWA install funnel events, and upgrade-prompt engagement. Nothing about document content, extracted text, or filenames is ever an event property.
Privacy constraints, non-negotiable: no third-party advertising pixels of any kind (no Meta Pixel, no Google Ads conversion tag) anywhere in the application, including the marketing site. No session-replay tooling (FullStory-style DOM/input recording) is used anywhere in the (app) route group, ever — this is an absolute rule, not a configuration choice, because a session replay tool by construction can capture rendered document content off the preview canvas or annotation layer. No event property, on any event in the taxonomy below, ever carries a filename, document content, or extracted text — the event shapes in Section 15.9.1 are a closed set precisely to make this enforceable at the type level, not just a policy.
Product analytics runs on self-hosted Plausible, the default analytics tool named in Section 1.1's customization decisions: an open-source, cookie-free analytics platform deployed inside the product's own infrastructure rather than a third-party vendor's. The app never calls a third-party domain directly from the browser; every event is posted to the first-party endpoint /internal/analytics/events, which forwards it server-side to the self-hosted Plausible instance (event metadata mirrored into PostgreSQL 18 for the plan-usage and product-analytics joins used elsewhere in the app, e.g., the upgrade-prompt trigger analysis in Section 16.4.4). Because the instance is self-hosted and the browser never talks to it directly, "a file never leaves your device" (Section 16.3, Section 16.8) remains true even in the presence of analytics instrumentation.
Event taxonomy:
| Event name | Key properties | Fired when |
|---|---|---|
page_viewed |
route, referrer |
Every route change |
tool_opened |
tool, location (client|server) |
Tool workbench mounts |
file_added |
tool, fileCount, totalBytesBucket (<1mb|1-10mb|10-100mb|>100mb) |
File added to the drop zone |
tool_started |
tool, location, fileCount, optionsHash (a non-reversible hash of the options object, for aggregate popularity of settings only) |
Primary action clicked |
tool_completed |
tool, location, durationMs, outputCount |
Job reaches succeeded |
tool_failed |
tool, location, errorCode, durationMs |
Job reaches failed or expired |
server_fallback_offered |
tool |
The opt-in server-fallback prompt from Section 6.3 is shown |
server_fallback_accepted |
tool |
The user clicks through to server-side processing |
upgrade_prompt_viewed |
trigger, currentPlan |
Plan/upgrade prompt (Section 16.4.4) renders |
upgrade_prompt_clicked |
trigger, currentPlan, targetPlan |
Its primary button is clicked |
checkout_started |
targetPlan, billingInterval |
Stripe Checkout redirect initiated |
checkout_completed |
targetPlan, billingInterval |
Post-redirect success confirmed via webhook-backed status |
account_created |
signupMethod |
Signup completes |
envelope_sent |
signerCount, fieldCount |
Envelope moves to envelope.sent (Section 10) |
pwa_install_prompted |
— | Custom install banner shown |
pwa_installed |
— | appinstalled event fires |
offline_mode_entered |
tool |
The offline banner (Section 15.6) is shown |
Event names and properties are stable and additive-only, mirroring the append-only discipline of the error code catalogue in Section 4.1.
Consent handling. Three consent categories: essential (always on — security telemetry, rate-limit counters, the events required to detect abuse; not subject to consent because they are necessary for service operation), analytics (the product-analytics events above; off by default for visitors geo-IP-located in the EU/UK/Switzerland until they accept a cookie/consent banner, on by default elsewhere with a visible opt-out in /settings/notifications), and marketing (third-party ad attribution — permanently unused per the privacy constraint above; the category exists in the consent model only so the banner's copy is accurate and future-proof, and it is never toggled on by any code path). The consent banner itself is a Client Component rendered only in (marketing) and the unauthenticated parts of (app); once a workspace account exists, the analytics category defaults to the account's own /settings/notifications preference instead of the geo-IP heuristic.
15.9.1 The tracking helper #
Every event fires through one typed function, so a new event is a one-line registry addition rather than a scattered fetch call:
// apps/web/lib/analytics/track.ts
type AnalyticsEvent =
| { name: 'tool_started'; properties: { tool: string; location: 'client' | 'server'; fileCount: number; optionsHash: string } }
| { name: 'tool_completed'; properties: { tool: string; location: 'client' | 'server'; durationMs: number; outputCount: number } }
| { name: 'tool_failed'; properties: { tool: string; location: 'client' | 'server'; errorCode: string; durationMs: number } }
// ... remaining event shapes from the table above, one union member each
;
export function track(event: AnalyticsEvent) {
if (!hasAnalyticsConsent()) return; // consent gate, Section 15.9
void fetch('/internal/analytics/events', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...event, occurredAt: getClientTimestamp(), sessionId: getAnonymousSessionId() }),
keepalive: true, // survives a navigation that starts immediately after the call
});
}optionsHash is computed with SHA-256 over a canonicalized JSON encoding of the options object client-side, truncated to 16 hex characters — enough to group identical settings in aggregate without the raw values (which could, for some tools, embed user-entered text such as a watermark string) ever leaving the device.
16. Design System, Accessibility & Internationalization #
16.1 Design principles #
Four principles specific to this product, applied at every design decision point in this section and in Section 15:
- Privacy legible at a glance. A user should never have to read fine print to know whether a file is about to leave their device. The Processing Location Indicator (16.3) is present everywhere a file-touching action is available, before the action is taken, not after.
- One obvious next action. Every screen has exactly one visually dominant action (the primary button, Section 16.4). Secondary actions exist but never compete with it in size, color saturation, or position. When two actions seem equally important, that is treated as a design defect to resolve, not a reason to make both prominent.
- Never hide the free path. Upgrade prompts (16.4.4) always appear alongside, never instead of, the path that keeps the user on their current plan. "Continue without an account" is always a visible, equally legible option next to a signup prompt for guest users, never a light-gray link buried below a saturated signup button. Dark patterns (forced continuity language, confirm-shaming button copy, hidden decline paths) are prohibited outright.
- Speed as a feature. The performance budgets in Section 15.8 are design constraints, not engineering afterthoughts: skeleton states (16.4) are designed alongside their loaded states, not bolted on later; optimistic UI is used wherever an action is safely reversible (Section 15.3); perceived performance (a visible drop zone before the engine loads) is treated as equal in importance to actual performance.
16.2 Tokens #
All tokens are CSS custom properties defined on :root (light, the default) and overridden under [data-theme="dark"], generated from a single TypeScript source of truth in packages/ui/tokens.ts so Tailwind CSS 4.x's theme (via @theme in the package's CSS entry point), the component library, and any raw CSS all read the same values with no duplication.
Color — semantic roles, light and dark, with contrast ratios against their paired background:
| Token | Light value | Dark value | Paired with | Contrast |
|---|---|---|---|---|
--color-bg |
#FFFFFF |
#0B0F14 |
— | — |
--color-surface |
#F7F8FA |
#131820 |
--color-bg |
— |
--color-surface-raised |
#FFFFFF |
#1B222C |
--color-surface |
— |
--color-border |
#E2E5EA |
#2A323D |
--color-surface |
— |
--color-border-strong |
#C7CCD4 |
#3C4653 |
--color-surface |
— |
--color-text-primary |
#12161C |
#F2F4F7 |
--color-bg |
16.1:1 / 15.8:1 |
--color-text-secondary |
#4B5563 |
#A9B2BE |
--color-bg |
7.3:1 / 7.1:1 |
--color-text-disabled |
#9CA3AF |
#5B6472 |
--color-bg |
2.9:1 (non-text use only) |
--color-accent |
#175CD3 |
#5A9CFF |
--color-bg |
6.1:1 / 7.4:1 |
--color-accent-fg |
#FFFFFF |
#0B0F14 |
--color-accent |
6.8:1 / 8.0:1 |
--color-success |
#0F7A3D |
#4ADE80 |
--color-bg |
5.2:1 / 9.6:1 |
--color-warning |
#9A5B00 |
#FBBF24 |
--color-bg |
5.1:1 / 10.7:1 |
--color-danger |
#B42318 |
#F87171 |
--color-bg |
6.4:1 / 6.9:1 |
--color-info |
#175CD3 |
#7DB4FF |
--color-bg |
6.1:1 / 8.6:1 |
The Processing Location Indicator pair (defined fully in Section 16.3, tokens declared here):
| Token | Light value | Dark value | Used for |
|---|---|---|---|
--color-onDevice-fg |
#0F7A3D |
#4ADE80 |
Text and icon, "On your device" |
--color-onDevice-bg |
#E6F6EC |
#12351F |
Filled pill background |
--color-onServer-fg |
#9A5B00 |
#FBBF24 |
Text and icon, "On our servers" |
--color-onServer-bg |
#FDF3E0 |
#3A2A05 |
Outline pill's inner fill |
Both pairs are ≥4.5:1 against their own background at every size the indicator renders at (Section 16.3 gives the non-color redundant encoding, since color alone is never the only distinguishing signal per WCAG 2.2 1.4.1).
Spacing scale (4px base unit): --space-0: 0, --space-1: 0.25rem, --space-2: 0.5rem, --space-3: 0.75rem, --space-4: 1rem, --space-6: 1.5rem, --space-8: 2rem, --space-10: 2.5rem, --space-12: 3rem, --space-16: 4rem, --space-20: 5rem, --space-24: 6rem, --space-32: 8rem.
Radius: --radius-sm: 6px, --radius-md: 10px, --radius-lg: 14px, --radius-xl: 20px, --radius-full: 9999px.
Shadow (light values; dark theme uses the same offsets with rgba(0,0,0,0.6) base and a 1px --color-border inset to keep raised surfaces legible against a dark background):
--shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06);
--shadow-md: 0 4px 8px rgba(16, 24, 40, 0.08), 0 2px 4px rgba(16, 24, 40, 0.06);
--shadow-lg: 0 12px 24px rgba(16, 24, 40, 0.10), 0 4px 8px rgba(16, 24, 40, 0.06);
--shadow-xl: 0 24px 48px rgba(16, 24, 40, 0.14), 0 8px 16px rgba(16, 24, 40, 0.08);Z-index layers: --z-base: 0, --z-sticky-header: 10, --z-dropdown: 20, --z-drawer: 30, --z-dialog: 40, --z-toast: 50, --z-tooltip: 60. Nothing in the component library is assigned a raw numeric z-index outside this list.
Typography scale. Font families: --font-sans: 'Inter Variable', system-ui, sans-serif (UI and body text, self-hosted per Section 15.8), --font-mono: 'JetBrains Mono', ui-monospace, monospace (code, API keys, hashes). Scale:
| Token | Size | Weight | Line height | Use |
|---|---|---|---|---|
--text-display |
2.75rem | 700 | 1.1 | Marketing hero only |
--text-h1 |
2rem | 700 | 1.2 | Page title |
--text-h2 |
1.5rem | 600 | 1.25 | Section heading |
--text-h3 |
1.25rem | 600 | 1.3 | Subsection / card title |
--text-body-lg |
1.125rem | 400 | 1.6 | Lead paragraph |
--text-body |
1rem | 400 | 1.6 | Default body and UI text |
--text-body-sm |
0.875rem | 400 | 1.5 | Secondary text, form hints |
--text-caption |
0.75rem | 500 | 1.4 | Timestamps, badges, metadata |
--text-code |
0.875rem | 400 | 1.6 | --font-mono contexts |
Motion: --motion-fast: 120ms, --motion-base: 200ms, --motion-slow: 320ms; --ease-standard: cubic-bezier(0.2, 0, 0, 1), --ease-decelerate: cubic-bezier(0, 0, 0, 1), --ease-accelerate: cubic-bezier(0.4, 0, 1, 1). All three durations are set to 1ms under prefers-reduced-motion: reduce rather than removed entirely, per Section 16.6.
Breakpoints (Tailwind CSS 4.x's default scale, reused as the single source of truth): --bp-sm: 640px, --bp-md: 768px, --bp-lg: 1024px, --bp-xl: 1280px, --bp-2xl: 1536px.
16.2.1 The token file, assembled #
The complete :root block, generated from packages/ui/tokens.ts into packages/ui/tokens.css and imported once at the root layout (Section 15.1.4). Only the light theme is shown in full; the dark theme overrides the same property names under [data-theme="dark"] with the values already given per-token above.
/* packages/ui/tokens.css (generated — do not hand-edit) */
:root {
/* color */
--color-bg: #FFFFFF;
--color-surface: #F7F8FA;
--color-surface-raised: #FFFFFF;
--color-border: #E2E5EA;
--color-border-strong: #C7CCD4;
--color-text-primary: #12161C;
--color-text-secondary: #4B5563;
--color-text-disabled: #9CA3AF;
--color-accent: #175CD3;
--color-accent-fg: #FFFFFF;
--color-success: #0F7A3D;
--color-warning: #9A5B00;
--color-danger: #B42318;
--color-info: #175CD3;
--color-onDevice-fg: #0F7A3D;
--color-onDevice-bg: #E6F6EC;
--color-onServer-fg: #9A5B00;
--color-onServer-bg: #FDF3E0;
/* spacing */
--space-0: 0; --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem;
--space-4: 1rem; --space-6: 1.5rem; --space-8: 2rem; --space-10: 2.5rem;
--space-12: 3rem; --space-16: 4rem; --space-20: 5rem; --space-24: 6rem; --space-32: 8rem;
/* radius */
--radius-sm: 6px; --radius-md: 10px; --radius-lg: 14px; --radius-xl: 20px; --radius-full: 9999px;
/* z-index */
--z-base: 0; --z-sticky-header: 10; --z-dropdown: 20; --z-drawer: 30; --z-dialog: 40; --z-toast: 50; --z-tooltip: 60;
/* motion */
--motion-fast: 120ms; --motion-base: 200ms; --motion-slow: 320ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-decelerate: cubic-bezier(0, 0, 0, 1);
--ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
/* breakpoints (referenced by packages/config's Tailwind preset, not consumed directly in media queries) */
--bp-sm: 640px; --bp-md: 768px; --bp-lg: 1024px; --bp-xl: 1280px; --bp-2xl: 1536px;
}
@media (prefers-reduced-motion: reduce) {
:root { --motion-fast: 1ms; --motion-base: 1ms; --motion-slow: 1ms; }
}Tailwind CSS 4.x's @theme directive in packages/ui/tailwind.css maps each of these custom properties to a Tailwind utility (bg-surface, text-text-secondary, rounded-lg, shadow-md, z-dialog, duration-base), so utility classes and hand-written CSS both read the identical values with zero duplication and zero drift.
16.3 The Processing Location Indicator #
This subsection is the component's visual definition — copy, iconography, color, shape, and accessibility treatment — used throughout Section 15. The behavioral contract — which tool locations trigger which state, where the indicator is mandatory before an action is taken, and the CI assertion that fails a tool whose indicator disagrees with its actual execution location — is specified in Section 6.2; this subsection implements that contract, it does not restate it.
Both states:
| On-device | On-server | |
|---|---|---|
| Copy | "This file never leaves your device." | "This file is uploaded, encrypted, and deleted within 24 hours." |
| Short label (card/badge) | "On your device" | "On our servers" |
| Glyph | lucide-react Lock |
lucide-react Cloud |
| Text/icon color | --color-onDevice-fg |
--color-onServer-fg |
| Background | --color-onDevice-bg |
--color-onServer-bg |
| Shape (non-color redundancy) | Solid filled pill | Pill with a 1.5px dashed border, no fill |
Non-color redundant encoding. Three independent signals distinguish the two states, so no single one carries the meaning alone: (1) a different glyph (lock vs. cloud), (2) different text that is always present — the indicator is never icon-only, (3) a different pill treatment (solid fill vs. dashed outline), which additionally makes the distinction legible to users with color vision deficiency even before considering the color values themselves (chosen, per Section 16.2's contrast table, to remain distinguishable under deuteranopia and protanopia simulation as green and amber rather than green and red).
Placements:
- Tool page header (Section 15.4): full variant, icon + label + full copy sentence, positioned immediately after the tool name, before any other header content.
- Tool grid card (
/tools): compact variant, icon + short label only, bottom-right corner of the card. - Batch job row (Section 13): compact variant inline with the job's tool name.
- Confirmation dialog (shown before any server-side action that a user has not yet acknowledged in the current session): full variant, placed directly above the dialog's primary button, so the last thing a user sees before confirming an upload is where the file is going.
Tooltip content. Hovering or focusing the compact variant reveals the full copy sentence from the table above in a Radix Tooltip (Section 16.4), plus, for the on-server state only, a "Learn more" link to /security.
Accessibility announcement. On mount, each tool page's indicator is announced once via a visually-hidden aria-live="polite" region separate from the visible component (so screen reader users get the announcement without a duplicate visible label): "Processing location: on your device. This file never leaves your device." or the on-server equivalent. The compact card and job-row variants do not re-announce on every render (which would spam a screen reader user scrolling the tool grid); instead each card's accessible name includes the short label directly (aria-label="Merge PDF — on your device"), readable on demand rather than pushed via a live region.
16.3.1 Component props #
interface ProcessingLocationIndicatorProps {
location: 'client' | 'server';
variant: 'full' | 'compact';
className?: string;
}
export function ProcessingLocationIndicator({ location, variant }: ProcessingLocationIndicatorProps) {
const t = useTranslations('common.processingLocation');
const Icon = location === 'client' ? Lock : Cloud;
const label = location === 'client' ? t('onDeviceShort') : t('onServerShort');
const copy = location === 'client' ? t('onDeviceFull') : t('onServerFull');
return (
<span
className={cn('processing-location-indicator', `processing-location-indicator--${location}`, `processing-location-indicator--${variant}`)}
role="img"
aria-label={`${label} — ${copy}`}
>
<Icon aria-hidden="true" size={variant === 'full' ? 20 : 16} />
<span>{variant === 'full' ? copy : label}</span>
</span>
);
}role="img" with a single combined aria-label is used deliberately over separate icon/text semantics — it presents the whole indicator to assistive technology as one atomic unit with one accessible name, which matches how the visual design treats it (icon and label are never meaningfully separable) and avoids a screen reader announcing the icon and the label as two disjoint pieces of content.
16.4 Component library #
Built on Radix UI 1.x primitives wherever an unstyled, accessible primitive exists for the pattern (dialog, drawer via a styled Radix Dialog variant, popover, tooltip, dropdown menu, tabs, accordion, checkbox, radio, switch, slider, select); everything else (button, input, textarea, combobox, file drop zone, table, page thumbnail/grid, tool card, badge, progress bar, spinner, skeleton, toast, banner, breadcrumb, avatar, empty state, card, pagination, plan/upgrade prompt) is a first-party component in packages/ui built on plain semantic HTML with ARIA applied directly, since Radix does not ship primitives for those patterns.
| Component | Built on | Key variants | States | Keyboard | ARIA |
|---|---|---|---|---|---|
| Button | native <button> |
primary, secondary, tertiary, destructive | default, hover, focus, active, disabled, loading | Enter/Space activates |
role="button" implicit; aria-busy when loading |
| Icon button | native <button> |
ghost, filled | as Button | as Button | aria-label required (no visible text) |
| Input | native <input> |
text, email, password, number, search | default, hover, focus, disabled, error, readonly | standard text field | <label for>, aria-invalid, aria-describedby for hint/error |
| Textarea | native <textarea> |
default, resizable, fixed | as Input | as Input, Shift+Enter newline |
as Input |
| Select | Radix Select | single | default, focus, disabled, error | Enter/Space opens, arrows navigate, Esc closes, type-ahead |
role="listbox"/role="option", aria-expanded |
| Combobox | Radix Popover + custom listbox | single, multi (tag) | default, focus, open, disabled, error, no-results | arrows navigate, Enter selects, Backspace removes last tag |
role="combobox", aria-controls, aria-activedescendant |
| Checkbox | Radix Checkbox | default, indeterminate | default, hover, focus, checked, disabled | Space toggles |
role="checkbox", aria-checked (incl. "mixed") |
| Radio | Radix RadioGroup | default | as Checkbox | arrows move selection within group, Space selects |
role="radiogroup"/radio, aria-checked |
| Switch | Radix Switch | default | default, hover, focus, checked, disabled | Space/Enter toggles |
role="switch", aria-checked |
| Slider | Radix Slider | single, range | default, hover, focus, disabled | arrows adjust by step, Home/End to bounds, PageUp/PageDown by 10 steps |
role="slider", aria-valuenow/min/max, aria-valuetext |
| File drop zone | custom | empty, populated, drag-over, error | default, drag-over, uploading, error | Enter/Space opens picker when focused; see Section 15.4 |
role="button", aria-describedby accepted-types hint |
| Tabs | Radix Tabs | line, pill | default, hover, focus, selected, disabled | arrows move selection, Home/End |
role="tablist"/tab/tabpanel |
| Accordion | Radix Accordion | single-open, multi-open | default, hover, focus, expanded, disabled | Enter/Space toggles, arrows move between headers |
role="region", aria-expanded on trigger |
| Dialog | Radix Dialog | default, destructive-confirm | default, entering, exiting | Esc closes, Tab trapped inside |
role="dialog", aria-modal="true", aria-labelledby |
| Drawer | Radix Dialog (side-anchored variant) | left, right, bottom (mobile) | as Dialog | as Dialog | as Dialog |
| Popover | Radix Popover | default | default, open | Esc closes, focus returns to trigger |
role="dialog" (non-modal) |
| Tooltip | Radix Tooltip | default | default, visible | shows on focus, not just hover; Esc dismisses |
role="tooltip", referenced by aria-describedby |
| Dropdown menu | Radix DropdownMenu | default | default, open, item-highlighted, item-disabled | arrows navigate, Enter activates, Esc closes |
role="menu"/menuitem |
| Toast | custom (Radix Toast primitive) | info, success, warning, danger | entering, visible, exiting | dismissible via Esc when focused |
role="status" (info/success), role="alert" (warning/danger) |
| Banner | custom | info, warning, danger, success | default, dismissible | Tab reaches dismiss button |
role="status" or role="alert" matching severity |
| Badge | custom | neutral, success, warning, danger, info | static | not interactive | none needed unless interactive (then Icon button rules apply) |
| Progress bar | custom | determinate, indeterminate | default | not interactive | role="progressbar", aria-valuenow/min/max |
| Spinner | custom | sm, md, lg | default | not interactive | role="status", visually-hidden label |
| Skeleton | custom | text, block, avatar, thumbnail | pulsing (disabled under reduced motion) | not interactive | aria-hidden="true" (real content announces on load, not the placeholder) |
| Table | custom + TanStack Table 9.x | default, sortable, selectable | default, hover-row, selected-row, empty | arrows navigate cells in grid mode; sort headers are buttons | role="table"/row/columnheader/cell |
| Pagination | custom | cursor-based (Section 14.7) | default, disabled (no more pages) | Tab to prev/next, Enter activates |
nav landmark, aria-label="Pagination" |
| Empty state | custom | with-action, without-action | static | as its action button | role="status" region announcing the empty condition |
| Card | custom | default, interactive | default, hover, focus (if interactive) | Enter activates if the whole card is a link/button |
role="link"/"button" only if interactive |
| Tool card | Card | client, server (Section 16.3 indicator embedded) | default, hover, focus | Enter opens the tool |
see 16.4.3 |
| Page thumbnail | custom | default, selected, drag-preview | default, hover, focus, selected | see Section 16.6 | see 16.4.2 |
| Page grid | custom | grid, list | default | see Section 16.6 | role="grid", see 16.4.2 |
| Breadcrumb | custom | default | default | Tab through links |
nav landmark, aria-label="Breadcrumb", aria-current="page" on last item |
| Avatar | custom | initials, image | default | not interactive unless a menu trigger | alt on image variant, aria-hidden on initials-only decorative ring |
| Plan/upgrade prompt | Card + Button | inline, modal | default | as Card/Dialog | see 16.4.4 |
16.4.1 File drop zone (detail) #
Anatomy: an outer role="button" region (so it is independently focusable and activatable without requiring the hidden native <input type="file"> to be tabbed to directly), a centered icon + instructional text ("Drag and drop a PDF here, or click to browse"), an accepted-types caption, and a hidden native file input used only to trigger the OS picker. Drag-over state adds a 2px dashed --color-accent border and a --color-accent-tinted background at 8% opacity. Full interaction behavior (paste, multiple files, per-file validation, reorder into a page grid) is specified in Section 15.4.
16.4.2 Page thumbnail and page grid (detail) #
The page grid is role="grid", each thumbnail a role="gridcell" containing a focusable element (tabindex="0" on the currently-active cell only, -1 on the rest, per the roving-tabindex pattern) labeled aria-label="Page {n} of {total}". Selected pages get a --color-accent 2px ring and aria-selected="true". The full keyboard reorder interaction — move mode, arrow-key relocation, commit/cancel, live-region announcements — is specified in Section 16.6 rather than here, since it is fundamentally an accessibility mechanism, not a visual one; this subsection owns only the component's visual anatomy and props (pages: PageModel[], selection: string[], onReorder: (next: string[]) => void, onSelectionChange: (ids: string[]) => void, variant: 'grid' | 'list').
16.4.3 Tool card (detail) #
Anatomy: icon (per-tool, from a fixed icon set in packages/ui/icons), tool name, one-line description, the Processing Location Indicator compact variant (Section 16.3) bottom-right. The whole card is a single focusable link (role="link", navigating to the tool route) rather than a card containing a nested link, so a single Tab stop and a single Enter press opens the tool — nested interactive elements inside a card are avoided everywhere in the component library, not just here, since they create ambiguous keyboard and screen-reader traversal.
16.4.4 Plan/upgrade prompt (detail) #
Two variants sharing one underlying component: inline (embedded in a banner or at the bottom of a quota-limited result panel) and modal (a Dialog, used when the blocking condition prevents the user from proceeding at all, e.g., a guest hitting the 5-tasks-per-day cap from Section 12.2). Props: trigger: string (which limit was hit, feeding the upgrade_prompt_viewed analytics property from Section 15.9), currentPlan, recommendedPlan, benefits: string[] (the specific entitlements the recommended plan unlocks relative to the trigger, e.g. for a file-size trigger: ["Files up to 1 GB", "Unlimited server-side tasks"]). The prompt's primary button always says exactly what happens ("Upgrade to Pro — $9/mo") and links to /pricing with the target plan pre-selected, which then hands off to Stripe Checkout per Section 3.4; it never says a vague "Learn more." The dismiss control is always present and equally sized to the primary button per the "never hide the free path" principle in Section 16.1.
16.4.5 Button anatomy and variant rules #
Anatomy: an optional leading icon, a label (always present — an icon-only button is the separate Icon button component, never a Button with a hidden label), an optional trailing icon, and an optional loading spinner that replaces the leading icon when loading is true without changing the button's width (reserved via a fixed-width icon slot) so a loading state never causes layout shift. Variant rules, applied consistently across every screen: primary — exactly one per screen or panel, --color-accent background, used for the single obvious next action from Section 16.1; secondary — bordered, transparent background, used for any action that is not the primary but still meaningful (Cancel next to a non-destructive Confirm); tertiary — no border, text-only styling, used for low-emphasis actions (Skip, Learn more); destructive — --color-danger background, reserved exclusively for actions that delete or irreversibly alter data, and always paired with a confirmation dialog (Section 16.4) except for actions with their own undo affordance (e.g., removing a page from the working set, which the undo stack in Section 15.3 already covers).
/* packages/ui/components/button.css (excerpt) */
.button--primary {
background: var(--color-accent);
color: var(--color-accent-fg);
}
.button--primary:hover { filter: brightness(0.92); }
.button--primary:active { filter: brightness(0.85); }
.button--primary:disabled { background: var(--color-border-strong); color: var(--color-text-disabled); cursor: not-allowed; }
.button--primary:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }16.4.6 Toast and banner anatomy #
A toast is: an icon matching its severity (from the badge/banner severity set — success/warning/danger/info, mapped to the color tokens in Section 16.2), a one-line message, an optional inline action link ("Undo," "View job"), and a dismiss icon button. A banner adds one element toasts do not have: an optional persistent action button (not just a link), since a banner communicates a standing condition (offline, approaching quota, an unread system notice) that often has a substantive next step rather than a quick undo.
interface ToastProps {
severity: 'info' | 'success' | 'warning' | 'danger';
message: string;
action?: { label: string; onClick: () => void };
onDismiss: () => void;
}The toast queue itself is the one piece of "global UI" state that lives in a small dedicated Zustand store rather than Context, noted already in Section 15.3, precisely because toasts are frequently triggered from non-render code (a mutation's onError/onSuccess callback) where reaching into React Context is awkward; useToastStore.getState().push(toast) works identically whether called from a component or a plain async function.
16.4.7 Table and pagination #
The Table component wraps TanStack Table 9.x's headless row model with the design system's visual layer; sorting state, column visibility, and row selection are all owned by TanStack Table's own state hooks rather than duplicated into a Zustand store. Sortable column headers render as buttons (aria-sort="ascending" | "descending" | "none" on the <th>) so both the visual sort indicator and its accessible state come from one source. Pagination (Section 16.4's table entry) always reflects the cursor model from Section 14.7: "Previous"/"Next" rather than numbered page links, since a cursor has no stable page-number semantics; the pagination control disables "Next" when the current page's response carried hasMore: false and disables "Previous" on the first page (tracked via a client-side cursor stack, since the API itself is forward-cursor-only).
16.5 Layout #
App shell. A fixed-height sticky header (--z-sticky-header) containing the logo, primary navigation (Tools, Jobs, E-Sign), and an account menu; below it, a persistent left sidebar at lg and above (workspace switcher, quick tool links, plan badge) and content area. Tool workbench routes replace the standard content-area layout with the three-column pattern from Section 15.4 (drop zone/page grid, canvas, options panel), which itself collapses responsively per the table below.
Responsive behavior by breakpoint:
| Breakpoint | Sidebar | Tool workbench layout | Options panel |
|---|---|---|---|
< sm (< 640px) |
Hidden; a hamburger menu opens it as a full-screen drawer | Single column: canvas full width, page grid becomes a horizontal filmstrip above it | Bottom sheet drawer, opened via a toolbar button |
sm–md (640–767px) |
Hidden; hamburger drawer | Single column, as above | Bottom sheet drawer |
md–lg (768–1023px) |
Collapsible (icon-only by default, expandable) | Two columns: canvas + page grid stacked on the left, options as a right-side drawer | Right-side drawer, toggled |
lg–xl (1024–1279px) |
Persistent, icon-only | Three columns as designed, options panel narrower (280px) | Persistent side panel |
≥ xl (1280px+) |
Persistent, full (icon + label) | Three columns at full width (options panel 340px) | Persistent side panel |
Mobile tool experience. Below md, the file drop zone's drag-and-drop affordance is de-emphasized (touch drag-and-drop for file input is unreliable across mobile browsers) in favor of a prominent "Choose files" button that opens the native file/photo picker directly; paste-from-clipboard remains available where the mobile OS supports it. The thumbnail rail becomes a horizontal scroll strip pinned above the canvas instead of a vertical side rail. The action bar becomes sticky-bottom, always visible without scrolling, since it is the single most important control on a small screen. Multi-step tools (e.g., create-form, esign/new) replace the desktop's single-screen panel layout with a linear step-by-step flow (one step per screen, a progress indicator, back/next navigation) rather than trying to fit every control on screen at once.
Touch target minimum. Every interactive element has a minimum hit area of 44×44 CSS pixels on any viewport narrower than lg (and is not reduced below that on wider viewports either, since mouse users lose nothing from a comfortable target size) — exceeding the WCAG 2.2 Level AA minimum of 24×24 (SC 2.5.8) by design, matching platform guidance from both major mobile operating systems. Where a visual icon is smaller than 44×44 (e.g., a 20px icon button), invisible padding extends the hit area to the full minimum rather than enlarging the icon itself.
16.5.1 App shell composition #
// apps/web/components/app-shell.tsx
'use client';
export function AppShell({ session, children }: { session: Session | null; children: React.ReactNode }) {
return (
<div className="app-shell">
<a href="#main-content" className="skip-link">Skip to main content</a>
<header className="app-shell__header">
<Logo />
<PrimaryNav />
<AccountMenu session={session} />
</header>
<div className="app-shell__body">
<Sidebar className="app-shell__sidebar" />
<main id="main-content" className="app-shell__content">{children}</main>
</div>
</div>
);
}.app-shell__body is a CSS grid with two columns (grid-template-columns: var(--sidebar-width) 1fr) above lg, collapsing to a single column with .app-shell__sidebar moved into a Drawer (Section 16.4) below lg, matching the responsive table above. --sidebar-width itself steps from 64px (icon-only, md–xl) to 240px (icon + label, xl and above) as a CSS custom property overridden per breakpoint, so no JavaScript-driven layout recalculation is needed for the collapse.
16.6 Accessibility #
WCAG 2.2 Level AA is a hard requirement for every route in every group, with no exception for pages considered "internal" or "power-user" — the redaction canvas and the annotation placement tool are held to the same bar as the marketing homepage.
Focus management and visible focus. Every focusable element shows a visible focus indicator via :focus-visible only (never on mouse click, so the indicator does not appear noisy for pointer users while remaining always-on for keyboard users): a 2px solid outline in --color-accent, offset 2px from the element's border, contrast-checked at ≥3:1 against both the element's own background and its immediate surroundings at every token pairing in Section 16.2.
Focus trapping in dialogs. Radix Dialog's built-in FocusScope handles trapping; the convention layered on top is: initial focus goes to the dialog's first interactive control unless the dialog's primary action is destructive (Section 16.4's destructive-confirm variant), in which case initial focus goes to the cancel button, so a keyboard user pressing Enter reflexively does not accidentally confirm a destructive action. Closing (via Esc, the close button, or a successful submit) always returns focus to the element that opened the dialog.
Skip links. The first tab stop on every (app) page is a visually-hidden-until-focused "Skip to main content" link. Tool workbench pages add a second skip link, "Skip to tool options," immediately after it, since the canvas region between the header and the options panel is otherwise a long tab-traversal detour for a keyboard user who only wants to change a setting.
Landmark structure. One <header>, one <nav> for primary navigation, one <main> containing the route's actual content, one <aside> for the options panel or sidebar where present, one <footer> on marketing pages only. Exactly one <main> per page, always. <h1> matches the document <title> in substance (not necessarily verbatim) — the tool name on a tool page, the page title elsewhere — with no skipped heading levels below it.
Form labeling and error association. Every form control has a programmatically associated <label> (via for/id, never a placeholder standing in for a label). Hint text is linked via aria-describedby; validation errors add a second id to the same aria-describedby list and set aria-invalid="true" on the control. The error text itself is inside a role="alert" region so it is announced immediately when it appears (on blur or on submit, whichever triggers validation for that field, per the schema in packages/contracts), without requiring the user to navigate to find it.
Live regions. Job progress (Section 4.2, Section 15.3) is announced through a visually-hidden aria-live="polite" region that speaks at milestones only — 25%, 50%, 75%, 100%, and any stage string change (e.g., "Compressing images…" → "Rebuilding pages…") — never on every integer percentage tick, which would make the region unusable noise for a screen reader user. The page organizer (Section 16.4.2) uses the same pattern for structural announcements: "Page 3 moved to position 1 of 12."
Keyboard-only page reordering. The page grid's roving-tabindex cell for a focused page responds to:
| Key | Effect |
|---|---|
Space or Enter |
Enters move mode for the focused page (visually: the cell gets a dashed accent outline and a "Moving" badge) |
| Arrow keys (while in move mode) | Relocate the page one position at a time in the direction pressed (left/right within a row, up/down by a full row in grid layout, or simply previous/next in list layout) |
Space or Enter (while in move mode) |
Commits the move, exits move mode, announces the result via the live region above |
Esc (while in move mode) |
Cancels, returns the page to its original position, no announcement beyond "Move canceled" |
This is a complete substitute for pointer drag-and-drop, not a degraded fallback — every reorder achievable by dragging is achievable through this path, and it is exercised by the same E2E accessibility suite referenced below.
Keyboard-only annotation and field placement. The annotation overlay (Section 15.5) and the e-signature field-placement canvas (Section 10) share one placement-mode mechanism: a toolbar button (or the keyboard shortcut A when the canvas region has focus) enters "Placement mode," during which Tab cycles through the available annotation/field types in the toolbar, Enter drops the currently selected type at a default position (the center of the current viewport, or immediately after the last-placed object if one exists in the same session), and once an object is placed and selected, arrow keys nudge it by 1 point, or 10 points held with Shift. Each nudge and each placement is announced via the live region with coordinates relative to the page's top-left origin in points ("Signature field placed at 72, 144" / "Moved to 82, 144"). Delete/Backspace removes the selected object with an announcement; Esc exits placement mode entirely.
Reduced motion. Under prefers-reduced-motion: reduce, the three motion duration tokens (Section 16.2) collapse to 1ms globally via a single CSS rule scoped to that media query, so every transition and animation in the component library is neutralized without requiring each component to implement its own check. Essential motion that conveys state (the loading spinner) switches to a simpler static "Loading…" text treatment rather than a spinning animation.
Zoom to 400% without loss of content. Every (app) and (marketing) route reflows to a single, non-horizontally-scrolling column at 400% browser zoom (WCAG 2.2 SC 1.4.10), verified at a 1280px baseline viewport (equivalent to 320px effective width at 400%). The PDF preview canvas itself (Section 15.5) is treated as the specification's exempt "two-dimensional dataset" — a document page legitimately needs both horizontal and vertical scrolling at high zoom — but the chrome around it (header, options panel, action bar) always reflows.
Target size. As specified in Section 16.5: 44×44 CSS pixels minimum for every interactive control, exceeding SC 2.5.8's 24×24 floor, with the WCAG exception permitted only for inline text links within a paragraph of prose (e.g., a link inside the privacy policy body text), which are exempt from a minimum target size under the specification's own inline-exception clause.
Testing regime. axe-core runs against every route in the Playwright-driven E2E suite (Section 19) and fails the build on any Level A or AA violation, with zero suppressed rules. In addition to automated coverage, a manual pass using NVDA (Windows/Firefox) and VoiceOver (macOS/Safari, iOS Safari) is performed against a fixed checklist (sign-up, run each of the three most-used tools, complete a signer session, navigate settings) before every release. The accessibility statement page (/accessibility, Section 15.1) states the conformance target (WCAG 2.2 AA), the date of the last manual audit, any known limitations, and a contact path for reporting accessibility issues.
The signer flow's accessibility is a legal exposure, not a courtesy. A signer does not choose to use PDFWorks — the sender does — so an inaccessible signing experience denies a specific named individual the ability to sign a document through no choice of their own, which is a materially different and more acute legal exposure (under frameworks like the ADA and the EU Web Accessibility Directive, borne by the sending customer, not only by PDFWorks) than an inaccessible marketing page. Consequently: the consent screen (Section 10) is fully screen-reader navigable and must be reached and accepted before any field becomes interactive; every signature capture method has an equally accessible path — draw (via the placement-mode keyboard mechanism above, or pointer/touch), type (a standard text input, fully accessible by construction), and upload (a standard file input) — so a signer who cannot use a pointer is never limited to the draw method; and the completion and verification pages (Section 10) meet the same AA bar as every other route in the product.
16.6.1 axe-core in CI, concretely #
// apps/web/e2e/a11y.spec.ts (Playwright, Section 19 owns the runner and full matrix)
import AxeBuilder from '@axe-core/playwright';
const ROUTES_TO_AUDIT = ['/', '/tools', '/tools/merge-pdf', '/tools/redact-pdf', '/jobs', '/settings/security', '/billing'];
for (const route of ROUTES_TO_AUDIT) {
test(`axe: ${route}`, async ({ page }) => {
await page.goto(route);
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag22aa']).analyze();
expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]);
});
}No rule is ever added to a suppression list; a violation that is judged a false positive is fixed by correcting the markup, not by exempting the rule. The signer portal (sign.pdfworks.io) carries its own equivalent spec file auditing /[envelopeId] and /verify against the same tag set, run against a seeded test envelope.
16.6.2 Live region implementation #
Both the job-progress and page-organizer live regions share one small utility component rather than each screen hand-rolling an aria-live element (which is easy to get wrong — an element that does not exist in the DOM before the content changes is not reliably announced by every screen reader):
// packages/ui/components/live-region.tsx
'use client';
export function LiveRegion({ message, politeness = 'polite' }: { message: string; politeness?: 'polite' | 'assertive' }) {
return (
<div aria-live={politeness} aria-atomic="true" className="sr-only">
{message}
</div>
);
}The component is mounted once, persistently, at the point in the tree where its announcements are relevant (inside <ToolWorkbench> for job progress and the page grid), and only its message text content changes — the <div> itself is never conditionally mounted or unmounted, which is what makes the announcement reliable across screen readers.
16.7 Internationalization architecture #
English is the only shipped locale at launch. This is stated explicitly and applies to every string, date, number, and currency format the product renders on day one. The architecture below exists so that adding a second locale later is a translation-and-QA exercise, not an engineering one — no code changes are required beyond adding a catalog file.
Library. next-intl 4.x, integrated at the root of (marketing) and (app) (the signer portal also uses it, since a future locale will need signer-facing pages translated too, even though only English ships now).
Message catalog format and file layout. One JSON file per locale, namespaced by feature area, under packages/ui/messages/:
packages/ui/messages/
en.json
en-XA.json (pseudo-locale, CI-only, generated — see below){
"common": {
"actions": { "cancel": "Cancel", "confirm": "Confirm", "tryAgain": "Try again" }
},
"tools": {
"mergePdf": {
"title": "Merge PDF",
"description": "Combine multiple PDFs into one file.",
"primaryAction": "Merge files"
}
},
"jobHistory": {
"pageCount": "{count, plural, =0 {No pages} one {# page} other {# pages}}"
},
"errors": {
"fileTooLarge": "The uploaded file is {sizeMb} MB. Your plan allows {limitMb} MB."
}
}Namespaces mirror the route/feature structure (tools.{camelCaseSlug}, jobHistory.*, errors.*, common.*) so a translator working on one feature area never has to search the whole file, and so the extraction lint rule below can flag a hardcoded string with the exact namespace it should have been added under.
Extraction workflow. There is no separate "extract strings from code" step — strings are written as translation keys from the start (t('tools.mergePdf.title') via useTranslations('tools.mergePdf')), and a custom ESLint rule in packages/config's preset, pdfworks/no-hardcoded-jsx-text, scans every .tsx file for JSX text nodes and string literal props on a fixed list of text-bearing prop names (label, title, placeholder, aria-label, description) that are not wrapped in a t()/useTranslations call, and fails the lint step in CI. This makes "no user-visible string is ever hardcoded" a build-blocking rule, not a style guideline.
ICU message format handles plurals (shown above) and, where a message's grammar depends on a signer's or user's referenced gender (rare in this product's copy, since most strings avoid third-person pronouns by design — Section 16.8), the {gender, select, male {...} female {...} other {...}} ICU select syntax, supported natively by next-intl's useFormatter/useTranslations without additional tooling.
Date, number, and currency formatting. All rendered via next-intl's useFormatter, which wraps Intl.DateTimeFormat/Intl.NumberFormat and resolves formatting rules from the active locale automatically — dates as formatter.dateTime(date, { dateStyle: 'medium' }), money using the integer-minor-units-plus-ISO-4217 representation from Section 4 as formatter.number(amountMinorUnits / 100, { style: 'currency', currency: currencyCode }). No date, number, or currency value is ever manually string-formatted with template literals anywhere in the codebase.
30 percent text expansion tolerance. Layouts are built to survive translated strings up to 30% longer than their English source without truncation or overlap. Components most at risk, called out explicitly: the action bar's primary/secondary buttons (fixed-looking but built with min-width and internal padding rather than a hard width, allowed to grow); tab labels in the Tabs component (allowed to wrap to two lines rather than truncate, since truncating a tab label hides navigational information); the Processing Location Indicator's short label (already the terse form of its message, with the tooltip carrying the full sentence, precisely so the card-level label has expansion headroom); and badge/pill labels (given a minimum internal horizontal padding of --space-3 on each side specifically so a longer translated word does not visually collide with the pill's rounded border).
RTL readiness. Every layout and spacing CSS property in packages/ui uses logical properties exclusively (margin-inline-start instead of margin-left, padding-inline-end instead of padding-right, inset-inline-start instead of left), enforced by a Stylelint rule banning the physical property names outright in any file under packages/ui. Directional icons (chevrons, arrows, the undo/redo icons) carry a .rtl-mirror utility class that applies transform: scaleX(-1) under [dir="rtl"]. Icons that must never mirror are enumerated explicitly: the PDFWorks logo, page thumbnails, the document preview canvas (Section 15.5 — a document's own script direction is intrinsic to its content and is never altered by the UI's direction), and the lock/cloud glyphs of the Processing Location Indicator (Section 16.3), since a mirrored padlock reads as a different, unfamiliar glyph. What remains genuinely untested until a right-to-left locale is turned on: bidi text selection inside the PDF text layer (Section 15.5) against RTL-authored document content, and translated email templates' RTL layout (React Email templates would need their own logical-property audit) — both are explicitly named here as future work rather than silently assumed to work.
Locale detection and switching. On first visit, locale is negotiated from the Accept-Language header against the set of shipped locales (currently ["en"], so this always resolves to English at launch) and stored in a NEXT_LOCALE cookie (1-year expiry) so subsequent visits skip renegotiation. An authenticated user's locale preference lives on their account row (users.locale) and, once set via /settings/profile, takes precedence over the cookie. A locale switcher exists in both /settings/profile and the marketing footer, rendering only the shipped locale set — with exactly one entry, "English," at launch.
Translated email templates. Transactional email (built with React Email 1.x per the technology stack) is rendered from the same message catalog as the app, parameterized by the recipient's locale field, so the templates are translation-ready; at launch every template renders only the en catalog, since it is the only shipped locale.
Pseudo-locale in CI. en-XA.json is generated at build time from en.json by a script that (a) wraps every string in bracket markers, (b) replaces Latin characters with accented look-alikes (á, é, ï, and similar), and (c) pads the string length by roughly 30% with filler characters inside the brackets — producing output like [σ Möörgé Þdƒ ~~~] for "Merge PDF." A Playwright pass runs against key screens (tool grid, three representative tool workbenches, settings, billing) with the app locale forced to en-XA in CI only (never reachable in production, since en-XA is never added to the shipped locale set). Any string rendering without accent marks indicates a hardcoded string that bypassed the extraction lint rule (a second line of defense on top of the ESLint rule); any layout overflow, clipped text, or broken alignment indicates a component that will not survive the 30% expansion tolerance above.
16.7.1 The pseudo-locale generator #
// scripts/generate-pseudo-locale.ts (build-time, CI-only)
const ACCENT_MAP: Record<string, string> = { a: 'á', e: 'é', i: 'ï', o: 'ö', u: 'ü', A: 'Á', E: 'É', I: 'Ï', O: 'Ö', U: 'Ü' };
function pseudoize(value: string): string {
const accented = [...value].map((ch) => ACCENT_MAP[ch] ?? ch).join('');
const padLength = Math.ceil(value.length * 0.3);
const padding = '~'.repeat(Math.max(padLength, 3));
return `[${accented} ${padding}]`;
}
function walk(node: unknown): unknown {
if (typeof node === 'string') return pseudoize(node);
if (Array.isArray(node)) return node.map(walk);
if (node && typeof node === 'object') return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, walk(v)]));
return node;
}
const en = JSON.parse(readFileSync('packages/ui/messages/en.json', 'utf8'));
writeFileSync('packages/ui/messages/en-XA.json', JSON.stringify(walk(en), null, 2));ICU placeholders ({count}, {sizeMb}) are left untouched by pseudoize — the generator recognizes {...} spans and skips accenting/padding inside them, so pseudo-locale output still interpolates correctly and a broken placeholder (one that would throw at render time) surfaces as a CI failure rather than being silently masked by the transformation.
16.7.2 Example ICU gender construction #
Used sparingly, since most product copy is written in second person specifically to avoid needing it (Section 16.8), but available for the rare third-person context — for example, a Team workspace's audit log entry describing another member's action:
{
"workspace": {
"auditEntry": "{actorName} {gender, select, male {signed} female {signed} other {signed}} the envelope {envelopeName}"
}
}In this specific case the verb does not actually vary by gender in English, so the select collapses to one outcome — included here as the canonical pattern a translator into a language with gendered past participles (a future locale) would need, not because English requires it.
16.8 Content and voice #
Microcopy rules. Sentence case for all UI labels, buttons, and form fields ("Upload a file," not "Upload A File"); Title Case reserved for page titles and marketing headlines only. Active voice and second person ("You can restore this file for 7 days," not "This file can be restored for 7 days by the user"). No jargon without immediate plain-language explanation on first use per surface (e.g., "Bates numbering" — a legal/records term — gets a one-line description on its tool card: "Add sequential identifiers to every page, common in legal document production"). Buttons state the action and, where it is not obvious from context, the object: "Merge files," not "Submit" or "Go."
Error message patterns. Every error follows the three-part structure specified in Section 15.7 (what happened, why, what to do next); this subsection adds the tone rule: never blame the user, never use exclamation points, never use "Oops" or similarly cute framing for anything that blocks a real task — document processing failures are not treated as lighthearted moments.
Empty state patterns. Icon, one-line explanation of what will appear there and why it is currently empty, one primary action where one exists. Example, the job history empty state: icon (a stacked-documents glyph), "No jobs yet. Files you process appear here for as long as your plan's history window allows." (cross-referencing the retention windows in Section 6 without restating the exact numbers, since a Free-plan user's window differs from a Team user's), primary action "Browse tools."
Privacy-claim copy rules. The phrases "runs on your device," "never leaves your device," and "processed locally" are used only on surfaces whose underlying execution is actually client-side per Section 1.1 and Section 6.1 — the 25 client-side tool pages, their marketing landing pages, and the on-device state of the Processing Location Indicator (Section 16.3). These phrases are never used as a blanket product-level claim on the homepage or in top-of-funnel marketing copy in a way that would misrepresent the 9 server-side tools or the public API (which, per Section 1.2, always executes server-side). Where marketing copy needs to describe the product as a whole, it uses the accurate, narrower framing: "Most tools run entirely on your device. A few — like OCR and Office conversion — process securely on our servers and are deleted within 24 hours," which is both true and matches the per-tool reality a user will actually encounter.
Glossary of product terms — approved wording:
| Term | Approved definition/usage |
|---|---|
| Workspace | "A shared account for a team, with shared billing, members, and templates." Never called "organization" or "team" in UI copy (the plan is named Team; the container is a Workspace). |
| Envelope | "A signature request sent to one or more people." Never "document" alone when referring to the signing container — a document is the file; an envelope is the request built around it. |
| Signer | "A person asked to sign, approve, or receive a copy of an envelope." Never "recipient" (too generic — Section 10 also has CC-only roles, which are recipients but not signers). |
| Job | "A single processing operation on a file, like a merge or a compression." Used for both client-side and server-side operations, matching the unified state machine in Section 4.7. |
| Batch | "Multiple files processed together in one job." Never "bulk" in UI copy (reserved for informal contexts like blog posts). |
| Guest | "Using PDFWorks without an account." Never "anonymous" (which reads as evasive in a privacy-forward product) or "visitor" (too generic for a state with specific entitlements, Section 12.2). |
| Tool | "One specific operation, like Merge or Compress." Never "feature" when referring to an item in the tool grid. |
| Processing location | "Where a file is handled — on your device, or on our servers." The canonical noun phrase for the concept in Section 16.3; never shortened to "location" alone outside that component's own copy, where the meaning is already established by context. |
| Redaction | "Permanent removal of content, not just visual hiding." Marketing and in-product copy for the redact tool always includes this distinction at least once per surface, given the mechanism specified in Section 9.1. |
| Flatten | "Turn editable elements — form fields, annotations, layers — into fixed page content that can't be changed." |
| API key | "A credential for the public API." Never "token" in user-facing copy (Section 17.4 reserves "token" for the signer link token and session tokens, which are conceptually different credentials with different lifetimes). |
| Quota | "The amount of a metered resource your plan includes." Used for the numeric limits in Section 12.2; never "limit" alone in billing copy, where "limit" is reserved for hard caps that cannot be exceeded (e.g., the 1 GB file size ceiling) as distinct from a quota that triggers overage billing (Section 12.6). |
| Webhook | "An HTTP notification your server receives when something happens in your account." Always paired with a one-line example event name on first mention in developer-facing copy. |
| Certificate of Completion | "The tamper-evident summary generated when an envelope finishes signing." Always capitalized as a proper noun in product copy, matching its treatment as a specific artifact in Section 10. |
| Verification | Reserved exclusively for the hash-based document-integrity check at /verify (Section 10). Never used for email or identity verification, which are always called "confirmation" ("email confirmation") to avoid conflating two different mechanisms in the same product. |
| Workspace owner / admin / member | The three Team roles, always referred to by these exact nouns (never "manager," never "user" for a role name — "user" is reserved for describing an account in the abstract, not a role). |
| Priority processing | The plan-level queue-lane benefit (Section 3.9's priority lane). Never "faster processing," which implies a speed guarantee the product does not make. |
| Hard delete / soft delete | Used only in developer-facing and internal-settings copy (e.g., the account-deletion confirmation), matching the exact mechanism split in Section 4; end-user-facing copy instead says "permanently deleted" or "deleted immediately." |
Content voice — quick reference:
| Do | Don't |
|---|---|
| "Your file is compressed." | "File successfully compressed!" |
| "This tool processes files on your device." | "🔒 100% private, we NEVER see your files!" |
| "Upgrade to Pro for files up to 1 GB." | "Unlock unlimited power with Pro!" |
| "We couldn't process this file. It may be corrupted." | "Oops! Something went wrong." |
| "Continue without an account" (equal visual weight to Sign up) | "No thanks, I don't want to save time" (confirm-shaming) |
| "Delete this document? This can't be undone." | "Are you sure??" |
Every string in the "Do" column follows the same underlying test: it states a fact or an action plainly, in the fewest words that remain unambiguous, without punctuation or emoji standing in for tone. This test is the operative definition of "voice" for this product — there is no separate brand-voice document beyond the rules in this subsection and the glossary above.
17. Security, Privacy & Compliance #
17.1 Threat Model #
17.1.1 Assets #
| Asset | Where it lives | Sensitivity |
|---|---|---|
| Document content (client-side tools) | Browser OPFS only, never transmitted | Highest — must never leave the device |
| Document content (server-side tools) | Encrypted object storage, worker tmpfs, transiently | High — time-boxed exposure by design (Section 6) |
| Document metadata (filename, page count, tool used) | PostgreSQL documents table |
Medium |
| Account credentials (password hash, MFA secret) | PostgreSQL users table |
Highest |
| Session tokens | HTTP-only cookie, sessions table |
High |
| API keys | Shown once to the holder; SHA-256 digest in api_keys table |
Highest |
| Signature audit trail (hash chain, signer PII) | audit_events table, retained 7 years (Section 10.8; schema in Section 5.14) |
High, long-lived |
| Payment instruments | Never stored by PDFWorks — held by Stripe | N/A (out of PDFWorks's boundary) |
| Webhook signing secrets | webhook_endpoints table, encrypted column |
High |
| KMS master key | Cloud KMS, never leaves the KMS boundary | Highest |
| Per-job data encryption keys | Wrapped by the KMS master key, stored on the job row | High |
| WASM build artifacts and server source | Object storage / container registry | Medium — integrity matters more than confidentiality |
The single most consequential design fact for this asset table is that the highest-sensitivity asset — document content for every tool in Section 6.1's client-side column — is deliberately never an asset PDFWorks holds at all. Security engineering effort for that category is spent on the browser-side sandboxing that keeps it that way (the WASM execution boundary, OPFS isolation, and the cross-origin isolation posture in Section 3.8) rather than on protecting a server-held copy, because no server-held copy exists to protect.
17.1.2 Trust boundaries #
- Browser tab ↔ operator's device. The strongest boundary in the system for client-side tools: the file never crosses it. The browser's origin isolation (Section 3.4) further separates the PDFWorks tab from every other tab.
- Browser ↔
apps/api(api.pdfworks.io,app.pdfworks.iointernal routes). Crossed only for server-side tools, account operations, and billing redirects. Authenticated by session cookie (first-party) orAuthorization: BearerAPI key (public API). apps/api↔ Redis/BullMQ. Internal network only, never internet-reachable. Job payloads crossing this boundary carry aworkspaceId/userIdscope that every downstream consumer must re-validate rather than trust.- BullMQ ↔ worker containers (
worker-media,worker-office). Workers pull jobs; they never accept inbound connections. This boundary is where the gVisor sandbox (Section 17.5) applies. - Worker ↔ object storage / KMS. Workers hold a narrowly scoped credential that can read/write only the object prefix and only the per-job data key relevant to the job they are currently executing.
apps/api↔ PostgreSQL. Every query at this boundary is tenant-scoped (Section 17.3).apps/api↔ Stripe / Resend / KMS provider. Third-party boundaries, each with its own credential, each treated as a circuit-breaker-protected external dependency (Section 18.7).- Public internet ↔
sign.pdfworks.io. The one deliberately unauthenticated boundary in the system: a signer never has a PDFWorks account. Every request across it is treated as attacker-controlled. - Public internet ↔
api.pdfworks.io/v1. Any registered developer, or anyone who has stolen a developer's key, is a peer across this boundary. apps/api↔ customer-owned webhook receivers. Outbound only; the customer's endpoint is untrusted infrastructure from PDFWorks's point of view (it could be misconfigured, redirect internally, etc.), which is why the SSRF control set in 17.4.5 also governs webhook delivery targets.
17.1.3 Adversaries #
| Adversary | Capability | Primary interest |
|---|---|---|
| Anonymous internet attacker | Can reach every public endpoint, including guest tool usage and the signer portal | Upload malicious files, probe for SSRF, scrape, credential-stuff |
| Authenticated free-tier abuser | Valid account, browser access | Bypass quotas, enumerate other tenants' resources |
| Malicious or compromised API key holder | Valid pk_live_ key |
Exceed intended scope, exfiltrate other tenants' data, run up billing on a stolen key |
| Malicious signer | A signing link they were sent, no account | Forge or repudiate a signature, replay a stale audit event, tamper with the document after signing |
| Compromised upstream dependency | Publishes a malicious npm/PyPI package version or a poisoned PDFium/QPDF source drop | Supply-chain compromise of the build |
| Network attacker | On-path or DNS-capable | TLS downgrade, DNS rebinding against the HTML-to-PDF fetcher, session cookie theft over an insecure network |
| Insider with production access | Valid operator credentials to infrastructure | Accidental or malicious cross-tenant data access, key exfiltration |
17.1.4 STRIDE threat table #
| Category | Threat | Mitigation | Owning section |
|---|---|---|---|
| Spoofing | Attacker submits a crafted PDFWorks-Signature header to a customer's webhook receiver, impersonating PDFWorks |
HMAC-SHA256 over timestamp.body with a secret only PDFWorks and the customer hold; receivers verify before trusting the payload |
14.9 |
| Spoofing | Attacker forges a signer.signed audit event without having actually completed the signing flow |
Every audit event is appended by the server on the authenticated signer session only; the event's hash incorporates the previous event's hash, so an inserted event breaks the chain and fails verification — the chain-continuity check this relies on is strengthened by the daily external anchoring described in Section 10.5, which is what lets the break be detected independently of PDFWorks's own database | 17.1.4 (below), 10.5, 10.6 |
| Spoofing | Session cookie theft over an unencrypted network segment lets an attacker impersonate a logged-in user | TLS 1.3 everywhere, Secure cookie flag, HSTS with preload (17.6.1) |
17.6.1 |
| Tampering | A malicious actor uploads a PDF crafted to exploit a parser bug in PDFium or QPDF, seeking code execution in the browser tab or in a worker container | PDF structural validation before any parser touches the file, gVisor sandbox with no outbound network for the server path (17.5), and the browser path runs inside the WASM sandbox with no ambient filesystem/network access regardless of a parser bug | 17.4.2, 17.5 |
| Tampering | A "redacted" PDF is produced with the redaction drawn only as a visual overlay, leaving the underlying text/image extractable | The redaction pipeline deletes content-stream operators, image pixels, annotations, and metadata before drawing the box, rewrites the file non-incrementally, and runs a verification pass that fails closed | 9.1 |
| Tampering | An attacker with read access to the object store or the job queue reads or modifies another tenant's document bytes or job payload | Every object key is namespaced by workspaceId/documentId; every object is encrypted with a job-specific data key the requesting worker does not hold unless it owns that exact job; queue consumers re-validate workspaceId against the authenticated job owner before acting (17.3.3) |
17.3.3, 17.6.2 |
| Tampering | An attacker uploads a Trojan document via the HTML-to-PDF tool by pointing it at an internal service and capturing that service's response as "converted" output | SSRF control set: scheme allowlist, DNS pinning, private/link-local range block, redirect cap, response size/time limits, dedicated egress path (17.4.5) | 17.4.5 |
| Tampering | A user with a lost/expired PDF password uses the unlock tool to brute-force or launder a document they do not own | Unlock only removes a password the user supplies at the time of the request (it is not a cracking tool); rate limiting and CAPTCHA-free but throttled retry on the password field; the tool is client-side, so an actual dictionary attack runs at the attacker's own CPU expense, not PDFWorks's — the abuse surface is therefore self-limiting, and PDFWorks additionally logs (metadata only, never content) unusually high unlock-attempt rates per account for the trust-and-safety review queue | 6.5, 17.1.4 |
| Repudiation | A signer denies having signed a document, claiming the audit trail was fabricated after the fact | Append-only SHA-256 hash chain per envelope, NTP-disciplined server timestamps, verified-email OTP before first field entry, a public hash-verification endpoint at sign.pdfworks.io/verify/{envelopeId} that anyone (including a court or counterparty) can use independently, and daily external anchoring of the chain's root hash to an independent third party (Section 10.5) so the tamper-evidence guarantee does not rest solely on PDFWorks's own database integrity |
10.5, 10.6, 10.7 |
| Repudiation | An attacker replays a captured signer.viewed or field.completed event to fabricate signer activity that did not occur |
Each event is generated server-side at the moment of the authenticated action, not client-submitted as free-form data; the client submits the field value, the server generates the event and hash; replaying an old signer-session token is rejected once the session's one-time OTP has been consumed | 10.5, 10.6 |
| Information disclosure | Cross-tenant leakage through the job queue: a worker or API bug returns tenant A's result to tenant B | Job IDs are UUIDv7 (unguessable), every job fetch re-checks job.workspace_id == requester.workspace_id server-side regardless of client-supplied context, and the automated cross-tenant test suite (17.3.4) runs this exact scenario against every endpoint in CI |
17.3.3, 17.3.4 |
| Information disclosure | Cross-tenant leakage through the object store: a signed URL or object key for tenant A's file is guessable or reused for tenant B | Object keys embed a UUIDv7 document ID; download URLs are short-lived pre-signed URLs (5-minute expiry) scoped to a single object, generated only after a tenant-ownership check | 17.3.3, 17.6.2 |
| Information disclosure | Document content or filenames leak into logs or error trackers | Pino redaction path list strips known-sensitive fields at the logger boundary (18.1.4); Sentry beforeSend scrubbing strips the same categories independently, so a single missed redaction path is not a single point of failure (18.4.4) |
18.1.4, 18.4.4 |
| Information disclosure | Verbose error messages or stack traces reveal internal paths, dependency versions, or query structure to an attacker | The public API only ever returns the error envelope (Section 14.6); internal exception detail is logged server-side with requestId, never serialized to the client in production |
14.6 |
| Denial of service | Zip-bomb or billion-laughs-style PDF (deeply nested object streams, extreme compression ratios) exhausts worker memory/CPU | Hard limits on object count, nesting depth, decompressed size, page count, and stream length enforced before decompression proceeds past the limit (17.4.3) | 17.4.3 |
| Denial of service | A single tenant's job volume starves other tenants' jobs in the shared queue | BullMQ priority lanes (priority vs standard) plus a per-tenant concurrency cap enforced at job-claim time in apps/api, independent of the plan-level daily task cap in Section 12.2 |
13.4 |
| Denial of service | Repeated failed login or API key guesses exhaust server resources or enable credential stuffing | Redis token-bucket rate limiting per IP and per account on /v1/auth/*, exponential backoff after five consecutive failures, breached-password check blocks known-compromised passwords at registration and reset (17.2.1) |
17.2.1, 14.9 |
| Elevation of privilege | A Team workspace member without the owner or admin role calls an admin-only endpoint directly (bypassing UI checks) |
Every mutating endpoint re-checks the caller's workspace role server-side from the authenticated session, never from a client-supplied role claim; the UI's role gating is a convenience, not a control (17.3.1) | 17.3.1, 11.4 |
| Elevation of privilege | An API key scoped to documents:read is used to call a documents:write or billing endpoint |
Per-key scopes are enforced in the auth middleware chain before the route handler runs; a scope mismatch returns 403 permission_error before any business logic executes |
17.2.4, 14.7 |
17.2 Authentication and Credential Security #
17.2.1 Password rules #
Passwords are the credential for human account holders (never for API access — see 17.2.4 for why the mechanism differs).
| Parameter | Value |
|---|---|
| Hashing algorithm | Argon2id |
| Memory cost | 64 MiB |
| Iterations (time cost) | 3 |
| Parallelism | 4 |
| Minimum length | 12 characters |
| Maximum length | 256 characters (rejects longer inputs with 422 processing_error, code password_too_long, to bound hashing cost) |
| Composition rules | None — no forced mix of character classes |
| Forced rotation | None |
| Breach check | Have I Been Pwned k-anonymity range API, queried on every registration and password change |
Why no composition rules and no forced rotation. Composition rules (forced uppercase/digit/symbol) push users toward predictable substitutions (Password1!) that are easier, not harder, to guess, and forced rotation pushes users toward incrementing a base password (Summer2026! → Summer2027!). Both practices are contraindicated by NIST SP 800-63B and by the empirical password-cracking literature. A 12-character minimum combined with a breach check against a corpus of hundreds of millions of previously exposed passwords removes far more real-world risk (credential-stuffing via reused, already-compromised passwords) than composition or rotation rules ever did. The breach check is implemented via the HIBP Pwned Passwords k-anonymity API: the client (or server, on the user's behalf) hashes the candidate password with SHA-1, sends only the first five hex characters of the hash, receives the set of matching suffixes, and rejects the password locally if a match is found — the full password and full hash never leave PDFWorks's boundary during the check.
Registration flow:
POST /internal/auth/register—{ "email": string, "password": string, "name": string }.- Validate email format and password length server-side (Zod schema in
packages/contracts). - Run the HIBP breach check; reject with
422 processing_error, codepassword_breached, if the password appears in the corpus. - Hash with Argon2id using the parameters above.
- Create the
usersrow, send an email verification link (24-hour expiry, single use) via Resend. - Account is usable immediately for guest-tier-equivalent actions; server-side tools and paid-plan features require a verified email.
17.2.2 Session management #
Sessions are issued and managed by better-auth (1.7.x) and stored server-side, referenced by an opaque cookie value.
| Attribute | Value |
|---|---|
| Cookie name | pw_session |
HttpOnly |
Yes |
Secure |
Yes |
SameSite |
Lax |
| Rolling expiry | 30 days from last activity |
| Absolute maximum | 90 days from issuance, regardless of activity |
| Storage | Server-side session record in PostgreSQL, cookie holds only a random session identifier — no session state is trusted from the client |
Session invalidation triggers:
- Explicit logout (single session).
- "Log out of all devices" (all sessions for the user).
- Password change (all sessions except the one that performed the change, which is reissued).
- MFA enrollment or MFA removal (all sessions).
- Per-device revoke from the account settings session list, which shows device/browser, approximate location (geo-IP country only), and last-active time for every live session.
- A workspace owner forcing a member's logout when removing them from a Team workspace.
Account settings expose the full session list with a revoke action per entry; revoking the session currently in use logs the user out immediately on their next request.
17.2.3 Multi-factor authentication and account recovery #
MFA: TOTP (RFC 6238), 30-second step, 6 digits, plus ten single-use recovery codes generated at enrollment and shown once. MFA is optional on Free and Pro. A Team workspace owner can set an mfa_required flag on the workspace; when set, every member is prompted to enroll on next login and is blocked from workspace resources after a 7-day grace period until they do.
Account recovery ("forgot password"):
POST /internal/auth/password-reset-request—{ "email": string }. Always returns202 Acceptedregardless of whether the email exists, to avoid account enumeration.- If the account exists, a single-use reset token (256-bit random, SHA-256 stored) is emailed with a 15-minute expiry.
- Rate limited to 3 requests per email per hour and 10 per IP per hour.
- Consuming the token requires a new password that passes the same length and breach checks as registration.
- On successful reset: all existing sessions are invalidated, an email notification of the change is sent to the account's address, and if MFA is enrolled the user must additionally complete MFA before the new session is granted.
- No security questions are used anywhere in the recovery path — they are a weaker secondary credential than the primary password and expand the attack surface without adding real assurance.
17.2.4 API keys #
API keys authenticate calls to the public REST API (Section 14) and are the only credential type accepted there — session cookies are never valid on /v1/*.
| Property | Value |
|---|---|
| Prefix | pk_live_ (production) or pk_test_ (test mode, operates against isolated test-mode data) |
| Entropy | 32 bytes from a CSPRNG, base32-Crockford encoded after the prefix |
| Display | Shown once, at creation, in full; never displayed again |
| Storage | SHA-256 digest of the full key; the digest, not the key, is what is compared on every request |
| Scopes | Per-key, drawn from a fixed set: documents:read, documents:write, jobs:read, jobs:write, esign:read, esign:write, webhooks:manage, billing:read |
| IP allowlist | Optional, per-key, CIDR list; when set, requests from outside the list are rejected with 403 permission_error before scope checks run |
| Spend cap | Optional, per-key, in minor currency units; defaults to off; when reached, further calls return 402 quota_exceeded (Section 12.6) |
| Revocation | Instant; a revoked key's digest is flagged, and the auth middleware rejects it on the next request with no propagation delay, since the check is a synchronous database read, not a cached decision |
| Last-used timestamp | Updated asynchronously (best-effort, not on the request's critical path) on every successful authentication |
Why SHA-256 (a fast hash) is correct for API keys while Argon2id (a slow KDF) is correct for passwords — stated explicitly so this is never read as an inconsistency. A slow KDF exists to defend against offline brute-force of a low-entropy, human-chosen secret: passwords cluster around a small effective keyspace because humans reuse patterns, so the defense is to make each guess expensive. An API key has none of that weakness — it is 256 bits of CSPRNG output with no human-memorable structure, so brute-forcing it by guessing is already computationally infeasible regardless of hash speed; the only realistic attack is theft of the plaintext key or theft of the digest, and a slow KDF defends against neither. Using Argon2id for API keys would instead impose real cost: every one of the public API's request-scoped authentications would pay tens of milliseconds of deliberate hashing latency on a hot path serving metered, latency-sensitive integrations. SHA-256 is fast, collision-resistant, and pre-image-resistant, which is exactly the property needed to verify "does this digest match a known key" without meaningfully slowing every API call.
// packages/db/src/repositories/api-keys.ts
import { randomBytes, createHash } from "node:crypto";
import { base32Crockford } from "@pdfworks/encoding";
export function generateApiKey(mode: "live" | "test"): { plaintext: string; digest: string } {
const entropy = randomBytes(32);
const plaintext = `pk_${mode}_${base32Crockford.encode(entropy)}`;
const digest = createHash("sha256").update(plaintext).digest("hex");
return { plaintext, digest }; // plaintext is returned to the caller exactly once and never persisted
}
export async function authenticateApiKey(presentedKey: string): Promise<ApiKeyContext | null> {
const digest = createHash("sha256").update(presentedKey).digest("hex");
const [row] = await db
.select()
.from(apiKeys)
.where(and(eq(apiKeys.digest, digest), isNull(apiKeys.revokedAt)))
.limit(1);
if (!row) return null;
void touchLastUsed(row.id); // fire-and-forget, never blocks the auth decision
return { workspaceId: row.workspaceId, scopes: row.scopes, ipAllowlist: row.ipAllowlist };
}17.2.5 Webhook secrets #
Webhook signing follows the canonical scheme: HMAC-SHA256 over timestamp.body, delivered as PDFWorks-Signature: t=<unix>,v1=<hex>, verified by the receiver against a shared secret with a 5-minute tolerance window on t. Secrets are rotatable with a 24-hour dual-secret overlap window during which both the old and new secret validate, so a customer can roll their receiver's secret without downtime (Section 14.9).
17.3 Authorization #
17.3.1 Defense in depth and the server-side-only enforcement rule #
Every authorization decision is made and enforced server-side, on every request, using only the authenticated identity (session.userId or apiKey.workspaceId) and data read fresh from PostgreSQL. The following are never trusted as authorization inputs: client-supplied workspaceId/userId fields in a request body, role claims embedded in a JWT payload (PDFWorks does not use JWTs for authorization decisions — the session/API-key lookup always re-reads current role from the database), UI-layer route guards, or the mere presence of a resource ID in a URL. The frontend's route guards and disabled buttons exist purely for user experience; removing them changes nothing about what the server will accept.
Authorization is layered:
- Authentication middleware resolves the caller to a
userId(session) orworkspaceId+scopes(API key) and rejects unauthenticated requests with401 authentication_errorbefore any handler runs. - Scope middleware (API key path only) rejects a request whose key lacks the scope the route requires, with
403 permission_error. - Workspace-role middleware (first-party app path, Team workspaces only) reads the caller's current role (
owner,admin,member) from theworkspace_memberstable for the target workspace on every request and rejects role-insufficient actions (for example, amembercalling an endpoint that changes billing or removes another member) with403 permission_error. - Row-level tenant scoping (17.3.2) is applied inside every query, independent of the three checks above, so that even a bug in an earlier layer cannot surface another tenant's row.
17.3.2 The tenant-isolation invariant #
Every database query that reads or writes a tenant-owned table includes an explicit workspace_id (or, for personal accounts, user_id) predicate sourced from the authenticated identity — never from a client-supplied parameter. This is enforced structurally, not by convention: packages/db exposes repository functions (for example, getDocument(workspaceId: string, documentId: string)) that require the scope as a mandatory first argument, and there is no lower-level "get by ID only" accessor exported for tenant-owned tables. A route handler that needs a document loads it exclusively through getDocument(ctx.workspaceId, params.documentId); there is no code path that can construct a document lookup without a scope value already bound from the authenticated context.
Tables covered by this invariant: documents, jobs, envelopes, signers, templates, api_keys, webhook_endpoints, audit_events, batch_jobs. Global tables not covered because they carry no tenant data: feature_flags, the error-code catalogue backing table, and reference data.
Cross-tenant access returns 404 not_found_error, never 403 permission_error. A tenant that does not own a resource is told it does not exist, rather than being told it exists but is forbidden — this prevents resource-ID enumeration from leaking which IDs are valid across tenant boundaries.
Queue and object-store layers restate the same invariant in their own terms: a BullMQ job payload always carries the owning workspaceId, and a worker re-validates that the job it pulled still belongs to an active, non-deleted workspace before writing any result; an object key is always prefixed workspaces/{workspaceId}/documents/{documentId}/..., and the pre-signed URL generator refuses to mint a URL for an object whose prefix does not match the caller's authenticated workspaceId.
17.3.3 Applying the invariant to the job queue and object store #
// packages/db/src/repositories/jobs.ts
export async function getJobForWorkspace(
workspaceId: string,
jobId: string,
): Promise<Job | null> {
const [job] = await db
.select()
.from(jobs)
.where(and(eq(jobs.id, jobId), eq(jobs.workspaceId, workspaceId), isNull(jobs.deletedAt)))
.limit(1);
return job ?? null;
}// apps/worker-media/src/handlers/ocr.ts
export async function handleOcrJob(payload: OcrJobPayload) {
const job = await getJobForWorkspace(payload.workspaceId, payload.jobId);
if (!job || job.status !== "queued") {
// Re-validated at pull time, not trusted from the enqueue-time payload alone,
// in case the job was canceled or the workspace was deleted between enqueue and pull.
return;
}
const objectKey = `workspaces/${job.workspaceId}/documents/${job.documentId}/source.pdf`;
// objectKey is derived from the re-validated job row, never from the raw payload.
...
}17.3.4 The automated cross-tenant access test #
Every route registered in apps/api is enumerated at test time from the OpenAPI document generated in packages/contracts (Section 14.1), and a shared Vitest suite runs the following scenario against each one that accepts a resource identifier:
- Provision two fully isolated tenants, A and B, each with a verified account, a session, and an API key.
- Tenant A creates a resource of the type the route operates on (a document, a job, an envelope, a template, a webhook endpoint).
- Tenant B calls the route under test, substituting tenant A's resource ID, using both tenant B's session and tenant B's API key.
- Assert the response is
404 not_found_errorin every case, and assert no side effect occurred (for read routes: no data about tenant A's resource is present anywhere in the response body, including in list/count metadata; for write routes: re-fetch the resource as tenant A and assert it is unchanged). - The suite fails the CI build if any route in the generated route list has no corresponding test case — a newly added endpoint cannot merge without being covered.
This suite runs on every pull request that touches apps/api, packages/db, or packages/contracts, and nightly against the full route set as a regression backstop.
17.4 Input Security #
17.4.1 Upload validation #
Every file accepted by a server-side tool or the public API passes the following checks, in order, before any parsing library touches its bytes:
- Size check against the caller's plan limit (Section 12.2) — rejected with
413-mappedinvalid_request_error, codefile_too_large, before the body is fully buffered where the transport allows streaming rejection. - Magic-byte sniffing. The
Content-Typeheader is never trusted for routing or validation — it is attacker-controlled and routinely wrong. The first 1,024 bytes are inspected for the%PDF-signature (PDF), the Office Open XML ZIP local-file-header signature plus[Content_Types].xmlpresence (DOCX/XLSX/PPTX), or the legacy OLE2 compound-file signature (.doc/.xls/.ppt, accepted as input to conversion but always normalized to OOXML on output). A mismatch between the declared tool and the sniffed type is rejected with422 processing_error, codeunrecognized_file_type. - Extension/content agreement. The filename extension must be consistent with the sniffed type (case-insensitively); a
.pdffile whose bytes sniff as a ZIP is rejected, since this is a common technique for smuggling a different parser's attack surface past a naive filter. - PDF structural validation (17.4.2) for any PDF input, before it reaches PDFium or QPDF.
A rejection at any step returns the canonical error envelope (Section 14.6) rather than a parser stack trace:
{
"error": {
"type": "processing_error",
"code": "unrecognized_file_type",
"message": "The uploaded file's content does not match a supported PDF or Office document format.",
"param": "file",
"docsUrl": "https://docs.pdfworks.io/errors/unrecognized_file_type",
"requestId": "req_01K7Y3P8T2VN4Q"
}
}17.4.2 PDF structural validation #
A dedicated, minimal-trust pre-parser walks the file's cross-reference structure (classic xref table or xref stream) and object graph without invoking the full rendering/editing engine, and rejects the file before PDFium or QPDF ever sees it if any of the following hold:
- No valid
%PDF-header within the first 1,024 bytes, or no%%EOFmarker within the last 1,024 bytes. - The object count exceeds 500,000.
- Indirect object reference nesting (an object referencing an object referencing an object, as seen in deeply nested
/Kidspage trees, nested/Groupstructures, or nested filter chains) exceeds a depth of 64. - Any single stream's declared
/Length, or its actual decompressed size after applying its/Filterchain, exceeds 2 GB. - The ratio of decompressed size to compressed size for any stream exceeds 300:1 (the zip-bomb / billion-laughs signature — legitimate PDF content streams essentially never compress this well).
- Page count (
/Countin the page tree root, cross-checked against an actual walk of the/Kidstree) exceeds 20,000. - Any content stream applies more than 12 chained filters (legitimate PDFs use at most 2–3).
- The file declares more than 10 levels of incremental update (multiple
%%EOFmarkers) — beyond this, treated as a malformed-file signal rather than a legitimate revision history, and rejected rather than repaired.
A file that fails structural validation returns 422 processing_error, code malformed_pdf_structure, and is never passed to any parser. This check runs identically in the browser (compiled into the same pdfcore WASM artifact, Section 3.6) and on the server, so the same limits apply regardless of execution location — a file too hostile for the browser is equally rejected server-side.
17.4.3 Resource limits and zip-bomb / billion-laughs defense #
The limits in 17.4.2 are PDFWorks's zip-bomb and billion-laughs defense for PDF input: both attack families work by declaring a small on-disk representation that expands to a memory- or CPU-exhausting size, and the decompressed-size cap plus the compression-ratio cap catch this regardless of which specific filter (FlateDecode, LZWDecode, CCITTFaxDecode) is used to achieve it. For Office document input (which are ZIP containers), the same class of attack is defended by:
- A cap of 10,000 entries per ZIP.
- A cap of 4 GB total decompressed size across all entries.
- A per-entry decompressed/compressed ratio cap of 300:1.
- A maximum nesting depth of 1 — a ZIP entry that is itself a ZIP is rejected outright; OOXML documents never legitimately nest archives.
These limits are enforced by streaming decompression with a running counter checked after every chunk, so the process aborts as soon as a limit is crossed rather than after fully expanding a hostile payload into memory.
17.4.4 Stripping active content on ingest #
Any PDF accepted for server-side processing has the following removed as an ingest-time normalization step, before the requested tool runs, regardless of which tool was requested:
- Embedded JavaScript (
/JavaScriptname tree entries and/JSactions on any object). - Launch actions (
/Launch) and any action that would invoke an external application or URI handler other than a plain/URIlink. - Embedded files (
/EmbeddedFilestreams and the/EFentries referencing them) — these are surfaced separately in the UI as attachments the user can choose to keep only if the requested tool is explicitly an attachment-preserving one (currently none are; this is deliberately conservative). - Remote-content references:
/GoToR(remote go-to actions),/ImportData, and any XFAsubmit/importbinding that would cause the rendered document to phone home to a URL when opened in a third-party viewer.
This normalization never touches, and never needs to touch, the proprietary conditional-form-logic dictionary described in Section 8.4.6. Fillable forms encode conditional field show/hide rules, calculations, and validation logic as a proprietary, non-executable dictionary structure specifically because this sanitizer strips embedded PDF JavaScript unconditionally, and form logic expressed as /JavaScript would be removed on ingest along with everything else in the list above. The dictionary is inert data: it contains no executable code, is interpreted only by this product's own form-rendering engine (in the browser and on the server, from the same pdfcore code path), is never passed to a script interpreter of any kind, and is therefore neither stripped by this sanitizer nor treated as active content requiring removal. A third-party PDF viewer that does not recognize the dictionary simply ignores it as an unrecognized private data structure and falls back to rendering the form's static field set, which is why this design choice does not compromise the sanitizer's guarantee that no server-side-processed document carries executable content.
This normalization is unconditional for the server-side path. Client-side tools apply the same stripping logic (again, the same compiled pdfcore code path) for any tool whose output the product represents as safe to share, and the redaction tool additionally performs the deeper, region-specific removal described in Section 9.1.
17.4.5 SSRF control set for URL inputs #
The HTML-to-PDF tool (Section 9.6) is the only feature that accepts a URL PDFWorks's own infrastructure will fetch. It is fully defended by the following control set, applied to every fetch including any fetch triggered by a redirect:
| Control | Rule |
|---|---|
| Scheme allowlist | Only https:// is accepted; http://, file://, ftp://, gopher://, and any other scheme are rejected with 422 processing_error, code invalid_url_scheme |
| DNS resolution pinning | The hostname is resolved once, the resolved IP is validated against the ranges below, and the same resolved IP is used for the actual connection (not re-resolved) — this defeats DNS rebinding, where a hostname resolves to a safe IP at validation time and an internal IP at connection time |
| Private and link-local range blocking | Resolved IPs in 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16 (including the 169.254.169.254 cloud metadata address specifically), ::1/128, fc00::/7, and fe80::/10 are rejected |
| Redirect limit | Maximum 3 redirects followed; each redirect target is independently re-validated against every control in this table, including DNS pinning |
| Response size limit | Aborted at 50 MB of response body |
| Response time limit | 15-second total fetch timeout, 5-second connect timeout |
| Egress path | The fetch is issued from a dedicated network path (a proxy egress point with its own security group, distinct from the general worker egress, which otherwise has no outbound network access at all per 17.5) so a bypass of the application-layer controls above still cannot reach internal infrastructure at the network layer |
| Content-type check | The fetched response must declare text/html (or application/xhtml+xml); anything else is rejected before rendering, code unsupported_content_type |
The same control set governs the outbound fetch PDFWorks makes when validating a customer's webhook endpoint at registration time (Section 14.9) and any future feature that accepts a caller-supplied URL.
17.4.6 Filename and path sanitization #
Uploaded filenames are treated as untrusted display strings, never as filesystem paths:
- Stored verbatim (Unicode-normalized to NFC, truncated to 255 bytes UTF-8) in the
documents.original_filenamecolumn for display purposes only. - Never interpolated into a filesystem path. Every on-disk or object-store path uses the document's UUIDv7 identifier, never the filename (
workspaces/{workspaceId}/documents/{documentId}/source.pdf, not.../{originalFilename}). - Path traversal sequences (
../,..\\), null bytes, and control characters are stripped before the filename is used in aContent-Dispositionheader on download, using RFC 6266'sfilename*extended syntax so non-ASCII names round-trip correctly without header injection. - Within Office-document ZIP containers, any entry path containing
../or an absolute path is rejected outright during conversion — this is the ZIP-slip defense, applied alongside the nesting and count limits in 17.4.3.
17.5 Processing Sandbox #
Every server-side job — OCR, Office/HTML conversion, and every job originating from the public API — executes inside a dedicated, single-use container with the following isolation profile:
| Control | Setting |
|---|---|
| Container runtime | gVisor (runsc) — a user-space kernel that intercepts syscalls, so a bug in PDFium, QPDF, LibreOffice, Poppler, or Ghostscript that would otherwise yield a host kernel exploit is contained to the sandbox's emulated kernel surface |
| Outbound network | None. The container's network namespace has no route to the internet or to PDFWorks's internal services beyond the one control-plane connection used to pull the job and push the result, which is a Unix-domain-socket-style local IPC to a sidecar, not a routable network path |
| Filesystem | Root filesystem mounted read-only; the container image contains no writable system paths |
| Scratch space | A per-job tmpfs mount, sized to 4 × declared input file size with a floor of 512 MB and a ceiling of 4 GB, unmounted and its backing memory reclaimed when the container exits |
| Capabilities | All Linux capabilities dropped (--cap-drop=ALL); none are added back. The processing binaries need no elevated capability — they read from the tmpfs input path and write to the tmpfs output path |
| Seccomp | The gVisor default syscall filter, further restricted by a custom seccomp profile denylisting ptrace, mount, unshare, clone with namespace flags, and socket-family calls other than the local IPC socket |
| User | Non-root, fixed UID/GID 65532 (nonroot), no sudo, no setuid binaries in the image |
| CPU limit | 2 vCPU per job (OCR and office-conversion jobs), enforced by the container runtime's cgroup limits |
| Memory limit | 4 GB per job, ceiling enforced by cgroups; a job that exceeds it is OOM-killed and the job transitions to failed with code processing_error / resource_limit_exceeded |
| Wall-clock timeout | Hard-killed if still running past the per-tool ceiling defined in Section 13.3.8; the job transitions to expired (Section 4.7). An absolute sandbox kill deadline of 60 minutes additionally applies to every container regardless of tool or per-tool ceiling — see the note below the pod specification for how the two relate |
The isolation profile is expressed as a Kubernetes pod security context (the executor's orchestrator of choice per Section 20), reproduced here as the concrete, checkable specification rather than a prose description an implementer could interpret loosely:
apiVersion: v1
kind: Pod
metadata:
name: worker-media-job
annotations:
io.kubernetes.cri.untrusted-workload: "true" # routes the pod to the gVisor RuntimeClass
spec:
runtimeClassName: gvisor
automountServiceAccountToken: false
securityContext:
runAsUser: 65532
runAsGroup: 65532
runAsNonRoot: true
fsGroup: 65532
seccompProfile:
type: Localhost
localhostProfile: profiles/pdfworks-worker.json
containers:
- name: worker-media
image: registry.pdfworks.io/worker-media:sha-<commit>
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
limits:
cpu: "2"
memory: "4Gi"
requests:
cpu: "1"
memory: "1Gi"
volumeMounts:
- name: scratch
mountPath: /tmp/job
volumes:
- name: scratch
emptyDir:
medium: Memory
sizeLimit: 4Gi
# No NetworkPolicy egress rule permits any destination — the pod's CNI configuration
# attaches it to a network namespace with zero routes beyond the local control-plane socket.Per-tool wall-clock ceilings (OCR, PDF ↔ Office conversion, HTML ↔ PDF, batch jobs, and e-signature server-side operations) are defined once, in Section 13.3.8, and are not restated here — this section's concern is the sandbox that enforces whatever ceiling the job orchestrator hands it, not the ceiling values themselves.
The 60-minute absolute sandbox kill deadline is a different kind of control from the per-tool ceilings, and the two are never in tension. The per-tool ceilings in Section 13.3.8 are the normal, expected mechanism by which a job is stopped — tuned per tool, and always well under an hour. The 60-minute deadline is a security backstop: a hard, unconditional kill issued by the sandbox's own supervisor process, independent of the orchestrator that enforces the per-tool ceiling, so that a bug or failure in the per-tool timeout enforcement path itself (rather than in the job) cannot leave a container running indefinitely. Under normal operation the per-tool ceiling always fires first and the 60-minute deadline never engages; it exists purely so that no single point of failure in the timeout-enforcement code can turn into an unbounded-runtime sandbox.
One worker process handles exactly one job and then exits. The container is not reused across jobs — after a job reaches a terminal state, the process exits and the orchestrator (Section 13) discards the container and starts a fresh one for the next job pulled from the queue. This is deliberate for three reasons: it eliminates any possibility of memory or temp-file state from tenant A's job being observable to tenant B's job in the same process, which matters because the native parsers involved (PDFium, QPDF, LibreOffice, Poppler, Ghostscript) are large C/C++ codebases where a use-after-free or uninitialized-memory bug is a realistic risk class, not a theoretical one; it bounds the blast radius of any single job's resource exhaustion or crash to that job alone; and it makes resource accounting exact — a fresh container's CPU-second and memory usage is attributable to exactly one job for the cost model in Section 18.9, with no cross-job noise.
17.6 Data Protection #
17.6.1 Encryption in transit #
- TLS 1.3 is the only version negotiated for
pdfworks.io,app.pdfworks.io,api.pdfworks.io,docs.pdfworks.io,sign.pdfworks.io, andstatus.pdfworks.io. TLS 1.2 and below are disabled at the edge; there is no legacy-compatibility fallback. - Cipher policy: only AEAD cipher suites (
TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256), server-preferred ordering. Strict-Transport-Security: max-age=63072000; includeSubDomains; preloadon every response, and the apex and all subdomains above are submitted to the HSTS preload list so even a user's first-ever request cannot be downgraded.- Internal service-to-service traffic (
apps/api↔ PostgreSQL,apps/api↔ Redis,apps/api/workers ↔ object storage) is TLS-encrypted even though it stays within the private network, on the assumption that network segmentation alone is not a sufficient boundary.
17.6.2 Encryption at rest #
Every object written to blob storage — every uploaded source file, every intermediate artifact, every job output — is encrypted with envelope encryption:
- At job creation, the API generates a fresh 256-bit data key using a CSPRNG.
- The object is encrypted with AES-256-GCM using that data key; the GCM authentication tag is stored alongside the ciphertext, so any tampering with the stored object is detected on decryption rather than silently producing corrupted output.
- The data key itself is immediately encrypted ("wrapped") by a KMS-managed master key that never leaves the KMS provider's boundary — PDFWorks's application code calls the KMS
Encrypt/DecryptAPI and never has direct access to the master key material. - The wrapped data key (ciphertext, not plaintext) is stored on the job's row in PostgreSQL.
- To read the object back, the worker calls KMS to unwrap the data key (an operation the KMS provider can itself audit and, if needed, deny), decrypts the object in the worker's tmpfs, uses it, and discards the plaintext data key from memory when the job ends.
Key rotation: the KMS master key is rotated automatically on an annual schedule by the KMS provider's built-in rotation, which retains prior key versions so previously wrapped data keys remain unwrappable — PDFWorks never needs to re-wrap historical data keys on a master-key rotation. Per-job data keys are never reused across jobs or documents; a new one is generated every time, so rotation of any single data key is meaningless — each one is already single-use.
The relevant columns on the jobs table (full schema in Section 5):
CREATE TABLE jobs (
id uuid PRIMARY KEY, -- UUIDv7, application-generated
workspace_id uuid NOT NULL REFERENCES workspaces(id),
document_id uuid NOT NULL REFERENCES documents(id),
tool text NOT NULL,
status text NOT NULL CHECK (status IN ('queued','running','succeeded','failed','canceled','expired')),
wrapped_data_key bytea, -- KMS ciphertext; NULL once cryptographically shredded
data_key_kms_key_id text NOT NULL, -- which KMS master key version wrapped this data key
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deletion_requested_at timestamptz
);// apps/worker-media/src/lib/envelope-crypto.ts
export async function encryptForStorage(plaintext: Buffer): Promise<{
ciphertext: Buffer;
wrappedDataKey: Buffer;
}> {
const dataKey = randomBytes(32); // fresh 256-bit key, never persisted in plaintext
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", dataKey, iv);
const ciphertext = Buffer.concat([iv, cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
const { CiphertextBlob: wrappedDataKey } = await kms.encrypt({
KeyId: process.env.PDFWORKS_KMS_MASTER_KEY_ID,
Plaintext: dataKey,
});
dataKey.fill(0); // scrub the plaintext data key from memory immediately after use
return { ciphertext, wrappedDataKey };
}
export async function decryptFromStorage(ciphertext: Buffer, wrappedDataKey: Buffer): Promise<Buffer> {
const { Plaintext: dataKey } = await kms.decrypt({ CiphertextBlob: wrappedDataKey });
const iv = ciphertext.subarray(0, 12);
const tag = ciphertext.subarray(ciphertext.length - 16);
const body = ciphertext.subarray(12, ciphertext.length - 16);
const decipher = createDecipheriv("aes-256-gcm", dataKey, iv);
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([decipher.update(body), decipher.final()]);
dataKey.fill(0);
return plaintext;
}Cryptographic shredding as the primary deletion mechanism: because each object's confidentiality depends entirely on its wrapped data key, destroying that wrapped key value on the job row renders the corresponding object's ciphertext permanently unrecoverable, even before the underlying bytes are physically deleted from blob storage. This is the deletion pipeline's first and fastest step (17.6.3) precisely because it provides an immediate, verifiable point of no return, independent of how quickly the storage backend gets around to reclaiming the underlying bytes.
17.6.3 The deletion pipeline, in order #
Triggered by the retention rules in Section 6 (the 2-hour-after-terminal-state / 24-hour-absolute default), by Section 10.8 (the envelope-lifetime exception), or by an explicit "Delete now" / DELETE /v1/documents/{id} call:
- Mark. The document/job row is flagged
deletion_requested_at = now()in the same transaction that authorizes the deletion (ownership already verified per 17.3.2). - Shred the key. The KMS wrapped data key is deleted from KMS (a
ScheduleKeyDeletion-equivalent call against the data key, not the master key) and the wrapped-key column on the row is overwritten with a deletion tombstone. This step alone makes the object's plaintext permanently unrecoverable, and it completes within the same request for a "Delete now" action. - Delete the bytes. An asynchronous job (queue
janitor) issues the object-store delete call for every object under the document's key prefix. This is expected to complete within 60 seconds of step 2 for an explicit delete, and is what the 2-hour/24-hour retention SLA in Section 6 measures against for the default (non-explicit) case. - Delete the row (soft or hard, per Section 5's policy). Document metadata rows are soft-deleted (
deleted_atset); the blob and any job payload referencing it are hard-deleted at this point — there is no soft-delete tier for the bytes themselves. - Purge from caches. Any CDN-edge or application-cache entry keyed by the document ID (there should be none for private documents, since private document bytes are never cached at the edge — see 18.6.7 — but pre-signed URL metadata caches are purged defensively).
- Verify. A separate verification job, running on a delay (15 minutes after step 3 enqueues, to allow for object-store eventual consistency) re-attempts a
HEADrequest against every object key that was targeted for deletion. A404/NoSuchKeyresponse confirms deletion; any other response is logged as adeletion_verification_failureaterrorlevel, paged to the on-call security channel (18.8), and retried up to 5 times with exponential backoff — a stricter count than the queue's general retry default in Section 13.3.5, justified by this step's role in confirming a compliance-relevant deletion — before being escalated as an incident. - Log. Every step above writes an entry to the
audit_events-adjacentdeletion_logtable:documentId, the step name, the timestamp, and — for step 6 — the verification outcome. This log is retained for 2 years (long enough to answer a customer's "was my data really deleted" inquiry well after the fact) and contains no document content, only the deletion pipeline's own metadata.
The e-signature exception (Section 10.8) applies the identical pipeline, just triggered at the envelope's 14–30 day expiry / 30-day post-completion window instead of the default 2-hour/24-hour window, and it exempts the audit_events hash-chain rows (not the document bytes, which are deleted exactly as above) from steps 3–4, per the reasoning stated in Section 10.8.
17.6.4 Coverage beyond the primary object store #
The seven steps above are written against the primary object store and the row of record, but a document's bytes or a reference to them can transiently exist in other systems too. The same commitment — no recoverable copy outlives the window below, or, where a system's own design means it structurally cannot delete on that exact schedule, an honest statement of the compensating control that makes the surviving copy harmless — applies to each:
| Copy location | Maximum window a copy can survive | Compensating control |
|---|---|---|
| Database backups and point-in-time-recovery (PITR) snapshots | Up to the backup retention cycle (Section 20) | A backup never contains document bytes — those live only in blob storage, never in PostgreSQL — so a backup can retain at most a pre-deletion copy of the wrapped_data_key ciphertext and row metadata. Once the KMS-side deletion of that data key's underlying key material (scheduled at step 2 above) completes, that ciphertext is permanently unreadable in every backup that contains it, regardless of how long the backup file itself is retained. Cryptographic shredding of the key, not physical deletion of the backup, is what makes the older copy safe |
| Read replicas | Bounded by streaming replication lag, alerted above 30 seconds | Every step of this pipeline (the tombstone write, the row soft/hard delete) is a normal write that replicates to every replica within the same lag window as any other write; no separate deletion action against a replica is needed or possible, since replicas never accept direct writes |
| CDN edge caches | Not applicable to document bytes | Private document content is never cached at the CDN edge at any layer (18.6.7). The only cacheable artifact tied to a document is pre-signed download URL metadata, purged defensively in step 5 above; any such entry that were somehow missed by that purge is self-limiting regardless, since the URL it references expires after 5 minutes by construction (17.6.2) |
| Application logs | The log's own retention window: 30 days hot, 1 year cold archive (18.1.6) | Logs never contain document content or filenames under any log level, as an absolute rule enforced by code review and an automated lint check (18.1.5) — only the document's UUIDv7 identifier appears, which carries no content to protect, so this pipeline has nothing to purge from the log store |
| Error-tracking payloads | Sentry's own event retention window (90 days) | The beforeSend scrubbing pass (18.4.3) strips document content, filenames, and PII from every event before it ever reaches the error tracker, so there is no document-derived data in an error-tracking payload for this pipeline to reach into and remove |
| Queue payloads (BullMQ/Redis) | Removed on job terminal state, with a 24-hour hard ceiling as a backstop | BullMQ is configured to discard a job's payload data immediately once the job reaches a terminal state (succeeded/failed/canceled/expired), retaining only the job ID in a short operational ring buffer; a job payload is therefore never resident in Redis long enough to interact meaningfully with the 2-hour/24-hour document retention window in the first place |
17.7 Application Security #
17.7.1 Security headers and Content Security Policy #
Every response from apps/web and apps/api carries the following headers:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
X-Frame-Options: DENY
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
Content-Security-Policy: <see below>CSP, generated per-response with a fresh nonce:
default-src 'self';
script-src 'self' 'nonce-{requestNonce}' 'wasm-unsafe-eval';
style-src 'self' 'nonce-{requestNonce}';
img-src 'self' data: blob:;
font-src 'self';
connect-src 'self' https://api.pdfworks.io https://*.ingest.sentry.io;
worker-src 'self' blob:;
frame-src https://checkout.stripe.com https://billing.stripe.com;
frame-ancestors 'none';
form-action 'self';
base-uri 'none';
object-src 'none';
upgrade-insecure-requests;- No
unsafe-inlineand nounsafe-evalanywhere. Every<script>and<style>tag the application itself emits carries the per-response nonce; there is no inline event-handler attribute (onclick=, etc.) anywhere in the codebase, enforced by an ESLint rule and a CI grep gate. Third-party analytics or embeds that requireunsafe-inlineare not used. wasm-unsafe-evalis used, and it is a distinct, narrower directive fromunsafe-eval.unsafe-evalpermits arbitrary string-to-code evaluation viaeval(),new Function(), andsetTimeout/setIntervalwith a string argument — a broad and dangerous permission that is never granted here.wasm-unsafe-evalpermits only WebAssembly module instantiation and compilation (WebAssembly.instantiate,WebAssembly.compile); it does not permit executing arbitrary JavaScript strings, and it is the mechanism by which thepdfcoreWASM artifact (Section 3.6) is allowed to load at all under an otherwise-strict CSP. It is required specifically because loading a WebAssembly module is treated by the CSP spec as a form of "eval" even though it carries none of the string-injection risk that directive family exists to prevent.frame-srcpermits only the two Stripe-hosted domains needed for Checkout and the Billing Portal redirects (Section 3.4); no other third-party frame is permitted anywhere in the application.connect-srcpermits only PDFWorks's own API origin and the Sentry ingest endpoint (Section 18.4); the public marketing site's CSP is a stricter subset with noconnect-srcexception needed beyond'self'.
17.7.2 CSRF strategy #
State-changing first-party requests (session-cookie-authenticated requests to /internal/*) are protected by three overlapping controls, any one of which independently defeats a classic cross-site CSRF:
SameSite=Laxon the session cookie (17.2.2) means the cookie is not attached to cross-site POST/PUT/PATCH/DELETE requests at all, which blocks the large majority of CSRF vectors at the browser level before the request even reaches PDFWorks.- Origin/Referer verification. Every state-changing
/internal/*request is checked server-side: theOriginheader (falling back toRefererifOriginis absent, which only legitimately happens for same-origin top-level navigations thatSameSite=Laxalready permits) must exactly matchhttps://app.pdfworks.io. A mismatch is rejected with403 permission_errorbefore any handler logic runs. - No state-changing action is ever exposed via
GET. Every mutation requires POST/PUT/PATCH/DELETE, so a bare<img src>or link-click CSRF vector has no target regardless of cookie policy.
The public API (/v1/*) is not cookie-authenticated at all (Bearer API keys only, 17.2.4), so CSRF — which depends on ambient browser credential attachment — does not apply to it as a threat class.
// apps/api/src/middleware/csrf.ts
const ALLOWED_ORIGIN = "https://app.pdfworks.io";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
export function csrfGuard(req: Request, res: Response, next: NextFunction) {
if (SAFE_METHODS.has(req.method)) return next();
const origin = req.headers.origin ?? deriveOriginFromReferer(req.headers.referer);
if (origin !== ALLOWED_ORIGIN) {
return res.status(403).json({
error: {
type: "permission_error",
code: "csrf_origin_mismatch",
message: "This request's origin does not match the expected application origin.",
param: null,
docsUrl: "https://docs.pdfworks.io/errors/csrf_origin_mismatch",
requestId: req.requestId,
},
});
}
return next();
}17.7.3 Clickjacking defense #
X-Frame-Options: DENY (for legacy user agents) and Content-Security-Policy: frame-ancestors 'none' (authoritative) together prevent every PDFWorks page, including the tool pages, the account dashboard, and the signer portal, from being embedded in a third-party frame. The two exceptions carved out anywhere in the product are the reverse direction — the two Stripe-hosted pages PDFWorks itself frames (17.7.1) — which is Stripe's own choice to permit, not PDFWorks's.
17.7.4 CORS policy #
| Surface | Policy |
|---|---|
app.pdfworks.io (/internal/*) |
No CORS headers issued at all — same-origin only, by design, since only the first-party frontend ever calls it |
api.pdfworks.io (/v1/*) |
Access-Control-Allow-Origin: * for GET requests without cookie credentials (the public API never accepts cookies, only Bearer keys, so a permissive origin policy carries no credential-leak risk); state-changing methods additionally require the Idempotency-Key header (Section 14.8) which is not automatically sent by a simple cross-origin request, providing an incidental extra barrier against naive drive-by cross-origin abuse even though the primary defense is that Bearer-token auth is immune to CSRF by construction |
sign.pdfworks.io/verify/{envelopeId} |
Access-Control-Allow-Origin: *, since this endpoint is explicitly designed to be called from arbitrary third-party verification tooling |
17.7.5 Subresource integrity, dependency and supply-chain posture #
- Subresource integrity (SRI): the marketing site and app shell load zero third-party-hosted JavaScript or CSS — every script and stylesheet is built and served from PDFWorks's own origin, which removes the need for SRI hashes on the primary bundles (there is no third-party origin to distrust). Where a future integration necessitates a third-party script, it will be loaded with an SRI hash and
crossorigin="anonymous"as a hard requirement, not an option. - Dependency scanning: automated dependency-update PRs (Renovate) run weekly, and every PR triggers
pnpm audit(Node dependencies) andpip-audit/osv-scanner(Python worker dependencies); acritical- orhigh-severity finding with an available fix blocks merge. - SBOM generation: a CycloneDX-format software bill of materials is generated at every release build for
apps/web,apps/api,worker-media, andworker-office, covering both language-package dependencies and the native libraries baked into thepdfcoreWASM artifact (PDFium, QPDF, zlib, libjpeg-turbo, libwebp, Brotli) and the office-conversion container (LibreOffice, Poppler, Ghostscript, OCRmyPDF, Tesseract, pikepdf). SBOMs are retained alongside the release artifact for the lifetime of that release's deployment. - Secret scanning in CI: every push and pull request is scanned for committed secrets (API keys, private keys, connection strings) using a pattern-and-entropy-based scanner running as a required CI check; a match blocks merge and rotates the exposed credential is treated as a P1 incident (17.10.4) regardless of whether the branch merged.
- Supply-chain posture for the WASM build: the
pdfcorebuild pins PDFium to a specific upstream commit (not a semver tag, since PDFium does not publish one — Section 3.6) and QPDF to the major line in that table, both fetched from their canonical upstream repositories with the fetched commit hash verified against a hash committed in the build script before the Emscripten compile step runs. The Emscripten toolchain version itself is pinned in the CI container image. The build is run in CI only (never from a developer's local machine for a release artifact), and the resulting.wasmfile's SHA-256 is recorded in the release's SBOM and in the deployment manifest, so a compromised build input would produce a hash mismatch detectable by comparing against the previous release's build provenance record.
17.8 Privacy #
17.8.1 Data inventory #
| Category | Where it lives | Why collected | Lawful basis (GDPR Art. 6) | Retention |
|---|---|---|---|---|
| Account data (email, name, password hash) | users table |
Account creation and authentication | Contract necessity | Life of account + 30 days after deletion request (17.8.3) |
| Document content — client-side tools | Browser OPFS only | Never transmitted to PDFWorks | N/A — not collected | Purged on tab close / 24-hour janitor sweep (Section 3.7) |
| Document content — server-side tools | Encrypted object storage | Perform the requested conversion/OCR/signature operation | Contract necessity | Per Section 6 (2 hr/24 hr default); Section 10.8 (envelope-lifetime exception) |
| Document metadata (filename, page count, tool used, timestamps) | documents, jobs tables |
Job history, support, quota enforcement | Contract necessity | Per plan's job-history window (Section 12.2), then soft-deleted |
| Payment data | Not stored by PDFWorks — held by Stripe | Billing | Contract necessity | Governed by Stripe's own retention, outside PDFWorks's data boundary |
| Signer identity (email, IP, user agent, geo-IP country) | audit_events table |
Legal defensibility of the signature (Section 10) | Contract necessity / legitimate interest (evidentiary record) | 7 years — seven years means seven years, with no shorter effective window created by any archival step (Section 10.8; schema and archived export in Section 5.14); PII fields salted-hash-anonymized on account deletion, chain preserved |
| Usage/telemetry (metrics, traces, non-content log fields) | Metrics store, trace store, log store (Section 18) | Reliability, performance, abuse detection | Legitimate interest | Per Section 18.1.5 (logs), 18.2 (metrics), 18.3 (traces) retention windows |
| Support communications | Support ticketing system (email-based, Section 12) | Resolve support requests | Contract necessity / legitimate interest | 2 years after ticket closure |
| Cookies | Browser, first-party only | Session identification, no advertising (17.8.5) | Consent not required for strictly necessary cookies (ePrivacy Directive Art. 5(3)) | Session cookie lifetime per 17.2.2 |
17.8.2 Privacy by design, following from client-side processing #
The product's default execution split (Section 6) is itself the primary privacy control: for the client-side column of the tool table in Section 6.1, document content is processed entirely within the operator's browser and is never transmitted, buffered, logged, or made visible to PDFWorks in any form — there is no server-side code path that could access it even under a bug, because no such path exists for those tools. This is a stronger guarantee than an access-control policy on server-held data, because it removes the data from PDFWorks's possession rather than restricting who may access it. For the server-side column of the same table (Section 6.1), where upload is unavoidable, the same design philosophy is applied one layer in: encryption at rest with per-job keys (17.6.2), a hard-isolated processing sandbox (17.5), and an aggressive, provable deletion pipeline (17.6.3) minimize both the exposure window and the number of components that ever handle plaintext content.
17.8.3 GDPR obligations #
| Obligation | Implementation |
|---|---|
| Access / DSAR | GET /internal/privacy/export (authenticated, self-service) generates a ZIP containing a JSON export of the requester's account data, job history metadata, envelope metadata, and audit-trail entries where they are a signer or sender; delivered as a downloadable link within 30 days of request (self-service typically completes within minutes; the 30-day figure is the worst-case SLA covering a manual request submitted via support) |
| Erasure | DELETE /internal/account (authenticated, requires password re-entry) soft-deletes the users row, hard-deletes all document blobs and job payloads owned by the account, and anonymizes (salted-hash replacement of email and IP) the PII fields on any audit_events rows where the account was a signer or sender, while preserving the hash chain itself — per the exception stated in Section 10.8, destroying the chain would invalidate signatures the account's counterparties legally rely on, and this is disclosed at envelope creation and in the privacy policy. Backups are excluded from the 30-day window and age out on the standard backup retention cycle (35 days, Section 20) |
| Rectification | Account settings allow direct self-service editing of name and email (with re-verification); a support request handles any other correction |
| Portability | The same export mechanism as access, delivered in JSON, a structured and machine-readable format satisfying GDPR Art. 20 |
| Record of Processing Activities (RoPA) | A living internal document (outside this specification's scope to author its content, but the data inventory in 17.8.1 is its primary input) maintained by whoever holds the Data Protection role, listing each processing activity, its purpose, categories of data subjects and data, recipients, and retention |
| DPA (Data Processing Agreement) | A standard-form DPA is published and available for a Team or API customer to countersign before processing their end users' personal data, referencing the sub-processor list below and incorporating the EU Standard Contractual Clauses for any transfer outside the EEA |
| Sub-processor list | Published at a durable URL and kept current; at launch it includes: the cloud infrastructure provider (compute, object storage, KMS), Stripe (payment processing), Resend (transactional email delivery), and Sentry (error tracking). Customers are notified of any addition or replacement with a 30-day objection window before it takes effect |
| International transfer mechanism | Where data is transferred outside the EEA (for a customer not using the EU residency option below), the EU Standard Contractual Clauses (2021 module set) are incorporated by reference in the DPA |
| EU data residency option | Available on the Team plan: a workspace can be pinned to an EU region for its database rows, object storage, and worker execution, so that no server-side processing for that workspace's jobs occurs outside the EU. This is a workspace-level, immutable-at-creation setting (changing it requires a support-assisted data migration) |
The DSAR export bundle's top-level manifest, manifest.json, so the requester can navigate the export programmatically rather than by inspecting file names:
{
"exportedAt": "2026-08-19T09:12:44.000Z",
"requestId": "req_01K7Y4A1F0GT3M",
"subject": { "userId": "usr_01K7Y1B4C6MQ2P", "email": "hello@cascady.ai" },
"files": [
{ "path": "account.json", "description": "Profile, plan, and workspace memberships" },
{ "path": "job-history.json", "description": "Job metadata for the account's retained history window" },
{ "path": "envelopes.json", "description": "Envelopes sent or signed by this account" },
{ "path": "audit-events.json", "description": "Audit-trail entries where this account is a party" },
{ "path": "security-audit-log.json", "description": "Security-relevant actions attributed to this account" }
],
"notes": "Document and file content is not included, since server-side copies are already deleted per the retention policy in Section 6, and client-side content was never transmitted to PDFWorks."
}17.8.4 CCPA obligations #
- Right to know / access: satisfied by the same export mechanism as the GDPR access right (17.8.3).
- Right to delete: satisfied by the same account-deletion mechanism, with the same audit-trail anonymization exception disclosed identically.
- Right to opt out of sale/sharing: not applicable in substance — PDFWorks does not sell personal information or share it for cross-context behavioral advertising (17.8.5) — but a "Do Not Sell or Share My Personal Information" link is nonetheless published in the site footer as a compliance-hygiene matter, landing on a page that states plainly that no such sale or sharing occurs.
- Right to non-discrimination: exercising any privacy right never changes a customer's plan pricing, features, or service level.
- Authorized agent requests: accepted via the same support channel as any DSAR, with identity verification appropriate to the requester (account credential proof, or a signed authorization for an agent acting on the data subject's behalf).
17.8.5 Cookies and consent posture #
PDFWorks sets no advertising cookies, no third-party tracking cookies, and runs no cross-context behavioral advertising. The only cookies set are:
| Cookie | Purpose | Category |
|---|---|---|
pw_session |
Authentication session (17.2.2) | Strictly necessary |
pw_csrf_check (mirrors the session's origin-check state, used only for the SameSite fallback path on browsers with partial support) |
CSRF defense (17.7.2) | Strictly necessary |
pw_locale |
Remembers the operator's selected UI language (Section 16) | Strictly necessary (functional) |
Because every cookie set falls under the ePrivacy Directive's "strictly necessary" exemption, no consent banner is shown. A brief, non-blocking notice in the site footer links to the cookie policy for transparency, but no interaction is required to use the site — this is a deliberate posture that follows directly from setting no non-essential cookies rather than a decision to suppress a legally required prompt.
17.8.6 Children's data #
PDFWorks is not directed at children and does not knowingly collect personal data from anyone under 16. Registration requires an affirmative statement of being 16 or older (aligned to the GDPR Art. 8 default digital-consent age; this is a conservative, single global threshold rather than a per-jurisdiction variable one). Upon learning that an account belongs to a child under this threshold, the account and its data are deleted through the standard erasure pipeline (17.8.3) without requiring the deletion request to go through the normal authenticated self-service flow.
17.8.7 Breach notification runbook #
| Step | Timeline | Action |
|---|---|---|
| 1. Detection and triage | T+0 to T+4 hours | Security incident process (17.10.5) determines whether the event constitutes a personal-data breach under GDPR Art. 4(12) — unauthorized access, disclosure, alteration, or loss of personal data |
| 2. Containment | T+0 to T+24 hours, in parallel with triage | Revoke compromised credentials, isolate affected systems, stop ongoing exposure |
| 3. Impact assessment | By T+48 hours | Identify affected data subjects, data categories involved, and likely consequences |
| 4. Supervisory authority notification (GDPR) | Within 72 hours of becoming aware, where required (a breach unlikely to result in risk to rights and freedoms may be exempt, documented either way) | Notify the lead supervisory authority with the information required by Art. 33(3); if full detail is not yet available, notify in phases |
| 5. Data subject notification | Without undue delay, where the breach is likely to result in a high risk to the individual (GDPR Art. 34) | Direct email notification describing the nature of the breach, likely consequences, and measures taken/recommended |
| 6. CCPA notification | "In the most expedient time possible and without unreasonable delay," consistent with law-enforcement needs and remediation measures | Notification to affected California residents where the breach involves unencrypted, unredacted personal information |
| 7. Post-incident report | Within 2 weeks of resolution | Full postmortem (18.8.4), root cause, remediation, and any process changes, retained internally and summarized for affected customers on request |
17.9 Compliance Posture #
17.9.1 What is claimed at launch #
At launch, PDFWorks claims: GDPR-compliant processing (17.8.3), CCPA-compliant processing (17.8.4), TLS 1.3 in transit, AES-256-GCM envelope encryption at rest, and ESIGN/UETA/eIDAS simple-and-advanced-electronic-signature compliance for the e-signature feature (17.9.3). No certification body has audited any of these at launch — they are engineering and policy commitments backed by the controls documented throughout Section 17, not third-party-attested certifications.
17.9.2 SOC 2 Type II — roadmap, not a launch claim #
SOC 2 Type II is explicitly not claimed at launch. It is a documented roadmap item, targeted for pursuit once the product has accumulated the minimum observation period (typically 6–12 months) a Type II audit requires to attest to operating effectiveness, not merely design. The following controls are designed in from day one specifically so that the eventual audit observation window can begin as early as possible rather than requiring a pre-audit remediation phase:
- Tenant-isolation invariant and its automated cross-tenant test suite (17.3.2–17.3.4), providing continuous evidence of logical access control.
- Encryption at rest and in transit with documented key management (17.6).
- The full audit log of security-relevant actions (17.10.6), providing an evidentiary trail for access-review controls.
- Vulnerability disclosure policy, patching SLAs, and penetration-testing cadence (17.10), providing evidence of a vulnerability-management program.
- Structured incident response process with defined severity levels (17.10.5), providing evidence of an incident-management program.
- Infrastructure-as-code for all environments (Section 20), providing evidence of change-management control.
- Least-privilege IAM roles for every service credential (workers,
apps/api, CI/CD), reviewed quarterly. - Mandatory code review on every pull request (no direct pushes to the deployment branch, enforced by branch protection, Section 20), providing evidence of a segregation-of-duties control over production changes.
- Centralized, retained logging and metrics (18.1, 18.2) with defined retention windows, providing evidence of a monitoring control.
- Annual third-party penetration testing (17.10.4) and the security-audit-log's immutability (17.10.6), providing evidence supporting both the vulnerability-management and the logical-access-review trust service criteria.
Framing this as a roadmap item rather than a launch claim is a deliberate choice: asserting SOC 2 Type II compliance without an actual audit report is itself a misrepresentation that would undermine the trust the certification is meant to establish. The controls above are real and operative from day one; only the third-party attestation of their sustained operating effectiveness is deferred to when an audit period can honestly be observed.
17.9.3 eIDAS and ESIGN mapping #
| Requirement | Mechanism | Section |
|---|---|---|
| Signer identity association (ESIGN §101, eIDAS Art. 26(a)) | Verified-email OTP before first field entry; signer email, IP, and user agent recorded on every event | 10.5, 10.6 |
| Intent to sign (ESIGN §101(c)) | Affirmative consent to the Electronic Record and Signature Disclosure before any field can be completed; declining ends the session | 10.6 |
| Signature linked to the signer (eIDAS Art. 26(b)) | Signature artifact (drawn/typed/uploaded) is bound to the specific signer session and recorded as a field.completed/signer.signed event tied to that signer's verified identity |
10.5, 10.6 |
| Detection of subsequent alteration (eIDAS Art. 26(c)) | SHA-256 document hash captured at each event; the public verification endpoint recomputes and compares (10.7); this is achieved through the audit hash chain, strengthened by the daily external anchoring of the chain's root hash described in Section 10.5, rather than an embedded PKI signature, consistent with the "advanced" (not "qualified") tier of eIDAS electronic signature | 10.5, 10.6, 10.7 |
| Signer control of the signing means (eIDAS Art. 26(d)) | Each signer authenticates their own session via a distinct emailed link and OTP; no signer can act on another signer's behalf | 10.5 |
| Retention of the record (ESIGN §101(d)) | Certificate of Completion and audit trail retained 7 years (Section 10.8) | 10.8, 10.6 |
| Consumer consent to electronic records (ESIGN §101(c)(1)) | The disclosure-and-consent step doubles as this consent; no paper-record opt-out flow is offered, consistent with the product being an all-electronic tool — a signer who wants a paper process is out of scope | 10.6 |
Explicitly not offered, stated once here:
- PDF/A archival certification is out of scope. PDF/A guarantees long-term self-contained renderability (no external font/content dependencies) for archival purposes; it is a distinct concern from the editing and conversion tools this product offers, and is not validated or claimed for any output file. A future archival-compliance product would need a dedicated PDF/A conformance checker and a font-embedding policy change.
- PKI-based digital signatures are out of scope. PDFWorks's e-signature system produces no
/Sigsignature dictionary, uses no X.509 certificate, and participates in no AATL/EUTL trust chain — its tamper-evidence comes from the server-side hash chain described in 10.6, strengthened by the daily external anchoring in 10.5, rather than from an embedded cryptographic signature; this boundary is exactly why it is positioned as ESIGN/UETA/eIDAS simple and advanced signature, not qualified signature. A future qualified-signature offering would require integrating a licensed trust-service provider and a certificate-issuance flow, which is a materially different product surface. - HIPAA is out of scope. PDFWorks signs no Business Associate Agreement and makes no claim of HIPAA-eligible handling for protected health information. The encryption, isolation, and deletion controls in this section are strong, but HIPAA eligibility additionally requires a formal BAA program, specific audit-control language, and a compliance posture PDFWorks has not undertaken at launch. A future healthcare-focused tier would need that program built and audited before the claim could be made.
17.10 Security Operations #
17.10.1 Vulnerability disclosure policy and security.txt #
https://pdfworks.io/.well-known/security.txt (RFC 9116 format):
Contact: mailto:security@pdfworks.io
Expires: 2027-08-19T00:00:00.000Z
Encryption: https://pdfworks.io/.well-known/pgp-key.txt
Preferred-Languages: en
Canonical: https://pdfworks.io/.well-known/security.txt
Policy: https://pdfworks.io/security/disclosure-policyThe published policy commits to: acknowledging a report within 3 business days, providing a remediation timeline once triaged, crediting the reporter (with their consent) on a public security acknowledgments page, and not pursuing legal action against a good-faith researcher who follows the policy.
17.10.2 Coordinated disclosure window #
Reports are handled under a 90-day coordinated-disclosure window from acknowledgment: PDFWorks commits to remediate (or provide a documented mitigation and extension rationale) within 90 days, after which the reporter is free to publish regardless of remediation status, consistent with industry-standard coordinated disclosure norms.
17.10.3 Patching SLAs #
| Severity (CVSS v4 base score) | Dependency/container patch SLA | Applies from |
|---|---|---|
| Critical (9.0–10.0) | 24 hours | Public disclosure or internal discovery, whichever is first |
| High (7.0–8.9) | 7 days | Same |
| Medium (4.0–6.9) | 30 days | Same |
| Low (0.1–3.9) | Next regularly scheduled dependency-update cycle (weekly, 17.7.5) | Same |
A patch that cannot land within its SLA (for example, a breaking upstream API change) requires a documented compensating control (network-level mitigation, feature flag disabling the affected path) applied within the same SLA window, with the underlying patch tracked to completion.
17.10.4 Penetration testing cadence #
An independent third-party penetration test is commissioned annually, covering the web application, the public API, and the e-signature verification endpoint, plus before any major architectural change that alters a trust boundary (for example, adding a new authentication method or a new public-facing service). Findings are triaged using the same severity table as 17.10.3, and a summary (not the full technical report, which may contain exploit detail) is made available to Team and API customers on request under NDA.
17.10.5 Security incident response plan #
| Severity | Definition | Examples | Response |
|---|---|---|---|
| SEV-1 (Critical) | Active exploitation, confirmed cross-tenant data exposure, or a compromised credential with production access | Cross-tenant document access confirmed in the wild; KMS master key or a production database credential leaked | Immediate page to the on-call security responder (18.8.2); incident commander assigned within 15 minutes; affected systems isolated first, root-caused second |
| SEV-2 (High) | Confirmed vulnerability with a plausible exploitation path, not yet observed as exploited | A newly disclosed critical CVE in a dependency PDFWorks runs in production | Page within 1 hour; remediation plan within 4 hours per the 17.10.3 SLA |
| SEV-3 (Medium) | Vulnerability with limited exploitability or requiring privileged access | A lower-severity finding from the pen test or a dependency scan | Tracked to the applicable patching SLA, no page required |
| SEV-4 (Low) | Hardening opportunity, no direct exploit path | A defense-in-depth improvement identified during code review | Tracked as normal backlog work |
Roles: Incident Commander (coordinates response, owns communication, does not personally fix the issue), Technical Lead (owns the technical investigation and remediation), Communications Lead (owns customer and, where applicable per 17.8.7, regulator notification), Scribe (maintains the incident timeline in real time for the postmortem). For a SEV-1, all four roles are staffed immediately; for a SEV-2, Incident Commander and Technical Lead are sufficient to start. Every SEV-1 and SEV-2 incident produces a blameless postmortem per 18.8.4.
The emergency hotfix path. For a declared SEV-1 only, the Incident Commander may invoke the emergency hotfix path to merge and deploy a fix outside the standard multi-reviewer pull-request cycle (Section 20). Invocation requires two distinct, named approvals recorded before merge: the Incident Commander's own authorization, plus a second, independent approval from the Technical Lead or — if the Technical Lead authored the fix — the secondary on-call engineer; the author of the change is never one of its own approvers. The path may skip the standard multi-reviewer review, the full cross-browser Playwright E2E matrix (Section 19), and non-blocking extended performance benchmarks. The path may never skip: the automated cross-tenant access test suite (17.3.4), the secret-scanning check (17.7.5), and the dependency-vulnerability blocking check (17.7.5) — these are precisely the gates a hotfix is under the most pressure to bypass, and the ones whose absence turns a fast fix into a new incident. Every invocation is logged to the security_audit_log (17.10.6) as an administrative-override entry naming both approvers, the incident it responds to, and the specific gates skipped. A full, standard-process retroactive review — re-running every gate that was skipped, performed by a reviewer who was not one of the two original approvers — is mandatory within 24 hours (one business day) of the emergency merge and is tracked as a required action item on the incident's postmortem (18.8.4); the postmortem does not close until that retroactive review is recorded as complete.
A representative SEV-1 timeline, illustrating how the roles and the breach-notification runbook (17.8.7) interlock rather than run as separate processes:
| Elapsed | Event |
|---|---|
| T+0 | PriorityQueueDepthHigh-adjacent security alert fires: security_audit_log shows one API key issuing documents:read calls against 4,000 distinct documentId values in 3 minutes — a cross-tenant enumeration pattern, not legitimate usage |
| T+3 min | On-call security responder acknowledges the page, declares SEV-1, assumes Incident Commander |
| T+8 min | Technical Lead confirms the pattern is a bug (a missing workspace_id predicate on a newly shipped endpoint) rather than a stolen credential. The endpoint had reached production through the emergency hotfix path described above, from a period before that path's never-skip rule for the cross-tenant suite was in force — exactly the gap the never-skip rule now exists to close |
| T+12 min | Containment: the affected route is disabled via the feature-flag accessor (Section 4) without a redeploy; the offending API key is not revoked, since it is the victim of the enumeration, not the attacker |
| T+20 min | Impact assessment begins: query the route's access logs for the affected time window to enumerate every documentId actually returned cross-tenant and the owning workspaces |
| T+45 min | Communications Lead notified; breach-notification runbook (17.8.7) invoked in parallel, since document metadata for other tenants was disclosed |
| T+2 hr | Fix merged through the normal PR path (closing the gap that let the hotfix bypass 17.3.4), deployed, feature flag re-enabled |
| T+48 hr | Impact assessment finalized; supervisory authority notification prepared for the 72-hour GDPR deadline |
| T+5 business days | Postmortem published (18.8.4), confirming the never-skip rule for the cross-tenant suite (17.3.4) in the emergency hotfix path above is sufficient to prevent a recurrence; no further action item is needed on that front since the rule already closes the gap |
17.10.6 Audit log of security-relevant actions #
A dedicated, append-only security_audit_log table (distinct from the e-signature audit_events table in Section 10, which serves a different legal purpose) records the following action classes for every workspace, queryable by a workspace owner/admin in account settings and exported in the DSAR export (17.8.3) where the requester is the actor:
| Action class | Examples recorded |
|---|---|
| Authentication events | Login success/failure, MFA enrollment/removal, password change, session revocation |
| API key lifecycle | Key creation, scope change, IP-allowlist change, revocation |
| Workspace membership | Member invited, role changed, member removed |
| Billing changes | Plan change, payment method change (event only — no card data ever reaches PDFWorks to log) |
| Data export/deletion | DSAR export generated, account deletion requested, envelope voided |
| Webhook configuration | Endpoint added/removed, secret rotated |
| Administrative overrides | Any support-initiated action taken on a customer's behalf (always attributed to the specific staff identity that performed it, never to a shared/system account) |
Each entry records actorId (user or API key), actorType, action, targetType/targetId, ipAddress, userAgent, and timestamp. Entries are retained for 2 years, immutable once written (no update or delete code path exists against this table other than the standard retention-driven purge job), and are themselves included within the scope of the tenant-isolation invariant (17.3.2) and the automated cross-tenant test (17.3.4).
18. Observability, Performance & Reliability #
18.1 Logging #
18.1.1 Format and library #
All server-side processes (apps/web server components, apps/api, worker-media, worker-office) log structured JSON via Pino, one JSON object per line to stdout, collected by the infrastructure's log pipeline (Section 20). No process writes to a local log file. Python workers (worker-office) emit the same field set via a thin JSON-logging shim so every log line across every service, regardless of language, is field-compatible.
18.1.2 Standard field set #
Every log line carries:
{
"level": "info",
"time": "2026-08-19T14:32:07.481Z",
"service": "api",
"env": "production",
"requestId": "req_01K7Y3M2QF8V6X",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"workspaceId": "wsp_01K7XZ9H2N4RT8",
"userId": "usr_01K7Y1B4C6MQ2P",
"msg": "job completed",
"jobId": "job_01K7Y3N0P5RS7W",
"tool": "merge",
"durationMs": 842
}workspaceId/userId/jobId/tool and other context fields are present only when applicable to the log line; requestId is present on every line emitted within an HTTP request's lifecycle (including asynchronous work it triggers), and traceId is present on every line once tracing context is available (18.3).
18.1.3 Propagation #
requestIdis generated at the edge (a UUIDv7, prefixedreq_per Section 5's public-ID convention) on every inbound HTTP request that does not already carry one, returned to the caller in the error envelope (Section 14.6) and in aX-Request-Idresponse header on every response (not only errors), and threaded through every downstream call — including the BullMQ job payload enqueued as a result of that request — so a job's log lines can be traced back to the API request that created it even though the job executes asynchronously, potentially seconds or minutes later.traceIdfollows the W3Ctraceparentpropagation described in 18.3; it is generated in the browser for a client-initiated action and propagates through the API into the worker, giving a single identifier that spans all three tiers.
18.1.4 Log levels #
| Level | When used |
|---|---|
fatal |
Process is about to crash / exit abnormally |
error |
An operation failed in a way that affected the caller (job failed, request returned 5xx, deletion verification failed) |
warn |
A degraded-but-recovered condition (a circuit breaker opened, a retry succeeded on attempt 2, a rate limit was hit) |
info |
Normal request/job lifecycle events (request received/completed, job queued/started/completed, plan change, login) — the default production level |
debug |
Detailed internal state, enabled only per-service via a runtime flag for active troubleshooting, never left on in steady-state production |
trace |
Field-by-field validation detail, used only in local development |
Production runs at info and above by default; debug can be toggled per-service through the feature-flag accessor (Section 4) without a redeploy, for a bounded troubleshooting window.
18.1.5 Redaction #
A Pino redact path list strips the following fields wherever they appear in a log call's argument object, replacing the value with [Redacted] rather than omitting the key (so the shape of logged objects stays predictable for log-search tooling):
// packages/config/src/pino.ts
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
redact: {
paths: [
"password", "passwordHash", "token", "sessionToken", "apiKey", "apiKeySecret",
"authorization", "req.headers.authorization", "req.headers.cookie",
"*.password", "*.token", "*.secret",
"otpCode", "mfaSecret", "recoveryCode", "cardNumber", "webhookSecret",
],
censor: "[Redacted]",
},
formatters: {
level: (label) => ({ level: label }),
},
base: { service: process.env.SERVICE_NAME, env: process.env.NODE_ENV },
});No document content and no filename ever reaches a log, under any log level, as an absolute rule enforced by code review and by an automated lint check that flags any log call passing a variable named or typed as containing file bytes, an originalFilename field, or a raw extracted-text result. A job's log lines describe that a merge of 4 files produced a 12 MB output in 842 ms; they never describe the files' names or contents. Where a filename must be surfaced for support debugging, the log carries the document's UUIDv7 identifier only, and a support agent looks up the filename (if needed, and only with the customer's consent for the specific ticket) through the authenticated support tooling, not through the log pipeline.
18.1.6 Retention and searchable fields #
Logs are retained for 30 days in the hot, searchable index and archived to cold object storage for 1 year before final deletion, covering the realistic window for both operational debugging and a delayed security investigation. Indexed/searchable fields: requestId, traceId, workspaceId, userId, jobId, tool, level, service, env, and HTTP statusCode where applicable — the field set deliberately excludes anything from the redaction list in 18.1.5, so a search can never surface a redacted value even by accident.
18.2 Metrics #
18.2.1 Metric catalog #
| Metric | Type | Labels | Decision it informs |
|---|---|---|---|
http_requests_total |
Counter | service, route, method, statusCode |
Traffic volume, error-rate calculation |
http_request_duration_seconds |
Histogram | service, route, method |
Latency SLOs (18.7.2), regression detection |
queue_depth |
Gauge | queue (ocr/convert/esign/batch/webhook/janitor), lane (priority/standard) |
Autoscaling trigger (18.9.3), capacity planning |
queue_job_age_seconds |
Histogram | queue, lane |
Detects a stuck or starved lane before users report slowness |
job_duration_seconds |
Histogram | tool, outcome (succeeded/failed/canceled/expired) |
Per-tool performance regressions, timeout tuning (17.5) |
wasm_operation_duration_ms |
Histogram | tool, pageCountBucket (1-10/11-50/51-200/201+) |
Client-side performance regressions, informs the performance budgets in 18.6.1 |
errors_total |
Counter | service, errorCode (Section 14.6/23.1), errorType |
Which error codes are actually firing in production, prioritizes error-message and docs improvements |
quota_denials_total |
Counter | plan, limitType (dailyTask/fileSize/batchSize/envelope) |
Upsell-prompt effectiveness, whether a plan's limits are miscalibrated |
conversion_funnel_total |
Counter | step (toolPageView/fileSelected/jobStarted/jobCompleted/downloadClicked/signupPrompted/signupCompleted) |
Product funnel health, informs Section 21's growth work |
stripe_webhook_lag_seconds |
Histogram | eventType |
Billing correctness — a large lag risks entitlement drift between Stripe and PDFWorks's own plan state |
storage_bytes_total |
Gauge | bucket (active/pendingDeletion) |
Cost tracking, deletion-pipeline health |
deletion_shred_to_verify_seconds |
Histogram | — | Confirms the deletion pipeline (17.6.3) meets its own timing expectations |
worker_container_starts_total |
Counter | service (worker-media/worker-office), outcome |
Sandbox scheduling health |
db_query_duration_seconds |
Histogram | service, queryName |
Slow-query detection (18.6.5) |
circuit_breaker_state |
Gauge (0=closed, 1=open, 2=half-open) | dependency (stripe/resend/kms/objectStorage) |
External-dependency health (18.7.7) |
rate_limit_rejections_total |
Counter | service, route, keyType (session/apiKey/ip) |
Abuse detection, rate-limit tuning |
esign_envelope_events_total |
Counter | eventType (mirrors Section 10.6's event list) |
E-signature funnel and reliability |
18.2.2 The four golden signals per service #
| Service | Latency | Traffic | Errors | Saturation |
|---|---|---|---|---|
apps/web |
http_request_duration_seconds (p50/p95/p99) |
http_requests_total rate |
http_requests_total{statusCode=~"5.."} rate |
Node event-loop lag, container CPU/memory |
apps/api (/v1) |
http_request_duration_seconds{service="api-public"} |
http_requests_total{service="api-public"} rate |
errors_total rate by errorType |
Container CPU/memory, DB connection pool utilization |
apps/api (/internal) |
Same, service="api-internal" |
Same | Same | Same |
Job queues (worker-media/worker-office) |
job_duration_seconds |
worker_container_starts_total rate |
job_duration_seconds{outcome="failed"} rate |
queue_depth, queue_job_age_seconds |
| PostgreSQL | db_query_duration_seconds |
Query rate (derived) | Query error rate (derived) | Connection pool utilization, replication lag |
The queue-depth gauge and the alert that watches it are defined together so the metric's purpose and its operational trigger stay in one place for whoever maintains either:
// apps/api/src/observability/metrics.ts
export const queueDepth = new Gauge({
name: "queue_depth",
help: "Number of jobs currently queued, not yet running",
labelNames: ["queue", "lane"],
});
export const jobDuration = new Histogram({
name: "job_duration_seconds",
help: "Wall-clock time from a job entering `running` to reaching a terminal state",
labelNames: ["tool", "outcome"],
buckets: [0.5, 1, 2, 5, 10, 30, 60, 120, 300, 600],
});# infra/alerting/rules/queue-depth.yaml
- alert: PriorityQueueDepthHigh
expr: queue_depth{lane="priority"} > 100
for: 2m
labels:
severity: critical
annotations:
summary: "Priority lane queue depth exceeds 100 for 2 minutes"
runbook: https://docs.pdfworks.io/internal/runbooks/queue-depth18.2.3 Dashboards #
A fixed set of dashboards is provisioned as code (not click-configured) alongside the metrics: a Service Overview dashboard per service showing its four golden signals; a Queues dashboard showing depth and age per queue/lane, correlated against the priority-lane definitions in Section 13.4; a Billing Health dashboard showing Stripe webhook lag and quota-denial rates; a Deletion Pipeline dashboard showing shred-to-verify latency and any deletion_verification_failure count (17.6.3); and an E-Signature dashboard showing the envelope event funnel. Each dashboard's top row is the metric that would page on-call if it breached its SLO (18.8.1), so the on-call responder's first look during an incident is also the dashboard's first row.
18.3 Tracing #
18.3.1 Setup #
OpenTelemetry (JS API, Section 3.2) instruments apps/web, apps/api, and both worker services. The browser initiates a trace for any user action that crosses the network boundary (a server-side tool invocation, an account action) using the OpenTelemetry Web SDK, propagating the W3C traceparent header on the outbound fetch to apps/api. Purely client-side tool operations (Section 6.1) do not initiate a distributed trace, since by design they never leave the browser — the client-side observability described in 18.5 covers their performance instead.
18.3.2 Trace boundary and span naming #
A single trace spans: browser (user action) → apps/api (HTTP request) → BullMQ (enqueue) → worker (job execution) → apps/api (result write) → browser (poll/websocket notification of completion). Span names follow {service}.{operation} (api.POST /v1/jobs, worker-media.ocr.process, web.upload.validate). Each span carries: service.name, workspaceId, requestId, and for job-processing spans, tool and jobId. Document content, filenames, and any PII are never set as span attributes, mirroring the logging rule in 18.1.5.
// apps/worker-media/src/handlers/ocr.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
const tracer = trace.getTracer("worker-media");
export async function handleOcrJob(payload: OcrJobPayload) {
return tracer.startActiveSpan("worker-media.ocr.process", async (span) => {
span.setAttributes({
"workspace.id": payload.workspaceId,
"job.id": payload.jobId,
"job.tool": "ocr",
});
try {
const result = await runOcr(payload);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
}18.3.3 Sampling #
Head-based sampling at 10% of traces for routine traffic, combined with 100% sampling for any request that results in a 5xx response, a processing_error, or a job outcome of failed/expired — so the traces retained are disproportionately the ones useful for debugging, without paying full storage cost for the high volume of uneventful successful requests.
18.3.4 Support-ticket-to-trace mapping #
Every error envelope returned to a caller includes requestId (Section 14.6). A support agent, given a customer's requestId (copied from the app's error toast or the API error response), looks it up in the trace store, which is indexed by requestId in addition to traceId, and lands directly on the full cross-service trace for that request — including every downstream span, its duration, and its outcome — without needing the customer to also supply a traceId, which they never see.
18.4 Error Tracking #
18.4.1 Configuration #
Sentry (Next.js and Node SDKs, Section 3.2) is configured for apps/web (both client and server runtime), apps/api, and both worker services, each as a distinct Sentry project with its own DSN, so alerting and ownership — and the per-service error budgets referenced elsewhere in this section — stay scoped per service rather than blended into one undifferentiated stream. Concretely, this is one DSN per service, exposed as four separate environment variables: SENTRY_DSN_WEB, SENTRY_DSN_API, SENTRY_DSN_WORKER_MEDIA, and SENTRY_DSN_WORKER_OFFICE — Section 23.2 carries the canonical list of environment variables, including these four.
// apps/api/src/observability/sentry.ts
import * as Sentry from "@sentry/node";
Sentry.init({
dsn: process.env.SENTRY_DSN_API,
environment: process.env.NODE_ENV,
release: process.env.GIT_COMMIT_SHA,
tracesSampleRate: 0.1, // matches the trace sampling policy in 18.3.3
sendDefaultPii: false, // never forward IP addresses or ambient PII (18.4.3)
beforeSend(event) {
return scrubSensitiveFields(event); // shared scrubbing pass, 18.4.3
},
beforeBreadcrumb(breadcrumb) {
if (breadcrumb.category === "http" && breadcrumb.data?.url?.includes("/documents/")) {
delete breadcrumb.data.requestBody; // never attach a document-bearing request body
}
return breadcrumb;
},
});18.4.2 Source maps and release tagging #
Every deployment uploads source maps to Sentry as part of the CI/CD pipeline (Section 20), associated with a release value equal to the deployed Git commit SHA, so a minified client-side stack trace resolves to the original TypeScript source and the exact commit is attributable. Releases are tagged with the env (production/staging) so issues never mix across environments in the same view.
18.4.3 Scrubbing rules #
A beforeSend hook runs on every event before it leaves the process, independently of the logging redaction in 18.1.5 (defense in depth — the two systems do not share a code path, so a gap in one does not become a gap in both):
- Strips any request-body or breadcrumb field matching the same sensitive-field name list as 18.1.5.
- Strips document content — file upload bodies and extracted text are never attached to an event even as a breadcrumb.
- Strips filenames — replaced with
[filename redacted]in any breadcrumb or exception message that would otherwise interpolate one. - Strips email addresses from free-text exception messages via a regex pass (structured fields like
user.idremain, using theusr_identifier rather than the email, since the identifier alone is sufficient to look the account up internally without embedding PII in the error-tracking system). - Strips IP addresses from the default Sentry payload (
sendDefaultPii: false); geo-IP country (already recorded on the e-signature audit trail per Section 10 for its own distinct legal purpose) is not duplicated into Sentry.
18.4.4 Grouping and alerting thresholds #
Issues group by Sentry's default fingerprinting (stack trace + exception type), with a custom fingerprint override for the error envelope's code field so that, for example, every file_too_large rejection groups as one issue regardless of which route emitted it, keeping the issue list meaningful rather than fragmented by route. Alerting thresholds: a new issue type not seen in the prior 14 days pages on-call at warn priority (Slack, not phone) within 5 minutes of the first occurrence; an issue whose event rate exceeds 50 events in 5 minutes pages at critical priority (phone, per 18.8.2); any issue tagged with an errorType of api_error (Section 14.6 — meaning PDFWorks's own fault, not the caller's) occurring more than 10 times in 5 minutes pages at critical priority regardless of whether it is a new or existing issue.
18.5 Client-Side Observability #
18.5.1 Real user monitoring #
The apps/web client reports Core Web Vitals (LCP, INP, CLS) via the web-vitals measurement APIs, batched and sent to the metrics pipeline (not to a third-party RUM vendor, to keep the privacy-by-design posture of 17.8.2 intact — see 18.5.4) on visibilitychange/page-unload, tagged with route, browser family, and device-class bucket (18.5.3).
// apps/web/src/observability/web-vitals.ts
import { onLCP, onINP, onCLS } from "web-vitals";
function report(metric: { name: string; value: number; id: string }) {
navigator.sendBeacon(
"/internal/telemetry/vitals",
JSON.stringify({
name: metric.name,
value: metric.value,
route: window.location.pathname,
deviceClass: getDeviceClassBucket(), // 18.5.3
sessionId: getEphemeralSessionId(), // in-memory only, never persisted (18.5.4)
}),
);
}
onLCP(report);
onINP(report);
onCLS(report);18.5.2 WASM load and execution timings #
- Load timing: time from tool-page navigation to the
pdfcoreWASM module reaching an instantiated, callable state, split intofetchMs(network),compileMs(WebAssembly.compile), andinstantiateMs(WebAssembly.instantiate) — the same three-way split is used to diagnose whether a regression is a network, compile, or instantiation problem. - Execution timing: reported as
wasm_operation_duration_ms(18.2.1) from the browser, withtoolandpageCountBucketlabels, letting the performance budgets in 18.6.1 be checked against real-world usage, not just synthetic CI benchmarks. - Threading fallback rate: the proportion of sessions running the single-threaded fallback artifact (Section 3.6) versus the multi-threaded build, broken out by browser family — this is the leading indicator for whether cross-origin isolation (Section 3.8) is failing to establish for a meaningful share of real users.
18.5.3 Client-side operation failure rate by browser and device class #
Every client-side tool operation reports a terminal outcome (succeeded/failed/canceled, matching the job-state vocabulary in Section 4.7 even though these operations never touch the server-side job table) tagged with browser family, browser major version, and a device-class bucket derived from navigator.hardwareConcurrency and navigator.deviceMemory where available (low / mid / high, falling back to unknown where the API is unavailable, notably Safari). This is the primary signal for whether a specific browser/device combination needs the single-threaded fallback promoted more aggressively, or whether a specific tool needs additional memory-pressure handling.
18.5.4 Privacy constraints #
All client-side observability data is: aggregate performance and outcome telemetry only — never document content, never filenames, never extracted text; sent to PDFWorks's own metrics ingestion endpoint, not a third-party RUM SaaS product, so no additional sub-processor is added to the list in 17.8.3 for this purpose; free of any persistent cross-session client identifier — a random per-session identifier is used only to de-duplicate a single session's own events and is discarded, never stored against the account; and covered by the same "strictly necessary" cookie-free posture as 17.8.5, since the telemetry batching uses an in-memory buffer and the navigator.sendBeacon API rather than a tracking cookie.
18.6 Performance #
18.6.1 The canonical performance budget table #
| Budget | Target | How measured | CI gate |
|---|---|---|---|
| Tool-grid LCP | < 2.0 s on a 4G profile | Lighthouse CI, throttled 4G profile, tool-grid route | Fails the build if p75 over the last 20 Lighthouse CI runs exceeds target |
| Tool shell interactivity | Interactive (drop zone accepts a file) before the WASM module finishes loading | Playwright timing assertion: drop-zone dragenter handler attached before wasmReady event fires |
Fails the E2E suite if the assertion order inverts |
| First-byte-to-first-page-preview | < 1.5 s for a 10 MB PDF on a mid-tier laptop profile | Playwright CPU-throttled (4x slowdown) benchmark against a fixed 10 MB fixture from the golden-file corpus (Section 19) | Fails the build if the median over 10 runs exceeds target |
| Ten-file merge (5 MB each) | < 3 s client-side | Vitest benchmark against packages/pdfcore bindings directly, bypassing UI |
Fails the build if the median over 10 runs exceeds target |
| Initial JS bundle per tool route | < 200 KB gzipped, excluding the WASM artifact | next build bundle analyzer output, per-route |
Fails the build if any tool route's first-load JS exceeds target |
| WASM artifact size | < 6 MB Brotli-compressed | Build-output size check against the compiled .wasm artifact |
Fails the build if the artifact exceeds target |
API p95 latency (/v1, excluding job-processing endpoints) |
< 300 ms | http_request_duration_seconds histogram, p95 over a rolling 5-minute window |
Not a CI gate — a production SLO (18.7.2), alerted per 18.8.1 |
| API availability | 99.9% monthly | Uptime check + http_requests_total{statusCode=~"5.."} rate |
Not a CI gate — a production SLO (18.7.1) |
The first six rows are enforced at build/PR time, so a regression is caught before it ships rather than discovered in production; the last two are runtime SLOs enforced by alerting, since they depend on live traffic and infrastructure conditions a CI run cannot reproduce.
18.6.2 WASM artifact size, compression, and caching #
The pdfcore .wasm artifact is built with Emscripten's size-optimizing flags (-Os), Brotli-compressed at build time (quality 11, the maximum static-compression setting, since the artifact is built once per release and served many times — the asymmetric cost is worth paying at build time), and served with:
Content-Encoding: br
Cache-Control: public, max-age=31536000, immutableThe filename is content-hashed (pdfcore.a1b2c3d4.wasm), so a new release ships under a new URL and the one-year immutable cache header is safe — a client never needs to revalidate a specific hashed artifact, and a new release is picked up automatically because the referencing JS bundle (which is not immutably cached) points at the new hash.
18.6.3 Worker pool tuning #
The browser worker pool (Section 3.7) is sized clamp(navigator.hardwareConcurrency - 1, 2, 4) — reserving one logical core for the main thread (UI responsiveness) and capping at 4 to avoid diminishing returns from scheduling overhead on high-core-count devices for workloads that are not perfectly parallel (most single-document operations parallelize across pages, not unboundedly). A batch operation across multiple files distributes files round-robin across the pool; a single large-file operation that supports page-range parallelism (Section 6) splits work across the pool instead.
18.6.4 Streaming for very large files #
Files approaching the 1 GB plan ceiling (Section 12.2) are never fully materialized in JS heap memory. The OPFS-backed scratch storage (Section 3.7) is read and written in page-range chunks; a tool that can operate page-range-at-a-time (merge, split, compress, OCR-adjacent rasterization) streams through OPFS rather than holding the whole file in a Uint8Array. This is what makes the 700 MB WASM64-avoidance threshold in Section 3.7 practical rather than a hard ceiling — files above that size are simply processed at a lower peak-memory footprint via smaller chunks, at a proportional cost in wall-clock time.
18.6.5 Database query budgets and slow-query policy #
Every query issued through packages/db is expected to complete in under 50 ms at p95 for a request-path query, and under 500 ms at p95 for an internal/reporting query not on a user-facing request path. db_query_duration_seconds (18.2.1) is labeled by queryName (a stable identifier assigned to each named query in packages/db, not the raw SQL text, which would explode label cardinality). A query exceeding 1 second at p95 over a rolling 24-hour window is automatically filed as a warning-level issue in the engineering backlog (not a page — this is a proactive-hygiene signal, not an incident) with its EXPLAIN ANALYZE plan attached from the slow-query log. PostgreSQL's own slow-query log (log_min_duration_statement) is set to 1,000 ms in production as the backstop that generates this data even for ad hoc queries outside packages/db.
The tenant-isolation invariant (17.3.2) means nearly every query filters on workspace_id, so every tenant-owned table carries a composite index leading with it:
CREATE INDEX idx_jobs_workspace_status ON jobs (workspace_id, status, created_at DESC);
CREATE INDEX idx_documents_workspace_deleted ON documents (workspace_id, deleted_at);
CREATE INDEX idx_envelopes_workspace_status ON envelopes (workspace_id, status, created_at DESC);A query plan missing an index scan against one of these composite indexes — falling back to a sequential scan on a tenant-owned table — is treated as a correctness issue as much as a performance one, since it is also a signal that a query may be missing the workspace_id predicate the tenant-isolation invariant requires; the code-review checklist for any new packages/db query includes an EXPLAIN check for exactly this.
18.6.6 Cache layers and invalidation #
| Cache | What it holds | Invalidation |
|---|---|---|
| CDN edge cache | Static assets (JS/CSS/WASM bundles, marketing site pages, docs site) | Content-hashed filenames (18.6.2) make most assets immutable and never need active invalidation; marketing/docs HTML uses a short max-age=300 with stale-while-revalidate rather than active purging |
| Application-layer cache (Redis) | Plan/entitlement lookups (Section 12.2), feature-flag reads (Section 4), rate-limit token-bucket state | Entitlement and feature-flag cache entries carry a 60-second TTL, which bounds the maximum staleness of a plan change to one minute — short enough that a customer who just upgraded is not left confused, without requiring a cache-busting event to be wired for every write path |
| TanStack Query (browser) | API responses backing the dashboard, job list, workspace settings | Invalidated by explicit query-key invalidation on the mutation that changed the data (standard TanStack Query pattern), plus a background refetch on window refocus for job-status polling |
No private document content is ever cached at the CDN edge, at any layer. Download URLs for server-processed documents are short-lived pre-signed URLs (17.6.2) served directly from object storage, deliberately bypassing the CDN, since caching private content at a shared edge cache — even briefly — is a class of risk this product's design avoids entirely rather than mitigates with cache-key scoping.
18.6.7 CDN strategy #
A CDN sits in front of pdfworks.io (marketing), docs.pdfworks.io, and the static-asset paths of app.pdfworks.io (the JS/CSS/WASM bundles, per 18.6.2/18.6.6). It is not placed in front of any API route or any document-bytes-serving route. TLS termination at the CDN edge still enforces TLS 1.3 and the full header set in 17.7.1 — the CDN is not a place where the security posture relaxes.
18.6.8 Image optimization #
Marketing and docs-site images are served through sharp-based build-time optimization (Section 3.6): responsive srcset generation, WebP with a JPEG fallback via <picture>, and dimensions specified in markup to avoid layout shift (protecting the CLS budget in 18.6.1). This is unrelated to, and does not share code with, the PDF ↔ JPG/PNG conversion tools (Section 7), which operate on user documents through pdfcore, not through sharp.
18.7 Reliability #
18.7.1 Availability target and error budget #
The public API's availability target is 99.9% monthly, published on the status page (status.pdfworks.io). 99.9% over a 30-day month permits 43 minutes and 12 seconds of downtime before the SLO is breached. This is tracked as a monthly error budget: every minute of measured unavailability (5xx rate exceeding 5% over a 1-minute window, or the health endpoint in 18.7.4 failing) debits the budget; when more than 50% of the monthly budget is consumed before the month is half over, new feature deployments are paused in favor of reliability work until the burn rate normalizes — a standard error-budget-policy pattern that keeps the 99.9% target meaningful rather than aspirational.
18.7.2 SLIs and SLOs per service #
| Service | SLI | SLO | Measurement |
|---|---|---|---|
Public API (/v1) |
Availability | 99.9% monthly | Non-5xx response rate |
Public API (/v1) |
Latency (excluding job-creation endpoints, which are inherently async) | p95 < 300 ms | http_request_duration_seconds |
| Job processing (all queues) | Job success rate | 99.5% of jobs that pass input validation reach succeeded |
job_duration_seconds{outcome} ratio |
| Job processing (all queues) | Time-to-start | p95 < 30 s from queued to running in the priority lane; p95 < 120 s in the standard lane |
queue_job_age_seconds at the running transition |
Signer portal (sign.pdfworks.io) |
Availability | 99.9% monthly | Non-5xx response rate |
| Webhook delivery | Delivery success within the retry schedule defined in Section 14.10.6 | 99% | esign_envelope_events_total-adjacent webhook-delivery counter (Section 14.9) |
| Deletion pipeline | Shred-to-verify completion | 99.9% within 24 hours | deletion_shred_to_verify_seconds |
18.7.3 Degradation ladder #
When capacity runs short (queue depth or latency SLOs breach, per the alert catalog in 18.8.1), load is shed in this order, each step reversible as soon as the triggering condition clears:
- Standard-lane job admission is throttled first. Free-plan (
standardlane) job submissions receive429 rate_limit_errorwith a longerRetry-Afterbefore any priority-lane customer is affected — this is a direct, intentional consequence of the plan/lane design in Section 12.2, not an emergency-only rule. - Non-essential background jobs pause. The
janitorqueue's non-time-critical sweeps (the 24-hour OPFS-adjacent server-side orphan cleanup, analytics rollups) are deprioritized; anything with a compliance deadline (the deletion pipeline itself, 17.6.3) is never paused. - New standard-lane job types are shed by category, starting with the most CPU-expensive (Office conversion, OCR) while continuing to admit lighter server-side operations, if the ladder must go this far.
- Priority-lane throttling, as a last resort. Only if the above is insufficient to protect the API's own availability SLO does the priority lane throttle, and this triggers an immediate SEV-2 incident (17.10.5) rather than being treated as routine, since it directly affects paying customers' expected service level.
Client-side tools (Section 6.1) are unaffected by every step of this ladder, by construction — they do not depend on apps/api capacity at all once the tool page and WASM module have loaded (18.7.4).
18.7.4 Offline and backend-outage behavior #
The application is a installable PWA (Section 15) with a service worker that precaches the app shell and the WASM artifact for every client-side tool. Consequence: every client-side tool (Section 6.1) continues to function with the backend fully unreachable — no API call is on the critical path for merge, split, compress, redact, or any other client-side operation. The UI detects backend unreachability (a failed /internal/health poll) and switches to an explicit offline/degraded banner rather than failing silently: client-side tools remain fully usable and are labeled as such; server-side tools (Section 6.1) are visibly disabled with an explanatory message rather than allowed to attempt and fail; account/billing actions queue where safe (for example, a plan-change intent) or are disabled where they cannot be safely queued (for example, anything requiring a fresh Stripe redirect). This is a specified, tested feature (covered in the Playwright E2E matrix, Section 19), not an incidental side effect of the architecture.
18.7.5 Health and readiness endpoints #
| Endpoint | Checks | Used by |
|---|---|---|
GET /internal/health (liveness) |
Process is running and its event loop is not blocked (a lightweight internal check, no downstream calls) | Container orchestrator liveness probe; a failure restarts the container |
GET /internal/ready (readiness) |
Database connection pool can acquire a connection and execute SELECT 1; Redis connection is established; at least one KMS Encrypt call has succeeded since process start (confirms KMS reachability without making a fresh call on every readiness probe) |
Container orchestrator readiness probe and load-balancer target-health check; a failure removes the instance from rotation without restarting it |
GET https://status.pdfworks.io |
Aggregated public status derived from the same SLI data as 18.7.2, plus manually-posted incident updates | Public status page, not an automated check target |
The distinction matters operationally: a liveness failure means "this instance is broken, replace it"; a readiness failure means "this instance is fine but a dependency is not, stop sending it traffic" — collapsing the two into one check would cause unnecessary container churn during a transient database or Redis blip.
A healthy readiness response:
{
"status": "ready",
"checks": {
"database": { "status": "ok", "latencyMs": 3 },
"redis": { "status": "ok", "latencyMs": 1 },
"kms": { "status": "ok", "lastSuccessfulCallAgoSeconds": 42 }
}
}A degraded response, returned with HTTP 503 so the load balancer removes the instance from rotation:
{
"status": "not_ready",
"checks": {
"database": { "status": "ok", "latencyMs": 4 },
"redis": { "status": "error", "error": "connection timeout after 500ms" },
"kms": { "status": "ok", "lastSuccessfulCallAgoSeconds": 42 }
}
}18.7.6 Graceful shutdown and in-flight job draining #
On receiving SIGTERM (issued by the orchestrator before terminating a container, whether for a deployment or a scale-down): apps/api immediately fails its readiness probe (removing it from load-balancer rotation) while continuing to serve in-flight requests, stops accepting new connections after a 5-second grace period, and exits once in-flight requests complete or a 30-second hard deadline is reached, whichever is first. Workers behave differently, consistent with the one-job-per-process model in 17.5: a worker that receives SIGTERM mid-job is given up to the job's own wall-clock timeout ceiling (17.5's table) to finish that single job — it does not accept a new job after SIGTERM — and only exits once that job reaches a terminal state or its timeout is hit, whichever is first, so a rolling deployment never silently drops a job that was already running.
18.7.7 Circuit breakers around external dependencies #
Every external dependency call (Stripe, Resend, the KMS provider, object storage) is wrapped by a circuit breaker with the shared policy: open after 5 consecutive failures or a 50% failure rate over a 10-request rolling window, whichever comes first; while open, calls fail fast with a 503-mapped api_error rather than waiting out the dependency's own timeout; half-open probing resumes after 30 seconds, admitting one trial request; closed again after 3 consecutive trial successes. circuit_breaker_state (18.2.1) is graphed per dependency, and an open breaker on any dependency pages on-call at warn priority immediately (18.8.1), since it is a leading indicator of an incident even before user-facing errors accumulate.
18.8 Alerting and On-Call #
18.8.1 Alert catalog #
| Alert | Condition | Severity | First response step |
|---|---|---|---|
| API error-rate breach | 5xx rate > 5% over 5 minutes | Critical (page) | Check the Service Overview dashboard (18.2.3); if a specific dependency's circuit breaker is open, that is the likely cause — see the corresponding runbook |
| API latency breach | p95 > 300 ms over 10 minutes | Warning (Slack) | Check db_query_duration_seconds and queue_depth for a correlated spike before assuming an API-tier regression |
| Queue depth breach | queue_depth{lane="priority"} > 500 for 5 minutes |
Critical (page) | Check worker_container_starts_total for a stalled scale-out; manually trigger the scaling policy (18.9.3) if automation has not reacted within 2 minutes |
| Queue age breach | queue_job_age_seconds{lane="priority"} p95 > 60s for 5 minutes |
Critical (page) | Same as above — age is the user-facing symptom of the same underlying depth problem |
| Deletion verification failure | Any deletion_verification_failure event (17.6.3) |
Critical (page) | Follow the deletion-pipeline runbook; this is a compliance-relevant failure, not merely an operational one |
| Circuit breaker open | Any circuit_breaker_state == 1 |
Warning (Slack) | Confirm the dependency's own status page; if the dependency is confirmed down, this is expected behavior — monitor for the auto-recovery half-open transition |
| Stripe webhook lag | stripe_webhook_lag_seconds p95 > 60s for 10 minutes |
Warning (Slack) | Check for a Stripe-side incident; billing entitlement drift risk grows the longer this persists, so escalate to Critical if it exceeds 30 minutes |
| New Sentry issue type | First occurrence, not seen in 14 days | Warning (Slack) | Triage within the current business day |
| High-frequency Sentry issue | > 50 events / 5 minutes | Critical (page) | Same as the API error-rate breach — usually the same underlying cause |
| Availability SLO burn | Error budget (18.7.1) burn rate implies exhaustion within 6 hours at current rate | Critical (page) | Invoke the degradation ladder (18.7.3) manually if automated shedding has not already engaged |
| Security audit-log anomaly | A single API key or account triggers > 100 403 permission_error responses in 5 minutes |
Warning (Slack, routed to the security channel) | Possible scope-probing or credential-stuffing; consider a temporary IP-level block pending investigation |
18.8.2 Paging policy #
Critical alerts page the on-call engineer via the paging system's phone/SMS escalation, with acknowledgment required within 5 minutes; an unacknowledged page escalates to the secondary on-call after 5 minutes and to the engineering lead after a further 10 minutes. Warning alerts post to the team's Slack alerts channel and require no page, but are triaged within the current business day. On-call rotation is weekly, one primary and one secondary at all times, published on a shared calendar; a security-specific secondary rotation (17.10.5) is paged in addition to the primary on-call for any alert tagged security-relevant (the audit-log anomaly alert above, any SEV-1/SEV-2 per 17.10.5).
18.8.3 Runbook index #
Every alert in the catalog (18.8.1) links directly to a runbook covering: the alert's likely causes ranked by frequency, the specific dashboard panels to check first, the exact commands/console actions to take for the most common cause, and the escalation path if the first response does not resolve it. Runbooks are version-controlled alongside the infrastructure code (Section 20) so they are reviewed under the same pull-request process as any other change, and each one names the alert it corresponds to in its title so the on-call engineer can navigate from the page notification straight to the relevant runbook with no search step.
The index itself is a flat table, generated from the runbook files' front matter rather than maintained by hand, so it cannot drift out of sync with the alert catalog:
| Alert | Runbook path | Most common cause |
|---|---|---|
| API error-rate breach | runbooks/api-error-rate.md |
An open circuit breaker on Stripe or the object store (18.7.7) |
| Queue depth / age breach | runbooks/queue-depth.md |
Worker fleet scale-out lagging a traffic spike; manual scale-out override documented as the first response |
| Deletion verification failure | runbooks/deletion-verification.md |
Object-store eventual-consistency delay past the 15-minute retry window; escalation path to the storage provider's status page |
| Circuit breaker open | runbooks/circuit-breaker.md |
The dependency's own outage; confirms via the provider's public status page before assuming a PDFWorks-side fault |
| Stripe webhook lag | runbooks/stripe-webhook-lag.md |
Stripe-side delivery delay; the fallback is apps/api's own periodic reconciliation job that polls Stripe's API directly if webhook lag exceeds 30 minutes |
| Security audit-log anomaly | runbooks/security-anomaly.md |
Credential-stuffing attempt against a single account; first response is a temporary IP block via the edge WAF, not a full account lockout, to avoid a denial-of-service against the legitimate owner |
18.8.4 Postmortem process #
Every SEV-1 and SEV-2 incident (17.10.5), and any Critical-severity alert (18.8.1) that took longer than 30 minutes to resolve, produces a blameless postmortem within 5 business days, following a fixed template: timeline (built from the Scribe's real-time notes and the trace/log data for the incident window), impact (affected customers/workspaces, duration, whether any SLO or data-protection guarantee was breached), root cause (the technical cause, not "human error" — the process explicitly asks "what made this mistake possible" rather than "who made it"), and action items, each with an owner and a due date, tracked to closure in the same backlog as regular engineering work. Postmortems for incidents with customer-visible impact are summarized (technical detail retained internally, customer-facing summary written separately) and made available to affected Team/API customers on request.
18.9 Capacity Planning #
18.9.1 Load model #
Traffic is modeled from two independent drivers: monthly active users (MAU), each contributing a mix of client-side (free, zero server cost beyond serving the static WASM/JS bundles) and server-side (metered, per-job cost) operations; and public API call volume, which is entirely server-side by definition (Section 6.1) and billed per-operation (Section 12.6). The baseline assumption, derived from the plan/limits table (Section 12.2) and the six-guest-tool / unlimited-client-side-on-Free design: roughly 80% of all tool invocations across the user base are client-side and impose no processing cost on PDFWorks's infrastructure, with the remaining 20% (OCR, Office/HTML conversion, e-signature, any batch containing a server-side tool, and 100% of public API traffic) driving the capacity plan below.
18.9.2 Per-tool cost model #
| Tool category | CPU-seconds (typical document) | Peak memory | Notes |
|---|---|---|---|
| OCR (10-page scanned document) | 18 CPU-seconds | 512 MB | Dominated by Tesseract's page-segmentation and recognition passes; scales roughly linearly with page count |
| PDF → DOCX/XLSX/PPTX (10-page) | 12 CPU-seconds | 768 MB | LibreOffice headless conversion, includes layout-reconstruction cost |
| DOCX/XLSX/PPTX → PDF (10-page) | 6 CPU-seconds | 512 MB | Cheaper direction — rendering a well-defined layout format is less work than reconstructing one |
| HTML → PDF | 4 CPU-seconds | 512 MB | Dominated by page-load and layout wait time (17.4.5's response-time limits bound the worst case) |
| E-signature server-side operations (certificate generation, flattening) | 1.5 CPU-seconds | 256 MB | Runs against an already-processed document, no OCR/conversion cost |
| Batch job overhead (per job, in addition to the sum of its constituent files) | 0.5 CPU-seconds | 128 MB | Orchestration and result-aggregation cost |
These figures are per-job averages against the golden-file corpus (Section 19); actual cost scales with page count and document complexity, and the wall-clock timeouts in 17.5's table bound the worst case regardless of the average.
18.9.3 Scaling triggers #
Worker container fleets (worker-media, worker-office) scale horizontally on queue_depth and queue_job_age_seconds (18.2.1): a scale-out event triggers when queue_depth{lane="priority"} > 100 sustained for 2 minutes, adding worker capacity in increments of 20% of current fleet size (bounded by a configured maximum), and a scale-in event triggers when queue_depth across both lanes stays below 20% of the current fleet's steady-state processing capacity for 15 minutes, removing capacity in the same 20% increments to avoid oscillation. apps/api scales on CPU utilization (target 60%) and in-flight-request count, independent of the worker fleet, since API request handling and job processing have different resource profiles and should not share a single scaling signal.
18.9.4 Projected infrastructure footprint #
| MAU | Estimated server-side operations/month (20% of estimated total tool invocations at ~15 invocations/active user/month) | Peak worker fleet size (concurrent containers) | apps/api instance count (steady state) |
PostgreSQL sizing |
|---|---|---|---|---|
| 1,000 | ~3,000 | 2–4 | 2 (minimum for availability, not load) | Single primary + 1 read replica, smallest production tier |
| 10,000 | ~30,000 | 8–16 | 3–4 | Single primary + 1 read replica, mid tier, connection pooling via a pooler in front of Drizzle |
| 100,000 | ~300,000 | 40–80 at peak, autoscaled down substantially off-peak given the daily/weekly usage pattern typical of a document-tooling product | 8–12 | Primary + 2 read replicas, largest standard tier before a sharding conversation becomes necessary (out of scope for this specification at the 100,000-MAU horizon) |
Worked CPU-hour calculation, 10,000 MAU tier. Weighting the per-tool cost model (18.9.2) by an assumed operation mix of 40% OCR, 25% PDF→Office, 20% Office→PDF, 10% HTML→PDF, 5% e-signature server-side operations yields a blended cost of approximately 11 CPU-seconds per server-side operation. At ~30,000 operations/month, that is 330,000 CPU-seconds (≈92 CPU-hours) of worker compute per month, comfortably covered by a peak fleet of 8–16 containers at 2 vCPU each running well under 100% duty cycle, consistent with the autoscaling triggers in 18.9.3 keeping the steady-state fleet much smaller than the peak.
Object storage sizing. Because the default retention window is 2 hours post-terminal-state / 24 hours absolute (Section 6), storage is sized for throughput, not accumulation: at the 100,000-MAU tier's ~300,000 monthly operations and an assumed average file size of 4 MB, gross monthly throughput is approximately 1.2 TB, but steady-state resident storage — the amount actually sitting in the bucket at any moment — is bounded by the retention window rather than the monthly total, and stays in the tens-of-GB range even at this tier. The one deliberate exception is e-signature envelope storage (14–30 day retention, Section 10.8), which is sized separately and remains a small fraction of total volume given that signature requests are the least-used server-side category in the assumed operation mix above.
Redis/BullMQ sizing. Redis holds queue state, rate-limit token buckets, and the entitlement/feature-flag cache (18.6.6) — all small, high-churn key sets, not bulk data. A single Redis primary with one replica is sufficient through the 100,000-MAU tier; the queue-depth alert thresholds in 18.8.1 are calibrated to page well before Redis memory pressure would become a factor at any of the three modeled tiers.
These figures are sizing guidance for initial capacity reservation and budget planning, not a commitment enforced by any code path — actual provisioning is driven live by the scaling triggers in 18.9.3, with this table serving as the basis for reserved-capacity purchasing decisions and cost forecasting at each growth milestone.
19. Testing & Quality Assurance #
19.1 Testing Philosophy and the Test Pyramid #
Testing exists to make three specific classes of regression impossible to ship silently. The product's entire value proposition rests on three load-bearing claims: files are processed correctly, the client and the server produce identical output, and every customer is billed and rate-limited exactly according to their plan. A bug in any of the other nineteen sections of this specification is usually visible — a broken button, a misaligned card, a slow page load. A bug in these three areas is invisible until it is a support ticket, a legal problem, or a revenue leak:
- The
pdfcorebindings (Section 3.6, Section 6).pdfcoreis the single WASM/Node engine that both the browser and the public API call through. A binding bug does not crash — it silently produces a subtly wrong PDF (a dropped glyph, a corrupted xref, an off-by-one page range) that looks fine in a quick visual check and fails a customer's downstream system weeks later. Byte-identical cross-host output (Section 3.6) is a testable claim, not a slogan, and it is tested as one. - The shared contracts (
packages/contracts, Section 4). Every request body, response body, and event payload in the product is validated against one Zod schema set shared by the web app, the API, the workers, and the published SDK. A contract regression does not fail loudly in one place — it silently desynchronizes the API from its own OpenAPI document, the SDK from the API, or the webhook payload from what the receiving customer's code expects. - Entitlement and quota math (Section 12). Every job, every API call, and every signature envelope is gated by a plan limit, a daily cap, a monthly cap, or a spend cap (Section 12.2, Section 12.6). An off-by-one here either lets a Free user do something they paid nothing for, or blocks a Team customer who paid for exactly this — the first is a margin problem, the second is a churn event, and both are silent until a human notices.
These three areas carry a higher coverage bar than the rest of the codebase (Section 19.2) and are the only areas where a passing CI run is treated as necessary but not sufficient — they additionally require the golden-file corpus (Section 19.3) and property-based tests to pass before merge.
The test pyramid, by volume and by what each layer is trusted to catch:
| Layer | Tooling | Volume (approx., steady state) | What it catches | Runs |
|---|---|---|---|---|
| Unit | Vitest | 4,000–6,000 tests | Logic errors in a single function or module, contract shape drift, entitlement math | Every commit, local pre-commit (changed files only) and full run in CI |
| Golden-file / snapshot | Vitest + PDFium harness | 229 fixtures × per-tool assertions | Real-world PDF regressions the engine cannot see in a synthetic unit test | Every PR touching packages/pdfcore, apps/worker-media, apps/worker-office; nightly full sweep |
| Integration | Vitest + Testcontainers | 400–700 tests | Wiring between the API, PostgreSQL, Redis, BullMQ, Stripe, and the mail provider | Every PR, against real containers |
| End-to-end | Playwright | ≥ 200 scenarios (25+ critical-path, Section 19.5) | Full user journeys across a real browser, including WASM execution | Every PR (smoke subset), nightly (full matrix), pre-release (full matrix × full browser grid) |
| Specialist (a11y, security, performance, PWA) | axe-core, ZAP, k6, Lighthouse CI | Continuous | Non-functional regressions that unit/E2E tests do not target by design | CI (a11y, SAST, dependency scan), nightly (perf smoke), weekly (DAST, full k6), quarterly (manual a11y pass), annual (penetration test) |
| Manual | Human QA | Every release | What automation structurally cannot catch: taste, real-device feel, ambiguous UX | Every release candidate |
The pyramid is intentionally bottom-heavy in test count but not in engineering effort — the golden-file corpus and the redaction/signature suites (Section 19.3, 19.7, 19.8) receive disproportionate investment relative to their test count because they are the tests that actually prevent the expensive class of bug described above.
19.2 Unit Testing #
Framework. Vitest 4.x across every TypeScript package and app in the monorepo (apps/web, apps/api, apps/worker-media, packages/pdfcore, packages/contracts, packages/db, packages/ui, packages/sdk-js). apps/worker-office is Python and uses pytest with pytest-cov; its coverage gate is enforced identically in principle even though the tool differs.
Each package owns its own vitest.config.ts extending a shared base config in packages/config/vitest.base.ts, which sets:
// packages/config/vitest.base.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
restoreMocks: true,
clearMocks: true,
testTimeout: 5_000,
hookTimeout: 10_000,
coverage: {
provider: "v8",
reporter: ["text", "lcov", "json-summary"],
exclude: [
"**/*.d.ts",
"**/*.config.ts",
"**/__fixtures__/**",
"**/generated/**",
],
},
},
});globals: false is deliberate: every test file imports describe, it, expect, and vi explicitly from vitest, because implicit globals make it easy to miss a missing import in a refactor and have a test silently not run.
Coverage thresholds, enforced per package via coverage.thresholds in each package's config and re-checked in CI (Section 19.10):
| Package | Line | Branch | Function | Rationale |
|---|---|---|---|---|
packages/pdfcore (TS bindings only, not the C++/WASM internals) |
90% | 85% | 90% | Highest bar per Section 19.1 — silent correctness bugs are the most expensive class |
packages/contracts |
95% | 90% | 95% | Every schema branch (optional field, discriminated union arm, refinement) must be exercised; this is the shape the whole system agrees on |
Entitlement/quota modules (apps/api/src/entitlements/**, apps/api/src/billing/quota.ts) |
95% | 95% | 100% | Every plan tier, every boundary (=== limit, limit + 1), every downgrade/expiry transition from Section 12.2 must have an explicit test |
packages/db (query builders, repositories) |
80% | 75% | 80% | Standard bar; exhaustively covered instead by integration tests against real PostgreSQL (Section 19.4) |
apps/api (routes, middleware) |
85% | 80% | 85% | Route handlers above standard bar because they are the public surface |
apps/web (components, hooks, stores) |
75% | 70% | 75% | Standard bar; UI correctness is primarily covered by E2E and visual regression (Section 19.5) |
packages/ui |
75% | 70% | 75% | Standard bar; each primitive additionally has a Storybook interaction test |
packages/sdk-js |
90% | 85% | 90% | Published artifact; a coverage gap here ships directly to a customer's codebase |
apps/worker-media, apps/worker-office |
80% | 75% | 80% | Standard bar; orchestration logic only — the underlying binaries are exercised by the golden-file corpus, not unit tests |
A PR that lowers any package below its threshold fails CI (Section 19.10) regardless of whether the specific changed lines are covered — thresholds are absolute, not delta-based, to prevent slow erosion.
What must be tested:
- Every exported function and class method in
packages/contracts,packages/pdfcore's TS layer, and the entitlement modules, including every error path. - Every Zod schema: valid input parses to the expected shape; every documented invalid input (wrong type, missing required field, value outside a declared range, extra field under
.strict()) is rejected with the expected Zod issue path. - Every entitlement decision: for each plan tier in Section 12.2, a test asserts the exact boundary (
Freeuser at task 2 of 2 succeeds, at task 3 is rejected withquota_error/402;Prouser has no such boundary). - Every state transition in the job state machine (Section 4.7): each documented edge is tested; every non-edge (e.g.,
succeeded → running) is tested to throw. - Pure functions in
apps/web(formatters, validators, the plan-comparison logic that drives the pricing page) at 100% regardless of the package-level 75% floor, because they are cheap to test exhaustively and easy to get subtly wrong (money formatting is a recurring source of bugs and is always tested with the exact integer-minor-units convention from Section 12).
What must NOT be unit-tested (covered by other layers instead, to avoid duplicate and brittle coverage):
- Actual PDF rendering or parsing correctness — that is the golden-file corpus's job (Section 19.3). A unit test may stub
pdfcoreand assert it was called with the correct arguments, but must never assert on real rendered pixels or extracted text; that duplication rots the moment a real-world PDF exposes a new edge case the stub does not model. - Database round-trips through Drizzle — covered by integration tests against real PostgreSQL (Section 19.4). Mocking the database layer at the query-builder level produces tests that pass against a mock and fail against real PostgreSQL's actual constraint and transaction behavior.
- Full HTTP request/response cycles through the Hono app — covered by integration tests using the real app instance in-process.
- Third-party SDK internals (Stripe, Resend, the S3 client) — never re-test vendor code; test only that this codebase calls the vendor SDK with the correct arguments and handles its documented error shapes.
- CSS layout, visual appearance, or pixel-level rendering — covered by visual regression (Section 19.5).
Mocking policy.
vi.mock()is permitted only at process boundaries: network calls (fetch, the Stripe SDK, the Resend SDK, the S3 client), the system clock (vi.useFakeTimers()), andcrypto.randomUUID/uuidv7where a test needs a deterministic identifier.pdfcoreitself is never mocked in a test that claims to test PDF correctness; it may be mocked in a test that is explicitly about orchestration (e.g., "the merge route callspdfcore.mergewith the uploaded document IDs in request order and returns ajobobject") where the label makes the boundary clear.- The database is never mocked below the repository layer in
packages/db; above that layer (inapps/apiroute handlers), an in-memory fake repository implementing the same TypeScript interface is permitted for unit tests, with the real repository exercised by integration tests. - No global mutable mock state survives between tests:
restoreMocks: trueandclearMocks: true(shown above) are non-negotiable defaults, and a test that needs shared setup usesbeforeEach, never module-level mutable state.
Test naming and structure.
- File naming:
*.test.tscolocated next to the file under test (quota.ts→quota.test.ts), except integration and E2E tests, which live in__tests__/integration/ande2e/respectively at the package or app root. - Structure:
describe(<ModuleOrFunctionName>, () => { describe(<method or scenario>, () => { it(<behavior, plain English, starts with a verb>, ...) }) }). - Example:
// apps/api/src/billing/quota.test.ts
import { describe, it, expect, beforeEach, vi } from "vitest";
import { checkServerSideTaskQuota } from "./quota";
import { createInMemoryUsageRepository } from "../__fixtures__/usage-repository";
describe("checkServerSideTaskQuota", () => {
let usage: ReturnType<typeof createInMemoryUsageRepository>;
beforeEach(() => {
usage = createInMemoryUsageRepository();
});
describe("for a Free-plan user", () => {
it("allows the 1st and 2nd server-side task of the UTC day", async () => {
await usage.recordServerSideTask("usr_01K7Y3M2QF8V6X", "2026-08-19");
const result = await checkServerSideTaskQuota(usage, "usr_01K7Y3M2QF8V6X", "free", "2026-08-19");
expect(result).toEqual({ allowed: true, remaining: 1 });
});
it("rejects the 3rd server-side task of the UTC day with quota_error", async () => {
await usage.recordServerSideTask("usr_01K7Y3M2QF8V6X", "2026-08-19");
await usage.recordServerSideTask("usr_01K7Y3M2QF8V6X", "2026-08-19");
const result = await checkServerSideTaskQuota(usage, "usr_01K7Y3M2QF8V6X", "free", "2026-08-19");
expect(result).toEqual({
allowed: false,
code: "server_side_task_limit_exceeded",
remaining: 0,
});
});
it("resets the count at UTC midnight, not the user's local midnight", async () => {
await usage.recordServerSideTask("usr_01K7Y3M2QF8V6X", "2026-08-19");
await usage.recordServerSideTask("usr_01K7Y3M2QF8V6X", "2026-08-19");
const result = await checkServerSideTaskQuota(usage, "usr_01K7Y3M2QF8V6X", "free", "2026-08-20");
expect(result.allowed).toBe(true);
});
});
describe("for a Pro-plan user", () => {
it("never rejects on the server-side task count", async () => {
for (let i = 0; i < 50; i++) {
await usage.recordServerSideTask("usr_01K7Y3M3RG9W7Y", "2026-08-19");
}
const result = await checkServerSideTaskQuota(usage, "usr_01K7Y3M3RG9W7Y", "pro", "2026-08-19");
expect(result.allowed).toBe(true);
});
});
});- Assertions use
expect(...).toEqual(...)over multiple chainedtoBecalls where a whole-object comparison communicates intent better than a series of field checks. - No conditional logic (
if, loops with a branch) inside a test body except a bounded, deterministic loop as in the example above; a test with a branch is really two tests that must be split. - Property-based tests (via
fast-check, included as a dev dependency inpackages/contractsandpackages/pdfcore) are used specifically for the entitlement/quota modules and the page-range/selection parsing logic in the tool contracts, generating hundreds of boundary values automatically rather than hand-enumerating them.
Representative examples from the two highest-bar packages.
A packages/contracts schema test exercises every branch of a discriminated-union request body, including the specific rejection shape a caller receives:
// packages/contracts/src/schemas/redact.test.ts
import { describe, it, expect } from "vitest";
import { RedactRequestSchema } from "./redact";
describe("RedactRequestSchema", () => {
it("accepts a request with at least one rectangular region per page", () => {
const result = RedactRequestSchema.safeParse({
documentId: "doc_01K7Y3M2QF8V6X",
regions: [{ page: 1, x: 72, y: 144, width: 200, height: 24 }],
fillColor: "#000000",
});
expect(result.success).toBe(true);
});
it("rejects a region with a negative width", () => {
const result = RedactRequestSchema.safeParse({
documentId: "doc_01K7Y3M2QF8V6X",
regions: [{ page: 1, x: 72, y: 144, width: -10, height: 24 }],
fillColor: "#000000",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(["regions", 0, "width"]);
});
it("rejects an empty regions array — a redact call must redact something", () => {
const result = RedactRequestSchema.safeParse({
documentId: "doc_01K7Y3M2QF8V6X",
regions: [],
fillColor: "#000000",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].code).toBe("too_small");
});
it("rejects a fillColor that is not a 6-digit hex value", () => {
const result = RedactRequestSchema.safeParse({
documentId: "doc_01K7Y3M2QF8V6X",
regions: [{ page: 1, x: 72, y: 144, width: 200, height: 24 }],
fillColor: "red",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(["fillColor"]);
});
it("rejects an unknown top-level field under strict parsing", () => {
const result = RedactRequestSchema.safeParse({
documentId: "doc_01K7Y3M2QF8V6X",
regions: [{ page: 1, x: 72, y: 144, width: 200, height: 24 }],
fillColor: "#000000",
extraneousField: true,
});
expect(result.success).toBe(false);
});
});A packages/pdfcore TypeScript binding test verifies argument marshaling into the WASM boundary without asserting on real rendered output (that assertion belongs to the golden-file corpus, Section 19.3):
// packages/pdfcore/src/bindings/merge.test.ts
import { describe, it, expect, vi } from "vitest";
import { merge } from "./merge";
import * as wasmModule from "./wasm-instance";
describe("merge binding", () => {
it("passes document handles to the WASM module in the requested order", async () => {
const invoke = vi.spyOn(wasmModule, "invoke").mockResolvedValue({ ok: true, handle: 42 });
await merge({ documentHandles: [7, 3, 9], deterministicTimestamp: 1_755_590_400 });
expect(invoke).toHaveBeenCalledWith("pdfcore_merge", {
handles: [7, 3, 9],
deterministic_timestamp: 1_755_590_400,
});
});
it("throws PdfCoreError with the WASM module's error code on failure, never a raw WASM exception", async () => {
vi.spyOn(wasmModule, "invoke").mockResolvedValue({ ok: false, errorCode: "invalid_page_range" });
await expect(merge({ documentHandles: [7], deterministicTimestamp: 1_755_590_400 })).rejects.toMatchObject({
code: "invalid_page_range",
});
});
});19.3 The Golden-File Corpus #
The golden-file corpus is the single highest-leverage testing asset in the product. Unit tests prove the code does what the code author believed it should do; the corpus proves the code does what real, messy, adversarial PDF files in the wild actually require. Every tool in Sections 7, 8, and 9 runs against the relevant slice of the corpus in CI before merge to packages/pdfcore, apps/worker-media, or apps/worker-office.
Size and taxonomy. The corpus contains no fewer than 229 fixtures, distributed across 26 categories with the following minimum target counts:
| Category | Target count | Notes |
|---|---|---|
| Simple text (single column, digital-native) | 20 | The baseline; every tool's happy path |
| Complex layout (multi-column, tables, footnotes, mixed text/image flow) | 15 | Sourced from real academic papers, financial statements, magazines |
| Tagged / accessible (PDF/UA structure tree present) | 12 | Structure tree, reading order, alt text on figures |
| Scanned, image-only (no text layer) | 15 | Varying scan quality: flatbed, mobile-camera, fax-quality |
| Mixed scanned and digital (some pages text, some pages scanned images) | 10 | Common in real-world contracts with a scanned signature page appended |
| CJK (Chinese, Japanese, Korean text and fonts) | 12 | Simplified and traditional Chinese, vertical and horizontal Japanese |
| RTL — Arabic and Hebrew | 10 | Bidi text runs, RTL-embedded-LTR (numbers, Latin brand names) |
| Indic scripts (Devanagari, Tamil, Bengali, others) | 8 | Complex shaping and ligatures |
| Encrypted with a user (open) password | 8 | RC4 40-bit through AES-256, across encryption revisions |
| Encrypted with an owner password only (no open password) | 8 | Permissions-only encryption; content must remain readable |
| AcroForm (interactive form fields) | 10 | Text fields, checkboxes, radio groups, dropdowns, calculated fields |
| XFA (dynamic and static) | 6 | Legacy government and financial forms |
| Linearized ("fast web view") | 8 | Verifies linearization is preserved or correctly rebuilt after edit |
Incrementally updated (multiple %%EOF markers, update chains) |
8 | Verifies the product's own outputs never do this (Section 9.1's rule against incremental save applies broadly, not only to redaction) while still correctly reading such inputs |
| Damaged xref (corrupt or missing cross-reference table/stream) | 8 | Requires the repair-on-read fallback (linear object scan) |
| Oversized (5,000+ pages) | 5 | Streamed page-range processing (Section 3.2) is exercised, not full in-memory load |
| Tiny (1 page, minimal content, near-empty) | 5 | Degenerate-input edge cases |
| Huge-image (single page dominated by a very large embedded raster) | 6 | Memory-bounded decode path |
| Vector-heavy (CAD/illustration exports, thousands of path objects) | 8 | Stresses the content-stream parser, not the raster path |
| Transparency groups (soft masks, blend modes, isolated/knockout groups) | 6 | Compress and flatten must not visibly alter blended output |
| Optional content layers (OCGs, e.g., CAD or map layer toggles) | 6 | Redaction (Section 9.1) must correctly remove a wholly-redacted layer |
| Embedded attachments / file specifications | 6 | Redaction and metadata-stripping must handle attachments explicitly |
JavaScript-bearing (document-level or field-level /JS) |
8 | Must be stripped on ingest (Section 17) and never executed |
| Non-standard fonts (obscure embedded Type 1, exotic encodings, subset naming collisions) | 6 | Text extraction and re-flow correctness |
| Type 3 fonts (glyphs defined as content-stream procedures rather than outlines) | 5 | A known historical weak point for PDF libraries; explicit coverage |
Known-malicious samples (crafted for parser exploits: object-stream loops, malformed /Length, decompression bombs) |
10 | Sourced from public CVE proof-of-concept repositories and internal fuzzing; never processed outside the sandboxed worker (Section 3.5) |
| Total | 229 | Exceeds the 200-fixture floor with margin retained for one addition per quarter |
Storage and licensing. Fixtures live in a dedicated, access-controlled object storage bucket (pdfworks-test-corpus, not the production document bucket) mirrored into CI as a build cache layer keyed by a manifest hash, not committed to the Git repository (binary PDFs bloat history and Git LFS billing outstrips the benefit). Every fixture is one of:
- Synthetically generated by the test suite itself (LibreOffice or a scripted PDF-generation library run at fixture-build time) — no licensing concern, regenerable on demand.
- Sourced from a public-domain or explicitly open-licensed corpus (e.g., government forms, standards-body sample PDFs, academic open-access papers) — the manifest entry records the source URL and license.
- Internally authored by the QA team specifically to exercise a taxonomy category no public source covers (e.g., a hand-crafted damaged-xref file) — owned outright.
- Known-malicious samples, sourced only from public security-research repositories that explicitly permit redistribution for defensive testing, never from a live incident.
No fixture that is a real customer's uploaded document, or derived from one, is ever added to the corpus — this is an absolute rule, not a case-by-case judgment call, and is enforced by a pre-merge check that rejects any fixture manifest entry lacking a recorded public source or an "internally authored" tag.
The fixture manifest. Every fixture is registered in packages/pdfcore/corpus/manifest.json:
{
"id": "rtl-arabic-invoice-04",
"category": "rtl",
"subcategory": "arabic",
"file": "rtl/rtl-arabic-invoice-04.pdf",
"source": "internally-authored",
"license": "proprietary-test-fixture",
"pageCount": 3,
"addedAt": "2026-08-19",
"addedBy": "qa-team",
"knownProperties": {
"hasTextLayer": true,
"isEncrypted": false,
"expectedExtractedTextSample": "فاتورة رقم"
},
"assertions": ["text-extraction-roundtrip", "merge", "split", "compress", "watermark"],
"sensitivity": "public"
}Adding a fixture. A contributor runs pnpm corpus:add --file <path> --category <category> --source <source-tag>, which (a) computes and stores the file's SHA-256, (b) runs a baseline extraction pass and stores the extracted text, page count, and structural metadata as the fixture's "known properties," (c) generates the initial perceptual-hash baseline for every page at 150 DPI, and (d) opens a PR adding the manifest entry and the file to the corpus bucket. A human reviewer confirms the category, license, and sensitivity tag before merge; the corpus bucket itself is append-only (no fixture is silently modified — a fixture that needs to change is added as a new id and the old one is deprecated, never overwritten in place) so that a historical CI run remains reproducible.
Sensitive samples. Known-malicious samples and any fixture flagged sensitivity: internal (a small number of fixtures that, while not customer data, are considered internal test material — for example, damaged-xref files that encode information about parser internals) are stored in a separate, more restricted prefix within the corpus bucket, accessible only to the CI service role and to the QA team's credentials, never fetched into a developer's local machine by the default pnpm corpus:sync command (a --include-sensitive flag is required and logged). Malicious samples are additionally processed exclusively inside the same gVisor-sandboxed, no-outbound-network container used for production worker jobs (Section 3.5), even in CI, so that a real exploit in a test sample cannot escape the test run.
Per-tool assertions run against the corpus. Each fixture's manifest lists which tool assertions apply to it (not every tool is meaningful against every fixture — running "extract pages" against a 1-page tiny fixture is skipped, for example). The assertion set, applied per applicable fixture:
| Assertion | What it checks |
|---|---|
text-extraction-roundtrip |
Extracted text matches the fixture's stored known-good text within a normalized whitespace/Unicode-normalization tolerance |
page-count-preserved |
Operations that should not change page count (compress, watermark, rotate) leave pageCount unchanged; operations that should (split, extract, delete pages) match the expected new count |
render-perceptual-hash |
Every output page's rendered bitmap matches the stored baseline hash within the threshold below |
structure-tree-preserved |
For tagged fixtures, the accessibility structure tree node count and reading order survive the operation unchanged (or are correctly updated for page-count-changing operations) |
encryption-round-trip |
For encrypted fixtures, the output remains correctly decryptable with the same password and permission set unless the tool under test is protect/unlock |
form-fields-preserved |
For AcroForm/XFA fixtures, field names, types, and values survive operations that are not the form tools themselves |
no-javascript-survives |
Post-ingest, /JS and /OpenAction launch entries are absent, regardless of the operation performed |
byte-identical-cross-host |
The identical operation run through the browser WASM build and through the Node/API build, given the same deterministicTimestamp (Section 3.6), produce SHA-256-identical output bytes |
valid-pdf-structure |
Output parses cleanly under both pdfcore and a second, independent parser (pikepdf) with zero structural warnings |
Perceptual-hash comparison. Each page of a fixture's expected output is rendered at 150 DPI to an 8-bit grayscale raster, then reduced to a 64-bit perceptual hash (pHash, DCT-based, following the standard 32×32 → 8×8 low-frequency DCT coefficient method). A comparison passes when the Hamming distance between the candidate hash and the stored baseline hash is ≤ 4 bits out of 64 (equivalent to ≥ 93.75% similarity). This threshold is deliberately looser than bit-exact pixel comparison because font hinting and anti-aliasing can shift by a handful of pixels between otherwise-correct renders on different CI runner hardware, but tight enough that any visible content change (a missing glyph run, a shifted watermark, a wrong fill color) fails.
When a hash legitimately changes. A rendering-affecting change (a PDFium version bump, a font-subsetting change, an intentional visual change to a tool's default output such as watermark opacity) will legitimately shift hashes across many fixtures at once. The process:
- The PR author runs
pnpm corpus:rehash --scope <affected-fixture-ids-or-glob> --reason "<explanation>", which regenerates hashes only for the named scope and writes acorpus/rehash-log.jsonentry recording the previous hash, the new hash, the reason, the PR number, and the commit SHA. - CI diffs the rehash-log entry count against the number of hash changes actually observed; a rehash that touches fixtures outside the declared scope fails CI, preventing a mistaken or overly broad rehash from silently masking a real regression.
- A second reviewer (required by branch protection, Section 19.10) visually inspects a rendered sample of at least 5 affected fixtures via the PR's attached before/after image artifact (generated automatically by the rehash CI job) before approving.
- Bulk unreviewed rehashing is disallowed:
corpus:rehashrefuses to run against more than 40 fixtures in a single invocation without a--bulk-approved-by <reviewer-username>flag that a human must supply out of band, to prevent a script bug from quietly rewriting the entire baseline.
19.4 Integration Testing #
Integration tests exercise real infrastructure — no mocked database, no mocked queue, no mocked Stripe network layer beyond Stripe's own official test mode — because the failure modes that matter here (a migration that violates a constraint under real concurrent load, a Redis eviction policy interacting badly with BullMQ, a webhook signature that verifies against the SDK's expectations but not Stripe's actual signer) do not reproduce against mocks.
PostgreSQL and Redis in containers. apps/api/__tests__/integration/ and packages/db/__tests__/integration/ use Testcontainers to launch a real PostgreSQL 18 container and a real Redis 8.x container per test file (a beforeAll hook starts the containers; a afterAll hook tears them down; containers are reused across the file's tests but never across files, to guarantee isolation). Local development reuses the same containers via pnpm test:integration, which is also exactly what CI runs (Section 19.10) — there is deliberately no separate "CI-only" integration harness, so a developer reproducing a CI failure locally runs the identical setup.
// packages/db/__tests__/integration/setup.ts
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { RedisContainer } from "@testcontainers/redis";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
export async function startTestInfrastructure() {
const pg = await new PostgreSqlContainer("postgres:18-alpine").start();
const redis = await new RedisContainer("redis:8-alpine").start();
const pool = new Pool({ connectionString: pg.getConnectionUri() });
const db = drizzle(pool);
await migrate(db, { migrationsFolder: "./drizzle" });
return { pg, redis, pool, db };
}Database fixture and reset strategy. Each test file runs its migrations once at container start, then wraps every individual test in a PostgreSQL transaction that is rolled back in afterEach — the test's writes are visible to the test's own queries (transactions are not isolated from themselves) but never committed, so no explicit TRUNCATE or fixture-teardown step is needed and tests run at full speed. Seed data needed by multiple tests in a file (a baseline workspace, a baseline plan row) is inserted once in beforeAll outside any test's transaction, and is therefore stable across the whole file's rollback-per-test tests. A test that specifically needs to verify commit-visible behavior (for example, a test of the janitor's cross-transaction delete) opts out of the wrapping transaction explicitly via a documented { noTransactionWrap: true } test option and is responsible for its own cleanup in afterEach.
Stripe testing. All Stripe interaction in integration tests runs against Stripe's official test-mode API using a dedicated test-mode secret key stored in the CI secret manager (Section 20.6), never a hand-rolled Stripe mock — Stripe's test mode already reproduces real validation and object-shape behavior more faithfully than any mock could. Two complementary techniques:
- Stripe CLI fixtures, run in CI via
stripe fixtures billing-fixtures/pro-monthly-subscription.json, which create a real test-mode customer, subscription, and payment method combination matching each of the plan tiers in Section 12.2, so tests exercise the actual object graph Stripe returns. - Stripe CLI event forwarding and replay,
stripe trigger checkout.session.completedand its siblings for every event type the webhook handler subscribes to (customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed,billing.meter_event), asserting the webhook handler (Section 14) correctly verifies the HMAC signature (Section 17), updates the workspace's entitlement row, and is idempotent when the same event id is delivered twice (Stripe's own at-least-once delivery guarantee makes this a mandatory, not optional, test).
Email testing. Resend's SDK is used against a sandboxed test API key that never sends real mail; instead, integration tests assert against Resend's returned message id and the React Email template's rendered output (rendered via @react-email/render in a unit test, not a live send) for every transactional email type: verification OTP, envelope invitation, envelope reminder, envelope completed, receipt, quota-warning, and password-reset. A separate, small suite of true end-to-end email tests runs nightly (not per-PR) against a real Resend sandbox domain and a disposable test inbox, verifying SPF/DKIM/DMARC alignment (Section 20.7) actually holds for a live-sent message, because signature alignment cannot be fully verified without real delivery.
Worker testing. apps/worker-media and apps/worker-office integration tests launch the worker's BullMQ consumer against the real Redis test container, enqueue a real job with a real (small, synthetic) fixture from the golden-file corpus, and assert on the full job lifecycle: queued → running → succeeded (or the documented failure path) as observed through the job state machine (Section 4.7), correct progress updates, correct object storage writes (using a local MinIO container as the S3-compatible target, matching Section 3.9's data-key envelope encryption exactly), and correct behavior when the sandbox's wall-clock timeout is exceeded (a deliberately slow fixture triggers running → expired).
Outbound webhook delivery testing. apps/worker-webhook's integration tests run against the real Redis test container and a locally bound test HTTP receiver (no external network call): a test triggers a job.succeeded event, enqueues its delivery, and asserts the receiver observes a request whose PDFWorks-Signature header verifies as HMAC-SHA256 over timestamp.body against the registered secret and whose timestamp falls within the documented tolerance window (Section 17); a second test has the receiver return a non-2xx status and asserts the delivery is retried on the documented schedule (Section 14.10.6), stopping at the documented final-attempt count with the delivery marked failed and surfaced in the workspace's webhook delivery log; a third test rotates the receiver's registered secret mid-test and asserts a delivery signed with either the old or the new secret verifies successfully during the documented dual-secret overlap window (Section 17), and only the new secret verifies once the overlap window is asserted (via the fake-timer control also used in Section 19.8) to have elapsed.
Retention enforcement testing. The janitor's hard-delete sweep (Section 6) is exercised directly rather than only monitored operationally: an integration test creates a job row in a terminal state with a backdated updated_at simulating two hours' elapsed time, invokes the janitor's sweep function against the real test database and a local MinIO-backed object store, and asserts the job's wrapped data key is destroyed and its object bytes are gone; a companion test creates a job still in a non-terminal state with the same backdated timestamp and asserts the janitor leaves it untouched, proving the sweep is state-aware and not purely age-based; a third test asserts an eligible row is not swept twice (idempotency) when the sweep function is invoked back-to-back, matching the concurrent-sweep safety property the operational runbook for sweep lag (Section 20.8) relies on.
Contract tests — proving the OpenAPI document matches the implementation. packages/contracts generates the public OpenAPI document from the same Zod schemas the API runtime validates against (Section 4), which structurally prevents most drift, but the generation step itself can have bugs. A dedicated contract-test suite in apps/api/__tests__/contract/:
- Loads the generated
openapi.json. - For every documented endpoint, sends a request built from the OpenAPI example values (every endpoint's Zod schema is annotated with
.openapi({ example: ... }), so an example always exists) against the real running API (in-process, via the Hono app'sfetchhandler, no network hop) and asserts the response matches the documented response schema, status code, and headers exactly. - For every documented error response (Section 14.6, Section 23.1), constructs the documented triggering condition and asserts the actual error
codeand HTTP status match what the catalogue claims. - Fails the build if any implemented route is undocumented, or any documented route is unimplemented — a route-inventory diff, not a sample check.
This suite is the safety net that makes the published SDK (packages/sdk-js) trustworthy: the SDK's types are generated from the same OpenAPI document, so a passing contract-test run is a transitive guarantee that the SDK's types match the live API.
Worked example — BullMQ worker integration test. The following demonstrates the full lifecycle assertion referenced above, run against the real Redis test container and a real MinIO-backed object store, using a small fixture from the e2e-safe golden-file corpus subset (Section 19.5):
// apps/worker-media/__tests__/integration/ocr-job.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { startTestInfrastructure } from "../../../packages/db/__tests__/integration/setup";
import { createOcrWorker } from "../../src/workers/ocr";
import { enqueueOcrJob } from "../../src/queues/ocr";
import { loadCorpusFixture } from "../../../packages/pdfcore/corpus/loader";
describe("OCR job lifecycle", () => {
let infra: Awaited<ReturnType<typeof startTestInfrastructure>>;
let worker: ReturnType<typeof createOcrWorker>;
beforeAll(async () => {
infra = await startTestInfrastructure();
worker = createOcrWorker({ redisUrl: infra.redis.getConnectionUri(), db: infra.db });
});
afterAll(async () => {
await worker.close();
await infra.pg.stop();
await infra.redis.stop();
});
it("transitions queued -> running -> succeeded and stores the extracted text layer", async () => {
const fixture = await loadCorpusFixture("scanned-image-only-mobile-03");
const job = await enqueueOcrJob(infra.db, { documentBytes: fixture.bytes, language: "eng" });
await worker.waitForJob(job.id, { timeoutMs: 30_000 });
const finished = await infra.db.query.jobs.findFirst({ where: (j, { eq }) => eq(j.id, job.id) });
expect(finished?.state).toBe("succeeded");
expect(finished?.progress).toBe(100);
expect(finished?.resultDocumentId).toBeDefined();
});
it("transitions to expired when the sandbox wall-clock timeout is exceeded", async () => {
const fixture = await loadCorpusFixture("oversized-5400-pages");
const job = await enqueueOcrJob(infra.db, {
documentBytes: fixture.bytes,
language: "eng",
testOnlyTimeoutMs: 100,
});
await worker.waitForJob(job.id, { timeoutMs: 5_000 });
const finished = await infra.db.query.jobs.findFirst({ where: (j, { eq }) => eq(j.id, job.id) });
expect(finished?.state).toBe("expired");
});
});19.5 End-to-End Testing with Playwright #
Browser matrix. Playwright 1.62.x drives the following configurations, matching the support matrix in Section 15:
| Project name | Engine | Notes |
|---|---|---|
chromium-desktop |
Chromium | Cross-origin isolation enabled; primary development target |
firefox-desktop |
Firefox | Cross-origin isolation enabled |
webkit-desktop |
WebKit | Cross-origin isolation enabled where supported; flags any WebKit-specific SharedArrayBuffer gap |
chromium-android-emulated |
Chromium (mobile viewport + touch emulation) | Represents Chrome Android |
webkit-ios-emulated |
WebKit (mobile viewport + touch emulation) | Represents iOS Safari; also runs the single-threaded fallback path explicitly (Section 19.6) |
Every project runs the full smoke subset on every PR; the full 25+ critical-path scenario suite runs nightly across all five projects and before every release across all five projects.
Critical-path scenarios (minimum set, each an independent, named Playwright test file under e2e/critical-path/):
- Guest performs a merge (2 files) and downloads the result without creating an account, and hits the 5-tasks-per-device daily cap on the 6th action with the documented upsell copy (Section 12.2).
- Guest attempts a server-side tool (OCR) and is prompted to create a free account rather than silently uploaded.
- New user signs up with email + password, completes email verification, lands on the tool grid.
- New user signs up via the "continue with account" flow after already using guest tools, and their guest-created local recent-files list is preserved (Dexie metadata survives account creation, per Section 3.7).
- Merge: 3 files, drag-reorder before merging, verify output page order.
- Split: split by fixed page ranges, verify each output file's page count and content.
- Extract pages: extract a non-contiguous page range (
1-3,7,9-11), verify output. - Organize (reorder/delete): keyboard-only reorder via arrow keys with live-region announcement verified via the accessibility tree (Section 19.6), delete a page, verify final order.
- Compress: compress a 10 MB fixture at each of the three compression presets, verify output size decreases and perceptual hash stays within the golden-file threshold (Section 19.3).
- Watermark: apply a text watermark with custom opacity and rotation, verify rendered output.
- Protect: encrypt a document with a user password, verify the immediate re-open requires the password.
- Unlock: remove a password from a fixture the user supplies the correct password for; verify the wrong-password path shows the documented error without retry lockout bypass.
- Redact: draw two redaction regions over text and an image, apply, download, and run the client-observable subset of the redaction verification pass (Section 19.7) against the downloaded file within the test.
- Fill a form: open an AcroForm fixture, fill three field types (text, checkbox, dropdown), flatten, verify flattened output is non-editable.
- Create a fillable form: place a text field and a signature field on a blank page, save, verify the resulting AcroForm structure round-trips.
- Self-sign: draw a signature on your own document (not an envelope), place it, download.
- PDF → JPG and JPG → PDF round trip, verifying both directions stay client-side (Processing Location Indicator, Section 16, asserted as
On your devicethroughout — see Section 19.6 for the CI assertion that ties the indicator to actual network activity). - Batch job: upload 12 files, run compress as a batch, verify per-file progress and a combined downloadable zip.
- An envelope end to end with three signers: sender creates an envelope from a document, places fields for three signers in sequential order, sends it; each signer (driven as three separate browser contexts with no shared session) receives the invitation, verifies email via OTP, accepts the disclosure, completes their fields, signs; sender's dashboard shows
envelope.completed; the Certificate of Completion downloads and its QR/verification URL resolves toMATCHagainstGET /verify/{envelopeId}(Section 10). - Signer declines: one of three signers declines mid-envelope; sender sees
signer.declinedand the envelope halts per Section 10's routing rules. - Upgrade: Free user upgrades to Pro via Stripe Checkout redirect (test-mode), returns to the app, and the daily server-side task cap is immediately lifted without re-login.
- Downgrade: Pro user cancels; UI shows access-persists-to-period-end messaging (Section 12.2); a simulated period-end webhook (via Stripe CLI trigger, Section 19.4) flips the account to Free limits and the job-history-over-7-days-unreadable state is verified.
- API key lifecycle: Team owner creates an API key with a scope and an IP allowlist, uses it successfully from an allowed IP (simulated via a header the test environment trusts only in test mode), is rejected from a disallowed IP, revokes the key, and confirms the revoked key is immediately rejected.
- Account deletion: user requests deletion, confirms via the documented re-authentication step, and on completion: profile data is gone, owned documents are hard-deleted, and — if the account has any completed envelopes — the audit trail persists with anonymized signer fields exactly as Section 6 specifies, verified by an internal-API assertion the test is permitted to make against the seeded test database.
- MFA enrollment and enforcement: user enables TOTP MFA, logs out, logs back in requiring a TOTP code; separately, a Team owner enables workspace-wide MFA enforcement and a non-MFA workspace member is required to enroll on next login before reaching the tool grid.
- Offline/degraded mode: with the service worker installed, the test simulates a network cut (Playwright's
context.setOffline(true)) and verifies client-side tools remain fully functional while server-side tool cards show the documented degraded-state messaging instead of a silent failure. - Accessibility keyboard-only pass on the annotation canvas: place a rectangle annotation using only the keyboard-driven placement mode (Section 16 quality bar), verify focus order and live-region announcements.
- Server-side fallback offered and accepted: force a client-side job (compress, using an oversized fixture) into a simulated device-memory failure via a test-only trigger, verify the UI displays the "Finish this on our servers instead" prompt with the Processing Location Indicator's
On our serverscopy per the opt-in server-fallback rule (Section 6.3), accept it with an explicit click, and verify the job completes server-side through the standard job state machine (queued → running → succeeded). - Server-side fallback declined: repeat the same simulated device-memory failure, decline the prompt, and verify the job transitions to
failedwith the documented device-side failure message, no job is created server-side, and a network-request listener attached for the duration of the test observes zero outbound request bodies carrying file bytes. - Server-side fallback never offered on excluded tools: for each of Redact, Protect, Unlock, and Self-sign in turn, force the same simulated device-memory failure and assert the UI never displays the server-fallback prompt, showing instead the documented device-side remediation message; a network-request listener attached for the full duration of each of the four sub-cases confirms zero request bodies leave the browser during the failure path, proving the exclusion holds at the network layer and not only in the UI copy.
- Office and HTML conversion round trip: convert a
.docxfixture to PDF, verify layout fidelity via perceptual hash against a stored baseline, then convert the resulting PDF back to.docxand verify the extracted text round-trips, exercising the full server-side conversion path through the actual UI. - Edit text and images: open a digital-native fixture, edit an existing text run's content and reflow, replace an embedded image, save, and verify both that the edited text extracts correctly and that the replaced image's content hash differs from the original while its position and dimensions are unchanged.
- Team workspace collaboration: a Team owner invites a second member by email, the invited member accepts and appears in the workspace's member list, the owner shares a tool preset as a workspace template, and the invited member sees and successfully uses that shared template.
(33 scenarios are specified above, exceeding the 25-scenario floor; each is an independently runnable, independently reportable Playwright test file, not a single mega-test, so a failure pinpoints exactly one user journey.)
Configuration. playwright.config.ts at the repository root defines the five projects from the browser matrix above, the smoke-vs-full test-tagging convention, and the sharding/reporting setup used both locally and in CI:
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
expect: { timeout: 5_000, toHaveScreenshot: { maxDiffPixelRatio: 0.002, threshold: 0.2 } },
retries: 0,
reporter: [["html", { outputFolder: "playwright-report" }], ["junit", { outputFile: "results.xml" }]],
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
trace: "retain-on-failure",
video: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium-desktop", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox-desktop", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit-desktop", use: { ...devices["Desktop Safari"] } },
{ name: "chromium-android-emulated", use: { ...devices["Pixel 8"] } },
{ name: "webkit-ios-emulated", use: { ...devices["iPhone 15"] } },
],
grep: process.env.E2E_SMOKE_ONLY ? /@smoke/ : undefined,
});Every critical-path scenario file tags itself test.describe("guest merge @smoke", ...) when it belongs to the PR-blocking smoke subset (Section below), so E2E_SMOKE_ONLY=1 playwright test --project=chromium-desktop --shard=1/6 is the exact command CI runs for the fast gate, and an untagged full run with no grep filter is what the nightly job runs.
Test data management. E2E tests never touch production or staging data. Each Playwright worker gets a dedicated, freshly seeded logical test environment: the E2E CI job spins up the full stack (web, API, workers, PostgreSQL, Redis, MinIO-as-S3) via the same Docker Compose file used for local preview (Section 20.1), seeds a fixed set of test accounts and plan states via a pnpm seed:e2e script, and uses a curated 15-fixture subset of the golden-file corpus (Section 19.3) tagged e2e-safe (small, fast-to-process, no known-malicious samples) rather than the full 229-fixture corpus, to keep run time bounded. Test accounts use a reserved email domain (@e2e.pdfworks.io, never resolvable to real mail) intercepted by a test-mode mail capture endpoint so OTP and invitation emails can be read back programmatically instead of requiring a real inbox.
Visual regression. A subset of E2E scenarios (the tool grid, each tool's default empty state, the pricing page, the envelope signer view, and the dashboard) additionally capture a full-page screenshot via Playwright's toHaveScreenshot(), compared against a committed baseline using pixel-diff with a 0.2% maximum differing-pixel ratio and an anti-aliasing-aware comparison (maxDiffPixelRatio: 0.002, threshold: 0.2 per-pixel color-distance tolerance in Playwright's config). Baselines are platform-pinned (Linux/Chromium, matching the CI runner) because font rasterization differs across operating systems; a developer never regenerates a baseline from a local macOS or Windows machine. A baseline update requires pnpm test:e2e:update-snapshots, run only in a dedicated CI job, with the resulting diff attached to the PR as a visual artifact for human review — the same reviewed-change discipline as the golden-file rehash process (Section 19.3).
Flake policy and quarantine. A test that fails intermittently (fails, then passes on an immediate re-run with no code change) is not silently retried into green — Playwright's retries is set to 0 in CI for this exact reason, because auto-retry hides real intermittency instead of surfacing it. Instead:
- A flagged-flaky test is moved within 24 hours of its first observed flake into
e2e/quarantine/, tagged@quarantinedwith a linked tracking issue, and excluded from the required-for-merge smoke subset (though it keeps running nightly, unblocking, so its flake rate is measured, not hidden). - A quarantined test has a 14-day SLA to be fixed or deleted; a test quarantined longer than 14 days is escalated to the engineering lead in the weekly quality review (Section 19.9) and is either fixed that week or explicitly deleted with a comment explaining why the scenario is no longer worth automated coverage.
- No more than 5% of the active (non-quarantined) E2E suite may be quarantined at any time; exceeding that threshold blocks all merges to
apps/webandapps/apiuntil the quarantine backlog is worked down, treating systemic flakiness as a release-blocking incident, not background noise.
Run time budgets and sharding. The PR-blocking smoke subset (critical-path scenarios 3, 5, 9, 13, 19, 21, 23, 30, and the visual-regression baseline set — chosen as the highest-signal-per-minute subset, with scenario 30 included despite the general preference for a lean smoke set because a regression there is treated as equivalent to a security incident, Section 6.3) is capped at 8 minutes wall-clock by sharding across 6 parallel Playwright workers (--shard=1/6 through 6/6) on chromium-desktop only. The full nightly run (all 33 critical-path scenarios × 5 browser projects) is capped at 35 minutes wall-clock using 16 parallel shards. A shard that exceeds twice its historical median run time fails fast and is treated as a hang, not a slow pass, alerting the on-call engineer (Section 20.8) rather than blocking the pipeline indefinitely.
19.6 Specialist Testing #
Accessibility. Automated axe-core scans run in CI against every route in apps/web (including at least one instance of every tool page, the pricing page, the dashboard, and the signer portal) as part of the Playwright E2E smoke subset — each E2E test additionally asserts zero axe-core violations of impact serious or critical on the page under test as a final step, and zero violations of any impact level on the annotation canvas, page-organizer, and signer-flow pages specifically (WCAG 2.2 AA is non-negotiable per Section 10's quality bar, and these three surfaces are its highest-risk custom-interaction components). In addition to automation, a manual screen-reader pass — NVDA on Windows/Chrome and VoiceOver on macOS/Safari — is run by a QA engineer once per release against a fixed script covering: sign-up, every tool category's primary flow, the batch flow, the full three-signer envelope flow as both sender and signer, and account settings including MFA enrollment. Findings are logged as accessibility defects with the same severity taxonomy as any other bug (Section 19.9) — an accessibility regression of severity Critical or High blocks release sign-off exactly as a functional regression would.
Security testing.
- SAST (static application security testing): Semgrep runs on every PR against the full monorepo using a ruleset combining the OWASP Top 10 pack, a TypeScript/React-specific pack, and a small internal ruleset that specifically flags any direct
Content-Typetrust (the magic-byte-sniffing rule from Section 17 has a corresponding Semgrep rule that fails a PR introducing a new upload path that skips it) and any use ofdangerouslySetInnerHTMLwithout a co-locatedDOMPurify.sanitizecall. - Dependency scanning:
pnpm auditplus GitHub Dependabot alerts run continuously; any dependency with a known Critical-severity CVE blocks merge, High-severity CVEs generate an issue with a 7-day remediation SLA, and license scanning (vialicense-checker) flags any newly introduced copyleft-licensed dependency (GPL, AGPL) for manual legal review before it can be merged, since the product ships proprietary and open-source components side by side. - Container scanning: every image built in Section 20.2 is scanned with Trivy before push to the registry; a Critical vulnerability in the final image layer blocks the push.
- DAST (dynamic application security testing): OWASP ZAP runs a full active scan against the staging environment (Section 20.1) weekly, targeting the public API and the marketing/app web surface, using an authenticated scan profile that covers both the Free-tier and API-key auth paths; findings feed the same defect-severity process as any other bug.
- Annual penetration test: a third-party firm performs a full black-box and gray-box penetration test annually and after any architecturally significant change (a new auth mechanism, a new payment flow); the report and remediation are tracked to closure before the next release cycle continues, and a summary is published on the trust/security page referenced from the marketing site (Section 21).
Performance and load testing with k6. A dedicated perf/ suite defines the following scenarios, each with an explicit pass threshold checked automatically by k6's thresholds config (a failed threshold fails the CI job, not just a warning in a log):
| Scenario | Load profile | Pass threshold |
|---|---|---|
| API read-heavy (list documents, list jobs, get job status) | Ramp to 200 virtual users over 2 minutes, hold 5 minutes | p95 latency < 300 ms; error rate < 0.1% |
| API write-heavy (create job, upload URL issuance) | Ramp to 100 virtual users over 2 minutes, hold 5 minutes | p95 latency < 800 ms; error rate < 0.5% |
| Job submission burst (simulating a batch upload) | 500 job-creation requests within 10 seconds | 100% accepted or correctly 429/402 rejected per entitlement rules (Section 12); zero 5xx |
| Webhook delivery throughput | 1,000 webhook events enqueued | 99% delivered within 30 seconds of the triggering event; retry/backoff behavior matches Section 14.10.6's documented policy |
| Sustained worker throughput (OCR queue) | Fixed arrival rate of 20 OCR jobs/minute for 30 minutes | Queue depth does not grow unbounded (steady-state or draining); p95 job completion time < 90 seconds for a 10-page fixture |
| Soak (long-duration stability) | 50 virtual users, mixed read/write, for 4 hours | No memory growth beyond 15% of baseline in the API process; zero connection-pool exhaustion errors |
The full k6 suite runs weekly against staging and is a mandatory pre-release gate (Section 19.10); the read-heavy and write-heavy scenarios at a reduced 60-second duration run nightly against staging as an early-warning smoke check. The sustained-worker-throughput scenario is expressed as:
// perf/scenarios/ocr-sustained-throughput.js
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
scenarios: {
ocr_arrival: {
executor: "constant-arrival-rate",
rate: 20,
timeUnit: "1m",
duration: "30m",
preAllocatedVUs: 30,
maxVUs: 60,
},
},
thresholds: {
"http_req_duration{stage:submit}": ["p(95)<800"],
"http_req_duration{stage:poll_to_completion}": ["p(95)<90000"],
http_req_failed: ["rate<0.01"],
},
};
export default function () {
const submit = http.post(
`${__ENV.API_BASE_URL}/v1/documents/ocr`,
JSON.stringify({ documentId: __ENV.FIXTURE_DOCUMENT_ID, language: "eng" }),
{
headers: {
Authorization: `Bearer ${__ENV.API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `${__VU}-${__ITER}-${Date.now()}`,
},
tags: { stage: "submit" },
},
);
check(submit, { "job accepted": (r) => r.status === 202 });
const jobId = submit.json("id");
let attempts = 0;
let finished = false;
const start = Date.now();
while (attempts < 45 && !finished) {
const poll = http.get(`${__ENV.API_BASE_URL}/v1/jobs/${jobId}`, {
headers: { Authorization: `Bearer ${__ENV.API_KEY}` },
tags: { stage: "poll" },
});
finished = ["succeeded", "failed", "expired"].includes(poll.json("state"));
attempts += 1;
sleep(2);
}
check(null, { "completed within budget": () => finished && Date.now() - start < 90_000 });
}Accessibility automation, wired into every E2E run. Each Playwright test file imports a shared helper that runs @axe-core/playwright against the page under test at the point the scenario reaches its primary interactive state, not only at initial load:
// e2e/support/assert-accessible.ts
import { AxeBuilder } from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
export async function assertAccessible(page: Page, opts: { strict?: boolean } = {}) {
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag22aa"]).analyze();
const failThreshold = opts.strict ? [] : ["moderate", "minor"];
const blocking = results.violations.filter((v) => !failThreshold.includes(v.impact ?? ""));
expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]);
}opts.strict is passed as true for the annotation canvas, the page-organizer, and every step of the signer flow (Section 19.8), matching the zero-tolerance bar stated above; every other route accepts serious/critical as the blocking threshold with moderate/minor findings logged but non-blocking, tracked instead as standard-severity defects (Section 19.9).
Cross-browser compatibility, including the non-isolated single-threaded fallback. Beyond the Playwright browser matrix (Section 19.5), a dedicated compatibility suite explicitly forces the single-threaded WASM fallback path (Section 3.6) by launching Chromium with cross-origin isolation headers stripped (simulating an embedding context or an older browser that cannot satisfy COEP: credentialless) and re-runs the full critical-path scenario list against it, asserting: every operation still completes correctly (never wrong, per Section 3.6's guarantee), and the UI's Processing Location Indicator and any performance-related messaging correctly reflects the slower path without claiming multi-threaded speed. This suite runs nightly, not per-PR, given its run-time cost, but any failure is treated as release-blocking.
PWA and offline testing. A dedicated suite installs the PWA (via Playwright's service-worker installation APIs), then: verifies the app shell and all client-side tool routes load from the service worker cache with the network fully disabled; verifies a client-side job started while online and continued after a mid-job network drop completes successfully (proving OPFS-resident processing genuinely never depends on the network); verifies server-side tool routes show the documented offline-unavailable state; and verifies the service worker's update flow (a new deployed version triggers the documented "refresh to update" prompt rather than silently swapping code under an open tab, which for WASM-heavy pages risks a broken in-flight worker pool).
19.7 The Redaction Test Suite #
A redaction that merely looks correct on screen is a defect, not a success — the failure mode this suite exists to prevent is a document that appears to redact a Social Security number while the digits remain fully recoverable by any user who runs strings against the file, opens it in a different PDF viewer, inspects its tagged structure tree with a screen reader, or looks at its thumbnail strip. Every one of the following is an automated, independently reportable assertion, run against every applicable fixture in the golden-file corpus (Section 19.3) plus a dedicated set of 25 redaction-specific fixtures authored to contain deliberately awkward redaction targets: text split across a TJ array at odd character boundaries, text inside a Type 3 font, text inside a rotated or transparency-grouped content stream, text that is only present inside an optional-content layer, text duplicated into the tagged structure tree's /ActualText or /Alt entries, a page thumbnail (/Thumb) pre-rendered before redaction, application-private data in a page's /PieceInfo dictionary, an image XObject shared across multiple pages, and an annotation carrying its own cached appearance stream over the redacted region.
Every assertion below runs against the raw, fully decompressed object graph of the output file — not merely against extracted text — because a bypass vector that survives only in an object never touched by a naive text-extraction pass is exactly the class of defect this suite exists to catch. The set of targets asserted below is kept item-for-item identical to the set the redaction mechanism actually removes (Section 9.1): a target added to what gets removed without a matching assertion here, or an assertion added here without a matching removal step, is itself treated as a specification defect and fails a dedicated manifest-parity check that diffs this table's target list against the mechanism's own step list at CI time.
| # | Assertion | Method |
|---|---|---|
| 1 | Text extraction contains none of the redacted strings. | Run pdfcore's text extraction (the same extraction path used by the "extract text" internals) against the full output document; assert none of the exact redacted strings, and none of their normalized-whitespace or Unicode-NFC-normalized variants, appear anywhere in the extracted text of any page. |
| 2 | Raw file bytes, after decompressing every stream, contain none of the redacted strings. | Parse the output PDF's full object graph, decompress every FlateDecode/LZWDecode/other filtered stream (content streams, embedded fonts, XMP metadata, object streams), and run a raw byte-level substring search for the redacted strings (as both literal text and as their PDF hex-string/octal-escaped encodings) across the fully decompressed byte set. This is the assertion that specifically catches an incremental-save leak or a content stream that was clipped but not deleted. |
| 3 | The tagged structure tree contains none of the redacted strings. | For every fixture with a /StructTree present, walk every structure element whose marked-content range intersects the redaction region and assert its /ActualText and /Alt entries contain none of the redacted strings — a screen reader or an accessibility-tree inspector reads this shadow text independently of both the visible content stream and a naive text-extraction pass, so it is a distinct bypass vector requiring its own check, not a corollary of assertion 1. |
| 4 | Page thumbnails do not depict redacted content. | For every page carrying a /Thumb entry, re-render the thumbnail bitmap and assert its perceptual hash reflects the post-redaction page (fill color present at the redaction location) and not the pre-redaction original; a thumbnail is a separately cached rendering and a redaction that only rewrites the main page content stream while leaving a stale /Thumb intact ships the original content through the thumbnail strip alone. |
| 5 | Page-piece dictionaries carry no residual data. | Assert the output's /PieceInfo dictionary on an affected page either does not exist, or — when the fixture's manifest declares a legitimate non-redaction-related /PieceInfo use — is asserted to contain none of the redacted strings and no reference to pre-redaction image or annotation data; authoring tools use /PieceInfo for private, application-specific per-page state that a purely content-stream-focused redaction pass can miss entirely. |
| 6 | Embedded font programs contain none of the removed glyphs. | For every font subset embedded in the output, extract its glyph list (via the font's cmap/CFF/glyf table) and assert no glyph corresponding to a character from the redacted strings remains mapped, proving font subsetting (Section 9.1) actually ran and actually removed the glyphs, not merely hid them from the current content stream. |
| 7 | Reused image XObjects are redacted everywhere they are referenced, not only on the triggering page. | For a fixture in which the same image XObject is referenced from more than one page's /Resources dictionary (a scanned letterhead reused across every page, for example), redact the region on one page and assert every other page referencing the same object also reflects the redaction — either because the shared object itself was patched, or because the tool forked a page-specific copy for the redacted page and left other pages' references correctly pointing at content that was never claimed to be redacted. A page whose reference silently continued resolving to unredacted image bytes after being marked redacted fails this assertion even if assertions 1–2 pass for the page the user directly interacted with. |
| 8 | Annotation appearance streams carry no redacted content, and no orphaned appearance stream survives. | For every annotation whose bounding box intersects a redaction region, assert both that the annotation was deleted from the page's /Annots array (Section 9.1) and, independently, that no /AP (appearance stream) XObject referencing the redacted region's content survives anywhere in the object graph as an orphaned but still-parseable object — an annotation unlinked from /Annots while its cached appearance stream remains reachable is a bypass a viewer can still render. |
| 9 | Exactly one cross-reference section, with no /Prev chain. |
Parse the output file's trailer chain and assert there is exactly one xref table or XRefStm, with no /Prev entry anywhere in the trailer chain — proving the file was fully rewritten and not incrementally saved (Section 9.1 treats an incremental save as a P0 defect for this specific tool, precisely because a /Prev chain leaves the pre-redaction bytes reachable in the file). |
| 10 | Metadata and attachments are clean. | Assert the output's /Info dictionary and XMP packet contain no reference to the redacted content (author/title/subject fields are checked against the redacted strings the same way as assertion 1), and assert zero embedded file specifications (/EmbeddedFiles) remain unless the fixture's test explicitly retains a non-redacted attachment, in which case that specific attachment's hash is asserted unchanged and all others are asserted absent. |
| 11 | Rendering the region produces the fill color. | Render the redacted page region at 300 DPI and assert every pixel within the redaction rectangle's bounds (inset by 1px to tolerate anti-aliasing at the exact boundary) matches the configured fill color (default opaque black, #000000) within a color-distance tolerance of ΔE < 2.0 (CIEDE2000), and assert the perceptual hash of the surrounding, non-redacted region of the same page is unchanged from the pre-redaction baseline within the corpus-wide threshold (Section 19.3), proving the redaction did not collaterally damage adjacent content. |
| 12 | A round-trip through a third-party parser agrees. | Open the output file with pikepdf (an independent, non-pdfcore-derived parser already in the dependency set per the version table) and repeat assertion 1 (text extraction), assertion 3 (structure-tree text), and assertion 9 (single xref section, no /Prev) using pikepdf's own extraction, structure-tree, and trailer-parsing logic; a disagreement between pdfcore's self-check and pikepdf's independent check fails the test even if pdfcore alone reported success, because a bug shared between the redaction step and the redaction's own verification step would otherwise be invisible to a single-parser test. |
| 13 | The Redaction Verification Report itself is correct. | Assert the per-page counts in the generated report (removed glyph runs, removed images, removed annotations, removed structure-tree entries, regenerated thumbnails, and cleaned /PieceInfo entries) exactly match ground truth computed independently by diffing the pre- and post-redaction object graphs, and assert the report's overall pass/fail flag matches the actual outcome of assertions 1–12 — the report is user-facing evidence and must never claim success when any underlying check fails. |
Any single assertion failure discards the output and surfaces the documented tool-level failure (per Section 9.1's rule that a failed verification never ships a partially redacted file) — the redaction test suite additionally includes a dedicated negative test that intentionally disables font subsetting (assertion 6's corresponding removal step) in a test-only build flag and asserts the pipeline correctly refuses to return success, plus a companion negative test that disables the structure-tree scrub (assertion 3's corresponding removal step) and asserts the same refusal, proving the discard-on-failure path itself is exercised for more than one bypass class and not merely assumed to work.
19.8 The Signature Audit Test Suite #
The e-signature system's entire evidentiary value rests on the hash chain and the certificate being genuinely tamper-evident (Section 10), so this suite treats the audit trail adversarially rather than only testing the happy path.
Hash-chain verification.
- For a completed envelope with N events, assert
event[i].previousHash === SHA256(event[i-1].previousHash + event[i-1].payload)for everyifrom 1 to N, and assertevent[0].previousHashequals the documented genesis value (SHA-256 of the envelope id itself, establishing a deterministic chain root with no arbitrary seed). - Assert the chain root hash recorded on the Certificate of Completion equals
SHA256(event[N-1].previousHash + event[N-1].payload)— the certificate must reference the actual terminal hash, not a recomputation performed at certificate-generation time from potentially-stale data. - Assert every event's stored
SHA-256(document bytes at that moment)matches an independently recomputed hash of the actual document blob retrievable at that point in the envelope's lifecycle (for in-progress states, from the working copy; for the completed state, from the final flattened PDF).
Tamper detection — modify one event and prove detection.
- A dedicated test directly mutates a single stored audit event's payload in the test database (bypassing the application layer entirely, simulating a compromised database rather than a compromised API) — for example, changing a
signer.viewedevent's recorded IP address — and asserts that recomputing the hash chain from that point forward produces a hash mismatch at the mutated event and every event after it, and thatGET /verify/{envelopeId}(Section 10) reportsALTERED, neverMATCHorUNKNOWN, for the corresponding document hash check when the mutation also touched a document-affecting field. - A second variant mutates only a non-document field (the recorded user agent string) to prove the detection mechanism catches audit-trail tampering even when the underlying document bytes are untouched — the chain hash still breaks, because the chain covers the full event payload, not only the document hash.
- A third variant tests the negative: an unmodified envelope's chain and document hash both verify as
MATCH, confirmed on every nightly run against a rotating sample of real (test-account) completed envelopes as a canary against silent chain-computation drift.
Hash-chain anchoring. A daily external anchor published over the audit events (Section 10.5) adds a second, independent layer of tamper evidence on top of the per-envelope hash chain above; this suite verifies the anchor lifecycle on its own, distinct from the per-envelope chain tests:
- Anchor generation. Using the same fake-timer control as the expiry and reminder tests below, a test advances simulated time across a UTC day boundary and asserts exactly one anchor record is generated for that day, covering every audit event across every envelope whose
created_atfalls within it (not scoped to a single envelope), and that the anchor value is deterministic — independently recomputing it in the test from the same event set produces an identical value. - Anchor publication. Asserts the generated anchor is submitted to the configured external publication target and that the resulting publication receipt is stored against the anchor record. A test double stands in for the external publication target in this per-PR suite so third-party latency or availability never makes this test flaky; a separate nightly canary exercises the real external target on a schedule outside the per-PR suite, mirroring the real-sandbox-domain pattern already used for email delivery (Section 19.4).
- Event-to-anchor verification. For a specific audit event, asserts that verifying the event's stored hash against its day's published anchor succeeds, and that
GET /verify/{envelopeId}surfaces this anchor coverage as part of aMATCHresponse, so a caller can confirm an event was independently anchored, not merely chained within the envelope's own records. - Publication failure for a day. A dedicated test forces the external publication call to fail (simulated network error and simulated non-2xx response, as two sub-cases) for a given day's anchor attempt and asserts: that day's audit events remain fully valid and chain-verifiable via the per-envelope hash chain regardless of anchor status (anchoring is a defense-in-depth addition, never a precondition for the chain's own tamper-evidence); the failed publication attempt is retried on the next scheduled run rather than silently dropped; the anchor record for that day is marked in a distinguishable pending-publication state rather than falsely reporting as published; and no envelope's completion or certificate generation is blocked by a pending anchor.
Certificate correctness. For a completed envelope, assert the generated Certificate of Completion page contains: the correct envelope id, every signer's verified identity (name and email as captured at OTP verification), every event with its correct UTC timestamp, the final document hash matching the actual flattened output, the chain root hash matching the hash-chain verification above, and a QR code that, when decoded (via a QR-decoding library in the test), resolves to exactly https://sign.pdfworks.io/verify/{envelopeId} with no encoding error.
Verification-endpoint outcomes. GET /verify/{envelopeId} (public, unauthenticated, rate-limited per Section 14.9) is tested for all three documented outcomes plus its error paths:
| Input | Expected outcome |
|---|---|
| The genuine completed document uploaded, or its hash supplied directly | MATCH |
| A completed document with a single byte flipped, uploaded | ALTERED |
| A hash that does not correspond to any known envelope | UNKNOWN |
| An envelope id that does not exist | 404 not_found_error per the standard error envelope (Section 14.6) |
| A request exceeding the endpoint's rate limit | 429 rate_limit_error with the standard rate-limit headers (Section 14.9) |
| A malformed or non-PDF upload | 422 processing_error with a specific code distinguishing "not a PDF" from "hash mismatch" |
Expiry and reminder timing. Using Vitest's fake-timer control (vi.useFakeTimers()) inside the worker integration harness (Section 19.4) rather than waiting real days, a test advances simulated time and asserts: a reminder job fires at day 3, day 7, and day 12 (the default cadence from Section 10) with no reminder sent to a signer who already signed; the envelope transitions to envelope.expired at exactly day 14 with no further reminders after expiry; and a sender-configured non-default expiry (e.g., 21 days) correctly shifts the reminder schedule proportionally rather than using the hardcoded default cadence unconditionally.
Full accessibility pass on the signer flow. In addition to the automated axe-core checks already applied to every route (Section 19.6), the signer flow specifically — because signers are frequently first-time, unauthenticated, and potentially using assistive technology on an unfamiliar device — receives its own manual NVDA and VoiceOver script covering: receiving and opening the invitation email, the OTP verification step, reading and accepting the disclosure, navigating between multiple fields using only the keyboard, completing a signature via the "type" method (the most screen-reader-accessible of the three signature methods) and confirming the "draw" method has an accessible keyboard/type-based alternative rather than being a pointer-only dead end, and receiving confirmation of successful completion. Any finding here is treated as release-blocking regardless of severity, given the signer is frequently outside the product's own account system and cannot be assumed to have any familiarity with the product to work around a barrier.
19.9 Manual QA #
Release checklist. Every release candidate (Section 20.5) is accompanied by a checklist executed by a QA engineer, tracked as a checklist item in the release's tracking issue, covering: full automated suite green (Section 19.10), the k6 weekly load results reviewed if the release falls within a weekly cycle, at least one full manual screen-reader pass completed within the last 7 days (reused across releases within that window, not re-run for every release, unless the release touches an assistive-technology-relevant surface), the redaction and signature audit suites (Sections 19.7, 19.8) green on the release candidate specifically (not merely on main at some earlier point), the changelog drafted and reviewed, and a rollback plan (Section 20.5) confirmed viable for this specific release's changes (a release containing a forward-only, non-backward-compatible migration requires an explicit sign-off exception, since Section 20.5's default rule requires migrations to be backward compatible for one release).
Exploratory charters. Scripted testing (automated and the release checklist above) finds what it was written to find; exploratory sessions find what nobody thought to script. Each release cycle, the QA engineer runs at least two time-boxed (60–90 minute) exploratory charters chosen from a rotating pool, each charter a short mission statement rather than a step list, for example: "Explore the batch tool's behavior when files of wildly different sizes and types (a 3-page text PDF and a 4,800-page scanned PDF) are mixed in one batch — look for progress-reporting inconsistencies, partial-failure handling, and download-bundle correctness." Findings are logged as defects using the same severity taxonomy below regardless of whether they came from a script or a charter.
Bug severity and priority definitions.
| Severity | Definition | Example |
|---|---|---|
| Critical (S1) | Data loss, security exposure, a redaction or signature-integrity failure, or a total outage of a core flow | Redaction verification report shows pass when text is recoverable |
| High (S2) | A core tool is broken or produces wrong output for a common input; no workaround | Compress corrupts output for files above 100 MB |
| Medium (S3) | A feature is broken or degraded for an uncommon input, or has a workaround | Watermark rotation off by a few degrees on rotated pages |
| Low (S4) | Cosmetic, or affects an edge case with negligible customer impact | Minor spacing inconsistency on the pricing page at an unusual viewport width |
| Priority | Definition |
|---|---|
| P0 | Fix immediately; blocks the current release and may warrant an out-of-band hotfix to production |
| P1 | Fix before the next release |
| P2 | Fix within the current quarter |
| P3 | Backlog; fix opportunistically |
Severity and priority are set independently (a Critical-severity bug affecting a feature used by 0.1% of accounts might still be P1, not P0, if a mitigation is already deployed) but a Critical/S1 defect is never lower than P1, and any S1 found on the redaction or signature systems specifically is always P0 given Section 19.1's stated cost asymmetry for those areas.
Triage process. New defects are triaged within one business day by the on-call engineer (Section 20.8) plus the QA lead: severity and priority are assigned or corrected, a suspected owning area (Sections 7–14 of this specification) is tagged, and a P0 is immediately escalated with a page (Section 20.8) rather than waiting for the next standup.
Release sign-off criteria. A release is authorized to deploy to production only when: every item on the release checklist is complete, zero open P0 defects exist against the release candidate, zero open S1 defects exist regardless of priority label, the quarantined-test ratio is within the 5% ceiling (Section 19.5), and the QA lead has recorded an explicit sign-off comment on the release tracking issue — sign-off is a named, accountable action, never an implicit "nobody objected."
19.10 Quality Gates in CI #
The following checks run, in this exact order, as required GitHub Actions status checks (Section 20.5) before a PR may merge to main; a failure at any step halts the pipeline and the remaining steps do not run, so the fastest, cheapest checks are ordered first to fail fast and save CI minutes:
- Lint and format — ESLint (using the shared
packages/config/eslintpreset) and Prettier check, plusrufffor the Python worker, across changed files. - Type check —
tsc --noEmitper package via Turborepo's task graph (only packages affected by the change, plus their dependents, are type-checked, using Turborepo's cache). - Unit tests — the full Vitest suite plus
pytestforapps/worker-office, with coverage thresholds enforced per package (Section 19.2); Turborepo caches and skips packages with no relevant changes. - Contract tests — the OpenAPI-vs-implementation suite (Section 19.4).
- Golden-file corpus (scoped) — for a PR touching
packages/pdfcore,apps/worker-media, orapps/worker-office, the relevant tool assertions run against the full 229-fixture corpus (Section 19.3); for any other PR, this step is skipped entirely (Turborepo dependency-graph-aware skip). - SAST and dependency scan — Semgrep and
pnpm audit/Dependabot check (Section 19.6). - Integration tests — Testcontainers-backed PostgreSQL/Redis/Stripe-test-mode suite (Section 19.4).
- Build — every app and package builds successfully, including the
pdfcoreWASM artifact rebuild if its sources changed (Section 20.2). - Container scan — Trivy scan of any newly built image (Section 19.6), only when a Dockerfile-relevant path changed.
- E2E smoke subset — the 8-minute-budgeted Playwright smoke suite on
chromium-desktop(Section 19.5). - Accessibility gate — zero
serious/criticalaxe-core violations across the smoke subset's pages (Section 19.6), evaluated as part of step 10 but reported and gated as a distinct required check so an accessibility regression cannot be merged with a generic "E2E failed" label that obscures the cause.
A separate, non-blocking pipeline runs nightly against main and covers what would be too slow to run per-PR: the full 33-scenario × 5-browser Playwright matrix, the full k6 suite, the single-threaded-fallback compatibility suite, and the full DAST scan; failures here open a tracked issue and page the on-call engineer for anything S1/P0 (Section 20.8) but do not block in-flight PRs, since by the time a nightly failure is discovered the offending PR has typically already merged and the fix is a forward fix, not a merge block.
Before deploy (distinct from before merge — merge to main and deploy to production are decoupled, Section 20.5), an additional gate runs against the release candidate build specifically: the redaction and signature audit suites (Sections 19.7, 19.8) at full scope, the release checklist (Section 19.9), and a smoke test against the actual staging deployment (not just the CI container network) confirming the built artifact — not merely the source — behaves correctly end to end.
19.11 Coverage Verification Across Sections 6–14 #
Walking the full tool list (Sections 7 through 9), the e-signature system (Section 10), and the public endpoint catalog (Section 14) against the testing layers described above confirms every tool and every documented endpoint has an explicit test approach. Five gaps were found during this walk and are closed as follows:
- Office and HTML conversion (PDF → DOCX/XLSX/PPTX, DOCX/XLSX/PPTX → PDF, HTML → PDF, PDF → HTML) had golden-file corpus coverage (Section 19.3) and worker-lifecycle integration coverage (Section 19.4) but no critical-path E2E scenario exercising the actual UI end to end. Closed by critical-path scenario 31 (Section 19.5).
- Edit text and images, a client-side tool, had no assertion distinct from the general page-operation tools. Closed by critical-path scenario 32 (Section 19.5).
- Outbound webhook delivery (this product's own webhooks to customer endpoints, as opposed to Stripe's inbound webhooks to this product) had only performance coverage (the k6 throughput scenario, Section 19.6) and no functional test of signing, verification, or the retry schedule. Closed by the outbound webhook delivery integration test (Section 19.4).
- Retention enforcement (Section 6) had operational monitoring (the storage-shredding-lag runbook, Section 20.8) but no test proving the janitor's sweep actually deletes on schedule and is state-aware. Closed by the retention enforcement integration test (Section 19.4).
- Team workspace collaboration (seats, shared templates, shared audit log — Section 11) had entitlement-boundary coverage (Section 19.2) but no scenario exercising the collaborative surface itself. Closed by critical-path scenario 33 (Section 19.5).
Every other tool and endpoint category is covered by the layers already described: page operations, compression, and image conversion by the golden-file corpus and critical-path scenarios 5–9 and 17; protect/unlock by scenarios 11–12; forms by scenarios 14–15; self-sign by scenario 16; the server-side fallback boundary by scenarios 28–30; the full envelope lifecycle by scenarios 19–20 and Section 19.8 in full; OCR by the worked worker-integration example (Section 19.4) and the golden-file corpus's scanned-fixture categories; documents, jobs, batches, API keys, and account endpoints by the contract-test suite (Section 19.4) and critical-path scenarios 1, 18, and 23–26; and billing and quota endpoints by Section 19.2's entitlement unit tests together with scenarios 21–22.
20. Infrastructure, Deployment & Operations #
20.1 Environments #
Four environments exist, strictly ordered by how close they sit to real customer traffic:
| Environment | Purpose | Infrastructure | Data policy |
|---|---|---|---|
| Local | Individual development | Docker Compose (Section 20.3) running PostgreSQL, Redis, MinIO (S3-compatible), and every app/service; pnpm dev runs the Next.js and Hono processes natively against those containers for fast reload |
Synthetic seed data only (pnpm seed:dev), regenerated from a fixed script; a developer's local database is disposable and never backed up |
| Preview-per-PR | Reviewing a specific change before merge | Ephemeral namespace in the reference Kubernetes cluster (Section 20.3), created and destroyed by CI (Section 20.5) for the lifetime of the PR, with its own PostgreSQL schema and Redis database index carved from shared preview infrastructure (not full dedicated instances, to keep cost bounded) | Synthetic seed data identical in shape to local; a preview environment is torn down (database schema dropped, storage prefix purged) automatically within 1 hour of the PR closing or merging |
| Staging | Full-stack integration testing, the target of nightly and pre-release test runs (Section 19.10), DAST scans (Section 19.6), and k6 load tests (Section 19.6) | A permanent, right-sized (not full production scale) deployment of the full reference architecture (Section 20.3) | Synthetic data only, refreshed weekly by a script that generates realistic-shaped accounts, documents, envelopes, and job history at roughly 2% of projected production volume; production data never reaches staging under any circumstance — there is no "restore a production backup into staging" procedure, by design, to eliminate the entire class of data-leak risk that procedure would create |
| Production | Real customers | The full reference architecture at production scale (Section 20.3) | Real customer data, governed entirely by Sections 6, 9, and 17 |
How realistic test data is generated instead of using production data. A dedicated packages/db/seed/synthetic/ generator uses a fixed, version-controlled random seed (never Math.random() or wall-clock entropy, so a given seed version always produces byte-identical synthetic data for reproducibility) to produce: accounts across all five plan tiers in realistic proportion, workspaces with realistic team sizes, job history with a realistic mix of tool types and success/failure ratios, and envelopes in every state of the signature lifecycle. Document content for synthetic accounts is drawn exclusively from the e2e-safe golden-file corpus subset (Section 19.5) and from procedurally generated filler PDFs (via a scripted LibreOffice template with placeholder Latin text) — never from any real uploaded file, guest or authenticated, at any retention stage. This generator is itself covered by a unit test asserting it produces no real personal data patterns (a regex scan for realistic-looking SSNs, credit card numbers, and email domains outside the reserved test domains fails the generator's own test suite if triggered).
20.2 Containerization #
Every deployable unit in apps/ has its own Dockerfile in infra/docker/<app-name>/Dockerfile, built as a multi-stage image to keep the final runtime image minimal and to fully separate build-time tooling from what ships:
# infra/docker/api/Dockerfile
FROM node:24-alpine AS base
RUN corepack enable
WORKDIR /repo
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/package.json
COPY packages/contracts/package.json packages/contracts/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/config/package.json packages/config/package.json
RUN pnpm install --frozen-lockfile --filter=api...
FROM deps AS build
COPY . .
RUN pnpm turbo run build --filter=api
FROM base AS runtime
ENV NODE_ENV=production
RUN addgroup -S pdfworks && adduser -S pdfworks -G pdfworks
COPY --from=build --chown=pdfworks:pdfworks /repo/apps/api/dist ./apps/api/dist
COPY --from=build --chown=pdfworks:pdfworks /repo/node_modules ./node_modules
COPY --from=build --chown=pdfworks:pdfworks /repo/apps/api/node_modules ./apps/api/node_modules
USER pdfworks
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD node ./apps/api/dist/healthcheck.js || exit 1
CMD ["node", "./apps/api/dist/index.js"]Base image choice. node:24-alpine (Section 3.2's version table entry, Node.js 24.x LTS) for every Node-based service (apps/api, apps/worker-media, and the build stage of apps/web), and python:3.13-slim for apps/worker-office. Alpine is chosen for the Node images for its small base footprint; the Python worker uses Debian-slim rather than Alpine specifically because LibreOffice, Poppler, and Ghostscript (Section 3.2) are far more reliably packaged against glibc than musl, and the resulting extra base size is a worthwhile trade against the debugging cost of musl-related native-library issues in a document-processing pipeline. apps/web's runtime stage uses node:24-alpine as well, serving the built Next.js standalone output.
Image size targets:
| Image | Target (compressed) | Rationale |
|---|---|---|
api |
< 180 MB | Node + minimal deps; fast cold start in autoscaling |
web |
< 250 MB | Next.js standalone build; includes static asset manifest |
worker-media |
< 400 MB | Node + sharp's native binaries + the pdfcore Node build |
worker-office |
< 1.8 GB | LibreOffice headless is unavoidably large; still bounded and monitored so it does not silently regress upward |
pdfcore build container (Section below) |
Not shipped to runtime; build-tool image, size unconstrained |
Non-root user. Every runtime image creates and runs as a dedicated non-root user (pdfworks, uid/gid assigned by the base image's adduser, never uid 0), with the working directory and all copied files explicitly chown'd to that user at build time as shown above — a container running as root is a failed image build, enforced by a Trivy/Hadolint config check (no-root-user) as part of the container-scan CI gate (Section 19.10).
Health checks. Every image declares a HEALTHCHECK (Docker/Compose level) and, redundantly, a Kubernetes-native livenessProbe/readinessProbe (Section 20.3) hitting the same underlying endpoint — GET /healthz (liveness: process is alive and event loop is not deadlocked) and GET /readyz (readiness: liveness plus a live check that the process can reach PostgreSQL and Redis with a sub-second timeout) for apps/api; an equivalent check for workers verifies the BullMQ Redis connection is live rather than an HTTP endpoint, exposed via a small internal HTTP listener used only for the probe.
The pdfcore WASM build container. infra/docker/pdfcore-build/Dockerfile is a dedicated, never-deployed build-only image containing the Emscripten SDK pinned to an exact, recorded version, the PDFium and QPDF source trees pinned by exact commit (per Section 3.6's rule that PDFium is pinned by commit, not semver), and the full native toolchain needed to compile them. Its output is not a runtime image but a single build artifact: pdfcore.wasm, pdfcore.js (the Emscripten glue), and the single-threaded fallback variant pdfcore-st.wasm, plus the TypeScript type-definition bindings.
- Reproducibility requirement. The build runs with
SOURCE_DATE_EPOCHpinned to the commit's authored timestamp and Emscripten's deterministic-build flags enabled; a CI job builds the artifact twice from a clean cache and asserts the two.wasmoutputs are byte-identical (SHA-256 equal) before the artifact is accepted — a non-reproducible build is a build failure, not a warning, because Section 3.6's byte-identical cross-host guarantee is meaningless if the artifact itself is not reproducibly built from its declared sources. - Versioning. The artifact is versioned independently of the application's own release cadence, as
pdfcore-v<PDFium-branch-year>.<QPDF-major>.<build-sequence>(for examplepdfcore-v2026.12.1), recorded in apackages/pdfcore/ARTIFACT_VERSIONfile checked into the repository so a given application commit always references an explicit, reviewable artifact version rather than "whatever the latest build happens to be." - Signing. The artifact is signed with
cosignusing a keyless OIDC-based signature tied to the CI identity that produced it (the same mechanism used for container image signing below), and the signature is verified before the artifact is published to the internal artifact registry and again before it is pulled into any application build — an unsigned or signature-mismatched artifact fails the build. - Caching. The signed artifact is published to a versioned prefix in the internal artifact registry (an S3-compatible bucket separate from customer data,
pdfworks-build-artifacts) and to the CDN (Section 20.4) for the browser-loaded copy, with a one-year immutable cache header and a content-hashed filename (Section 18.6's performance budget) — a rebuild only occurs whenARTIFACT_VERSIONchanges or the pinned commits change, not on every application commit, since the vast majority of application changes never touch the C++ sources.
Every production container image (application images, not the build-only pdfcore-build image) is signed with cosign at push time using the same keyless OIDC mechanism, and the Kubernetes admission controller (Section 20.3) refuses to schedule any pod whose image signature does not verify against the expected CI identity — an unsigned image can be built locally by a developer for testing but can never reach a real cluster namespace.
20.3 The Reference Deployment #
Kubernetes, provider-neutral. The reference deployment targets any conformant Kubernetes distribution (no dependency on a specific cloud provider's managed-Kubernetes-only feature); it is expressed as a Helm chart in infra/helm/pdfworks/.
Workload inventory:
| Deployment | Replicas (baseline) | Requests (CPU / memory) | Limits (CPU / memory) | HPA trigger |
|---|---|---|---|---|
web |
3 | 250m / 512Mi | 1 / 1Gi | CPU > 65% average over 3 min, min 3 / max 20 |
api |
3 | 500m / 512Mi | 2 / 1Gi | CPU > 65% average over 3 min, min 3 / max 30 |
worker-media |
2 | 1 / 1Gi | 4 / 4Gi | BullMQ queue depth (ocr + convert queues combined) > 50 waiting jobs per replica, via a custom-metrics adapter reading BullMQ's Redis-backed counters; min 2 / max 25 |
worker-office |
2 | 2 / 2Gi | 4 / 6Gi | Same queue-depth trigger applied to the convert queue's Office/HTML-tagged jobs; min 2 / max 15 |
worker-esign (a dedicated worker deployment for the esign queue — certificate generation, reminder dispatch, expiry sweeps) |
2 | 500m / 512Mi | 1 / 1Gi | Queue depth > 100 waiting jobs per replica; min 2 / max 10 |
worker-batch (batch-job orchestration; fans work out to the other queues rather than doing the processing itself) |
2 | 250m / 256Mi | 500m / 512Mi | Queue depth > 100; min 2 / max 10 |
worker-webhook |
2 | 250m / 256Mi | 500m / 512Mi | Queue depth > 200 (webhook delivery is designed to tolerate latency); min 2 / max 10 |
worker-janitor |
1 | 250m / 256Mi | 500m / 512Mi | No HPA; a single scheduled reconciler is sufficient and a second replica would risk duplicate delete attempts without additional locking |
Every worker deployment additionally sets terminationGracePeriodSeconds: 120 and traps SIGTERM to stop pulling new jobs while letting an in-flight job finish or checkpoint, so a rolling deploy or scale-down never silently kills mid-processing work — an in-flight job that cannot finish within the grace period is released back to the queue (BullMQ's stalled-job recovery) rather than lost.
Ingress and TLS. An ingress controller (any NGINX-Ingress-compatible or Traefik-compatible controller; the Helm chart targets the common Ingress API resource, not a controller-specific CRD, to stay provider-neutral) terminates TLS using certificates issued by cert-manager via ACME/Let's Encrypt (Section 20.7), routing app.pdfworks.io to web, api.pdfworks.io to api, and sign.pdfworks.io to the signer route group served by the same web deployment (Section 3.1's monorepo layout) but distinguished at the ingress layer by hostname so the signer surface can carry its own, stricter rate-limit and WAF rule set independent of the main app.
Secret management. Kubernetes-native Secret objects are never committed or hand-applied; they are synced from an external secret manager (Section 20.6) via the External Secrets Operator, which reconciles a SealedSecret-free, drift-detected copy into each namespace, so the actual credential material lives in exactly one system of record outside the cluster.
Network policies. A default-deny NetworkPolicy applies to every namespace; explicit allow rules are added per workload. The rule of particular note: every worker deployment (worker-media, worker-office, worker-esign, worker-batch) has egress denied to the public internet entirely, matching the gVisor sandbox's own no-outbound-network guarantee (Section 3.5) with a second, independent enforcement layer — workers may reach only the cluster-internal PostgreSQL, Redis, and the object storage endpoint (via a private VPC endpoint or an explicitly allow-listed internal hostname, never a public S3 URL), and DNS resolution for anything else fails closed. api and web have egress permitted only to PostgreSQL, Redis, object storage, and the specific external API hostnames they legitimately call (Stripe, Resend, the HIBP k-anonymity range endpoint for password checks per Section 17).
Pod security standards. Every namespace is labeled to enforce the Kubernetes-native restricted Pod Security Standard: no privileged containers, no host namespace sharing, readOnlyRootFilesystem: true with explicit emptyDir volumes mounted for the specific paths that need write access (a job-scoped scratch directory, matching the per-job tmpfs described in Section 3.5), runAsNonRoot: true enforced at the admission-controller level as a second check beyond the Dockerfile's own non-root user, and all Linux capabilities dropped (drop: ["ALL"]) with none re-added.
Ingress and HPA, expressed as chart templates. The two resource kinds an operator most often needs to reason about directly:
# infra/helm/pdfworks/templates/ingress.yaml (excerpt)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: pdfworks-api
annotations:
cert-manager.io/cluster-issuer: letsencrypt-dns01
nginx.ingress.kubernetes.io/rate-limit-rps: "50"
spec:
tls:
- hosts: ["api.pdfworks.io"]
secretName: api-pdfworks-io-tls
rules:
- host: api.pdfworks.io
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: api, port: { number: 8080 } }# infra/helm/pdfworks/templates/hpa-worker-media.yaml (excerpt)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: worker-media
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: worker-media }
minReplicas: {{ .Values.workerMedia.hpa.minReplicas }}
maxReplicas: {{ .Values.workerMedia.hpa.maxReplicas }}
metrics:
- type: External
external:
metric:
name: bullmq_queue_waiting_per_replica
selector: { matchLabels: { queue: "ocr-convert-combined" } }
target: { type: AverageValue, averageValue: "50" }The external metric is served by a small custom-metrics adapter (infra/metrics-adapter/) that reads BullMQ's Redis-backed waiting-job counters and exposes them through the Kubernetes External Metrics API, since queue depth — not CPU — is the correct scaling signal for a job-processing worker fleet.
Helm chart values that matter. infra/helm/pdfworks/values.yaml exposes, as the values an operator is expected to actually tune per deployment: replicaCount and HPA min/max per workload (defaults matching the table above), resources.requests/resources.limits per workload, image.tag (pinned per release, Section 20.5), postgresql.connectionString and redis.connectionString (as references to the external-secret-synced Secret names, never inline values), ingress.hosts (the five hostnames from Section 1.1, overridable for a self-hosted fork under a different domain), featureFlags.corsOriginAllowlist, and retention.hardDeleteSweepIntervalMinutes (default 15, feeding the janitor's schedule per Section 6).
The smaller launch path — single-VM Docker Compose. The Kubernetes reference deployment above is the default, production-grade target, but it assumes an operator comfortable running a cluster. infra/compose/docker-compose.prod.yml is a fully documented, equally supported alternative for a solo founder or a small team not ready to operate Kubernetes:
# infra/compose/docker-compose.prod.yml (excerpt)
services:
api:
image: pdfworks/api:${RELEASE_TAG}
restart: unless-stopped
env_file: .env.production
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
deploy:
resources:
limits: { cpus: "2", memory: 1g }
worker-media:
image: pdfworks/worker-media:${RELEASE_TAG}
restart: unless-stopped
env_file: .env.production
deploy:
replicas: 2
resources:
limits: { cpus: "4", memory: 4g }
worker-office:
image: pdfworks/worker-office:${RELEASE_TAG}
restart: unless-stopped
env_file: .env.production
deploy:
replicas: 1
resources:
limits: { cpus: "4", memory: 6g }
web:
image: pdfworks/web:${RELEASE_TAG}
restart: unless-stopped
env_file: .env.production
postgres:
image: postgres:18
restart: unless-stopped
volumes: [ "pgdata:/var/lib/postgresql/data" ]
healthcheck: { test: ["CMD", "pg_isready", "-U", "pdfworks"], interval: 10s }
redis:
image: redis:8
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes: [ "redisdata:/data" ]
caddy:
image: caddy:2
restart: unless-stopped
ports: [ "443:443", "80:80" ]
volumes:
- "./Caddyfile:/etc/caddy/Caddyfile"
- "caddydata:/data"
volumes:
pgdata:
redisdata:
caddydata:Caddy is used as the single-VM reverse proxy/TLS terminator specifically because it obtains and renews Let's Encrypt certificates with zero manual cert-manager-equivalent configuration, matching the "a solo founder can actually run this" bar. Object storage in this path points at an external S3-compatible provider (never a self-hosted MinIO for production data, to avoid taking on unmanaged durability risk) via the same packages/db and worker configuration used by the Kubernetes path — the application code has no branch for "which deployment topology am I in"; only the infrastructure layer differs.
What the single-VM path gives up, stated plainly: no horizontal autoscaling (capacity is whatever the one VM provides; scaling requires resizing the VM and briefly restarting), no rolling zero-downtime deploys (a deploy is a docker compose pull && docker compose up -d, which briefly interrupts api and web for the seconds it takes containers to restart — acceptable for a pre-revenue or early-revenue product, explicitly not acceptable once the 99.9% availability target in Section 10 is a contractual commitment to paying customers at meaningful scale), a single point of failure for every component including the database (mitigated only by the backup policy in Section 20.4, not by replication), and no network-policy-level egress-deny for workers (mitigated by continuing to rely on the gVisor sandbox boundary alone, per Section 3.5, which is why that boundary is never treated as optional even on this simpler path).
Which is the default. The Kubernetes deployment in this section, not the single-VM path, is the default and the target the rest of this specification assumes (autoscaling behavior referenced elsewhere, the HPA-driven worker fleet sizing in Section 13, and the 99.9% availability target in Section 10 all assume it). The single-VM path is an explicitly supported on-ramp for pre-scale operation, documented as a migration starting point, not a permanent alternative architecture.
20.4 Managed Dependencies #
PostgreSQL.
- Sizing. Production baseline: 4 vCPU / 16 GB RAM / 200 GB SSD-backed storage with autoscaling storage enabled, sized to the "1,000 paying customers" cost-model tier in Section 20.8; reassessed quarterly against actual connection count, query latency percentiles, and storage growth.
- Connection pooling. PgBouncer in transaction-pooling mode sits between
api/workers and PostgreSQL, with a pool size of 100 server-side connections shared across up to 1,000 client-side (application-side) connections — Drizzle's own connection pool perapi/worker replica is kept small (10 connections per replica) specifically because PgBouncer, not the application, owns pool sizing at scale. - Read replica policy. One read replica is provisioned once production traffic exceeds roughly 500 requests/second sustained on read-heavy endpoints (list documents, list jobs, dashboard aggregates); read-replica routing is explicit at the query-repository layer (
packages/dbexposes areadDb/writeDbsplit), never automatic statement-based routing, so a developer must deliberately opt a query into potentially-stale-read behavior rather than accidentally reading stale data on a path that requires read-your-own-write consistency (for example, immediately re-reading a job row right after creating it always useswriteDb). - Backup and point-in-time recovery. Continuous WAL archiving to object storage plus a full base backup daily, giving point-in-time recovery to any second within a 35-day retention window. Backups are stored in the dedicated
pdfworks-backupsbucket, encrypted at rest, in a different storage region from the primary database region (Section below on cross-region considerations). - Tested restore procedure and its cadence. A scripted restore (
infra/scripts/restore-drill.sh) provisions a fresh, isolated PostgreSQL instance from the latest base backup plus WAL replay to a specified point in time, runs the application's own migration-status check and a fixed data-integrity query set (row counts per major table within an expected range, referential-integrity spot checks) against it, and tears it down — run monthly as an automated, alerting-on-failure job, with the annual disaster-recovery drill (Section 20.8) additionally requiring a human to manually execute this script end to end and sign off on the timing.
Redis.
- Persistence configuration. AOF (append-only file) enabled with
appendfsync everysec, plus RDB snapshotting every 15 minutes as a second recovery layer — Redis here backs BullMQ queues and rate-limit counters, not a pure ephemeral cache, so silent data loss on restart is not acceptable. - Eviction policy.
noeviction— Redis is sized with sufficient memory headroom (production baseline 4 GB, alerting at 70% utilization) that eviction should never trigger;noevictionensures that if memory pressure ever does occur, writes fail loudly (visible as job-enqueue errors, immediately actionable) rather than silently dropping an arbitrary key, which for a queue backend could silently drop a customer's job. - What happens if it is lost. A total, unrecovered Redis loss (no AOF, no RDB survives) means: in-flight and queued jobs are lost and must be resubmitted by the affected users (client-visible as jobs stuck in
queuedpast their expected time, which the UI surfaces as a retryable failure after a timeout, not a silent hang); rate-limit counters reset (briefly permissive, not a security issue given entitlement checks are also enforced from PostgreSQL-sourced plan data, only the token-bucket counters themselves are Redis-resident); and active user sessions are unaffected (session validation, Section 17, is cookie-plus-database-backed via better-auth, not Redis-resident). This bounded blast radius is a deliberate design property: Redis loss is an inconvenience and a support-ticket generator, never a data-integrity or security incident.
Object storage.
- Bucket layout. Four buckets, never combined:
pdfworks-documents(customer-uploaded and job-output files, per-job envelope-encrypted per Section 3.5),pdfworks-envelopes(e-signature source/working/completed PDFs and certificates, same encryption scheme, longer retention per Section 6),pdfworks-backups(database backups, described above), andpdfworks-build-artifacts(thepdfcoreWASM artifact and other build outputs, Section 20.2) — kept separate so that bucket-level lifecycle rules, access policies, and blast radius are independently scoped; a misconfigured lifecycle rule on the backups bucket, for instance, can never affect customer document retention. - Lifecycle rules that enforce retention independently of the application. Beyond the application's own job-completion-triggered delete (Section 6), each bucket carries a bucket-level lifecycle policy as a second, independent enforcement layer:
pdfworks-documentsobjects are force-expired at 25 hours from creation regardless of application state (one hour past the documented 24-hour maximum, as a deliberate safety margin against an application-layer bug that fails to delete on schedule);pdfworks-envelopesobjects are force-expired at 31 days from creation (one day past the documented 30-day maximum). This is the same defense-in-depth principle as the network-policy-plus-sandbox egress-deny in Section 20.3: the application is expected to delete on time, and the bucket policy exists specifically for the case where it does not. - Versioning policy. Object versioning is disabled on
pdfworks-documentsandpdfworks-envelopes— the retention model in Section 6 depends on hard deletion actually being hard, and object versioning would silently preserve a "deleted" object's prior version, directly undermining the crypto-shred-then-delete guarantee. Versioning is enabled onpdfworks-backupsandpdfworks-build-artifacts, where preserving history is the entire point. - Cross-region considerations. The primary region hosts the live application and its buckets; backups (
pdfworks-backups) are additionally replicated to a second region via the storage provider's native cross-region replication, so a full primary-region outage does not also take out the only copy of the restore path. Customer document buckets are not cross-region replicated by default (replication would mean a "deleted" file's bytes linger in a second region during propagation, again in tension with the retention guarantee); a Team-plan customer's optional EU-data-residency election (Section 17) is implemented as bucket selection at upload time (the job is routed to an EU-region bucket and EU-region workers for that workspace), not as replication.
CDN. Static assets (the Next.js build output, the pdfcore WASM artifact and its fallback variant, marketing site images) are served through a CDN in front of app.pdfworks.io and pdfworks.io's static paths, honoring the one-year immutable cache header on content-hashed filenames (Section 10) and a short (60-second) cache on the marketing site's HTML for near-immediate content updates. The CDN layer additionally provides the first line of DDoS absorption and is configured to pass through, not cache, every /v1/* and /internal/* API path and every sign.pdfworks.io route, since those are dynamic and often authenticated.
20.5 CI/CD with GitHub Actions #
Workflow files, job by job. .github/workflows/ contains three top-level workflows:
ci.yml— triggered on every PR and every push tomain; runs the full ordered quality-gate sequence from Section 19.10 (jobslint,typecheck,unit-test,contract-test,golden-file-corpus[conditional],sast-and-deps,integration-test,build,container-scan[conditional],e2e-smoke,a11y-gate), each a distinct GitHub Actions job so failures are individually visible in the PR checks list, withneeds:edges encoding the fail-fast ordering.preview.yml— triggered on PR open/synchronize; builds images tagged with the PR's commit SHA, deploys them into the ephemeral preview namespace (Section 20.1), and posts the preview URL as a PR comment; triggered on PR close to tear the namespace down.release.yml— triggered on a push of av*tag (Section below on the release/versioning convention); runs the full pre-deploy gate (Section 19.10's "before deploy" checks), builds and signs production images (Section 20.2), publishes the npm SDK ifpackages/sdk-jschanged since the last release tag, runs database migrations against production (with the safety rules below), and performs the rolling deployment.
Ordered quality gates. As specified in Section 19.10; release.yml additionally inserts the redaction suite (Section 19.7), the signature audit suite (Section 19.8), and the release checklist confirmation (Section 19.9) between build and the deployment steps. A representative excerpt of ci.yml showing the fail-fast job graph:
# .github/workflows/ci.yml (excerpt)
name: CI
on: [pull_request, push]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint format:check --filter=...[origin/main]
typecheck:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run typecheck --filter=...[origin/main]
unit-test:
needs: typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run test:unit --filter=...[origin/main]
- run: pnpm turbo run test:coverage-check --filter=...[origin/main]
golden-file-corpus:
needs: unit-test
if: contains(github.event.pull_request.changed_files, 'packages/pdfcore') || contains(github.event.pull_request.changed_files, 'apps/worker-')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: pnpm corpus:sync
- run: pnpm turbo run test:corpus --filter=pdfcore...
integration-test:
needs: [unit-test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run test:integration --filter=...[origin/main]
e2e-smoke:
needs: [integration-test]
runs-on: ubuntu-latest
strategy:
matrix: { shard: [1, 2, 3, 4, 5, 6] }
steps:
- uses: actions/checkout@v5
- run: docker compose -f infra/compose/docker-compose.ci.yml up -d
- run: pnpm exec playwright install --with-deps chromium
- run: E2E_SMOKE_ONLY=1 pnpm exec playwright test --project=chromium-desktop --shard=${{ matrix.shard }}/6Caching strategy. Turborepo's remote cache (self-hosted, pointed at an internal object storage bucket rather than a third-party SaaS cache, keeping build artifacts inside the same trust boundary as the rest of the infrastructure) caches lint, typecheck, unit-test, and build task outputs keyed by a hash of each package's source files and its dependency graph — a PR that touches only apps/web skips re-running unit-test for apps/worker-office entirely, both locally and in CI, because Turborepo's task graph already proves no affected input changed. Docker layer caching uses docker buildx with a registry-backed cache (--cache-to type=registry,ref=<registry>/cache/<image>), so an unchanged base-dependency layer (the deps stage in Section 20.2's Dockerfile example) is reused across builds even on ephemeral CI runners with no persistent local disk.
Build matrix. The unit-test job matrixes across {node: [24], os: [ubuntu-latest]} for the TypeScript packages (a single cell today; the matrix shape exists so a future Node version bump can run in parallel across two Node versions during a migration window without a structural change) and separately runs the Python suite on {python: [3.13], os: [ubuntu-latest]}. The e2e-smoke job matrixes across the five Playwright projects from Section 19.5 only in the nightly workflow, not per-PR, per that section's run-time budget.
Artifact publication. Built, signed container images publish to the internal container registry tagged both with the release's Git tag (v2.14.0) and the immutable commit SHA; the pdfcore WASM artifact publishes as described in Section 20.2; Playwright HTML reports, coverage reports, and k6 result summaries publish as workflow run artifacts retained for 90 days for audit and debugging purposes.
The npm SDK release process. packages/sdk-js is versioned independently using its own semantic version (distinct from the application's release tag, since a customer's installed SDK version is decoupled from which backend release they happen to be talking to — the API's own versioning, /v1, is the actual compatibility contract, per Section 14). On a release.yml run, if packages/sdk-js's CHANGELOG.md (maintained via Changesets, pnpm changeset) records unreleased changes, the workflow runs pnpm changeset version to bump the package version and regenerate the changelog, publishes to the public npm registry with provenance attestation (npm publish --provenance), and opens an auto-merged PR committing the version bump back to main — the SDK's types are regenerated from the OpenAPI document (Section 19.4) as part of its own build step, so a contract change that alters the public API surface always produces a corresponding SDK change in the same release, never silently drifting.
Database migration execution and its safety rules. drizzle-kit migrations (Section 5) run as a distinct step before the new application code is deployed, never as part of application container startup (a migration running inside a horizontally-scaled api pod's startup would risk N replicas racing to apply the same migration) — instead, release.yml runs a single, dedicated migration job (drizzle-kit migrate against the production connection string, using a short-lived, elevated-privilege database role scoped only to this job) as its own workflow step, gated by a required manual approval in GitHub Environments for production specifically (every other environment auto-applies). The safety rules, enforced by a linter over the migrations directory as part of the lint job:
- One migration per PR. A PR introducing a schema change contains exactly one new migration file; combining unrelated schema changes into one migration is rejected, so each migration has a single, reviewable, revertible purpose.
- Forward-only.
drizzle-kit's down-migration generation is not used; instead, every migration's PR description documents its compensating migration (the specific forward migration that would undo it) — "forward-only, reversible by compensation," matching Section 5's canonical convention — because a trueDOWNmigration against a database that has already received production writes under the new schema is rarely actually safe to run blind. - Migrations run before code that needs them, and are always backward compatible for one release. A migration that adds a nullable column, a new table, or a new enum-mirroring CHECK constraint value is safe to run before the code that uses it deploys. A migration that removes or renames a column the currently-running code still reads is not run in the same release as the code change that stops using it — it is split into two releases: release N deploys code that stops reading the old column (while the column still exists, unused), and release N+1's migration drops it. This ordering rule is the concrete mechanism behind rolling, zero-downtime deploys (Section 20.3's stateless rolling strategy) actually being safe: during a rolling deploy, old and new code run simultaneously against the same database for the deploy's duration, so the schema must be a superset compatible with both.
Deployment strategy. web, api, and every worker deployment use Kubernetes' native rolling update strategy (maxSurge: 1, maxUnavailable: 0 for web/api so capacity never drops during a deploy; maxSurge: 1, maxUnavailable: 1 for workers, tolerable because in-flight jobs survive the graceful-shutdown handling described in Section 20.3). Stateful components (PostgreSQL, Redis) are managed dependencies (Section 20.4), not application deployments, and are never subject to the application's own rolling-deploy process.
Smoke tests after deploy. Immediately following a successful rolling deploy to production, release.yml runs a small, fast (under 2 minutes) smoke suite against the live production URL: an unauthenticated health check on api.pdfworks.io/healthz, a synthetic guest merge operation against app.pdfworks.io end to end, and a read-only check that the latest database migration's version matches what the release expects. A smoke-test failure immediately triggers the automatic rollback below rather than leaving a known-bad release live while a human investigates.
Automatic rollback triggers. The deployment is automatically rolled back to the immediately prior release's image tags (Kubernetes' kubectl rollout undo equivalent, invoked by the workflow) when: the post-deploy smoke suite fails, the post-deploy error-rate SLO burn-rate alert (Section 20.8) fires within the first 10 minutes at more than 3× the normal baseline, or the readiness probe fails to reach a healthy state across the required replica count within 5 minutes of the rollout starting. Database migrations are not automatically reverted on rollback (consistent with the forward-only/backward-compatible rule above — a rollback to the prior release's code is safe precisely because that prior code is still compatible with the new schema).
Manual rollback procedure. For an issue detected after the automatic-rollback window (for example, a correctness bug found by a customer hours later, not an availability regression), an engineer runs infra/scripts/rollback.sh --to <previous-release-tag>, which re-deploys the named prior image set through the same rolling-update mechanism, requires the same production-environment manual approval gate as a forward deploy, and is logged identically to a forward release in the deployment history for audit purposes — a rollback is a deployment, not a special, less-tracked operation.
20.6 Configuration and Secrets #
The twelve-factor rule. Configuration that varies between environments (Section 20.1) is read exclusively from environment variables at process startup; nothing environment-specific is ever baked into a built container image, and no configuration file checked into the repository contains a real credential — .env.production (referenced in Section 20.3's Compose example) exists only on the deployment host or as a Kubernetes-Secret-mounted file, never in Git, and .env.example in the repository documents every variable's name and purpose with a placeholder value.
The complete environment-variable specification lives in Section 23.2 and is not duplicated here; every variable listed there is validated as described below.
Boot-time validation that refuses to start on bad config. apps/api, apps/worker-media, apps/worker-office, and apps/web's server runtime each import a single env.ts/env.py module at the very top of their entry point, which parses process.env (or os.environ) through a Zod schema (a Pydantic model for the Python worker, mirroring the same field set and constraints) enumerating every variable the process actually uses, with type coercion, required-vs-optional marking, and format validation (a DATABASE_URL must parse as a valid PostgreSQL connection string; a STRIPE_SECRET_KEY must match the sk_live_/sk_test_ prefix pattern). A missing required variable or a value failing its format check throws synchronously before any server socket opens or any queue consumer starts, with a clear, itemized error message naming every failing variable at once (not just the first one encountered) — a process that starts with silently-wrong configuration is treated as a worse outcome than a process that refuses to start at all.
// apps/api/src/env.ts
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url().startsWith("postgres://"),
REDIS_URL: z.string().url().startsWith("redis://"),
STRIPE_SECRET_KEY: z.string().regex(/^sk_(live|test)_/),
STRIPE_WEBHOOK_SECRET: z.string().min(1),
RESEND_API_KEY: z.string().min(1),
KMS_MASTER_KEY_ARN: z.string().min(1),
SESSION_SIGNING_SECRET: z.string().min(32),
S3_BUCKET_DOCUMENTS: z.string().min(1),
S3_BUCKET_ENVELOPES: z.string().min(1),
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
});
function loadEnv() {
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
const issues = result.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
// eslint-disable-next-line no-console
console.error(`Refusing to start: invalid configuration.\n${issues}`);
process.exit(1);
}
return result.data;
}
export const env = loadEnv();Secret storage and rotation. Real secret material (database passwords, the Stripe secret key, the Resend API key, the KMS master key reference used for the per-job data-key envelope encryption in Section 3.9, session-signing keys) is stored in an external secret manager (any of AWS Secrets Manager, HashiCorp Vault, or an equivalent — the Helm chart's External Secrets Operator integration (Section 20.3) is provider-agnostic via its pluggable backend) and synced into the cluster, never stored directly as a Kubernetes Secret object's system of record. Rotation cadence: database and Redis credentials rotate every 90 days via the secret manager's native rotation Lambda/equivalent with a dual-credential overlap window so in-flight connections are not dropped; the Stripe and Resend API keys rotate every 180 days or immediately on suspected compromise; the KMS master key rotates on the cloud KMS provider's automatic annual rotation, which re-wraps existing per-job data keys transparently without re-encrypting the underlying objects (envelope encryption's specific benefit); webhook signing secrets follow the 24-hour dual-secret overlap rotation already specified in Section 9.
Per-environment override mechanism. Each environment (Section 20.1) has its own isolated set of secrets in the secret manager, namespaced by environment (prod/api/DATABASE_URL, staging/api/DATABASE_URL), so there is no code-level "if staging, use X" branching — the same env.ts validation module and the same container image run unmodified in every environment, and only the injected environment variables differ, which is the entire point of twelve-factor configuration and is what makes the preview/staging/production images from Section 20.5 genuinely identical artifacts rather than environment-specific builds.
20.7 Domains, DNS, and Email #
Domain map (placeholder domains per Section 1.1, intended to be find-and-replaced by the executor with the real registered domain before launch, per Section 1):
| Hostname | Serves |
|---|---|
pdfworks.io |
Marketing site (Section 21) |
app.pdfworks.io |
The web application (tool pages, dashboard, account) |
api.pdfworks.io |
The public REST API (/v1) |
docs.pdfworks.io |
Public API and product documentation |
sign.pdfworks.io |
The e-signature signer portal (Section 10) |
status.pdfworks.io |
The public status page (Section 20.8) |
TLS certificate management. cert-manager (Section 20.3) issues and auto-renews certificates via ACME/Let's Encrypt for every hostname above using DNS-01 challenge validation (rather than HTTP-01) specifically so certificates can be issued for the apex domain and wildcard subdomains without requiring the ACME challenge traffic to route through the same ingress being provisioned — renewal runs automatically at 30 days before expiry with a 3-retry backoff, and a certificate that fails to renew within 7 days of expiry pages the on-call engineer (Section 20.8's runbook for certificate expiry) well before the certificate is actually expired.
DNS records required:
| Record | Purpose |
|---|---|
A/AAAA for each hostname above |
Points to the ingress load balancer (Section 20.3) or, for the single-VM path, the VM's public IP |
CNAME docs → documentation hosting provider |
If documentation is hosted on a third-party platform rather than served from apps/web |
TXT v=spf1 include:_spf.resend.com ~all on the sending domain |
SPF — authorizes Resend as a permitted sender |
CNAMEs for Resend's per-domain DKIM selectors |
DKIM — signs outbound mail so receiving servers can verify it was not forged |
TXT _dmarc.pdfworks.io → v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@pdfworks.io; pct=100 |
DMARC — starts at p=quarantine rather than p=reject during the warm-up period below, tightened to p=reject once alignment is confirmed clean for 30 consecutive days |
TXT _mta-sts.pdfworks.io → v=STSv1; id=<policy-id> plus the corresponding mta-sts.pdfworks.io policy file over HTTPS |
MTA-STS — enforces that receiving mail servers only accept TLS-secured delivery to this domain, mitigating downgrade attacks on outbound mail |
TXT _smtp._tls.pdfworks.io → a TLS-RPT reporting address |
Receives reports on any TLS delivery failures observed by receiving servers, surfacing MTA-STS problems before they cause silent delivery failures |
Email warm-up plan. A freshly registered sending domain has no sender reputation, and a burst of transactional volume from day one (envelope invitations, OTPs, receipts) risks landing in spam and poisoning reputation before real customers ever see reliable delivery. The warm-up plan, executed before the domain is used for any real customer traffic:
- Weeks 1–2: send only to the reserved internal test-account domain and a small panel of employee-controlled real mailboxes across the major providers (Gmail, Outlook, Yahoo, a smaller regional provider), at low volume (under 50 messages/day), monitoring inbox placement manually.
- Weeks 3–4: ramp to early-access beta users who explicitly opted in, capping volume at 500 messages/day, monitoring Resend's bounce/complaint dashboards and the DMARC aggregate reports (
rua) for alignment failures. - Week 5 onward: remove the volume cap once bounce rate has held under 2% and spam-complaint rate under 0.1% for two consecutive weeks, and only then tighten DMARC from
p=quarantinetop=reject.
Launch readiness (Section 20.9) does not permit going live to paying customers before this warm-up sequence has completed its first two phases at minimum, since a new customer's very first email (their signup verification) is the single highest-stakes message to lose to a spam filter.
20.8 Operations #
Runbook index. A living index in the internal operations documentation lists a runbook for every alert the on-call rotation can receive (Section 20.8's alerting is defined in Section 18; this section specifies the runbook content itself). Six worked runbooks, specified here in full:
1. Queue backlog (ocr, convert, esign, batch, or webhook queue depth exceeds its alert threshold for 10+ minutes).
Detect: alert fires from the queue-depth metric (Section 18) crossing 500 waiting jobs (200 for esign) sustained. Diagnose: check whether the HPA (Section 20.3) has actually scaled the corresponding worker deployment to its max replica count — if not yet at max, this is expected transient behavior during a burst and the runbook ends with a monitoring note; if at max replicas and still growing, check per-job processing time (Section 18) for a regression (a slow fixture, a stuck LibreOffice process) versus a genuine demand spike. Mitigate: for a genuine demand spike beyond max replicas, temporarily raise the HPA max via kubectl patch (logged as a manual intervention) while root-causing; for a processing-time regression, identify and kill stuck pods (kubectl delete pod, which triggers the graceful-shutdown-then-requeue path from Section 20.3) and, if caused by a specific job, quarantine that job's document for offline investigation. Verify: queue depth trending back to baseline within 30 minutes of mitigation. Follow-up: a postmortem if customer-visible processing-time SLA (Section 12) was breached.
2. Worker crash loop (a worker pod repeatedly fails its liveness probe or exits non-zero within seconds of starting).
Detect: Kubernetes' CrashLoopBackOff status alert. Diagnose: kubectl logs --previous on the crashing pod, most commonly either a boot-time config validation failure (Section 20.6 — check for a recent secret rotation or environment-variable change that broke validation) or an out-of-memory kill (kubectl describe pod showing OOMKilled, most often from worker-office processing an oversized document — cross-reference the corpus's oversized-fixture category, Section 19.3, to confirm whether a similar fixture is covered by existing memory limits). Mitigate: for a config issue, roll back the triggering secret/config change; for a memory issue, confirm the per-job memory limit (Section 3.5's sandbox constraints) actually applied and, if a single pathological job is the cause, manually fail that job and refund/notify the affected customer per the standard job-failure UX rather than raising cluster-wide memory limits as a first response. Verify: the deployment's pods reach Ready and stay ready for 10 minutes. Follow-up: if OOM-caused, evaluate whether the sandbox's memory ceiling (Section 3.5) needs revisiting for a newly observed legitimate document shape, versus whether the input was actually adversarial (Section 19.3's malicious-sample category) and should instead be rejected earlier.
3. Storage shredding lag (the janitor's hard-delete sweep is falling behind the 2-hour/24-hour retention commitment, Section 6).
Detect: a metric tracking the age of the oldest unswept eligible-for-deletion job row exceeds 3 hours (a 1-hour safety margin past the 2-hour commitment). Diagnose: check worker-janitor's own health (it runs as a single non-autoscaled replica, Section 20.3 — confirm it is not itself crash-looping) and check for a spike in eligible rows (a large batch job completing all at once) outpacing its fixed sweep rate. Mitigate: if the janitor process is unhealthy, restart it; if it is healthy but under-provisioned for a genuine volume spike, temporarily run a second, manually-invoked sweep job in parallel (safe because the sweep operation is idempotent — deleting an already-deleted object's wrapped key is a no-op) rather than scaling the deployment itself. Verify: oldest-unswept-row age trending back under 1 hour. Follow-up: because this metric is directly tied to a customer-facing data-handling commitment (Section 6), any breach past the bucket-level 25-hour/31-day backstop (Section 20.4) is treated as a P0 incident regardless of whether any actual retention promise was technically violated by the backstop catching it.
4. Stripe webhook outage (Stripe reports repeated delivery failures to the api.pdfworks.io webhook endpoint, or the endpoint's health check fails).
Detect: Stripe's own dashboard alerting plus an internal alert on worker-webhook inbound processing error rate. Diagnose: confirm whether the failure is on the receiving side (the endpoint is down or erroring — check api deployment health) or is a signature-verification failure (Section 9 — check for a recent, unintended webhook-secret rotation without the documented dual-secret overlap window having been honored). Mitigate: if the endpoint itself is down, resolve as a standard availability incident (this runbook defers to general incident response); if it is a signature mismatch, restore the prior secret into the dual-secret verification list immediately. Once the endpoint is healthy again, Stripe's own automatic retry-with-backoff redelivers missed events for up to 3 days, so no manual event replay is normally needed — but if the outage exceeded that window, use the Stripe CLI (stripe events resend) to manually replay the affected event range from the Stripe dashboard's event log. Verify: entitlement state (Section 12) for any workspace with a subscription event during the outage window is spot-checked against Stripe's own dashboard record for that customer. Follow-up: none beyond the standard incident review unless the 3-day auto-retry window was exceeded, in which case document exactly which customers needed manual reconciliation.
5. Database failover (the primary PostgreSQL instance becomes unreachable and the managed service or Patroni-based HA setup promotes a replica).
Detect: connection-error-rate alert across api and worker deployments simultaneously, plus the managed database provider's own failover notification (or, for a self-managed Patroni cluster, its own leader-election alert). Diagnose: this runbook is deliberately mostly passive — automatic failover is expected to complete within the provider's documented RTO (typically under 60 seconds for a managed service), and the primary diagnostic action is confirming the failover actually completed and the new primary is accepting writes, not attempting to manually intervene in the failover mechanism itself. Mitigate: if automatic failover has not completed within 3 minutes, escalate immediately to the database provider's support channel (for a managed service) or manually force a Patroni leader election (for self-managed); confirm PgBouncer (Section 20.4) has picked up the new primary's connection string (it should reconnect automatically on connection refusal, but a manual PgBouncer restart is the fallback). Verify: api's /readyz probe (Section 20.2) returning healthy across all replicas, and a synthetic write-then-read check succeeding. Follow-up: confirm no in-flight transactions were silently lost (PostgreSQL's synchronous replication configuration, if enabled for the replica used in failover, determines whether this is even possible) and check whether any customer-visible errors occurred during the failover window that warrant a status-page post (Section 20.8, status page).
6. Certificate expiry (a TLS certificate for one of the six hostnames is approaching or has passed its expiry date).
Detect: cert-manager's own renewal-failure alert (Section 20.7), which should fire at 7 days before expiry if auto-renewal has failed three times. Diagnose: check cert-manager's logs for the specific ACME challenge failure (most commonly a DNS-01 challenge failing because of a DNS provider API credential expiring — check the secret manager, Section 20.6, for that specific credential's rotation status). Mitigate: fix the underlying ACME challenge blocker and manually trigger a cert-manager Certificate resource re-issuance (kubectl annotate ... cert-manager.io/issue-temporary-certificate=true plus a forced re-sync); if time is critically short (under 24 hours to actual expiry), manually obtain a certificate via certbot as a break-glass fallback and apply it directly as a Kubernetes Secret, bypassing cert-manager temporarily. Verify: openssl s_client -connect <hostname>:443 shows a validity window extending at least 60 days out, for every one of the six hostnames, not just the one that alerted (a shared underlying cause, like the DNS credential, often affects several at once). Follow-up: if the root cause was a rotating credential expiring silently, add that credential's expiry to the proactive monitoring described in Section 20.6's rotation cadence rather than relying solely on the reactive cert-manager alert next time.
Backup and disaster-recovery plan.
| Metric | Target |
|---|---|
| RPO (Recovery Point Objective) — PostgreSQL | ≤ 5 minutes (bounded by WAL archiving frequency, Section 20.4) |
| RPO — object storage (documents, envelopes) | 0 (no backup/restore concept applies; loss is bounded instead by the encryption-at-rest and multi-AZ durability the storage provider itself guarantees, not by this product's own DR plan) |
| RTO (Recovery Time Objective) — full production restoration from a total primary-region loss | ≤ 4 hours |
The annual restore drill (distinct from the monthly automated restore-procedure test in Section 20.4, though it exercises the same script) is a full, scheduled, cross-team exercise: a fresh environment is stood up in a secondary region from backups alone, the application is pointed at it, the smoke suite (Section 20.5) runs against it, and the actual wall-clock time from "drill starts" to "smoke suite green" is measured against the 4-hour RTO target — a miss is treated as a finding that must produce a concrete infrastructure or process change before the next drill, not merely a number to note and repeat.
Maintenance-window policy. Planned maintenance requiring downtime (rare, given the rolling-deploy default, but occasionally needed for a major PostgreSQL version upgrade or a cluster-level change) is scheduled during the lowest-traffic window observed in the prior 90 days of traffic data (typically 03:00–05:00 in the majority timezone of the current customer base, reassessed as that base grows internationally), announced on the status page (below) at least 72 hours in advance for any expected downtime over 5 minutes, and capped at a stated maximum duration with an automatic escalation to the on-call lead if the window is about to be exceeded.
Status page. status.pdfworks.io is a public, independently hosted status page (hosted on a third-party status-page provider specifically so it stays reachable even during a full outage of the product's own infrastructure) tracking component-level status for: the web application, the public API, the e-signature signer portal, and email delivery — each independently markable as Operational / Degraded / Partial Outage / Major Outage — plus the published 99.9% monthly API availability target (Section 10) with a rolling 90-day historical uptime graph. Incidents are posted within 15 minutes of confirmation (not necessarily within 15 minutes of detection, if detection requires diagnosis to confirm customer impact) and updated at least every 30 minutes until resolved.
Alerting thresholds feeding the on-call rotation. The following table is the operational complement to the observability instrumentation itself (Section 18 owns metric definitions and dashboards); it states what actually pages a human versus what only opens a tracked, non-urgent ticket:
| Condition | Response |
|---|---|
| API error-rate burn rate > 3× the 99.9% monthly SLO's error budget, sustained 5 minutes | Page immediately |
| Any queue depth exceeding its Section 20.3 HPA-max-and-still-growing state for 15 minutes | Page immediately |
worker-janitor sweep lag exceeding 3 hours (Section 20.8 runbook 3) |
Page immediately |
| TLS certificate within 7 days of expiry with renewal already failed 3 times | Page immediately |
| PostgreSQL replication lag (once a read replica is provisioned) exceeding 30 seconds | Page immediately |
| A single S1/P0 defect discovered by the nightly test suite (Section 19.10) | Page immediately |
| Non-S1 nightly test failures, quarantine-ratio approaching (not yet exceeding) the 5% ceiling | Open a ticket, next-business-day triage |
| Cost-model actual spend exceeding the relevant scale tier's estimate by more than 25% for a full billing cycle | Open a ticket for capacity/cost review, not urgent |
The on-call rotation is a weekly, two-engineer-deep shift (a primary and a secondary who is paged if the primary does not acknowledge within 10 minutes), and every page includes a direct link to the relevant runbook from the index above where one exists.
Cost model — monthly estimate at three usage scales. Figures are all-in infrastructure cost (compute, managed database and cache, object storage and egress, CDN) excluding personnel, using the Kubernetes reference deployment (Section 20.3) at each scale's right-sized workload replica counts, in United States dollars, and are explicitly stated as directional planning estimates rather than a quote — actual figures depend on the chosen cloud provider and region.
| Scale | Profile | Estimated monthly infrastructure cost |
|---|---|---|
| Early (≈ 100 paying customers, ≈ 5,000 registered accounts) | Baseline replica counts throughout (Section 20.3's table minimums), no read replica, single-region | $600 – $900 |
| Growth (≈ 2,000 paying customers, ≈ 80,000 registered accounts) | HPA regularly scaling worker-media/worker-office to 2–3× baseline during peak hours, one read replica active, CDN egress meaningfully non-trivial |
$4,500 – $7,000 |
| Scale (≈ 20,000 paying customers, ≈ 600,000 registered accounts, meaningful API-tier usage) | Sustained high worker replica counts, two read replicas, cross-region backup replication, EU-residency bucket set active for Team customers electing it | $28,000 – $40,000 |
20.9 Launch Readiness #
The following must all be true before the first paying customer transaction is accepted in production. This is a numbered gate, not a suggestion — every item is a hard blocker on launch:
- The full CI quality-gate sequence (Section 19.10) is green on the exact commit tagged for release, including the golden-file corpus at full 229-fixture scope.
- The redaction test suite (Section 19.7) and the signature audit test suite (Section 19.8) are green against the release candidate, not against an earlier point on
main. - The manual QA release checklist (Section 19.9) is complete with explicit QA-lead sign-off, and zero open P0 or S1 defects exist.
- A full manual screen-reader accessibility pass (Section 19.6) has been completed within the prior 30 days with no unresolved Critical or High findings.
- The annual (or, pre-launch, the initial baseline) penetration test (Section 19.6) has been completed with all Critical and High findings remediated and verified closed.
- The Kubernetes reference deployment (Section 20.3) is running in the production region with every workload at its documented baseline replica count, HPA active and verified (a synthetic load test confirms it actually scales), and network policies confirmed enforcing egress-deny on every worker deployment.
- TLS certificates are issued and auto-renewing for all six hostnames (Section 20.7), verified by an actual
openssl s_clientcheck against each live hostname. - DNS records — SPF, DKIM, DMARC (at
p=quarantineminimum), and MTA-STS (Section 20.7) — are live and verified via an independent third-party DNS-record checker, not only via the DNS provider's own console. - The email warm-up plan's first two phases (Section 20.7) are complete; sending domain bounce rate is under 2% and spam-complaint rate is under 0.1% over the trailing two weeks.
- PostgreSQL point-in-time recovery is confirmed working via a successful run of the automated monthly restore drill (Section 20.4) within the prior 30 days, and the annual full disaster-recovery drill (Section 20.8) has completed at least once with a measured RTO within the 4-hour target.
- Every secret in the production environment (Section 20.6) has been rotated at least once since being first provisioned (proving the rotation mechanism, not just the initial provisioning, actually works) and boot-time configuration validation has been confirmed to correctly refuse startup on a deliberately broken test configuration.
- Stripe is fully switched from test mode to live mode: live-mode API keys are in the production secret store, the live webhook endpoint is registered and its signature verified against a real live-mode test transaction, and the plan/price objects in Stripe match the Section 12.2 plan table exactly (product IDs, price IDs, and metered-billing meter IDs for the API tier's overage billing, Section 12.6).
- The status page (Section 20.8) is live, independently reachable, and has been smoke-tested by simulating a component going Degraded and confirming the update actually publishes.
- The runbook index (Section 20.8) is complete for all six worked scenarios plus every other alert currently configured in the alerting system (Section 18), and the on-call rotation (Section 20.8) has at least two trained engineers per shift slot, not a single point of human failure mirroring the infrastructure's own no-single-point-of-failure requirement.
- The go-live checklist itself — this list — has been reviewed and signed off by both engineering leadership and whoever holds business/product accountability for the launch, recorded in the release tracking issue exactly as a release sign-off is recorded under standard operating procedure (Section 19.9).
Only once every item above is checked does the release process (Section 20.5) proceed with removing any pre-launch access restriction (a feature flag or an allowlist gate, Section 4's feature-flag mechanism) that had been limiting production access to internal and beta accounts only.
21. Marketing Site, SEO Strategy & Product Copy #
For this product category, the marketing site is not a brochure attached to the product — it is the primary acquisition channel. The overwhelming majority of new users arrive by searching for a specific task ("merge pdf," "compress pdf," "redact pdf") and landing directly on the page that does that one thing. This section specifies that site: its structure, its SEO mechanics, and the actual words it ships with. Copy blocks in this section are final, publishable text, not descriptions of copy — the executor lifts them directly into the codebase.
21.1 Positioning and Messaging #
21.1.1 The core promise #
PDFWorks is the PDF toolkit that keeps your files on your device by default — merge, edit, sign, and convert without uploading them, unless a tool needs a server and says so before you click, or you choose to send an oversize file to one yourself.
21.1.2 Three pillars and their proof points #
| Pillar | Statement | Proof point |
|---|---|---|
| Privacy by default | Most tools process files entirely on your device by default. Nothing is sent anywhere unless you're told first, or you choose to send it yourself. | Twenty-five of the product's thirty-four tools run as WebAssembly in the browser. Four of them — Redact, Protect, Unlock, and Self-sign — never send a file to a server under any circumstance, a guarantee enforced by a build-time product rule and a release-blocking check (Section 6.3). The rest process on-device by default and only reach a server if a file is too large for the visitor's device and the visitor explicitly clicks to allow the fallback (Section 6.3). Every tool page and tool card carries a processing-location badge stating which category a tool falls into, before the user acts (Section 16). |
| Genuinely complete | One workspace replaces five single-purpose sites: edit, organize, convert, e-sign, and OCR. | Thirty-four tools live under one account and one file history, spanning page operations, editing, forms, redaction, security, OCR, Office/HTML conversion, and e-signature (Sections 7–10). |
| Built for people and pipelines | The tools available by hand on the website are the same tools available by code through the API. | The public REST API (Section 14) runs the identical processing engine used in the browser; a merge run through the API and a merge run by dragging files into the browser produce byte-identical output given identical inputs (Section 3). |
21.1.3 Messaging hierarchy #
- Tagline (used in the browser title bar, social cards, and ad headlines): "PDFs, handled privately."
- One-sentence promise (21.1.1) — used in the hero and in meta descriptions for brand-intent pages.
- Three pillars (21.1.2) — used on the home page (21.4) and in the developer teaser (21.4.6).
- Proof points — used in FAQ answers, comparison pages (21.6), and the security page (21.10.8).
- Feature-level claims — used only on the page for that specific feature (a tool page states what that tool does; it does not restate the pillars).
Nothing below the tagline is asserted without something above it to back it up. A tool page never states pillar-level claims ("the most private PDF tool") without the specific mechanism ("this tool runs entirely in your browser") directly beside it.
21.1.4 Tone of voice #
Direct, specific, unhyped. PDFWorks writes like an engineer who respects the reader's time and technical judgment, not like an ad. Five do/don't pairs:
| Do | Don't |
|---|---|
| "This file never leaves your device." | "Bank-level security for total peace of mind." |
| "Compress typically reduces image-heavy PDFs by 40–70%." | "The world's best PDF compressor." |
| "Free includes every client-side tool, unlimited, for as long as you use it." | "Totally free forever, no catches!" |
| "Uploaded files are encrypted, processed in an isolated sandbox, and deleted within 24 hours (Section 6)." | "Your files are 100% safe with us." |
| "Redaction deletes the underlying text and image data, not just a black rectangle on top (Section 9.1)." | "Military-grade redaction." |
Rules the tone follows in every piece of copy in this section: no exclamation points in headlines, no rhetorical questions as headlines ("Tired of bloated PDFs?"), no invented urgency ("Limited time!"), numbers over adjectives wherever a number is available, and every claim about what a tool does is checkable by using the tool.
21.1.5 The privacy-claims constraint #
This is the single constraint that overrides all other copywriting freedom: a privacy claim must be literally true for the specific tool or page it appears on, in every state that tool can be in — not just the default state. "Your file never leaves your device" is a factual, testable statement about a client-side tool with no fallback. It is false, and therefore banned, on any page describing a server-side tool, and it is false on a client-side tool that offers a fallback unless that tool never actually falls back.
Two tiers of client-side tool exist, and the copy must say which tier a tool is in:
- The four no-fallback tools — Redact, Protect, Unlock, and Self-sign — never offer server processing under any circumstance. This is a hard product rule, not a policy choice that could quietly change: it is enforced at build time and checked on every release (Section 6.3). Copy for these four tools may use the strongest absolute phrasing available, and should — it is a guarantee, not a preference.
- Every other client-side tool may offer an explicit, opt-in, click-required fallback when a file is too large for the visitor's device to finish the job. The fallback never triggers itself; it only appears as an offer, and only proceeds after the visitor clicks to accept it (Section 6.3). Copy for these tools states the on-device default plainly and names the fallback as a visitor-chosen exception, not a hidden asterisk.
Approved phrasing, by context:
| Context | Approved copy |
|---|---|
| Redact, Protect, Unlock, Self-sign (no-fallback client-side tools) | "This file never leaves your device." / "Processed entirely in your browser. No upload, no server, no fallback — not even for a huge file. It's a build rule, checked on every release, not just a promise." |
| Other client-side tools (fallback-eligible) | "Processed on your device. If a file is too large for your device to finish, you'll get the option to send it to our servers instead — only if you choose to." / "This file stays on your device unless you tell it not to." Never drop the second sentence to save space; a bare "never leaves your device" is banned for these tools (see below). |
| Server-side tool page | "This file is uploaded over an encrypted connection, processed in an isolated sandbox, and permanently deleted within 24 hours (Section 6)." Never claim the file "never leaves your device" or is "100% private" on these pages. |
| Product-wide (home, about, security) | "Most PDFWorks tools process files entirely on your device, and four of them — Redact, Protect, Unlock, and Self-sign — never touch a server no matter what. The rest offer a server fallback only for a file too large for your device, and only if you choose it. The tools that are server-side by design tell you before you click, encrypt what they receive, and delete it within 24 hours (Section 6)." |
Banned phrasing, product-wide, and why:
| Banned phrase | Reason |
|---|---|
| "PDFWorks never uploads your files" | False — nine of the thirty-four tools require an upload by design, and most of the remaining client-side tools can be sent to a server if the visitor chooses the large-file fallback (Section 6). |
| "This file never leaves your device," on a fallback-eligible tool, with no mention of the fallback | True only in the default case; once the fallback exists as an option, stating the absolute without the exception is misleading by omission. Use the fallback-eligible phrasing above instead. |
| "We'll finish it on our servers," or any fallback copy not preceded by an explicit click | The fallback is opt-in only; copy must never imply it happens automatically. Silent upload is a defect, not a feature, and the copy must never suggest otherwise. |
| "We never see your data" | False for server-side tools and for a client-side tool's fallback path; the processing worker necessarily reads the file to do its job. |
| "Your files are 100% safe / 100% secure" | Absolute claims are unverifiable and legally indefensible; state the specific control instead ("AES-256-GCM encryption at rest," Section 17). |
| "Military-grade encryption" | Undefined marketing term. State the actual algorithm and key size. |
| "Bank-level security" | Undefined marketing term with no fixed technical meaning. |
| "Anonymous" / "we don't track you" (applied to the whole product) | The product operates accounts, billing, and analytics; state precisely what is and is not collected and link to the privacy policy (21.10.1) rather than asserting anonymity. |
21.2 The Site Map #
All marketing pages are statically generated at build time unless noted, and rebuilt on content publish (blog) or on a scheduled revalidation window (help center). Application routes (app.pdfworks.io/*), the signer portal (sign.pdfworks.io/*), and the status page (status.pdfworks.io) are separate applications and are out of scope for this section beyond the links to them described below.
Every URL in this section is the canonical tool route defined once, in Section 15.1, and repeated here verbatim so the copy and the routing table can never drift apart. A tool's marketing page is its canonical, indexable URL — there is no separate app.pdfworks.io page for an individual tool. A signed-in visitor using a tool keeps working on the same marketing URL; app.pdfworks.io is reserved for account-wide surfaces (dashboard, job history, billing, team settings) and carries no content of its own for a single tool (Section 15.1, 21.7.3).
21.2.1 Core pages #
| URL | Purpose | Target keyword / intent | Template | Static? |
|---|---|---|---|---|
/ |
Home — brand and primary conversion | "pdf editor online," "pdf tools" | home |
SSG |
/pdf-tools |
Tool index — the hub every tool page and every footer links back to | "pdf tools," "all pdf tools" | tool-index |
SSG |
/pricing |
Plan comparison and upgrade conversion | "pdfworks pricing" | pricing |
SSG |
/developers |
API landing page | "pdf api," "pdf conversion api" | dev-landing |
SSG |
/about |
Company page | "about pdfworks" (branded) | about |
SSG |
/security |
Trust page summarizing Section 17 for a non-technical buyer | "pdfworks security" | trust-page |
SSG |
21.2.2 Tool pages (34 total) #
Every tool page is flat off the root (no /tools/ path segment) to keep the URL as close to the search query as possible. Template tool-page for every row; the template block-by-block spec is in 21.3.1. The URL and processing-location columns match Section 15.1's route table exactly.
| URL | Tool | Processing location |
|---|---|---|
/merge-pdf |
Merge PDF | On your device |
/split-pdf |
Split PDF | On your device |
/extract-pdf-pages |
Extract Pages | On your device |
/organize-pdf |
Organize PDF | On your device |
/rotate-pdf |
Rotate PDF | On your device |
/delete-pdf-pages |
Delete Pages | On your device |
/add-blank-pages |
Insert Blank Pages | On your device |
/crop-pdf |
Crop PDF | On your device |
/compress-pdf |
Compress PDF | On your device |
/watermark-pdf |
Watermark PDF | On your device |
/add-page-numbers |
Add Page Numbers | On your device |
/bates-numbering |
Bates Numbering | On your device |
/protect-pdf |
Protect PDF | On your device (no server fallback, ever) |
/unlock-pdf |
Unlock PDF | On your device (no server fallback, ever) |
/flatten-pdf |
Flatten PDF | On your device |
/redact-pdf |
Redact PDF | On your device (no server fallback, ever) |
/annotate-pdf |
Annotate PDF | On your device |
/edit-pdf |
Edit PDF (text & images) | On your device |
/fill-pdf-forms |
Fill PDF Forms | On your device |
/create-pdf-forms |
Create PDF Forms | On your device |
/sign-pdf |
Sign PDF (self-sign) | On your device (no server fallback, ever) |
/pdf-to-jpg |
PDF to JPG | On your device |
/jpg-to-pdf |
JPG to PDF | On your device |
/repair-pdf |
Repair PDF | On your device |
/edit-pdf-metadata |
Edit PDF Metadata | On your device |
/ocr-pdf |
OCR PDF | On our servers |
/pdf-to-word |
PDF to Word | On our servers |
/pdf-to-excel |
PDF to Excel | On our servers |
/pdf-to-powerpoint |
PDF to PowerPoint | On our servers |
/word-to-pdf |
Word to PDF | On our servers |
/excel-to-pdf |
Excel to PDF | On our servers |
/powerpoint-to-pdf |
PowerPoint to PDF | On our servers |
/html-to-pdf |
HTML to PDF | On our servers |
/pdf-to-html |
PDF to HTML | On our servers |
Every "On your device" row other than Protect, Unlock, Redact, and Sign PDF may offer an explicit, opt-in fallback to our servers if a file is too large for the visitor's device to finish; that offer only appears when needed and only proceeds after an explicit click (21.1.5, Section 6.3).
The e-signature product (sending an envelope to other people, Section 10) is marketed on its own landing page rather than as a 35th tool card, because it is a distinct workflow with its own onboarding rather than a single-file operation:
| URL | Purpose | Target keyword / intent | Template | Static? |
|---|---|---|---|---|
/esign |
E-signature product landing page | "e-signature software," "send document for signature" | product-landing |
SSG |
21.2.3 Comparison, content, and help #
| URL | Purpose | Target keyword / intent | Template | Static? |
|---|---|---|---|---|
/compare/ilovepdf |
Comparison page | "pdfworks vs ilovepdf" | comparison |
SSG |
/compare/smallpdf |
Comparison page | "pdfworks vs smallpdf" | comparison |
SSG |
/compare/sejda |
Comparison page | "pdfworks vs sejda" | comparison |
SSG |
/blog |
Blog index | content hub, category browsing | blog-index |
SSG, revalidated on publish |
/blog/[slug] |
Blog post | long-tail informational queries (21.8) | blog-post |
SSG, revalidated on publish |
/blog/category/[category] |
Category archive (only for categories with 3+ posts, 21.7.11) | category-level informational | blog-category |
SSG, revalidated on publish |
/help |
Help center index | "pdfworks help," support intent | help-index |
SSG, revalidated on publish |
/help/[article] |
Help article | task-support intent | help-article |
SSG, revalidated on publish |
docs.pdfworks.io |
API reference (separate build, Section 14) | "pdfworks api docs" | external app | separate SSG build |
status.pdfworks.io |
Uptime and incident history (Section 18) | n/a — linked from the footer, not indexed as marketing content | external | n/a |
21.2.4 Legal and trust pages #
| URL | Purpose | Template | Static? |
|---|---|---|---|
/privacy |
Privacy policy (21.10.1) | legal |
SSG |
/terms |
Terms of service (21.10.2) | legal |
SSG |
/acceptable-use |
Acceptable use policy (21.10.3) | legal |
SSG |
/cookies |
Cookie notice (21.10.4) | legal |
SSG |
/esign-disclosure |
Electronic Record and Signature Disclosure (21.10.5) | legal |
SSG |
/dpa |
Data Processing Addendum (21.10.6) | legal |
SSG |
/subprocessors |
Sub-processor list (21.10.7) | legal |
SSG, revalidated on change |
21.3 The Tool Landing Page Template #
The tool landing page is the highest-leverage page in the product: it is where a searcher becomes a user in one action, with no signup wall for guest-eligible tools. The template is fixed across all 34 tool pages; only the copy and the specific benefit/FAQ content change per tool.
21.3.1 Template specification #
Block order, top to bottom:
- Breadcrumb —
Home / PDF Tools / {Tool Name}, rendered from the same data that feeds theBreadcrumbListstructured data below. - H1 — pattern
{Verb} PDF {Object}matching the primary search query for that tool exactly (e.g., "Merge PDF Files," "Compress PDF Files"). One H1 per page, no exceptions. - One-line promise — a single sentence directly under the H1, specific to the tool, never a restatement of the pillars.
- The drop zone (above the fold, no scrolling required) — the actual tool. Not a screenshot, not a "try it" button that navigates elsewhere: a working drag-and-drop / click-to-browse surface that accepts a file immediately. This is the entire conversion strategy — the visitor is using the product before they have made any commitment. For guest-eligible tools (21.9.1) the drop zone is fully functional with no account. For non-guest tools, the drop zone still accepts a file and shows the tool's controls; the processing action itself prompts for a free account (21.9.3).
- Processing Location Indicator — rendered immediately beside the drop zone, using the canonical component (Section 16) and the state contract it implements (Section 6.2): lock glyph / green token / "This file never leaves your device" for Redact, Protect, Unlock, and Self-sign, which never fall back to a server under any circumstance; lock glyph / green token / "Processed on your device — send it to our servers instead if a file's too big for it, only if you say so" for every other client-side tool; or cloud glyph / amber token / "This file is uploaded, encrypted, and deleted within 24 hours" for server-side tools. This is the same component used on tool cards and job rows elsewhere in the product; it is never a page-specific rewrite.
- Three-step "how it works" — numbered, each step one short sentence, specific to the tool's actual interaction, never generic ("Upload, Process, Download" is banned as too vague to be useful; each tool's steps name what actually happens).
- Benefits row — three or four short benefit cards, each with a bolded label and one sentence, specific claims only (numbers where available).
- FAQ block — exactly five questions, real answers (two to four sentences), matching verbatim the
FAQPagestructured data so the page qualifies for the rich result. - Related tools grid — four tool cards, chosen from tools commonly used before or after this one in the same task (curated per tool, not randomized or purely categorical).
- Structured data —
SoftwareApplication,FAQPage, andBreadcrumbList, emitted as a single@graphin one<script type="application/ld+json">block.
Structured data example (Merge PDF, the canonical pattern every other tool page follows with its own name, description, FAQ, and breadcrumb):
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "SoftwareApplication",
"name": "Merge PDF - PDFWorks",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Any (runs in browser)",
"url": "https://pdfworks.io/merge-pdf",
"description": "Combine multiple PDF files into one document entirely in your browser, with no upload required.",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
}
},
{
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Is my file uploaded to a server?",
"acceptedAnswer": {
"@type": "Answer",
"text": "By default, no — Merge runs entirely in your browser using WebAssembly, and your files are read, combined, and written back to a new PDF without leaving your device. If a merge is too large for your device to finish, you'll be offered the option to complete it on our servers instead, but that only happens if you click to accept it; it never happens on its own."
}
},
{
"@type": "Question",
"name": "Is there a limit to how many files I can merge?",
"acceptedAnswer": {
"@type": "Answer",
"text": "There is no hard limit on file count. Free and guest accounts cap individual files at 25 MB; Pro and Team raise that to 1 GB per file."
}
},
{
"@type": "Question",
"name": "Will merging affect the quality of my PDFs?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. Pages are copied as native PDF objects rather than re-rendered, so text, fonts, and vector graphics stay exactly as they were in the source files."
}
},
{
"@type": "Question",
"name": "Can I reorder pages after merging?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. Use Organize PDF after merging to reorder, delete, or rotate individual pages of the combined file."
}
},
{
"@type": "Question",
"name": "Does PDFWorks keep a copy of my merged file?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. Nothing is transmitted by default, so there is nothing on PDFWorks servers to keep. The output stays in your browser's local storage until you download it or close the tab."
}
}
]
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://pdfworks.io/" },
{ "@type": "ListItem", "position": 2, "name": "PDF Tools", "item": "https://pdfworks.io/pdf-tools" },
{ "@type": "ListItem", "position": 3, "name": "Merge PDF", "item": "https://pdfworks.io/merge-pdf" }
]
}
]
}Every other tool page emits the identical shape with its own name, url, description, five Question/Answer pairs matching its on-page FAQ verbatim, and its own three-item breadcrumb.
21.3.2 Merge PDF — full copy #
- Meta title:
Merge PDF Files Online, Free & Private | PDFWorks - Meta description:
Combine PDFs into one file in your browser. No upload, no file-size games, no account needed for occasional use. Drag, drop, done. - H1: Merge PDF Files
- One-line promise: Drag in your files, drop them in the order you want, and download one PDF — processed on your device, not ours.
- Drop zone copy: "Drop PDF files here or click to browse. Add as many as you like." Button:
Merge {n} files. - Processing badge: On your device — "Processed on your device. If a merge is too big for your device to finish, you can choose to send it to our servers instead — only if you say so."
- How it works:
- Add your files — drag and drop, or click to browse.
- Arrange the order — files merge top to bottom; drag any file up or down before you merge.
- Download the result — one combined PDF, ready in seconds. Nothing was uploaded.
- Benefits row:
- No file-size roulette. Merge files up to 25 MB each on a guest or Free account, or up to 1 GB each on Pro and Team (Section 12).
- No page limit. Combine as many files as your device's memory allows — and if a huge batch ever outgrows it, you'll get the option to finish on our servers instead, only if you choose to.
- No account required for casual use. Merge is one of the six tools guests can use immediately, up to 5 tasks per device per day.
- No accuracy loss. Pages are copied as native PDF objects, not re-rendered, so text stays selectable and fonts stay sharp.
- FAQ: as in the structured data example in 21.3.1.
- Related tools: Split PDF, Organize PDF, Compress PDF, PDF to JPG.
21.3.3 Compress PDF — full copy #
- Meta title:
Compress PDF Online Free — Reduce File Size | PDFWorks - Meta description:
Shrink large PDFs by 40-70% without leaving your browser. Choose a compression level, preview the result, and download — nothing uploads. - H1: Compress PDF Files
- One-line promise: Pick a compression level, watch the size drop, download — all before the file ever leaves your device.
- Drop zone copy: "Drop a PDF here or click to browse. We'll show you the new size before you download."
- Processing badge: On your device — "Processed on your device. If a file is too big for your device to finish, you can choose to send it to our servers instead — only if you say so."
- How it works:
- Add your PDF — drag and drop or browse; compress works on one file at a time.
- Choose a compression level — Low, Medium, or High. Medium is the default and works well for most documents.
- Compare and download — see the before-and-after size, then download the smaller file.
- Benefits row:
- Real size reduction. Most PDFs shrink 40–70% depending on how many embedded images they carry.
- Level control. Low keeps images near-original quality; High favors the smallest possible file for email attachment limits.
- Text stays text. Compression re-samples embedded images; it never rasterizes your pages, so selectable and searchable text stays that way.
- No queue, no wait. Runs at your device's speed, not shared server capacity — and if a file's too big for your device, you can choose to send it to our servers instead.
- FAQ:
- How much smaller will my file get? It depends on content: image-heavy PDFs shrink the most (40–70% is typical); text-only PDFs may only shrink 5–15% because there is little to compress.
- Does compression reduce quality? At Medium and Low, no visible difference for most viewing and printing. High reduces image resolution more aggressively and is meant for email-size constraints, not print production.
- Is compression lossless? No for images — Compress PDF re-encodes embedded images at a lower quality or resolution. It is lossless for text and vector content.
- Can I compress a scanned PDF? Yes, and this is where compression matters most: each page is a large embedded image, so the reduction is typically the largest of any document type.
- Will compressing strip my PDF's metadata? No. Compress touches image data and internal object structure only. Use Edit PDF Metadata if you want to remove metadata.
- Related tools: Repair PDF, Merge PDF, PDF to JPG, OCR PDF.
21.3.4 Redact PDF — full copy #
- Meta title:
Redact PDF Online — True, Permanent Redaction | PDFWorks - Meta description:
Draw a box and remove the content underneath, permanently. Redaction deletes the underlying text and image data, not just a black rectangle. - H1: Redact PDF — Permanently Remove Sensitive Content
- One-line promise: A black box that only looks redacted is not redaction. PDFWorks deletes the text and image data underneath, on your device, before you download.
- Drop zone copy: "Drop a PDF here to start redacting. Nothing is uploaded, ever — Redact has no server fallback, full stop. It's a build rule, checked on every release, not just a promise."
- Processing badge: On your device — "This file never leaves your device. Redact has no server fallback under any circumstance — enforced by a release-blocking check (Section 6.3)."
- How it works:
- Mark what to remove — draw a box over any text, image, or region on any page; add as many marks as you need.
- Apply — PDFWorks deletes the underlying glyphs and image pixels under each mark, not just paints over them (Section 9.1).
- Verify and download — a Redaction Verification Report confirms nothing marked survives in the output, then download the sanitized file.
- Benefits row:
- True removal, not a cosmetic overlay. The text run and image pixels under a redaction box are deleted from the file, not hidden behind it (Section 9.1).
- Metadata cleared too. Hidden metadata, embedded files, and scripts are stripped along with the visible content.
- A report you can keep. Every redaction produces a downloadable verification report listing exactly what was removed, page by page.
- No incremental-save leak. The file is rewritten in full, so there is no earlier version of the removed content still sitting inside it.
- FAQ:
- Is this different from just drawing a black box? Yes, fundamentally. A drawn box only covers content visually; the text underneath can still be selected or extracted. PDFWorks redaction deletes the underlying glyph data and image pixels before drawing the box, so there is nothing left to extract.
- Can redacted content be recovered? No. The affected text runs, image regions, annotations, and form fields are deleted from the file's internal structure, fonts are subset to remove unused glyphs, and the file is rewritten from scratch rather than saved incrementally — the failure mode that leaves old content recoverable.
- What does the Redaction Verification Report show? A per-page count of glyph runs, images, and annotations removed, plus a pass/fail result confirming that none of the redacted text or pixels remain detectable in the output.
- Does redaction remove metadata too? Yes. Applying a redaction also strips the document's XMP metadata, embedded files, JavaScript, and any named destination pointing into the removed content.
- Can I redact a scanned, image-only PDF? Yes. Draw the box over the sensitive region; the underlying image pixels in that region are overwritten and re-encoded so the source pixels are gone, not just covered.
- Related tools: Flatten PDF, Protect PDF, Edit PDF Metadata, OCR PDF.
21.3.5 PDF to Word — full copy #
- Meta title:
PDF to Word — Convert PDF to Editable DOCX | PDFWorks - Meta description:
Turn a PDF into an editable Word document with layout, tables, and formatting preserved. Uploaded over an encrypted connection, deleted within 24 hours. - H1: PDF to Word Converter
- One-line promise: Upload once, get back an editable .docx that keeps your layout, fonts, and tables intact.
- Drop zone copy: "Drop a PDF here or click to browse. This tool uploads your file to convert it — here's what that means below."
- Processing badge: On our servers — "This file is uploaded, encrypted, and deleted within 24 hours."
- How it works:
- Upload your PDF — sent over an encrypted connection to an isolated conversion worker.
- Automatic layout reconstruction — text, tables, images, and formatting are rebuilt into a native Word document, not dumped as unstructured text.
- Download your .docx — ready to edit in Word, Google Docs, or any compatible app. Your uploaded file is deleted from PDFWorks systems within 24 hours (Section 6).
- Benefits row:
- Layout-aware conversion. Tables stay tables, columns stay columns, headings stay headings.
- Fonts preserved where licensing allows, substituted with a close visual match otherwise.
- Scanned PDFs work too — if a page has no extractable text, run OCR PDF first, then convert.
- Deleted on a clock, not on request. The retention rule in Section 6 applies automatically; nothing to remember to do.
- FAQ:
- Why does this tool require uploading, unlike most PDFWorks tools? Reconstructing an accurate Word document requires a full layout-analysis engine that is impractical to run inside a browser tab. This is one of a small number of PDFWorks tools that processes server-side; the badge above tells you this before you upload.
- What happens to my file after conversion? It is encrypted at rest, processed in an isolated sandboxed worker with no outbound network access, and permanently deleted no later than 24 hours after upload — sooner if the job finishes and you don't return to it (Section 6).
- Will the formatting match my original PDF exactly? Closely, not pixel-for-pixel. Text, tables, headings, and images are reconstructed as native Word elements, which can shift line breaks slightly compared to the fixed layout of a PDF.
- Can I convert a scanned document? Not directly — a scanned page is an image with no extractable text. Run OCR PDF first to add a text layer, then convert the result with PDF to Word.
- Is there a file-size or page limit? Free accounts get 2 server-side conversions per day at up to 25 MB per file; Pro and Team get unlimited conversions at up to 1 GB per file (Section 12).
- Related tools: OCR PDF, Word to PDF, PDF to Excel, PDF to PowerPoint.
21.3.6 Remaining tool pages — copy table #
The following thirty tool pages use the identical template (21.3.1). Each row gives the complete copy the executor needs to build the page: H1, meta title, meta description, and one-line promise. Processing badge, how-it-works steps, benefits, and FAQ follow the same pattern established in 21.3.2–21.3.5, phrased around each tool's specific action; for Protect PDF, Unlock PDF, and Sign PDF specifically, follow the Redact PDF pattern (21.3.4) — the strongest absolute processing-badge and drop-zone phrasing, since these three, like Redact, never offer a server fallback (21.1.5, Section 6.3).
| Tool | H1 | Meta title | Meta description | One-line promise |
|---|---|---|---|---|
| Split PDF | Split PDF into Multiple Files | Split PDF Online — Free & Private | PDFWorks | Break a PDF into separate files by page range, or split every page into its own file. Processed on your device — nothing is uploaded. | Choose page ranges or split every page into its own file, without uploading anything. |
| Extract Pages | Extract Pages from a PDF | Extract PDF Pages Online | PDFWorks | Pull specific pages out of a PDF into a new file. Select pages visually and download the result, processed in your browser. | Pick the pages you need, leave the rest behind, download a new PDF in seconds. |
| Organize PDF | Organize and Reorder PDF Pages | Organize PDF Pages — Reorder, Delete, Rotate | PDFWorks | Drag pages into a new order, delete the ones you don't need, and rotate any page in one view. Runs on your device. | Drag pages into the order you want and delete what you don't need, all in one view. |
| Rotate PDF | Rotate PDF Pages | Rotate PDF Online Free | PDFWorks | Fix sideways or upside-down pages. Rotate one page or the whole document, processed on your device. | Fix sideways scans in one click, per page or across the whole document. |
| Delete Pages | Delete Pages from a PDF | Delete PDF Pages Online | PDFWorks | Remove unwanted pages from a PDF without affecting the rest of the document. Processed entirely in your browser. | Select the pages you don't want and remove them without touching the rest. |
| Insert Blank Pages | Add Blank Pages to a PDF | Add Blank Pages to PDF Online | PDFWorks | Insert blank pages anywhere in a PDF, for notes, dividers, or print layout. Runs on your device, no upload. | Insert a blank page anywhere you need one, for notes, dividers, or layout. |
| Crop PDF | Crop PDF Pages | Crop PDF Online Free | PDFWorks | Trim margins or resize the visible page area of a PDF with a live preview. Processed on your device. | Drag the crop handles, preview the result, apply it to one page or all of them. |
| Watermark PDF | Add a Watermark to a PDF | Watermark PDF Online — Text or Image | PDFWorks | Stamp a text or image watermark across every page, with control over position, opacity, and rotation. | Stamp your logo or a text label across every page, with full control over placement. |
| Add Page Numbers | Add Page Numbers to a PDF | Add Page Numbers to PDF Online | PDFWorks | Number every page with your choice of position, starting number, and format. Processed in your browser. | Choose a position, a starting number, and a format — every page numbered in seconds. |
| Bates Numbering | Add Bates Numbering to a PDF | Bates Numbering for PDF Online | PDFWorks | Apply sequential Bates numbers with a prefix and fixed digit count across one or many documents. | Apply consistent, sequential Bates numbers across a document set, prefix and all. |
| Protect PDF | Password-Protect a PDF | Protect PDF with a Password | PDFWorks | Encrypt a PDF with a password so only people you share it with can open it. Processed on your device — no server fallback, ever. | Set a password, encrypt the file, and it stays unreadable to anyone you don't share it with. |
| Unlock PDF | Remove a Password from a PDF | Unlock PDF Online — Remove Password | PDFWorks | Remove a password from a PDF you already have the password to. Processed on your device — no server fallback, ever. | Enter the password you already have, and get back an unlocked copy. |
| Flatten PDF | Flatten a PDF | Flatten PDF Online | PDFWorks | Turn form fields, annotations, and layers into permanent page content so nothing can be edited afterward. | Turn fillable fields and annotations into permanent, uneditable page content. |
| Annotate PDF | Annotate a PDF | Annotate PDF Online — Comments & Shapes | PDFWorks | Add comments, highlights, shapes, and freehand marks to a PDF. Fully keyboard-operable, processed on-device. | Highlight, comment, and mark up a document without printing it first. |
| Edit PDF | Edit PDF Text and Images | Edit PDF Online — Text & Images | PDFWorks | Change existing text, move or replace images, and adjust layout directly in a PDF, in your browser. | Click on text or an image and change it, right where it sits on the page. |
| Fill PDF Forms | Fill Out a PDF Form | Fill PDF Forms Online Free | PDFWorks | Complete an existing fillable PDF form, or type directly onto a flat form, then save or print. | Tab through the fields, type your answers, download a completed form. |
| Create PDF Forms | Create a Fillable PDF Form | Create Fillable PDF Forms Online | PDFWorks | Add text fields, checkboxes, dropdowns, and signature fields to turn a static PDF into a form. | Drop in text fields, checkboxes, and signature blocks to turn any PDF into a form. |
| Sign PDF | Sign a PDF Yourself | Sign PDF Online Free | PDFWorks | Draw, type, or upload your signature and place it on your own document. Processed on your device — no server fallback, ever. | Draw, type, or upload your signature and drop it exactly where it belongs. |
| PDF to JPG | Convert PDF to JPG | PDF to JPG Converter — Free | PDFWorks | Turn every page of a PDF into a JPG image, or pick just the pages you need. Processed in your browser. | Every page becomes its own JPG, ready to download individually or as a batch. |
| JPG to PDF | Convert JPG to PDF | JPG to PDF Converter — Free | PDFWorks | Combine one or more JPG or PNG images into a single PDF, in the order you choose. On your device. | Drop in your images, set the order, get back one PDF. |
| Repair PDF | Repair a Damaged PDF | Repair PDF Online — Fix Corrupted Files | PDFWorks | Recover a PDF that won't open or displays incorrectly by rebuilding its internal structure. | Rebuilds a damaged file's internal structure so it opens normally again. |
| Edit PDF Metadata | Edit PDF Metadata | Edit PDF Metadata Online | PDFWorks | View and change a PDF's title, author, subject, and keywords, or strip metadata entirely. | See exactly what's in your file's metadata, and change or clear it. |
| OCR PDF | Make a Scanned PDF Searchable (OCR) | OCR PDF Online — Make Scans Searchable | PDFWorks | Add a searchable, selectable text layer to a scanned PDF. Uploaded, processed, deleted within 24 hours. | Adds an accurate, invisible text layer under your scan so you can search and copy it. |
| PDF to Excel | Convert PDF to Excel | PDF to Excel Converter — XLSX | PDFWorks | Turn tables in a PDF into an editable Excel spreadsheet with rows and columns intact. | Tables come out as real spreadsheet rows and columns, not a wall of pasted text. |
| PDF to PowerPoint | Convert PDF to PowerPoint | PDF to PowerPoint Converter — PPTX | PDFWorks | Turn PDF slides or pages into an editable PowerPoint presentation. Deleted within 24 hours. | Get back editable slides you can present from or redesign, not flattened images. |
| Word to PDF | Convert Word to PDF | Word to PDF Converter — Free | PDFWorks | Turn a Word document into a PDF that looks the same on every device. Deleted within 24 hours. | Your document, locked into a PDF that looks identical everywhere it's opened. |
| Excel to PDF | Convert Excel to PDF | Excel to PDF Converter — Free | PDFWorks | Turn a spreadsheet into a properly paginated PDF, with print areas respected. Deleted within 24 hours. | Your spreadsheet becomes a properly paginated PDF, print areas and all. |
| PowerPoint to PDF | Convert PowerPoint to PDF | PowerPoint to PDF Converter — Free | PDFWorks | Turn a presentation into a PDF that keeps its exact layout, fonts, and slide order. | Every slide, exactly as designed, in a file anyone can open. |
| HTML to PDF | Convert a Web Page to PDF | HTML to PDF Converter | PDFWorks | Turn a web page or HTML file into a paginated, printable PDF. Deleted within 24 hours. | Give us a URL or an HTML file, get back a clean, paginated PDF. |
| PDF to HTML | Convert PDF to HTML | PDF to HTML Converter | PDFWorks | Turn a PDF into structured HTML you can publish on the web or edit in a CMS. | Turn a static PDF into structured HTML you can publish or drop into a CMS. |
21.4 Home Page Copy #
21.4.1 Hero #
Headline: The PDF toolkit that doesn't need your files.
Subhead: Merge, edit, convert, and sign PDFs right in your browser. Most
tools stay on your device unless you choose otherwise — the ones that
upload by design tell you first, encrypt everything, and delete it within
a day.
Primary CTA: Open a tool — no account needed
Secondary CTA: See all 34 tools21.4.2 Tool grid section #
Section header: Every tool, one page each
Section subhead: Click a tool, drop a file, done. No setup, no install — and for
most tools, no upload.The grid is organized into the categories used across the tool specifications (page operations, editing & forms, security & redaction, conversion, e-signature), with each tool card carrying its own Processing Location Indicator (Section 16) so the location is visible before any click, not just on the destination page.
21.4.3 Privacy explainer section #
This section gets real space on the page — a full-width section below the tool grid, not a footnote — because it is the product's actual differentiator and the thing most visitors are skeptical of.
Section header: How "never leaves your device" actually works
Body:
Most PDF tools work like this: you upload your file to a server, a program on
that server changes it, and you download the result. Somewhere in that trip,
a copy of your file sat on a computer you don't control.
PDFWorks runs differently for most tools. When you drop a file into Merge,
Compress, Redact, or twenty-two other tools, the file is read directly by
your browser and processed by the same kind of code a desktop application
would use — it just happens to run inside a web page instead of an installed
program. By default, nothing is sent anywhere. If you turned off your Wi-Fi
before clicking "Merge," a normal-sized file would still merge just fine.
This is possible because of a browser technology called WebAssembly, which
lets a full document-processing engine run at near-native speed inside your
browser tab. It's the same engine PDFWorks runs on its own servers for the
tools that do need one — so the results are identical either way.
Four of these on-device tools — Redact, Protect, Unlock, and Self-sign — take
this further: they never send a file to a server, under any circumstance.
That's not a setting or a promise; it's a build-time rule enforced by a
check that blocks every release where it isn't true.
The other twenty-one on-device tools stay on your device by default too, and
add one honest exception: if a file is too large for your device's memory to
finish the job, the tool offers to complete it on PDFWorks's servers instead.
That offer only appears when it's needed, and it only proceeds if you click
to accept it — the file never uploads on its own. Decline, and the tool
simply can't finish that particular file on that particular device; nothing
happens behind your back either way.
A handful of tools can't work this way at all. Turning a scanned page into
searchable text (OCR), converting to and from Word, Excel, PowerPoint, or
HTML, and sending a document to someone else for a signature all require
processing power and software that doesn't run practically inside a browser
tab. For those tools — nine of the thirty-four — PDFWorks tells you before
you click: the file is uploaded over an encrypted connection, handled in an
isolated processing environment with no other job able to see it, and
permanently deleted within 24 hours (Section 6). You'll see exactly which
category a tool falls into, and whether it ever offers a fallback, on every
tool card and every tool page, before you commit to using it.
Read the full technical detail on the security page.CTA at the end of the section: Read how we handle your data → /security.
21.4.4 Social proof placeholder policy #
The home page does not ship with a customer-logo strip, a testimonial carousel, or star-rating badges at launch. The rule: no testimonial, review quote, or customer logo appears anywhere on the site until it is real, attributed to a named and consenting customer, and verifiable. Invented quotes, stock-photo "customers," generic review-aggregator badges with no linked source, and vague claims like "loved by thousands" are prohibited outright — they fail the same truthfulness bar as the privacy claims in 21.1.5.
Until real social proof exists, the space it would occupy is filled with factual, self-referential signals instead: a link to the live status page (status.pdfworks.io) showing current uptime, the count of tools available ("34 tools, one account"), and a link to the security page. These are replaced with real testimonials and logos as they are collected and verified; this replacement does not require another specification change, only content.
21.4.5 Plan comparison teaser #
Section header: Start free. Upgrade when a limit actually gets in your way.
Free — $0
Every client-side tool, unlimited. 2 server-side tasks (OCR, Office
conversion, and similar) per day.
Pro — $9/mo
Unlimited server-side tasks, 1 GB files, batch processing, priority
processing, 90-day history.
Team — $15/user/mo
Everything in Pro, plus shared workspaces, shared templates, and a shared
audit log for the whole team.
CTA: See full pricing → /pricing21.4.6 Developer teaser #
Section header: Everything above, as an API.
Body: The same engine behind every tool on this site is available over a
REST API — merge, convert, redact, and e-sign from your own code, with the
same output whether a human clicked a button or your server made a request.
curl -X POST https://api.pdfworks.io/v1/documents/merge \
-H "Authorization: Bearer pk_live_..." \
-H "Idempotency-Key: 8f14e45f-ceea-467e-9de9-8b9a4b1c1a3e" \
-H "Content-Type: application/json" \
-d '{
"documentIds": ["doc_01K7Y3M2QF8V6X", "doc_01K7Y3M8H1J2K3"],
"outputName": "combined.pdf"
}'
CTA: Read the API docs → docs.pdfworks.io21.4.7 Footer #
Product
Tool Index → /pdf-tools
Merge PDF → /merge-pdf
Compress PDF → /compress-pdf
Redact PDF → /redact-pdf
Sign PDF → /sign-pdf
E-Signature → /esign
Pricing → /pricing
Developers / API → /developers
Company
About → /about
Blog → /blog
Compare PDFWorks vs iLovePDF → /compare/ilovepdf
Compare PDFWorks vs Smallpdf → /compare/smallpdf
Compare PDFWorks vs Sejda → /compare/sejda
Resources
Help Center → /help
API Docs → docs.pdfworks.io
System Status → status.pdfworks.io
Security → /security
Legal
Privacy Policy → /privacy
Terms of Service → /terms
Acceptable Use Policy → /acceptable-use
Cookie Notice → /cookies
E-Signature Disclosure → /esign-disclosure
Data Processing Addendum → /dpa
Sub-processors → /subprocessors
© {current year} PDFWorks. All rights reserved.21.5 Pricing Page Copy #
21.5.1 Plan cards #
Eyebrow line above the cards: No account? You can already use 6 tools free,
no signup. → /pdf-tools
Card: Free — $0/month
For anyone who needs a PDF tool right now.
- Every client-side tool (25 of them), unlimited, forever
- 2 server-side tasks per day (OCR, Office and HTML conversion)
- Files up to 25 MB
- 7-day job history
- 3 signature envelopes per month
CTA: Create free account
Card: Pro — $9/month [Most popular]
For anyone who hits Free's server-side cap more than occasionally.
- Everything in Free, unlimited server-side tasks
- Files up to 1 GB
- Batch processing, up to 100 files per job
- Priority processing queue
- 90-day job history
- 100 signature envelopes per month
CTA: Upgrade to Pro
Card: Team — $15/user/month
For a group that shares document work.
- Everything in Pro, per seat
- Shared workspace, shared templates, shared audit log
- 250 files per batch job
- 500 signature envelopes per month per workspace
- 90-day history, workspace-wide
- MFA enforcement for the whole workspace
CTA: Start a Team workspace
Card: API — usage-based, from $29/month
For building PDF processing into your own product.
- Every tool, called by code
- Starter: 2,000 operations/mo included, then $0.012/operation
- Growth ($99/mo): 10,000 operations included, then $0.009/operation
- Scale ($399/mo): 50,000 operations included, then $0.006/operation
- OCR pages meter separately at $0.004/page beyond plan inclusions
CTA: Read the API docs21.5.2 Feature comparison table — row labels #
The table beneath the cards compares Guest, Free, Pro, Team, and API across these rows, with values taken verbatim from the entitlements table in Section 12.2 (not restated here to avoid two sources of truth): Client-side tools · Daily task cap · Max file size · Batch processing · Queue lane · OCR · Office/HTML conversion · Signature requests · Job history · Shared workspace, seats, shared templates, shared audit log · Public API access · Support.
21.5.3 Annual toggle #
Toggle label: Monthly | Annual — 2 months free
Pro annual: $90/year (equivalent to $7.50/month)
Team annual: $150/user/year (equivalent to $12.50/user/month)
Microcopy under the toggle: Switch anytime from account settings. Annual
plans are billed once a year and renew automatically until canceled.21.5.4 Pricing FAQ (8 entries, full answers) #
Q: What exactly do I get for free, and is it really free forever?
A: Free includes every client-side tool — 25 of the 34 on the site —
completely unlimited, for as long as you use PDFWorks. There's no trial
clock counting down on those. The only cap is 2 server-side tasks per day
(OCR, Office conversion, HTML conversion), which resets at midnight UTC.
No credit card is required to create a Free account.
Q: What's the difference between a "client-side" and "server-side" task,
and why does it affect my limits?
A: Client-side tools run entirely in your browser and don't upload your
file by default, so there's no server cost to limit — they're unlimited on
every plan. Server-side tools (OCR, Office and HTML conversion) require
uploading your file to a processing worker, which does cost server
capacity, so Free caps those at 2 per day. Pro and Team remove that cap
entirely.
Q: What happens if I hit my daily server-side task limit on Free?
A: You'll see a prompt explaining the limit has reset time (midnight UTC)
and an option to upgrade to Pro for unlimited server-side tasks. Every
client-side tool keeps working without interruption.
Q: What happens to my data and job history if I downgrade or cancel?
A: Your access continues through the end of the period you already paid
for. After that, your account reverts to Free limits: job history beyond 7
days becomes unreadable but isn't deleted until 30 days after the
downgrade, in case you resubscribe. Any signature envelope already in
progress is allowed to complete rather than being cut off (Section 12).
Q: How does Team pricing work if my workspace has a mix of active and
inactive seats?
A: You're billed per seat, monthly or annually depending on your billing
cycle. Adding or removing a seat mid-cycle is billed exactly as described
in your plan and billing settings (Section 12) — your workspace never
loses access to in-progress work as a result of a seat change.
Q: How does API billing and metering work?
A: Each API plan includes a monthly allotment of operations (2,000 on
Starter, 10,000 on Growth, 50,000 on Scale). Usage beyond the included
amount is metered per operation and billed automatically at the end of the
cycle. OCR pages meter separately from other operations. You can set a hard
spend cap per API key in your dashboard; once it's reached, further calls
return a quota-exceeded error until the cap is raised or the cycle resets.
Q: Is there a discount for paying annually?
A: Yes. Annual billing on Pro and Team is priced at 10 months for 12 —
effectively two months free compared to paying monthly.
Q: Do you offer a trial of Pro or Team before I pay?
A: There's no separate time-limited trial, because Free already functions
as an ongoing trial for every client-side tool — the exact tools most
people need most often. If you subscribe to Pro or Team and it's not for
you, you can cancel anytime and keep access through the period you already
paid for; there's no risk of losing time you paid for.21.5.5 Honest framing of Free #
Free is not a crippled trial designed to expire. Every client-side tool — the majority of the product — is fully unlimited on Free, indefinitely, with no account decay and no feature paywalled behind a "free for 7 days" clock. The only constraint on Free is the 2-per-day server-side task cap, which exists because those specific tasks consume server compute that client-side tools don't. This framing is stated plainly on the pricing page itself (in the Free card's subhead and in FAQ entry one, 21.5.4) rather than left for the visitor to discover by reading the fine print.
21.6 Comparison Pages #
21.6.1 Template and rules #
Comparison pages exist to help a visitor who is already comparing PDF tools make an informed choice — not to disparage a competitor. Every comparison page follows this structure:
- H1:
PDFWorks vs {Competitor} - One-paragraph, neutral framing of what both products do.
- A feature comparison table (rows: privacy model, pricing, tool count, batch processing, e-signature, API access, offline/PWA support).
- "Where PDFWorks is a better fit" — three to four honest, specific points.
- "Where {Competitor} may be a better fit" — at least one honest point. A comparison page with no acknowledged competitor strength reads as biased and is explicitly disallowed.
- A dated "last verified" line and a one-sentence methodology note.
- A short FAQ (three questions).
- CTA to the tool index.
Rules every comparison page follows:
- Factual and sourced. Every claim about a competitor's pricing, limits, or features is checked against that competitor's own public pricing or documentation page at the time of writing, and the comparison table cites the access date.
- Never disparaging. No adjectives applied to a competitor ("bloated," "outdated," "sketchy"). Differences are stated as facts, not judgments.
- Updated on a stated cadence. Each comparison page is reviewed quarterly; the "last verified" date on the page reflects the most recent review, and a review that finds no changes still updates the date.
- Never claim an unverifiable limitation. If a competitor's current behavior can't be confirmed from a public source at review time, the claim is omitted rather than asserted from memory or an old review.
21.6.2 Model comparison page: PDFWorks vs iLovePDF #
H1: PDFWorks vs iLovePDF
Intro: iLovePDF and PDFWorks both offer a broad set of browser-based PDF
tools. The biggest structural difference is where your file is processed:
iLovePDF's tools upload your file to their servers for every operation;
most of PDFWorks's tools process your file locally in your browser by
default and never upload it unless you choose to. Below is a factual
comparison as of the date shown.
| | PDFWorks | iLovePDF |
|---|---|---|
| Where most tools process files | On your device by default (25 of 34 tools); 4 of those never touch a server under any circumstance* | Uploaded to their servers for every tool |
| Free plan server-side task cap | 2/day | Varies by tool, generally session-based |
| Paid plan starting price | $9/month (Pro) | Comparable single-user paid tier, per iLovePDF's pricing page |
| Batch processing | Pro and up, 100+ files/job | Available on paid tiers |
| Native e-signature | Yes, ESIGN/UETA-compliant with a tamper-evident, hash-chained audit trail and a daily published verification anchor | Available as an add-on tool |
| Public REST API | Yes, usage-based pricing from $29/month | Yes, separate API pricing |
| Works offline once loaded | Yes, for anything your device alone can handle — client-side tools keep working during a backend outage | No — every tool requires connectivity |
\* Redact, Protect, Unlock, and Self-sign never send a file to a server,
under any circumstance. The other twenty-one on-device tools may offer an
explicit, opt-in fallback to PDFWorks's servers if a file is too large for
the visitor's device — only after an explicit click.
Where PDFWorks is a better fit:
- You handle sensitive documents (contracts, medical forms, financial
statements) and want the default behavior to be "processed on your
device" rather than "uploaded, then deleted," with four tools that never
touch a server at all.
- You want one account that also covers e-signature and a developer API,
instead of separate subscriptions.
- You want tools that keep working if your internet connection drops
mid-task.
Where iLovePDF may be a better fit:
- iLovePDF has a longer track record and a larger existing library of
region-specific tool variants; if you specifically need a tool PDFWorks
doesn't yet offer, check iLovePDF's tool list.
Last verified: this comparison is reviewed quarterly against iLovePDF's
published pricing and tool pages; figures reflect the most recent review.
FAQ:
Q: Is iLovePDF also private?
A: iLovePDF's published documentation describes uploading files to their
servers for processing; PDFWorks's default for most tools is to process on
your device and only reach a server if you choose to. Review iLovePDF's own
privacy policy for the specifics of their retention and deletion practices.
Q: Can I switch from iLovePDF to PDFWorks without losing anything?
A: Yes — PDFWorks doesn't require importing anything from another service.
Your files stay wherever they already are; you just start using PDFWorks's
tools directly on them.
Q: Does PDFWorks have everything iLovePDF has?
A: PDFWorks covers the core PDF workflow — page operations, editing, forms,
security, redaction, OCR, Office conversion, and e-signature — described in
full on the tool index. For a tool-by-tool comparison, check the specific
tool page on each site.
CTA: See every PDFWorks tool → /pdf-tools21.7 SEO Technical Specification #
21.7.1 URL structure and the trailing-slash decision #
The route table itself — every tool slug and the marketing/workbench relationship for each tool — is defined once, in Section 15.1, and repeated verbatim throughout this section; nothing here diverges from it.
- All URLs are lowercase kebab-case.
- Tool pages are flat off the root (
/merge-pdf), not nested under a/tools/segment, to keep the URL as short and keyword-proximate as possible; the tool index at/pdf-toolsis the hub page, not a path prefix. - No trailing slashes.
https://pdfworks.io/merge-pdfis canonical;https://pdfworks.io/merge-pdf/issues a 308 permanent redirect to the non-slash form. Enforced by the Next.js router configuration (trailingSlash: false) plus an edge-middleware catch for any inbound link that includes one.
21.7.2 Title and meta description templates #
| Page type | Title template | Max length | Description max length |
|---|---|---|---|
| Tool page | {Tool Action} Online, Free & Private | PDFWorks |
60 characters | 155 characters |
| Home | PDFWorks — PDF Tools That Stay on Your Device |
60 characters | 155 characters |
| Pricing | PDFWorks Pricing — Free, Pro, Team & API Plans |
60 characters | 155 characters |
| Blog post | {Post Title} | PDFWorks Blog |
60 characters | 155 characters |
| Comparison | PDFWorks vs {Competitor} — Full Comparison |
60 characters | 155 characters |
| Legal | {Page Name} | PDFWorks |
60 characters | 155 characters |
A CI check runs a lint pass over every statically generated page's rendered <title> and <meta name="description"> and fails the build if either exceeds its limit — this is a build gate, not a style guideline.
21.7.3 Canonical rules #
- Every page emits a self-referencing
<link rel="canonical">. - Any URL reachable with a query string (tracking parameters,
?ref=) canonicalizes to the clean path. - Blog pagination pages (21.7.11) canonicalize to themselves, not to page 1 — each page's content is unique.
- The
sign.pdfworks.ioandapp.pdfworks.iosubdomains are excluded from the marketing sitemap and carrynoindex— they are application surfaces, not search-facing content.
21.7.4 Sitemap structure and generation #
/sitemap.xml → sitemap index, references the files below
/sitemap-pages.xml → home, pricing, developers, about, security, esign
/sitemap-tools.xml → all 34 tool pages
/sitemap-legal.xml → privacy, terms, acceptable-use, cookies,
esign-disclosure, dpa, subprocessors
/sitemap-comparisons.xml → all /compare/* pages
/sitemap-blog-{n}.xml → blog posts, paginated at 5,000 URLs per file
/sitemap-help.xml → help center articlesEach file is generated at build time from the route manifest (static pages) and the published-content list (blog, help), and regenerated on every deploy and on every blog publish event. The sitemap index is submitted to Google Search Console and Bing Webmaster Tools via their submission APIs as part of the deploy pipeline.
21.7.5 robots.txt #
User-agent: *
Allow: /
Disallow: /api/
Disallow: /_next/
Disallow: /account/
Sitemap: https://pdfworks.io/sitemap.xmlThe application (app.pdfworks.io), signer portal (sign.pdfworks.io), and docs subdomain each ship their own robots.txt; app.pdfworks.io and sign.pdfworks.io disallow all crawling (Disallow: /) since they contain no search-facing content and much of their content is behind authentication or is a signer's private session.
21.7.6 Internal linking model #
- Every tool page links to four related tools (21.3.1, block 9), curated per tool rather than random or purely category-based, modeling the tasks a real workflow chains together (e.g., Merge → Compress, Redact → Flatten).
- The tool index links to all 34 tool pages in their category groups.
- The footer (21.4.7) links to the eight highest-search-volume tools sitewide, so every page on the site is at most two clicks from any tool page.
- Blog posts link contextually to at least one relevant tool page and one relevant help article (21.8.3), never as an unrelated end-of-post link dump.
- Every tool page carries the breadcrumb
Home / PDF Tools / {Tool}, reinforcing the tool index as a hub in both the visible UI and theBreadcrumbListstructured data.
21.7.7 Core Web Vitals #
Search ranking treats Core Web Vitals as an input signal, and tool pages are the pages carrying the acquisition load, so the performance budgets owned by Section 18.6 apply here without relaxation — this section doesn't restate the figures, only the SEO consequence of missing them. Because the drop zone is the primary above-the-fold content on every tool page (21.3.1, block 4), it is also the LCP element on most tool pages — it is server-rendered with its final layout dimensions reserved, so it doesn't wait on client-side JavaScript to determine its size and doesn't contribute to layout shift.
21.7.8 Image and font strategy #
- All marketing images are served through
next/image, in AVIF with WebP and JPEG fallback, with explicitwidth/heightattributes to prevent layout shift. - Below-the-fold images (blog post images, comparison page illustrations) are lazy-loaded; the hero and drop-zone graphics are not.
- Fonts are self-hosted (not loaded from a third-party font CDN, avoiding both an extra network origin and a third-party tracking surface), served as variable fonts subset to the Latin character set, with
font-display: swap.
21.7.9 Structured data coverage #
| Page type | Structured data |
|---|---|
| Tool page | SoftwareApplication, FAQPage, BreadcrumbList (21.3.1) |
| Home | Organization, WebSite (with SearchAction if on-site search ships) |
| Pricing | Product with Offer per plan |
| Blog post | Article, BreadcrumbList |
| Comparison page | FAQPage, BreadcrumbList |
| Help article | FAQPage where the article is Q&A-formatted, BreadcrumbList |
21.7.10 hreflang readiness #
The product ships English-only at launch (translations are out of scope; see Section 16 for the internationalization architecture that supports future localization). No hreflang tags are emitted at launch. The URL structure reserves a locale-prefix pattern (/{locale}/merge-pdf) that the routing layer already supports through the internationalization library in the stack (Section 3), so adding a localized site later requires adding locale directories and hreflang tags, not restructuring existing URLs or losing existing search equity on the English pages.
21.7.11 Blog pagination and faceting #
- The blog index paginates at 12 posts per page:
/blog(page 1, no query parameter) and/blog?page=2,/blog?page=3, and so on. - Each paginated page is a unique, canonical-to-itself URL (21.7.3);
rel=next/rel=prevlink hints are not emitted, since major search engines no longer use them as a discovery signal and each page is independently valid content. - Category archive pages (
/blog/category/{category}) are generated and indexed only for categories with three or more published posts; a category with fewer posts is browsable in the UI but the archive page carriesnoindex, followuntil it clears the threshold, to avoid indexing near-empty pages. - Tag-based filtering (as opposed to category archives) is UI-only, implemented with a query parameter, and is not a crawlable or indexed route — it exists for readers browsing the blog, not as a search entry point.
21.8 Content Strategy #
21.8.1 The first twenty posts #
| # | Title | Target intent | Primary keyword | Brief |
|---|---|---|---|---|
| 1 | How to Redact a PDF Without Leaving a Recoverable Trace | Informational, tool-adjacent | how to redact a pdf | Explains why a drawn black box isn't redaction and what true removal requires; links to Redact PDF (Section 9.1). |
| 2 | Is an Electronic Signature Legally Binding? | Informational | is an electronic signature legally binding | Plain-language primer on ESIGN and UETA; links to the e-signature product page and disclosure. |
| 3 | PDF vs. PDF/A: What's the Difference and Do You Need It | Informational | pdf vs pdf/a | Explains the archival format and states plainly that PDFWorks does not certify PDF/A compliance, with a note on what that means practically. |
| 4 | How to Password-Protect a PDF, and What the Encryption Actually Does | Informational, tool-adjacent | password protect a pdf | Explains PDF encryption at a non-technical level; links to Protect PDF. |
| 5 | Why Your Browser Can Now Edit PDFs Like a Desktop App | Thought leadership | pdf editor in browser | Explains WebAssembly in plain terms; reinforces pillar one (21.1.2). |
| 6 | The Difference Between Compressing and Downsampling a PDF | Informational, tool-adjacent | reduce pdf file size | Explains what actually shrinks a PDF; links to Compress PDF. |
| 7 | OCR Explained: Turning a Scanned PDF into Searchable Text | Informational, tool-adjacent | what does ocr mean pdf | Plain explanation of optical character recognition; links to OCR PDF. |
| 8 | A Practical Guide to Bates Numbering for Legal Document Review | Informational, niche/professional | bates numbering pdf | Explains the convention and its use in legal discovery; links to Bates Numbering. |
| 9 | Digital Signature vs. Electronic Signature: What's the Difference | Informational | digital signature vs electronic signature | Clarifies PKI-based digital signatures versus ESIGN electronic signatures; states plainly which PDFWorks offers (Section 10). |
| 10 | Merging PDFs Without Losing Bookmarks or Hyperlinks | How-to, tool-adjacent | merge pdf keep bookmarks | Practical walkthrough; links to Merge PDF. |
| 11 | How to Fill Out a PDF Form on Any Device | How-to, tool-adjacent | fill pdf form online | Walkthrough for both fillable and flat forms; links to Fill PDF Forms. |
| 12 | What Actually Happens to Your File When You Use an Online PDF Tool | Trust, thought leadership | are online pdf tools safe | Explains the client-side/server-side split and what "deleted" means in practice; links to the security page. |
| 13 | Converting a Scanned Contract into an Editable Word Document | How-to | convert scanned pdf to word | Two-step workflow combining OCR PDF and PDF to Word. |
| 14 | GDPR and Document Processing: What "Deleted Within 24 Hours" Actually Means | Trust, compliance | gdpr pdf tool | Explains retention mechanics (Section 6) in plain language; links to the privacy policy. |
| 15 | Building a PDF Watermarking Workflow for a Design Team | Use case, tool-adjacent | watermark pdf batch | Covers batch watermarking; links to Watermark PDF and batch processing. |
| 16 | Redlining and Reviewing Contracts with PDF Annotation Tools | Use case | annotate pdf online | Covers markup workflows for contract review; links to Annotate PDF. |
| 17 | How to Sign a Document You Received, Without Creating an Account | Informational, support | sign document without account | Written for signers, not senders; explains the no-account signer experience (Section 10). |
| 18 | Flatten vs. Lock: Two Ways to Make a PDF Form Uneditable | Informational, tool-adjacent | flatten pdf form | Distinguishes Flatten PDF from Protect PDF and when to use each. |
| 19 | A Field Guide to PDF Metadata: What's Hiding in Your Files | Informational, tool-adjacent | pdf metadata | Explains what metadata a PDF typically carries; links to Edit PDF Metadata and Redact PDF. |
| 20 | Converting Word to PDF in Ten Lines of Code | Developer, technical | pdf conversion api | Walks through calling the public API to convert a document; links to the developer landing page and API docs. |
21.8.2 Publishing cadence #
Two posts per week for the first ten weeks to seed the initial twenty-post library above, then one post per week ongoing. Each /compare/* page is reviewed quarterly per competitor (21.6.1) independent of the blog cadence.
21.8.3 Editorial standards #
- Every post carries a real byline; anonymous or generic "The PDFWorks Team" bylines are not used.
- Any post describing how a tool works technically (redaction mechanics, OCR, encryption) is reviewed for factual accuracy against the actual tool specification before publishing.
- Any post touching a legal or compliance topic (electronic signatures, GDPR, retention) carries an explicit statement that it is general information, not legal advice.
- Every post links to at least one relevant tool page and, where one exists, one relevant help article.
- Minimum length: 900 words for an informational explainer, 1,400 words for a how-to guide with multiple steps. Length is a floor set by the topic needing that much space to be genuinely useful, not a target to pad toward.
21.8.4 The rule against AI-generated filler #
Posts may be drafted with AI assistance, but every published post is fact-checked against actual product behavior by a human editor before it ships, and none may contain a fabricated statistic, an invented customer quote or anecdote, or a restated-question paragraph that adds no information beyond the heading above it ("In today's digital world, PDFs are everywhere..." openers are rejected on sight). The bar: if a post could have been published about a competing product by changing only the product name, it does not ship. Every post must contain at least one piece of information — a specific mechanism, a specific number, a specific workflow — that is true of PDFWorks and not interchangeable with a generic description of PDF tools in general.
21.9 Conversion #
The guiding constraint across every prompt in this section: the decline option is always exactly as easy to take as the accept option — same visual weight, same button size, never grayed out, never phrased to induce guilt ("No thanks, I like losing my work" is a banned pattern; the decline label states a neutral fact or action, such as "Try again tomorrow" or "Back to free tools").
21.9.1 Guest-to-account flow #
1. Guest opens a guest-eligible tool (merge, split, rotate, organize,
compress, PDF <-> JPG) → tool works immediately, no prompt, up to 5
tasks per device per day (Section 12).
2. Guest opens a non-guest tool (e.g., Redact PDF) → the tool UI and drop
zone are visible, but the processing action is gated → sign-up prompt
(copy in 21.9.3).
3. Guest reaches the 5-task daily device cap on a guest-eligible tool →
soft-block prompt (copy in 21.9.3) offering a free account, which
removes the cap entirely for client-side tools.
4. Guest creates a free account (email + password, or an OAuth provider) →
lands back on the exact tool they were using, with the in-progress file
still present from local storage (Section 15) — the signup does not
discard their work.21.9.2 Free-to-paid flow #
1. Free user runs a server-side task (OCR, Office/HTML conversion) for the
2nd time in a day → 3rd attempt that day triggers the cap-reached
prompt (21.9.3).
2. Free user opens the batch-processing panel on any tool → panel shows
the feature with an upgrade prompt in place of the "Start batch" action.
3. Free user attempts to create a 4th signature envelope in a rolling
calendar month → envelope creation is blocked with an upgrade prompt.
4. Free user opens a job in their history that is older than 7 days →
the job details are replaced with an upgrade prompt explaining the
history window.21.9.3 Upgrade prompt copy, by trigger #
Trigger: Guest daily task cap reached (client-side tool)
Headline: You've used today's 5 free tasks on this device
Body: Create a free account and use every core tool without limits — merge,
split, rotate, organize, compress, and PDF-to-image, unlimited, forever.
Primary: Create free account
Secondary: Try again tomorrow
Trigger: Guest opens a non-guest-eligible tool
Headline: This tool needs a free account
Body: {Tool Name} isn't one of the six tools guests can use directly.
Everything else takes a 30-second signup, no credit card required.
Primary: Create free account
Secondary: Back to free tools
Trigger: Free server-side daily cap reached
Headline: You've used both server-side tasks for today
Body: Free includes 2 OCR, conversion, or similar tasks per day. Pro
removes the daily cap entirely for $9/month.
Primary: Upgrade to Pro
Secondary: Come back tomorrow
Trigger: Free user opens batch processing
Headline: Batch processing is a Pro feature
Body: Process up to 100 files in one job instead of one at a time. $9/month,
cancel anytime.
Primary: Upgrade to Pro
Secondary: Process one file instead
Trigger: Free user hits the 3-envelope monthly signature cap
Headline: You've sent 3 signature requests this month
Body: Pro includes 100 envelopes a month. Your Free limit resets on
{resetDate}, or upgrade now to send today.
Primary: Upgrade to Pro
Secondary: Wait for reset
Trigger: Free user opens a job older than 7 days
Headline: This job is older than 7 days
Body: Free keeps job history for 7 days. Pro keeps 90, and Team shares 90
days across the whole workspace.
Primary: Upgrade to Pro
Secondary: Back to job history21.9.4 Measurement plan #
Each conversion touchpoint is instrumented to an event name and tied to the funnel stages defined in Section 2, so the marketing site's contribution to acquisition, activation, and conversion is measurable independent of product-side funnel changes.
| Touchpoint | Event name | Funnel stage (Section 2) |
|---|---|---|
| Guest opens any tool page and uses the drop zone | guest_tool_used |
Acquisition → Activation |
| Guest hits daily cap prompt | guest_cap_prompt_shown |
Activation |
| Guest converts to a Free account from a prompt | signup_from_prompt |
Activation → Conversion |
| Free user hits any upgrade prompt (21.9.3) | upgrade_prompt_shown (tagged by trigger) |
Conversion |
| Free user completes checkout | subscription_started (tagged by plan) |
Conversion |
| Welcome email opened / clicked | email_welcome_opened / email_welcome_clicked |
Activation |
| Activation nudge email clicked | email_activation_nudge_clicked |
Activation |
| Cap-reached email clicked | email_cap_reached_clicked |
Conversion |
| Dunning email resolves in a successful retry | dunning_resolved |
Retention |
| Win-back email leads to a login | email_winback_login |
Retention → Reactivation |
21.9.5 Email lifecycle #
1. WELCOME — sent immediately on signup
Subject: You're in — here's what Free unlocks
Body:
Hi {firstName},
Your PDFWorks account is ready. Here's what's unlimited starting now:
- Merge, split, organize, rotate, crop, compress, and 19 other tools —
all client-side, all unlimited, no daily limit.
- 2 server-side tasks a day (OCR, Word/Excel/PowerPoint conversion, HTML
conversion) — resets every day at midnight UTC.
- 3 signature requests a month if you need to get something signed.
Jump back in: {toolGridLink}
— The PDFWorks team
---
2. ACTIVATION NUDGE — sent 48 hours after signup if no tool has been used
Subject: Still have a PDF to deal with?
Body:
Hi {firstName},
You created a PDFWorks account a couple of days ago but haven't used a tool
yet. Most people start with Merge or Compress — both take under a minute
and stay on your device by default.
Try Merge PDF: {mergeToolLink}
Try Compress PDF: {compressToolLink}
— The PDFWorks team
---
3. CAP REACHED — sent transactionally, at most once per day, the first
time a Free user hits the server-side daily cap on a given day
Subject: You've hit today's free limit — here's what's next
Body:
Hi {firstName},
You've used both of today's server-side tasks (OCR, Office, or HTML
conversion). Your limit resets at midnight UTC — or upgrade to Pro now for
unlimited server-side tasks, 1 GB files, and batch processing, starting at
$9/month.
Upgrade to Pro: {pricingLink}
— The PDFWorks team
---
4. UPGRADE PROMPT — sent when a Free user has hit the cap-reached state
three or more times within a 14-day window
Subject: You keep needing more than Free gives you
Body:
Hi {firstName},
You've bumped into the daily server-side limit a few times this month.
That usually means Pro pays for itself: unlimited OCR and conversion,
1 GB files, batch processing, and a 90-day job history, for $9/month —
or $90/year, which works out to two months free.
See what's included: {pricingLink}
— The PDFWorks team
---
5. DUNNING — sent when a subscription payment fails
Subject: We couldn't process your payment
Body:
Hi {firstName},
Your last payment for PDFWorks {planName} didn't go through. We'll
automatically retry over the next several days, following the retry
schedule in your billing settings (Section 12); if it keeps failing, your
account will revert to Free limits at the end of your current billing
period, and your work in progress and settings will stay exactly as they
are.
Update your payment method: {billingPortalLink}
— The PDFWorks team
---
6. WIN-BACK — sent 30 days after a cancellation or downgrade to Free
Subject: What we've shipped since you left
Body:
Hi {firstName},
It's been a month since your PDFWorks plan changed. Your account is still
here with every client-side tool available, unlimited, whenever you need
it.
If server-side limits or batch processing were the reason you left, Pro is
still $9/month with no long-term commitment — cancel anytime and keep
access through the period you've already paid for.
See what's included: {pricingLink}
— The PDFWorks teamThe billing portal link in the dunning email routes to the hosted Stripe Billing Portal (Section 3), not an embedded payment form, consistent with the product's cross-origin isolation requirements.
21.10 Legal and Trust Pages #
Every subsection below is a content outline for an attorney to draft the enforceable text from — not the legal text itself. Final wording for every page in this section requires review by qualified counsel before publication; the outlines specify what each page must cover and why, so counsel is drafting against a complete requirements list rather than starting from nothing.
21.10.1 Privacy Policy (/privacy) #
Must cover, aligned to the data inventory in Section 17.8:
- What is collected: account data (email, hashed password or OAuth identifier), billing data (handled by Stripe, not stored directly), usage and product analytics, file data for server-side tools and for any client-side tool a visitor explicitly sends to a server via the large-file fallback (transient in both cases, per the retention rule in Section 6), and cookies (per 21.10.4).
- What is never collected: content of files processed entirely on-device, since those files never reach PDFWorks servers unless the visitor explicitly chooses the fallback; content of files processed by Redact, Protect, Unlock, or Self-sign, which never reach PDFWorks servers under any circumstance.
- Legal basis for processing (contract performance, legitimate interest, consent, as applicable per processing activity).
- Retention specifics: the default 24-hour rule (Section 6) and the e-signature audit-trail exception (Section 10.8), stated in plain language with a pointer to the security page for technical detail.
- Sub-processors: a link to the live sub-processor list (21.10.7) rather than a static list embedded in the policy, so the policy doesn't go stale every time infrastructure changes.
- User rights: data subject access requests (export and delete), how to exercise them, and expected response time, per the compliance posture in Section 17.
- International data transfers and the EU data residency option available on Team.
- Children's privacy statement (the product is not directed at children and does not knowingly collect data from them).
- Contact information for privacy inquiries and, if applicable, a designated data protection contact.
- Policy change notification process (how and when users are notified of material changes).
21.10.2 Terms of Service (/terms) #
Must cover: acceptance of terms and account eligibility; description of the service including the two processing locations, the four tools that never use a server, the opt-in large-file fallback available on the rest, and the fact that server-side processing is opt-in and disclosed per tool (Section 6); the plans and billing terms cross-referencing Section 12 rather than restating prices (so the legal page never contradicts the pricing page); user content ownership (the user retains all rights to files they process; PDFWorks claims no license beyond what's needed to perform the requested operation); service availability and the 99.9% monthly API target as a target, not a guarantee, with the status page as the source of truth; limitation of liability; termination rights for both parties; dispute resolution and governing law; and a change-of-terms notification process.
21.10.3 Acceptable Use Policy (/acceptable-use) #
Must cover: prohibited content (illegal material, malware, content that infringes third-party rights); prohibited use of conversion tools to launder malicious content through file-format conversion; prohibited automated abuse of the public API beyond documented rate limits (Section 14); and, explicitly, the no-password-cracking position for Unlock PDF: Unlock PDF is for removing a password from a file you already know the password to, or otherwise have the legal right to access — it is not a password-recovery or brute-force tool, and using it to attempt to access a document you do not have the right to open is a violation of this policy (Section 9.6). Also covers the boundaries of the e-signature product: it is not to be used for identity documents or contexts requiring in-person identity verification or knowledge-based authentication, which are explicitly out of scope (Section 10).
21.10.4 Cookie Notice (/cookies) #
Must cover: the categories of cookies used (strictly necessary — session and CSRF protection; functional — remembering tool preferences; analytics — product usage measurement); which categories require consent under applicable law versus which are exempt as strictly necessary; the consent mechanism and how a visitor can change their choice after the fact; and that the strictly-necessary session cookie is HttpOnly, Secure, and SameSite=Lax per the session model in Section 17.
21.10.5 Electronic Record and Signature Disclosure (/esign-disclosure) #
Must cover, per the consent mechanism in Section 10: the signer's right to receive records electronically and the requirement to affirmatively consent before signing; the right to request a paper copy and any fee or process for obtaining one; the right to withdraw consent to electronic records and the consequence of doing so (the signing process cannot continue electronically); the hardware and software needed to view, sign, and retain electronic records (a modern browser and an active email address, stated plainly); how a signer updates their contact information if it changes; and a statement that this disclosure and the signer's acceptance of it are themselves logged as part of the envelope's audit trail (Section 10), satisfying the consumer-consent requirements of ESIGN and UETA.
21.10.6 Data Processing Addendum (/dpa) #
Must cover: the parties and their roles (PDFWorks as processor, the customer as controller, for any personal data contained in files processed through server-side tools, through a visitor-chosen fallback, or entered into e-signature envelopes); the subject matter, duration, nature, and purpose of processing; the categories of data subjects and personal data involved; sub-processor authorization and the obligation to keep the public sub-processor list (21.10.7) current with advance notice of changes; security measures, summarized with a pointer to Section 17 rather than restated; audit rights; the process for assisting with data subject requests and breach notification; and the international transfer mechanism relied upon (standard contractual clauses, referenced by name once counsel selects the applicable version).
21.10.7 Sub-processor List (/subprocessors) #
A structured, machine-editable table (not prose) with columns: sub-processor name, purpose (e.g., "cloud hosting and blob storage," "transactional email delivery," "payment processing"), and location/region. Populated from the actual infrastructure choices in Section 3 (cloud hosting, S3-compatible blob storage, Resend for transactional email, Stripe for billing) plus any additional infrastructure vendor added after launch. The page states that customers on the Team plan with EU data residency enabled will see the region-appropriate sub-processor set, and that material additions are announced with the advance-notice period committed to in the DPA (21.10.6).
21.10.8 Security Page (/security) #
A plain-language summary of Section 17 aimed at a non-technical buyer evaluating whether PDFWorks is safe to use for sensitive documents, structured in five parts:
- How your files are handled — the client-side/server-side split, including the four tools (Redact, Protect, Unlock, Self-sign) that never use a server under any circumstance and the opt-in, click-required fallback available on the other twenty-one on-device tools (Section 6.3); the processing-location badge shown before every action (Section 16); the 24-hour deletion rule with the e-signature exception explained honestly (Section 6, Section 10.8); and, for the e-signature product specifically, the fact that every signing event is chained by hash to the one before it and the finished document's hash is anchored to a daily published record, so tampering with the record after the fact is independently detectable rather than something a buyer has to take on trust (Section 10).
- How data is protected in transit and at rest — TLS in transit, AES-256-GCM envelope encryption at rest with per-job data keys, and the fact that deleting a job's wrapped key cryptographically shreds the object even before the underlying bytes are removed.
- How processing is isolated — sandboxed workers with no outbound network access, a read-only root filesystem, and a hard wall-clock timeout per job, in plain terms ("each file is processed alone, in a locked-down environment that can't reach the internet or see any other customer's job").
- Compliance posture, stated honestly — GDPR and CCPA support available at launch (data export and deletion, a DPA, an EU sub-processor list, EU data residency on Team), and SOC 2 Type II named explicitly as a roadmap item with controls designed in from launch, never described as an existing certification until it is one. HIPAA is stated as not offered, plainly, so a buyer with that requirement can rule it out immediately rather than discover it later.
- How to report a security issue — a link to
security.txt, the published vulnerability disclosure policy, and the 90-day coordinated-disclosure commitment (Section 17).
The page links out to the live status page for uptime history and to the sub-processor list (21.10.7) and DPA (21.10.6) for the contractual detail a procurement reviewer would need next.
22. Milestones & Execution Plan #
This section is the build plan. It takes the executor from an empty repository to a launched product in eleven milestones, M0 through M10. Every milestone names its goal, its ordered task list, its dependencies, the specification sections it implements, its definition of done, a demo script that proves the milestone works, milestone-specific risks with mitigations, and an effort estimate in engineer-weeks.
22.1 Sequencing principle and dependency graph #
The sequencing principle is: build the engine before the surface, build the surface before the account system, build the account system before billing, build billing before anything that spends money on infrastructure (server-side processing), and build the public-facing surfaces (API, marketing) last, against a system that already works end to end for a human in a browser.
Three sub-principles follow from this:
pdfcoreis the critical-path bottleneck. Nothing that touches a PDF byte can start before M1 delivers a working WASM build with a stable TypeScript binding surface. M0 and the earliest half of M1 are the only work that can proceed with zero PDF-specific code.- Client-side before server-side. The client-side tool suite (M1, M2) does not require queues,
workers, object storage, or encryption-at-rest — it only requires the engine and the browser
runtime from Section 3.3. Everything server-side (M5 onward) reuses the same
pdfcoreartifact through its Node binding, so M5 is cheaper once M1 is done, not before. - Monetization gates infrastructure spend. M3 (accounts) and M4 (billing) exist before M5 (server pipeline) so that every server-side job that ever runs in production is already attributable to a workspace, a plan, and a quota — there is never a window where server costs are incurred without an entitlement check in front of them.
M0 Foundations
│
▼
M1 Engine + first 3 tools ──────────────┐
│ │
▼ ▼
M2 Client-side tool suite M3 Accounts, guests, entitlements
│ │
│ ▼
│ M4 Billing
│ │
│ ┌──────────────────────┼───────────────────────┐
│ ▼ ▼ ▼
│ M5 Server pipeline + (M4 unlocks paid (M4 unlocks paid
│ OCR server quotas) API quotas)
│ │
│ ┌───────────┼───────────┐
│ ▼ ▼ ▼
│ M6 Office/ M7 Batch M8 E-signature
│ HTML conv.
│ │ │ │
│ └─────┬─────┴─────┬─────┘
│ ▼ ▼
│ M9 Public API, webhooks, SDK, docs
│ │
└────────┼────────────────────────────┐
▼ │
M10 Hardening & launch ◄───────────┘| Milestone | Depends on | Unlocks |
|---|---|---|
| M0 Foundations | — | Everything |
| M1 Engine + first 3 tools | M0 | M2, M5 (engine reuse) |
| M2 Client-side tool suite | M1 | M10 (client tools must be complete for launch) |
| M3 Accounts, guests, entitlements | M0 (auth skeleton), M1 (tools to gate) | M4 |
| M4 Billing | M3 | M5, M6, M7, M8, M9 (all spend real infrastructure money and must be quota-gated) |
| M5 Server pipeline + OCR | M1 (engine), M4 (quotas) | M6, M7, M8, M9 |
| M6 Office and HTML conversion | M5 | M7 (batch can include office jobs), M10 |
| M7 Batch processing | M5, and M6 for office-inclusive batches | M10 |
| M8 E-signature | M5 | M9 (API exposes envelopes), M10 |
| M9 Public API, webhooks, SDK, docs | M5, M6, M7, M8 (API surfaces all of them) | M10 |
| M10 Hardening & launch | M2, M6, M7, M8, M9 | Launch |
22.2 Milestones #
22.2.0 M0 — Foundations #
Goal: stand up a monorepo, tooling, CI, database, an authentication skeleton, design tokens, and a deploy pipeline so that every later milestone starts from a working, tested, deployable base.
Tasks:
- Initialize the pnpm workspace and Turborepo pipeline at the repository root (
pnpm-workspace.yaml,turbo.json, rootpackage.jsonwithpackageManagerpinned). - Scaffold
apps/webas a Next.js App Router project; scaffoldapps/apias a Hono service with a health route (GET /internal/health). - Scaffold empty packages
packages/pdfcore,packages/contracts,packages/db,packages/ui,packages/sdk-js,packages/configwith their ownpackage.jsonandtsconfig.jsonextendingpackages/config/tsconfig.base.json. - Author
packages/configpresets: shared ESLint flat config, shared Tailwind CSS config, shared TypeScript base config withstrict: true. - Configure Vitest at the workspace root with a per-package
vitest.config.tsand a coverage threshold gate matching Section 19. - Configure Playwright in
apps/webwith the browser matrix from Section 15.6. - Write the CI workflow (
/.github/workflows/ci.ymlor the executor's chosen CI provider): install, lint, typecheck, unit test, build, on every pull request; block merge on any red step. - Provision PostgreSQL 18 locally via
infra/docker-compose.ymland in the target cloud environment; wirepackages/db's Drizzle config (drizzle.config.ts) to the connection string fromDATABASE_URL(Section 23.2). - Write the first Drizzle migration:
users,sessions,workspaces,workspace_memberstables per Section 5's schema, using the identifier and naming conventions from Section 5.1. - Wire
better-authinapps/apiandapps/web: email/password provider, session cookie configuration per Section 17.2, and the/internal/auth/*route group. - Build the auth skeleton UI: sign-up, sign-in, forgot-password, verify-email pages in
apps/web/app/(auth)/, styled with placeholder tokens (design tokens land in task 15). - Implement the email verification flow using Resend and the transactional templates package
(React Email), sending through the
email.service.tsmodule described in Section 18.4. - Set up Redis 8.x locally and in the target environment; add a
packages/configRedis client factory used by bothapps/apiand future workers. - Provision S3-compatible object storage (bucket per environment) and KMS; store credentials per Section 23.2 environment variables; write a smoke-test script that uploads and deletes a throwaway object.
- Establish
packages/ui's design tokens: color, spacing, typography, radius, motion primitives as Tailwind CSS 4.x@themetokens, matching the palette and scale defined in Section 16.2. - Build the base Radix UI component wrappers used everywhere:
Button,Dialog,DropdownMenu,Tooltip,Toast,Tabs, inpackages/ui/src/components/. - Implement the Processing Location Indicator component (Section 16.7) as a
packages/uicomponent with its two canonical states (on-device,on-server), consumed by every tool page from M1 onward. - Set up
next-intlwith a singleenmessage catalogue and the message-key convention from Section 16.7, even though no other locale ships at launch (Section 2.8 scope fence). - Configure Pino structured logging in
apps/apiwith the redact path list from Section 18.2; configure OpenTelemetry SDK initialization (instrumentation.ts) with trace export disabled until an environment variable enables it (Section 23.2). - Configure Sentry in
apps/webandapps/apiwith environment-gated DSNs and source map upload wired into the CI build step. - Write the Terraform (or the executor's chosen IaC) modules in
infra/for: the database instance, the Redis instance, the object storage bucket, the KMS key, and the container registry. - Write the Dockerfiles for
apps/web,apps/api,worker-media,worker-office(the latter two start as empty placeholder services that boot and pass a health check; real logic lands in M5 and M6). - Write the Helm chart (or the executor's chosen deploy manifest format) covering
apps/web,apps/api, and a Redis-backed queue dashboard, deploying to a staging environment on every merge to the main branch. - Implement the
feature_flagstable and its single typed accessor (packages/db/src/feature-flags.ts) per Section 4's canonical conventions. - Write the root
README.md(a repository file, not a specification file) describing local setup, the monorepo layout, and how to run the full stack withdocker compose up. - Write the root
DECISIONS.mdfile, initialized empty except for a header, per the process defined in Section 23.6. - Add a pre-commit hook (via
lint-stagedor equivalent) running lint and format on staged files. - Stand up the status page (a static page or third-party status page product) at
status.pdfworks.io, even if it has nothing to report yet.
Dependencies: none — this is the starting milestone.
Sections implemented: 3 (Technology Stack & System Architecture), 4 (Conventions & Engineering Standards), 5 (Data Model, partial — auth tables only), 16 (Design System, partial — tokens and base components), 17 (Security, partial — auth and session config), 18 (Observability, partial — logging and tracing scaffolding), 20 (Infrastructure, Deployment & Operations).
Definition of done:
- A fresh clone,
pnpm install,docker compose up,pnpm devrenders a working sign-up → verify email → sign-in flow end to end against a local database. - CI is green on a trivial pull request and blocks merge on a deliberately broken one.
- A merge to the main branch deploys
apps/webandapps/apito staging automatically and the staging health route returns200. pnpm turbo lint typecheck testpasses with zero errors across every package.- The Processing Location Indicator renders both states correctly in a Storybook-style isolated preview (or equivalent component sandbox).
Demo: "You can now create an account, verify your email by clicking a link from a real email, sign in, and see an empty authenticated shell deployed at a staging URL, built by a CI pipeline that would have stopped you had you broken the build."
Risks:
| Risk | Mitigation |
|---|---|
| Monorepo tooling churn (Turborepo cache misconfiguration) slows every subsequent milestone. | Get remote caching working in M0 itself, before any package has real logic to slow the build down. |
| Auth skeleton decisions (cookie settings, session model) get baked in wrong and are expensive to change later. | Follow Section 17.2 exactly; do not improvise session semantics. |
| Cloud infrastructure provisioning blocks engineers who only need local Docker Compose. | Keep local development fully functional without any cloud credentials; cloud provisioning is additive. |
Effort estimate: 2 engineer-weeks, assuming one senior full-stack engineer with prior Next.js, Hono, and Terraform experience working alone; parallelizable to 1.5 weeks with a second engineer taking infrastructure (tasks 13, 14, 21–23) while the first takes application code.
22.2.1 M1 — The engine #
Goal: produce the single pdfcore WASM artifact and its TypeScript binding, wire it into a
worker pool with OPFS staging, build the viewer shell, and ship the first three tools end to end
(merge, split, rotate) to prove the whole pipe works before building the other twenty-plus tools on
top of it.
Tasks:
- Vendor PDFium and QPDF as git submodules (or a pinned source tarball fetch script) inside
packages/pdfcore/vendor/, pinned by commit hash per the version table's PDFium note. - Write the Emscripten build script (
packages/pdfcore/build.sh) that compiles PDFium, QPDF, zlib, libjpeg-turbo, libwebp, and Brotli into a single.wasmmodule with pthreads enabled. - Produce the single-threaded fallback build target in the same script (a second Emscripten
invocation without
-pthread) per Section 3.6's fallback requirement. - Write the C++ binding layer (
packages/pdfcore/src/bindings.cpp) exposing a minimal first surface:openDocument,closeDocument,getPageCount,mergeDocuments,splitDocument,rotatePages. - Generate the TypeScript declaration layer (
packages/pdfcore/src/index.ts) wrapping the Emscripten glue with a Promise-based API and typed error results. - Write the cross-host byte-equality test harness (
packages/pdfcore/test/byte-equality.test.ts) that runs the same operation through the browser build (via Playwright) and the Node build (via direct import) against a fixeddeterministicTimestampand asserts identical SHA-256 hashes, satisfying the guarantee in Section 3.6. - Set up the golden-file PDF corpus fixture loader (
packages/pdfcore/test/fixtures/) with the first 20 of the 200+ documents required by Section 19.3 (tagged, scanned, encrypted, malformed subset first; the full corpus completes across M1–M2). - Build the browser worker pool (
apps/web/src/engine/worker-pool.ts) sized per Section 3.7'sclamp(navigator.hardwareConcurrency - 1, 2, 4)formula, using Comlink to exposepdfcoreoperations as RPC calls. - Implement OPFS staging (
apps/web/src/engine/opfs.ts): file write, streamed read, and the 24-hour janitor sweep that runs on app load per Section 3.7. - Implement the Dexie metadata store (
apps/web/src/engine/local-db.ts) with therecentFiles,toolPresets,draftAnnotations,queuedBatchDescriptorstables, storing no file bytes. - Implement cross-origin isolation headers (
COOP: same-origin,COEP: credentialless) in the Next.js middleware, scoped to tool routes only, per Section 3.8. - Implement WASM module loading with progressive enhancement: the drop zone accepts a file and queues it before the module finishes streaming in, per the performance budget in Section 18.6.
- Build the PDF viewer shell (
apps/web/src/components/viewer/): page thumbnail rail, zoom controls, page navigation, rendered via PDFium through the worker pool. - Implement the client-side job state machine (
apps/web/src/engine/job-store.ts) using the statesqueued → running → succeeded/failed/canceled/expiredfrom Section 4.7, backed by Zustand. - Build the tool page shell (
apps/web/app/tools/[tool]/page.tsx) shared by every client-side tool: drop zone, Processing Location Indicator, progress UI, result download panel. - Implement merge: multi-file drop, page-thumbnail reordering across source documents, output download, per the tool specification in Section 7.1.
- Implement split: page-range selection UI, single or multi-file output (zipped), per Section 7.2.
- Implement rotate: per-page or all-page rotation in 90-degree increments, per Section 7.4.
- Wire the "Finish this on our servers instead" opt-in fallback UI stub (Section 6.3's opt-in rule) for out-of-memory failures, even though the server pipeline does not exist until M5 — the button is present and disabled with a "coming soon" state until M5 ships, and it is never wired up at all for tools already known to be excluded from fallback under Section 6.3: redact, protect, unlock, and self-sign do not render this stub, even in its disabled state.
- Write Playwright E2E tests for merge, split, rotate covering the primary path and the empty-input and corrupt-file error paths.
- Write the perceptual-hash regression comparison utility (
packages/pdfcore/test/phash.ts) used by the golden-file corpus tests, per Section 19.3. - Implement the PWA manifest and service worker shell (
apps/web/public/manifest.json,apps/web/src/service-worker.ts) with offline caching of the app shell, laying groundwork for the offline/degraded mode required at launch (Section 18, expanded in M10). - Benchmark the WASM artifact size and confirm it meets the < 6 MB Brotli-compressed budget; if it does not, strip debug symbols and re-evaluate which PDFium features are compiled in.
- Document the
pdfcorepublic binding surface with TSDoc comments sufficient to generate an API reference page later (consumed by Section 14's documentation tooling).
Dependencies: M0 (monorepo, CI, design tokens, Processing Location Indicator component).
Sections implemented: 3.2 (pdfcore), 3.3 (Browser runtime), 3.4 (Cross-origin isolation), 6
(The Processing Engine), 7.1 (Merge), 7.2 (Split), 7.4 (Rotate), 15 (Frontend Application
Architecture, partial), 19.3 (golden-file corpus, partial).
Definition of done:
packages/pdfcorebuilds both the threaded and single-threaded WASM artifacts from a clean checkout via one script invocation.- The cross-host byte-equality test passes for merge, split, and rotate on at least 10 golden-file corpus documents.
- A user can merge two PDFs, split a PDF by page range, and rotate pages entirely in-browser with network devtools showing zero outbound requests carrying file bytes.
- The Processing Location Indicator reads "On your device" on all three tool pages and this is asserted in an E2E test per Section 6.2.
- LCP and bundle-size budgets from the performance budgets pass in a Lighthouse CI run for the tool grid and one tool page.
- Unit test coverage on
packages/pdfcorebindings meets the 80% line coverage bar.
Demo: "You can now drop three PDFs into the merge tool, reorder their pages by dragging thumbnails, and download a single merged file — entirely offline, with the network tab empty, in under three seconds for ten five-megabyte files."
Risks:
| Risk | Mitigation |
|---|---|
| Emscripten build of PDFium + QPDF together is unexpectedly fragile (symbol conflicts, exception-handling ABI mismatches). | Spike the combined build in the first two days of M1 before committing to the task breakdown above; budget contingency days if the spike reveals a blocker. |
| Cross-origin isolation breaks an unrelated third-party script already relied on elsewhere in the app. | Scope COEP/COOP headers to tool routes only (task 11); audit every third-party script against credentialless compatibility before M1 exit. |
| WASM artifact exceeds the 6 MB budget once real-world PDFium feature flags are enabled. | Track artifact size on every CI build; treat a budget breach as a build-blocking regression, not a warning. |
| pthreads-based builds behave inconsistently across the browser matrix, producing hard-to-reproduce bugs. | The single-threaded fallback is not optional polish — validate it explicitly on the browser matrix in Section 15.6 as part of M1 exit, not deferred to M10. |
Effort estimate: 4 engineer-weeks, assuming one engineer with prior Emscripten/WASM experience
owns the pdfcore build (tasks 1–7, 21, 23–24) while a second engineer owns the browser runtime and
tool UI (tasks 8–20, 22) starting in week 2 once a preliminary WASM artifact exists.
22.2.2 M2 — The client-side tool suite #
Goal: implement every remaining client-side tool from the Section 1.1 list, including redaction, so the full client-side surface is feature-complete ahead of the account and billing milestones that gate it.
Tasks:
- Implement extract pages (Section 7.6): page-range selection reusing the split UI, single-PDF output.
- Implement organize/reorder (Section 7.3): drag-and-drop thumbnail grid with keyboard arrow-key reordering and live-region announcements, satisfying the WCAG 2.2 AA requirement in Section 16.
- Implement delete pages (Section 7.5) and insert blank pages (Section 7.7), sharing the organizer grid built in task 2.
- Implement crop (Section 7.8): a bounding-box selection UI per page or applied to all pages,
backed by a
pdfcorecropPagebinding. - Implement compress (Section 7.9): quality-tier selector (low/medium/high), image recompression via libjpeg-turbo/libwebp bindings, before/after size comparison in the result panel.
- Implement PDF → JPG/PNG and JPG/PNG → PDF (Section 7.10, 7.11), confirming both remain client-side per the non-negotiable rule in Section 6.1.
- Implement repair (Section 7.12): a
pdfcorebinding that runs QPDF's object-recovery pass on malformed input and reports what was fixed. - Implement edit metadata (Section 7.13): a form bound to the
/Infodictionary and XMP fields. - Implement watermark (Section 9.2): text and image watermark, opacity/rotation/tiling controls, applied via new content-stream operators per the tool's binding.
- Implement page numbers (Section 9.3) and Bates numbering (Section 9.4): position, format string, and starting-number controls, sharing a numbering-stamp binding.
- Implement protect (encrypt) (Section 9.5) and unlock (Section 9.6): password-based AES-256 encryption via QPDF bindings; unlock requires the user to supply the existing password (never a password-cracking feature).
- Implement flatten (Section 9.7): form fields and annotations converted to page content.
- Build the redaction tool UI (
apps/web/app/tools/redact/): region-drawing on the page canvas, a redaction-region list per page, an "Apply redactions" action. - Implement the redaction pipeline (
packages/pdfcore/src/redact.ts) exactly per the twelve-step mechanism in Section 9.1, including content-stream operator deletion, image XObject scrubbing, annotation and form-field deletion, optional-content-group purge, orphaned-object and font-subsetting cleanup, full metadata strip, non-incremental rewrite, redaction-box drawing, and the verification pass. - Implement the Redaction Verification Report generation and download (per-page counts of removed glyph runs, images, annotations, and the additional content categories Section 9.1 defines, plus pass/fail), and its persistence into local job history.
- Implement annotate & shapes (Section 8.2): highlight, underline, strikeout, freehand draw, shape primitives (rectangle, ellipse, arrow, line), sticky notes, with the keyboard-driven placement mode required by Section 16's accessibility bar.
- Implement edit text & images (Section 8.1): direct content-stream text-run editing and image XObject replacement, scoped to the fidelity limits documented in Section 8.1.
- Implement fill forms (Section 8.3): AcroForm field detection and an overlay form-filling UI for both AcroForm and non-form PDFs (click-to-place text).
- Implement create fillable forms (Section 8.4): a field-placement toolbar (text, checkbox, radio, dropdown, signature) that writes AcroForm field dictionaries.
- Implement self-sign (Section 8.5): draw/type/upload a signature image placed onto the user's own document, explicitly distinct from the e-signature envelope flow in Section 10.
- Extend the golden-file corpus regression suite to cover every tool shipped in this milestone, completing the 200-document corpus requirement from Section 19.3.
- Write Playwright E2E tests for every tool in this milestone covering the primary path, an empty/invalid-input path, and — for redaction specifically — an assertion that the redacted string is absent from the extracted text of the output file, checked across every content category Section 9.1's verification pass covers.
- Run an automated axe-core accessibility pass across every new tool page and fix violations before milestone exit, per Section 16.
- Conduct a manual screen-reader pass (NVDA and VoiceOver) on the organizer, the annotation canvas, and the redaction tool specifically, per Section 16.
- Performance-test compress and merge against the budgets in Section 18.6 (merge of ten 5 MB files under three seconds) and optimize the hot path if the budget is missed.
Dependencies: M1 (engine, worker pool, viewer, job state machine, tool page shell).
Sections implemented: 7 (Tool Specifications A), 8 (Tool Specifications B), 9.1–9.7 (Redaction, Watermarks & Numbering, Document Security — server-side OCR (9.8) and Office/HTML conversion (9.9) are out of scope for this milestone and land in M5/M6).
Definition of done:
- All twenty-two remaining client-side tools listed in Section 1.1 are implemented, each showing "On your device" on its Processing Location Indicator, verified by E2E test.
- The redaction verification pass fails closed: a deliberately crafted test case where a glyph run is not fully removed causes the tool to discard output and surface an error, verified by a unit test that asserts this failure path.
- The full 200-document golden-file corpus passes perceptual-hash regression across every tool that touches page rendering.
- Zero axe-core violations across all tool pages in CI.
- Every tool page meets the performance budgets in Section 18.6.
Demo: "You can now redact a Social Security number from a scanned contract, download a verification report proving it was structurally removed rather than painted over, then fill out and self-sign a form on the same document — all without the file ever leaving the laptop."
Risks:
| Risk | Mitigation |
|---|---|
| In-PDF text editing (task 17) has fundamentally limited fidelity against arbitrarily generated PDFs (font subsetting, kerning, non-embedded fonts). | Scope text editing to embedded-font, non-CID-keyed documents at launch and say so in the tool's own help text; document the limitation in Section 8.1 rather than silently failing. |
| Redaction verification false negatives (a removal method that looks complete but leaves recoverable data) are a severe trust failure for the product. | Treat the verification pass as adversarial: the corpus in task 21 must include documents specifically constructed to try to defeat each of the twelve redaction steps, not just typical documents. |
| Annotation-canvas keyboard accessibility is disproportionately time-consuming relative to its usage. | Timebox the keyboard-placement-mode implementation and use the same interaction pattern across annotate, forms, and redaction so the investment amortizes across three tools. |
Effort estimate: 5 engineer-weeks with two engineers working in parallel on independent tool groups (page operations and image/security tools vs. annotation/forms/redaction), assuming the M1 engine bindings for each operation already exist or are added incrementally by the owning engineer.
22.2.3 M3 — Accounts, guest limits, entitlements, and job history #
Goal: turn the anonymous tool suite into a product with accounts, enforce guest and free-tier limits, and record job history — the prerequisite for billing in M4.
Tasks:
- Extend the M0 auth skeleton with full sign-up/sign-in/sign-out flows wired to real UI states (loading, error, success) per Section 11.1.
- Implement workspace creation on sign-up (a personal workspace is created automatically) per the data model in Section 5.4.
- Implement the
entitlementscomputation module (packages/contracts/src/entitlements.ts): a pure function mapping(plan, workspaceId)to the limits table in Section 12.2, shared by web, API, and workers. - Implement guest device identification: a signed, HTTP-only cookie holding a random device identifier, set on first tool use, per Section 11.5.
- Implement the guest daily task counter (5 tasks/device/day) backed by a Redis counter keyed by device identifier with a 24-hour TTL, per Section 12.2.
- Implement the guest tool gate: only the six guest tools (merge, split, rotate, organize, compress, PDF ↔ JPG) are reachable without an account; every other tool page shows an account-required prompt, worded per the "never a dark pattern" requirement in Section 12.2's footnote.
- Implement the free-tier server-side task counter (2 server-side tasks/day) — implemented now even though no server-side tool exists yet, so M5 only has to consume the counter, not build it.
- Build the account settings pages: profile, password change (invalidates other sessions per Section 17.2), session list with per-device revoke, MFA enrollment (TOTP plus ten recovery codes) per Section 17.3.
- Implement client-side job history: every completed client-side job writes a
jobHistoryEntryto the Dexie metadata store (tool name, timestamp, file name, outcome) with no file bytes, subject to the 7-day (Free) / 90-day (Pro/Team) retention window enforced client-side. - Build the job history UI (
apps/web/app/dashboard/history/): a table of past jobs, filterable by tool and date, with a re-run action where the source file is still present in OPFS. - Implement workspace membership: invite by email, roles (owner, member), and the members list UI, laying groundwork for Team seats in M4.
- Implement the
DELETE /internal/accountflow: soft-deletes the user row, anonymizes personal fields, and schedules dependent-data cleanup per the retention rules in Section 6. - Implement the DSAR (data subject access request) export: an authenticated endpoint that packages a user's account data, workspace memberships, and job history metadata into a downloadable archive, per the GDPR/CCPA posture in Section 17.6.
- Write an entitlement-check middleware in
apps/api(/internalroutes only at this milestone) that every tool-invoking route passes through before executing, asserting quota state server-side even for actions that are visually client-side — this is the first instance of the "never trust a client-side entitlement check" rule from Section 23.6. - Instrument entitlement and quota checks with unit tests reaching the 80% coverage bar specified for this exact area in Section 19, since it is named as one of the three highest-cost-of-bug areas.
- Write E2E tests: a guest exhausts the 5-task daily cap and sees the correct upgrade prompt; a free user exhausts the 2 server-side-task cap (simulated, since M5 does not exist yet, via a feature-flagged stub route) and sees the correct upgrade prompt.
- Add the account-required and quota-exceeded UI states to the design system's shared "paywall prompt" component so M4, M5, and later tool-gating reuse one component.
Dependencies: M0 (auth skeleton), M1 (tools to gate — entitlements must have something to enforce against).
Sections implemented: 11 (Accounts, Guest Access, Teams & Workspaces), 12.1–12.2 (Plans, Limits, Entitlements table), 17.2–17.3 (Sessions, MFA), 17.6 (Compliance posture, DSAR).
Definition of done:
- A guest can use exactly 5 tasks in 24 hours across the six guest tools, verified by an E2E test that performs a sixth task and receives a blocking prompt with an explicit, non-dark-pattern explanation of what an account unlocks.
- A signed-up Free-tier user can use client-side tools without limit and is blocked on a third server-side-shaped action within 24 hours (via the feature-flagged stub).
- Job history persists across a page reload and respects the 7-day Free retention window in a test that fast-forwards the local clock.
- The entitlement-check middleware rejects a forged client request that claims a quota it has not earned, verified by an integration test that bypasses the UI and hits the route directly.
- MFA enrollment and login-with-TOTP work end to end, including recovery-code login.
Demo: "You can now sign up, invite a teammate to your workspace, watch your job history fill up as you use tools, turn on two-factor authentication, and get a plain-language upgrade prompt the moment you hit your daily limit — with the limit enforced by the server, not just hidden by the UI."
Risks:
| Risk | Mitigation |
|---|---|
| Guest device identification via cookie is trivially bypassed by clearing cookies or using a private window. | Accept this as a known, documented limitation (Section 11.5) rather than fighting it with fingerprinting, which raises its own privacy and compliance concerns; the six free guest tools have low enough infrastructure cost that abuse is not economically significant. |
| Entitlement logic drifts between the web app's optimistic UI state and the server's authoritative check, producing confusing "why was I blocked" moments. | Single source of truth: the entitlements module in packages/contracts is imported by both, never reimplemented. |
| MFA recovery-code UX is a common support burden if done poorly. | Show the ten codes once with a mandatory "I have saved these" confirmation and a re-generate action in settings; document this flow precisely in the account settings pages. |
Effort estimate: 3 engineer-weeks for one engineer, or 2 weeks with a second engineer taking the account/workspace UI (tasks 1, 2, 8, 10, 11) while the first takes entitlements and quota enforcement (tasks 3–7, 9, 12–17).
22.2.4 M4 — Billing #
Goal: wire Stripe Checkout and the Stripe Billing Portal so users can subscribe to Pro or Team, so workspaces carry a real plan, and so every entitlement computed in M3 reflects real payment state.
Tasks:
- Create the Stripe products and prices for Pro ($9/mo, $90/yr), Team ($15/user/mo, $150/user/yr), and the three API tiers (Starter $29/mo, Growth $99/mo, Scale $399/mo) plus their overage meters, per Section 12.
- Implement
POST /internal/billing/checkout-session: creates a Stripe Checkout session for the selected plan and redirects, per the hosted-redirect requirement in Section 3.4 (no Stripe Elements). - Implement
POST /internal/billing/portal-session: creates a Stripe Billing Portal session for plan changes, payment method updates, and cancellation. - Implement the Stripe webhook receiver (
POST /internal/webhooks/stripe): signature verification, idempotent event handling, and a durable event log table (stripe_events) keyed by Stripe's event ID to prevent double-processing. - Handle
checkout.session.completed: upgrade the workspace'splancolumn and reset relevant quota counters. - Handle
customer.subscription.updatedandcustomer.subscription.deleted: reflect plan changes and the downgrade/cancellation behavior from Section 12's plan table (access persists to period end, job history over 7 days becomes unreadable but is retained 30 days, in-flight envelopes complete). - Handle
invoice.payment_failed: move the workspace into apast_duestate, surface a non-blocking banner, and retry per Stripe's dunning schedule before downgrading. - Implement Team seat management: adding a member increases the Stripe subscription item quantity; removing a member decreases it at the next billing cycle boundary.
- Implement the annual-billing toggle on the pricing and upgrade UI, mapping to the discounted annual price IDs.
- Implement API-tier metering: a Stripe Meters event is recorded per billed API operation (wired fully in M9, stubbed here with a no-op event emitter interface so M9 only has to implement the call site).
- Implement the spend-cap configuration for API keys (off by default) and the
402 quota_exceededresponse path when a configured cap is reached, per Section 12's API pricing detail — enforced fully once M9 ships real API traffic, but the data model and check function land here. - Build the pricing page (
apps/web/app/pricing/) rendering the plan table from Section 12.2 with the upgrade CTA wired to the checkout-session route. - Build the in-app upgrade prompts (reusing the paywall component from M3) triggered from quota warnings, wired to plan-specific checkout sessions.
- Build the billing settings page showing current plan, next invoice date, payment method summary (read from Stripe, never stored locally), and a "Manage billing" link to the portal session.
- Write integration tests against Stripe's test mode covering: successful upgrade, failed payment, downgrade, cancellation, and seat quantity changes, using Stripe CLI webhook forwarding in CI.
- Write a reconciliation job (
workercron, wired via BullMQ's repeatable jobs once M5's queue infrastructure exists — implemented here as a scheduledapps/apicron endpoint) that compares workspace plan state against Stripe's subscription state nightly and alerts on drift.
Dependencies: M3 (workspaces, entitlements module to update on plan change).
Sections implemented: 3.4 (Billing and cross-origin isolation), 12 (Plans, Billing, Quotas & Entitlements) in full.
Definition of done:
- A Free user can upgrade to Pro through Stripe Checkout and their workspace's entitlements reflect Pro limits within one webhook round trip, verified by an integration test.
- A Pro user can downgrade and retains Pro-level access until the current period ends, verified by advancing Stripe's test clock.
- A failed payment surfaces a banner and does not immediately revoke access.
- The billing settings page never displays a raw card number or CVC (nothing beyond what Stripe's API itself exposes, e.g., last four digits and brand).
- Team seat count changes are reflected in the next Stripe invoice, verified against Stripe's test mode invoice preview API.
Demo: "You can now click Upgrade to Pro, complete a real Stripe test-mode checkout, and immediately create a batch job that was blocked on the Free plan a moment earlier — with the receipt emailed by Stripe, not by us."
Risks:
| Risk | Mitigation |
|---|---|
| Webhook delivery is not guaranteed exactly-once; double-processing an upgrade event could double-charge internal state (e.g., double-reset a quota counter). | Idempotent handling keyed by Stripe event ID (task 4) is mandatory, not optional; every handler is a pure upsert, never an increment. |
| Stripe Billing Portal configuration (which fields are editable) defaults to settings that expose more than intended. | Explicitly configure the portal's allowed actions in the Stripe dashboard/API to match exactly what Section 12 promises (plan change, payment method, cancellation) and nothing else. |
| Seat-based Team billing and per-workspace quota resets interact in a way that is easy to get wrong at the boundary (a member removed mid-cycle). | Write the seat-change and quota-reset test matrix (task 15) before writing the handlers, not after. |
Effort estimate: 2.5 engineer-weeks for one engineer with prior Stripe integration experience.
22.2.5 M5 — The server pipeline (plus OCR) #
Goal: stand up the queue-backed, encrypted, isolated server-side processing pipeline described in Section 3.9, and ship the first server-side tool category, OCR, end to end.
Tasks:
- Stand up BullMQ 6.x on the Redis instance provisioned in M0: define the
ocr,convert,esign,batch,webhook,janitorqueues per Section 3.9. - Implement the priority-lane job-priority mapping: paid plans (Pro/Team/API) submit with a higher BullMQ priority value than Free, per Section 3.9's clarification that this is a priority value, not a separate queue.
- Implement the server-side job data model (
jobstable) per Section 5's schema: state machine columns matching Section 4.7 exactly,workspace_id,tool,input_document_id,output_document_id,progress,stage, timestamps. - Implement the upload endpoint (
POST /internal/documents): magic-byte sniffing, PDF structural validation, object-count/nesting-depth/decompressed-size limits, embedded-JavaScript and launch-action stripping on ingest, per Section 17.5. - Implement per-job envelope encryption: generate a data key, encrypt the uploaded object with AES-256-GCM, wrap the data key with the KMS master key, store the wrapped key on the job row, per Section 3.9.
- Implement the object storage client (
packages/db's or a newpackages/storagemodule) wrapping the S3-compatible SDK with the encrypt-on-write, decrypt-on-read behavior from task 5. - Build the
worker-mediacontainer: a Node process that pulls jobs from theocrqueue, invokes OCRmyPDF and Tesseract OCR engine, and writes progress updates back through Redis pub/sub or BullMQ's built-in progress API. - Configure
worker-media's gVisor sandbox: no outbound network, read-only root filesystem, a per-job tmpfs mount, and a hard wall-clock timeout mapped to therunning → expiredjob transition from Section 4.7. - Implement the OCR tool specification (Section 9.8): language selection, output mode (searchable PDF vs. plain text extraction), and the invocation wrapper around OCRmyPDF/Tesseract.
- Wire the retention janitor (
janitorqueue, a BullMQ repeatable job): deletes objects 2 hours after job terminal state and unconditionally within 24 hours of upload, per Section 6, by destroying the wrapped data key and then removing bytes. - Implement
DELETE /internal/documents/{id}andDELETE /v1/documents/{id}(public API stub wired fully in M9) for immediate shred: wrapped key destroyed within the request, bytes removed within 60 seconds, per Section 6. - Wire the free-tier server-side task counter built in M3 into the real upload/job-creation path, replacing the M3 stub route.
- Implement server-side job progress UI in
apps/web: a polling or WebSocket-backed progress indicator reusing the client-side job state machine's visual language from M1. - Implement the "Finish this on our servers instead" fallback for real: the explicit-click opt-in flow stubbed in M1 now uploads the in-progress file and creates a real server job, per Section 6.3's opt-in rule; build the hard exclusion list from Section 6.3 into the fallback button's own eligibility check so that redact, protect, unlock, and self-sign never render the fallback affordance at all — not disabled, not hidden-but-present, entirely absent from the DOM — and add a CI assertion that fails the build if any of those four tool pages ships with the fallback control present in any state.
- Add the Processing Location Indicator's "On our servers" state to every server-side tool page, with the amber token and the 24-hour deletion copy, verified against actual job behavior by an E2E test per Section 6.2's CI contract.
- Instrument OpenTelemetry tracing across the upload → queue → worker → completion path with
traceparentpropagation from the browser request through to the worker, per Section 18.3. - Write load tests (k6) against the OCR pipeline targeting the throughput and latency numbers specified in Section 18's availability and performance targets.
- Write integration tests for the encryption lifecycle: upload, verify object is encrypted at rest (fetch raw bytes from storage and assert they are not plaintext PDF), verify deletion cryptographically shreds access even if raw bytes have not yet been removed.
- Write chaos/failure-path tests: worker crash mid-job transitions the job to
failed, not stuck inrunning; a wall-clock timeout transitions toexpired. - Implement multipart, resumable upload for
POST /internal/documents: files above a configurable threshold (default 25 MB) upload as a sequence of parts to the S3-compatible backend's native multipart API, tracked by anupload_sessionsrow keyed by areq_-prefixed session token; a dropped connection resumes from the last acknowledged part rather than restarting the whole upload, and a session left incomplete for more than 24 hours is discarded by the retention janitor (task 10) along with any parts already received.
Dependencies: M1 (pdfcore Node binding reused by worker-media where applicable), M4
(quota-gated server spend).
Sections implemented: 3.5 (Server runtime), 4.2 (Job state machine), 6 (Retention), 9.8 (OCR), 17.5 (Upload security), 18.3 (Tracing).
Definition of done:
- Uploading a scanned PDF and running OCR produces a searchable PDF, with the job visibly
transitioning
queued → running → succeededin the UI. - An object fetched directly from storage outside the application is unreadable ciphertext.
- A job deleted via the API is unrecoverable (wrapped key destroyed) within the request, verified by attempting decryption immediately after.
- The retention janitor removes an untouched upload within 24 hours in a time-accelerated test.
- Free-tier users are blocked after 2 server-side tasks/day, now enforced on the real pipeline, not the M3 stub.
- Worker containers have no outbound network access, verified by a test that attempts an egress connection from inside the sandbox and confirms it fails.
- The four excluded tools (redact, protect, unlock, self-sign) never render the server-fallback affordance in any state, verified by the CI assertion added in task 14, not by manual review.
- A multipart upload interrupted mid-transfer resumes from its last acknowledged part rather than restarting from byte zero, verified by an integration test that kills the connection mid-upload.
Demo: "You can now upload a scanned contract, watch OCR run through a real queue on a real worker, download a searchable PDF, and see the underlying file cryptographically shredded on our servers within two hours — with the Processing Location Indicator having told you exactly that before you clicked."
Risks:
| Risk | Mitigation |
|---|---|
| OCR quality on low-resolution scans or non-Latin scripts falls short of user expectations. | Set explicit, documented quality expectations per Section 9.8 (recommended minimum 200 DPI, language pack selection required for non-English text) rather than promising universal accuracy; surface a low-confidence warning when Tesseract's mean confidence score falls below a threshold. |
| gVisor sandbox overhead measurably increases job latency versus a bare container. | Benchmark sandboxed vs. unsandboxed throughput during task 8 and budget the overhead into the load-test targets rather than discovering it in production. |
| Envelope encryption key management (KMS availability, wrapped-key loss) can turn a transient outage into permanent data loss. | KMS calls are retried with backoff and the upload request fails closed (no object written) if key wrapping fails, rather than falling back to unencrypted storage. |
Effort estimate: 4 engineer-weeks, assuming one engineer owns queue/worker infrastructure (tasks 1–3, 6–8, 10, 16–19) and a second owns the upload/encryption/security path and OCR tool UI (tasks 4–5, 9, 11–15, 20).
22.2.6 M6 — Office and HTML conversion #
Goal: ship PDF → DOCX/XLSX/PPTX, DOCX/XLSX/PPTX → PDF, HTML → PDF, and PDF → HTML, all
server-side, using the worker-office container.
Tasks:
- Build the
worker-officecontainer image: Python 3.13.x runtime, LibreOffice headless, Poppler utils, Ghostscript, pikepdf installed, sized and hardened with the same gVisor sandbox profile asworker-media. - Implement the
convertqueue consumer inworker-office, subscribing alongsideworker-media'socrconsumer on the shared Redis instance. - Implement DOCX/XLSX/PPTX → PDF (Section 9.9): invoke LibreOffice headless in conversion mode with a per-job timeout, capturing stderr for diagnostic logging.
- Implement PDF → DOCX (Section 9.9): a layout-reconstruction pipeline using
pdf2docx-style text/table/image extraction, with an explicit, documented fidelity tier system (Text-reconstructed vs. Layout-preserved) so users understand what to expect before conversion. - Implement PDF → XLSX (Section 9.9): table-detection heuristics per page producing one worksheet per detected table region, falling back to one worksheet of extracted text blocks when no table is detected.
- Implement PDF → PPTX (Section 9.9): one slide per PDF page, each page rendered as a background image with extracted text boxes overlaid where confidently positioned.
- Implement HTML → PDF (Section 9.9): a headless-Chromium-based rendering path (bundled inside
worker-officeor a sibling lightweight service) honoring@media printCSS, with a configurable page-size and margin parameter set. - Implement PDF → HTML (Section 9.9): a semantic HTML5 reconstruction using PDFium's tagged-PDF
structure tree where present, falling back to positioned
<div>elements over an image background where the source PDF is untagged. - Implement the fidelity-expectation UI: every Office/HTML conversion result page shows a fidelity-tier badge and a one-line explanation, matching the file-format support matrix in Section 23.4.
- Wire the free-tier and paid-tier quota counters for Office/HTML conversion into the real pipeline (2/day free, unlimited Pro/Team, metered API), replacing the M3/M4 stubs for this tool category specifically.
- Add conversion-specific error codes to the shared error catalog module (Section 23.1) for corrupt source files, unsupported embedded fonts, and conversion-engine timeouts.
- Extend the golden-file corpus with Office and HTML source/target fixtures and wire perceptual or structural diffing (DOCX/XLSX/PPTX are diffed structurally via their XML, not visually).
- Load-test the
worker-officepipeline; LibreOffice headless instances are process-heavy, so size the worker pool's concurrency per container against measured memory usage per job. - Write E2E tests for all six conversion directions covering primary path, a source file with embedded fonts, and a source file that exceeds the plan's max file size.
Dependencies: M5 (queue infrastructure, upload/encryption pipeline, job state machine, retention janitor — all reused as-is).
Sections implemented: 9.9 (Office & HTML Conversion), 23.4 (File-format support matrix, partial contribution).
Definition of done:
- All six conversion directions (DOCX/XLSX/PPTX → PDF, PDF → DOCX/XLSX/PPTX, HTML → PDF, PDF → HTML) work end to end against real sample files.
- Every conversion result page displays a fidelity-tier badge that matches the actual reconstruction method used.
- Structural diffing on the golden-file corpus's Office fixtures passes for at least 95% of table and heading structures on well-formed source documents.
worker-officeruns under the same sandbox constraints (no outbound network, read-only root, tmpfs, wall-clock timeout) asworker-media.
Demo: "You can now upload a ten-page PDF report, convert it to an editable PowerPoint deck with one slide per page and the headline text still selectable, and separately convert a marketing HTML page into a print-ready PDF that respects its print stylesheet."
Risks:
| Risk | Mitigation |
|---|---|
| PDF-to-Office fidelity is inherently lossy for complex layouts (multi-column, nested tables, custom fonts); user expectations may exceed what any engine delivers. | Explicit fidelity-tier badges (task 9) set expectations before download rather than after; document known limitations in the file-format support matrix rather than implying universal fidelity. |
| LibreOffice headless is known to be resource-heavy and occasionally hangs on pathological input. | Hard wall-clock timeout per job (already part of the sandbox profile) plus a process-level watchdog that force-kills and marks the job expired rather than blocking the worker slot indefinitely. |
| Font licensing: converting to PDF may require embedding fonts not licensed for redistribution. | Only embed fonts already present in the source document or from an open-license fallback set; never substitute a commercial system font into the output. |
Effort estimate: 3 engineer-weeks for one engineer with prior document-conversion pipeline experience; parallelizable to 2 weeks by splitting Office conversions (tasks 3–6) from HTML conversions (tasks 7–8) across two engineers.
22.2.7 M7 — Batch processing #
Goal: let paid users run any tool, client-side or server-side, across many files at once, per the plan-scaled batch limits in Section 12.2.
Tasks:
- Implement the batch job data model (
batchesandbatch_itemstables) per Section 5's schema, with abatch_idforeign key onbatch_itemsreferencing individual job rows. - Implement the batch queue consumer on the
batchqueue: fans a batch out into per-item jobs on the appropriate tool-specific queue (or a client-orchestrated loop for all-client-side batches). - Implement the rule from Section 1.2: any batch containing at least one server-side tool runs the whole batch server-side, even if some items are individually client-side-eligible — implement this as an explicit routing decision in the batch-creation handler, not an emergent behavior.
- Build the batch upload UI: multi-file drop supporting up to the plan's file-count limit (100 Pro, 250 Team, 1000 API), with per-file validation before submission.
- Implement batch-level entitlement checks: file-count limit, per-file size limit, and the Free-tier "no batch access" restriction, reusing the entitlements module from M3.
- Build the batch progress UI: an aggregate progress bar plus a per-item status list, reusing the job state machine's terminal states per item.
- Implement partial-failure handling: a batch reaches
succeededonly if every item succeeds; if any item fails, the batch reaches a terminal state that surfaces per-item results (some succeeded, some failed) rather than discarding successful outputs. - Implement batch result packaging: a single ZIP download containing all successful outputs, named
per a documented, collision-safe naming scheme (
{originalFilename}-{tool}.{ext}, with a numeric suffix on collision). - Wire batch jobs into job history (Section 11) as a single collapsible entry expandable to per-item detail.
- Implement the client-side-only batch path: for a batch of exclusively client-side-eligible tools invoked from the browser (not the API), process items sequentially through the M1 worker pool without ever uploading, and reflect this correctly in the Processing Location Indicator.
- Add rate/priority handling: batch items respect the same priority-lane assignment (Section 3.5) as single jobs, so a large Free-adjacent... (not applicable, Free has no batch access) — ensure Pro/Team/API batch items are enqueued at the paid priority level, not silently downgraded.
- Write load tests simulating a 1000-file API batch (the Scale-tier ceiling) to validate queue throughput and worker autoscaling triggers.
- Write E2E and integration tests: a mixed-success batch, a batch exceeding the plan's file-count limit (rejected before any item starts), and a client-side-only batch producing zero network requests carrying file bytes.
Dependencies: M5 (server pipeline, for any batch containing a server-side tool), M2 (full client-side tool suite, for client-side-only batches).
Sections implemented: 13 (Batch Processing & Job Orchestration) in full, cross-referencing 1.2's routing rule and 12.2's plan-scaled limits.
Definition of done:
- A Pro user can batch-compress 100 files client-side with zero uploads, verified by network inspection in an E2E test.
- A Team user can batch-OCR 250 scanned files server-side and download one ZIP of results.
- A batch with 3 failing items out of 50 completes with 47 successful downloads available and the 3 failures individually diagnosable in the UI.
- A batch exceeding the plan's file-count limit is rejected at submission time with a clear entitlement error, never partially started.
Demo: "You can now drag two hundred and fifty scanned invoices into the batch tool, walk away, and come back to a single ZIP file of two hundred and forty-eight searchable PDFs plus a clear list of the two that failed and why."
Risks:
| Risk | Mitigation |
|---|---|
| Large batches (1000 files) create a queue thundering-herd that starves single-job latency for other users. | Batch items are enqueued at the same priority value as an equivalent single job, not elevated; queue concurrency is capped per workspace to prevent one large batch from monopolizing workers. |
| Partial-failure UX is easy to get wrong (users losing successful outputs because of one bad file). | The definition of done explicitly requires successful outputs remain downloadable independent of sibling failures; this is asserted by a dedicated integration test, not left to manual QA. |
| ZIP packaging of very large batches (1000 files at up to 1 GB each) can exceed practical single-download sizes. | Cap total batch download size and, when exceeded, split output into multiple sequentially numbered ZIP parts with this behavior documented in the batch result UI. |
Effort estimate: 2.5 engineer-weeks for one engineer, reusing the job state machine, queue infrastructure, and entitlements module built in prior milestones.
22.2.8 M8 — E-signature end to end #
Goal: implement the full envelope lifecycle specified in Section 10 — sender creates an envelope, signers sign without an account, the audit trail and Certificate of Completion are generated, and the public verification endpoint works.
Tasks:
- Implement the e-signature data model:
envelopes,signers,fields,audit_eventstables per Section 5's schema, withaudit_eventsappend-only (noUPDATEorDELETEpermitted at the database-role level). - Implement the hash-chain mechanism: each
audit_eventsinsert computesSHA-256(previous_event_hash || this_event_payload)in the same transaction as the insert, per the mechanism in Section 10.2. - Build the envelope creation flow (
apps/web/app/dashboard/esign/new/): upload source PDF (server-side per Section 1.2), add signers with roles (signer, approver, CC-only), choose sequential or parallel routing. - Build the field-placement UI: sender drags field types (signature, initials, date-signed, text, checkbox, radio group, dropdown, attachment request) onto page positions, assigned to a specific signer.
- Implement envelope sending: creates signer sessions, sends the initial email via Resend with a
unique, unguessable, single-signer link (a
req_-prefixed token, per the ID convention in Section 4). - Build the signer portal (
apps/signer, served atsign.pdfworks.io): no account required; email verified by 6-digit OTP before first field entry, per Section 10.5. - Implement the Electronic Record and Signature Disclosure consent screen: must be affirmatively
accepted before any field is shown; declining ends the session and records
signer.declined. - Implement the three signature input types: draw (pointer/touch canvas capture), type (rendered in four bundled typefaces), upload (image file, validated and normalized).
- Implement sequential routing: signer N+1's session is not activated (and no notification sent) until signer N completes, enforced server-side, not just hidden in the UI.
- Implement parallel routing: all signers notified simultaneously; envelope completes when the last signer finishes regardless of order.
- Implement the full audit event set from Section 10.2 (
envelope.createdthroughreminder.sent), each recording UTC timestamp from an NTP-disciplined server clock, verified signer email, IP address, user agent, geo-IP country, and the current document hash. - Implement reminder scheduling: a BullMQ repeatable/delayed job pattern sending reminders at day 3, 7, 12 by default (configurable per envelope), and expiring the envelope at day 14 by default (configurable 1–30 days per Section 6's retention exception).
- Implement envelope completion: once every required signer has signed, generate the Certificate of Completion page (envelope ID, all signer identities, every event with timestamp, final document hash, chain root hash, QR code, short verification URL) and append it to the flattened output PDF.
- Implement the flattening step: filled fields and signature marks become non-editable page content on completion, reusing the flatten tool binding from M2 where applicable.
- Implement
GET https://sign.pdfworks.io/verify/{envelopeId}(public, unauthenticated, rate-limited): accepts an uploaded PDF or a hash, recomputes, and reportsMATCH/ALTERED/UNKNOWN, per Section 10.6. - Implement the retention exception from Section 10.8 precisely: source/in-progress PDFs live for the envelope's configured expiry (default 14 days, max 30); the completed signed PDF and certificate are downloadable for 30 days post-completion then deleted; audit events (metadata and hashes, never document content) retain for 7 years; account deletion anonymizes signer email/IP via salted hash while preserving the chain.
- Implement envelope voiding: a sender can void an in-progress envelope, recording
envelope.voidedand notifying any signer who had already started. - Build the sender-facing envelope dashboard: status per signer, resend/void/remind actions, audit trail viewer, certificate download.
- Wire signature-request quota enforcement (3/month Free, 100/month Pro, 500/month/workspace Team) into envelope creation, reusing the entitlements module.
- Write the consent-and-declination E2E test, the sequential-routing-order E2E test, and the
tamper-detection test (alter one byte of a completed PDF and confirm the verify endpoint reports
ALTERED). - Write an audit-chain integrity test: verify that recomputing the hash chain from stored events matches the stored chain root hash for a completed envelope, and that the daily anchored digest from task 22 recomputes correctly for that day's covered envelopes.
- Implement the daily audit-chain anchoring mechanism specified in Section 10.5: on a scheduled cadence, publish a verifiable digest covering every envelope's hash chain that advanced that day, so retroactive tampering with a completed chain is detectable independently of PDFWorks's own database; record the digest's publication reference and timestamp against each covered envelope so the Certificate of Completion can point to it.
Dependencies: M5 (server pipeline for source-document handling and encryption), M4 (quota-gated envelope creation).
Sections implemented: 10 (E-Signature System) in full, 6 (Retention, the e-signature exception specifically).
Definition of done:
- A sender can create a two-signer sequential envelope, and signer 2 cannot access their fields until signer 1 completes, verified by an E2E test.
- A signer with no PDFWorks account can verify their email by OTP, consent to the disclosure, sign, and receive a copy of the completed document by email.
- The Certificate of Completion, once generated, is byte-stable, and altering one byte of the
completed PDF causes
GET /verify/{envelopeId}to reportALTERED. - The audit hash chain for a completed envelope recomputes correctly from stored events in an automated integrity test.
- The daily audit-chain anchor is independently verifiable, and a test confirms that altering a stored audit event after the fact is detectable by recomputing against the published digest.
- Signature-request quotas are enforced per plan at envelope creation, not after.
- Every document in this milestone's scope is explicitly ESIGN/UETA/eIDAS simple-and-advanced
electronic signature; no
/Sigsignature dictionary, X.509 certificate, or AATL/EUTL trust chain is present anywhere in the output, and the product's own copy says so.
Demo: "You can now send a two-page NDA to two people by email, watch the second signer's fields stay locked until the first signer finishes, have both sign without ever creating an account, and download a certificate whose QR code lets a stranger verify the document has not been altered since the moment it was completed."
Risks:
| Risk | Mitigation |
|---|---|
| An e-signature product without PKI-based signatures faces skepticism about legal enforceability, and the boundary must be communicated honestly rather than overstated. | Product copy and the certificate itself state plainly that this is ESIGN/UETA/eIDAS simple/advanced electronic signature, not a qualified or PKI-backed digital signature; the daily audit-chain anchoring from task 22 gives the tamper-evidence claim an externally verifiable backstop beyond database-internal hashing, narrowing the practical gap with PKI-based schemes without claiming to be one; this remains a legal-exposure risk carried into Section 22.4's risk register, not something engineering alone can close. |
| NTP clock discipline on worker/API hosts drifting would undermine the evidentiary value of event timestamps. | Run NTP synchronization as a host-level requirement verified by a monitoring check (Section 18) that alerts if clock skew exceeds one second. |
| Reminder/expiry scheduling logic (day 3/7/12, expiry day 14) is a common source of off-by-one and timezone bugs. | All scheduling arithmetic operates in UTC on timestamptz columns exclusively; a dedicated unit test suite covers the reminder and expiry boundary days. |
| Sequential routing enforcement that lives only in the UI is trivially bypassed by a signer who saves and replays a later signer's link. | Server-side session activation gating (task 9) is the actual enforcement; the UI merely reflects state the server has already decided. |
Effort estimate: 4.5 engineer-weeks, assuming one engineer owns the audit/hash-chain/certificate core (tasks 1, 2, 11, 13, 15, 16, 21, 22) while a second owns the sender and signer UI flows (tasks 3–10, 12, 14, 17–20).
22.2.9 M9 — The public API, webhooks, SDK, and developer docs #
Goal: expose every server-side capability (and, through the API path, every tool — since Section 1.2 routes all API-invoked operations server-side) through the versioned public REST API, with webhooks, an official SDK, and documentation.
Tasks:
- Implement the
/v1route tree inapps/apiper the HTTP conventions in Section 14: kebab-case plural resource paths,camelCaseJSON, cursor pagination, bare single-resource responses. - Implement API key authentication middleware:
pk_live_/pk_test_key parsing, SHA-256 lookup (never a slow KDF, per the explicit rationale in Section 17.4), per-key scopes, per-key IP allowlist enforcement. - Implement
Idempotency-Keyhandling for every/v1POST that creates or spends: 24-hour replay window, stored response replay,409 idempotency_key_reuseon conflicting-body replay, per Section 4's canonical conventions. - Implement the Redis token-bucket rate limiter with
RateLimit-Limit/RateLimit-Remaining/RateLimit-Resetresponse headers andRetry-Afteron 429, per Section 14. - Implement the error envelope (Section 4.1) as the single error-formatting middleware for every
/v1route, sourcingcodefrom the catalog in Section 23.1. - Implement
POST /v1/documents(upload),GET /v1/documents/{documentId},DELETE /v1/documents/{documentId}reusing the M5 upload/encryption/deletion pipeline, including the multipart/resumable upload path from Section 22.2.5, task 20. - Implement
POST /v1/jobs(create a tool job against an uploaded document),GET /v1/jobs/{jobId},GET /v1/jobs(cursor-paginated list), reusing the job state machine from Section 4.7. - Implement
POST /v1/batches,GET /v1/batches/{batchId}reusing M7's batch orchestration. - Implement
POST /v1/envelopes,GET /v1/envelopes/{envelopeId},POST /v1/envelopes/{envelopeId}/voidreusing M8's e-signature engine. - Implement per-operation API metering: every billable
/v1call emits a Stripe Meters event (completing the M4 stub) and increments the per-key spend total checked against the configured spend cap. - Implement webhook subscription management:
POST /v1/webhook-endpoints,GET /v1/webhook-endpoints,DELETE /v1/webhook-endpoints/{webhookEndpointId}, storing a per-endpoint secret and the endpoint's current lifecycle state (active,failing,disabled). - Implement webhook delivery: on job/batch/envelope terminal-state transitions, enqueue a
webhookqueue job that POSTs the event payload withHMAC-SHA256overtimestamp.body, sent asPDFWorks-Signature: t=<unix>,v1=<hex>, a 5-minute tolerance window, and exponential-backoff retry on non-2xx response, per Section 17.4's webhook security spec and the retry schedule in Section 14.10.6; after the number of consecutive delivery failures configured in Section 23.2, the endpoint transitions fromactivetofailing, the transition is surfaced in the endpoint list and the dashboard, and the workspace owner is notified by email — delivery attempts continue on the same retry schedule whilefailing, the endpoint is never silently dropped. - Implement webhook secret rotation with a 24-hour dual-secret overlap window, per Section 17.4.
- Generate the OpenAPI specification from the
packages/contractsZod schemas (a build-time codegen step) and publish it at a stable, documented URL. - Build
packages/sdk-js: a typed TypeScript client generated from or hand-aligned to the OpenAPI spec, covering every/v1resource, published to npm as the first-party SDK. - Write SDK usage examples covering document upload, job polling, webhook signature verification, and envelope creation, each a complete, runnable TypeScript snippet.
- Build the developer documentation site at
docs.pdfworks.io: API reference (generated from OpenAPI), guides (authentication, idempotency, webhooks, rate limits, errors), and the full error-code catalog from Section 23.1 as individual linkable pages. - Implement the API keys management UI in the dashboard: create, scope, IP-allowlist, spend-cap, revoke, last-used timestamp display, per Section 17.4.
- Write contract tests asserting the OpenAPI spec matches actual route behavior for every
/v1endpoint (request/response shape, status codes, error codes). - Write API-focused load tests validating the rate limiter's accuracy under concurrent load and the idempotency layer's correctness under concurrent duplicate requests.
- Write an SDK integration test suite that runs the published SDK against a staging API instance end to end.
Dependencies: M5, M6, M7, M8 (the API surfaces documents/jobs, conversions, batches, and envelopes built in each).
Sections implemented: 14 (Public REST API & Webhooks) in full, 17.4 (API keys and webhook security), 12.6 (API pricing detail — metering wiring).
Definition of done:
- Every
/v1endpoint documented in Section 14 is implemented, contract-tested against its OpenAPI definition, and returns the canonical error envelope on every documented failure path. - A webhook subscriber can verify signature authenticity using only the published algorithm and receives a retried delivery after a simulated transient failure.
- A webhook endpoint that fails delivery repeatedly transitions to a
failingstate visible in the dashboard, and the workspace owner is notified, verified by a test that simulates a run of consecutive non-2xx responses. - The published
sdk-jspackage can upload a document, create a job, poll to completion, and download the result in under twenty lines of example code. - Idempotent replay of an identical request returns the original response; replay with a different
body returns
409 idempotency_key_reuse. - Rate-limit headers are present and accurate on every
/v1response, verified by a load test that intentionally exceeds a test key's bucket.
Demo: "You can now write twenty lines of TypeScript against our published SDK, upload a PDF, request an OCR job, get a webhook the moment it finishes, and see the whole request traced end to end in the API reference documentation you just read it from."
Risks:
| Risk | Mitigation |
|---|---|
| API surface and internal web-app surface drift apart over time since they are different route trees on one codebase. | packages/contracts Zod schemas are the single source of truth consumed by both trees; the contract tests in task 19 catch drift automatically in CI. |
| Webhook delivery reliability under subscriber downtime could silently lose events. | Exponential-backoff retry on the schedule in Section 14.10.6, the failing-state transition and owner notification from task 12, and a dashboard view of failed deliveries the customer can manually replay. |
| Published SDK versioning gets out of sync with the API's own versioning, breaking integrators. | SDK major version tracks the API's /v1 prefix; a breaking API change requires a /v2 prefix and a corresponding SDK major bump, never a silent breaking change within /v1. |
Effort estimate: 4 engineer-weeks, assuming one engineer owns the API route tree and webhook infrastructure (tasks 1–13, 20) while a second owns the SDK and documentation site (tasks 14–19, 21).
22.2.10 M10 — Hardening and launch #
Goal: close every remaining quality gap against the bars in Section 16, 18, and 19, ship the marketing site, and execute a go-live checklist so the product launches to real, paying customers with confidence.
Tasks:
- Run a full WCAG 2.2 AA audit across every page shipped in M1–M9: automated axe-core sweep plus a complete manual NVDA and VoiceOver pass, fixing every violation found, per Section 16.
- Run the full performance-budget validation pass from Section 18.6 (LCP, bundle size, WASM artifact size, tool-shell interactivity, merge throughput) across the entire shipped tool catalog, not just the M1 sample.
- Commission or conduct an external security review (penetration test) covering the upload pipeline, authentication, API surface, and e-signature verification endpoint; remediate every finding rated medium severity or above before launch.
- Complete the security baseline checklist from Section 17 end to end: headers (HSTS preload,
X-Content-Type-Options,Referrer-Policy, CSP with nonces andwasm-unsafe-evalbut nounsafe-inline/unsafe-eval,Permissions-Policy),security.txt, published disclosure policy. - Run k6 load tests against production-equivalent infrastructure targeting the 99.9% monthly availability target and the specific per-tool throughput numbers established in each milestone's own load tests, now validated together under combined load.
- Build the marketing site (Section 21 owns its content and SEO strategy; this task is the engineering build-out of the pages that section specifies) as a set of statically generated Next.js routes outside the cross-origin-isolated tool route segment, per Section 3.8's routing split.
- Implement the offline/degraded-mode PWA behavior specified as a launch feature in Section 18: client-side tools remain usable during an API outage, with a visible "offline" state rather than silent failure.
- Run the full E2E suite across the entire browser matrix (last two major versions of Chrome, Edge, Firefox, Safari, plus iOS Safari and Chrome Android) and fix matrix-specific regressions, confirming the single-threaded fallback behaves correctly where cross-origin isolation is unavailable.
- Verify every Processing Location Indicator instance against actual execution location with a dedicated CI assertion sweep across all tool pages, batch rows, and confirmation dialogs, closing out the contractual requirement from Section 6.2.
- Execute a full disaster-recovery drill: restore the database from backup, verify RPO/RTO targets from Section 20, and document the runbook.
- Verify GDPR/CCPA launch requirements from Section 17.6: DSAR export and delete flows tested end to end, Record of Processing Activities document finalized, DPA template published, EU sub-processor list published, EU data residency option validated on a Team workspace.
- Load and validate the environment variable specification from Section 23.2 in every deployment environment (staging, production) with the boot-time validation schema rejecting any missing required variable.
- Finalize and publish the status page with real uptime monitoring wired to the infrastructure provisioned in M0.
- Conduct a final cost review: confirm infrastructure spend (workers, storage, KMS calls, Stripe fees) against the pricing model in Section 12 leaves a sustainable margin at expected launch volume.
- Write and rehearse the go-live checklist (task 16) as a live drill in the staging environment before executing it in production.
- Execute the go-live checklist in production: DNS cutover for all five domains from Section 1 (marketing, app, API, docs, signer), SSL certificate validation, final smoke test of one end-to-end flow per major feature area (a client-side tool, a server-side conversion, an OCR job, an e-signature envelope, an API call with a webhook), and a rollback plan documented and understood by everyone executing the launch.
- Set up post-launch monitoring dashboards (error rate, job success rate, queue depth, API latency percentiles, Stripe payment failure rate) as the first artifact of the first-90-days plan in Section 22.5.
Dependencies: M2, M6, M7, M8, M9 (every user-facing surface must exist before it can be hardened and launched).
Sections implemented: 16 (Accessibility bar) in full, cross-referencing every tool section for the scope of the accessibility and performance sweeps, 17 (Security baseline) in full, 18 (Performance budgets and availability target) in full, 20 (Infrastructure, Deployment & Operations), 21 (Marketing Site — engineering build-out only; content and SEO strategy are that section's own scope).
Definition of done:
- Zero unresolved axe-core violations and zero unresolved manual screen-reader findings across the entire application.
- Every performance budget in Section 18.6 is met in a production-equivalent environment, not just locally.
- The external security review's medium-and-above findings are all remediated and verified closed.
- The disaster-recovery drill completes within the documented RTO with no data loss beyond the documented RPO.
- The go-live checklist has been rehearsed once in staging with zero manual steps skipped.
- The product is reachable at all five production domains with valid TLS and the marketing site live.
Demo: "You can now watch the go-live checklist executed against production in real time — the marketing site goes live, a real signup completes, a real payment processes, a real signature envelope completes, and the monitoring dashboard shows every one of those events landing green."
Risks:
| Risk | Mitigation |
|---|---|
| WASM performance on low-end mobile devices (older Android hardware, iOS Safari without threading) may fall well short of desktop budgets. | Explicitly test against a low-end Android reference device (not just browser version emulation) during task 8; document a lower, honest performance expectation for mobile in the support matrix in Section 15 rather than silently shipping a degraded experience unlabeled. |
A cross-origin-isolation requirement (COEP: credentialless) breaks a third-party embed added late (analytics pixel, chat widget) that was not evaluated against isolation compatibility. |
Task 9's CI sweep plus a manual audit of every third-party script loaded on an isolated route before launch; anything incompatible moves to a non-isolated route segment per Section 3.8's fallback rule, never disables isolation on a tool route. |
| Competitor price pressure (iLovePDF, Smallpdf, Sejda undercutting on price shortly after launch) erodes the pricing model's assumed margin. | This is a business risk, not an engineering one, and is carried forward into the post-launch plan (Section 22.5) as a metric to watch (conversion rate at current price points) rather than something M10 can close. |
| SEO dependence: if the marketing site's search rankings underperform, the primary acquisition channel assumed by the pricing model does not materialize. | Section 21 owns SEO strategy; M10's engineering task is limited to ensuring the technical SEO prerequisites (static generation, structured data, sitemap, Core Web Vitals) are met, which is verified in task 2's performance sweep. |
| Security review findings arrive late enough that remediation threatens the launch date. | Commission the external review (task 3) at the start of M10, not the end, so there is runway to remediate before the go-live checklist is scheduled. |
Effort estimate: 3 engineer-weeks with the full team (all engineers who built M1–M9) engaged in parallel across accessibility, performance, security remediation, and launch-checklist execution; this is a convergence milestone and does not parallelize cleanly into independent single-owner tracks the way earlier milestones do.
22.3 Parallelization plan #
The dependency graph in Section 22.1 admits real concurrency once the engine (M1) and accounts (M3) exist. The minimum critical path and the concurrency opportunities:
| Stage | Can run concurrently | Cannot start until |
|---|---|---|
| M0 | — (single track) | — |
| M1 | — (single track, though internally two engineers split engine-build vs. runtime, Section 22.2.1) | M0 done |
| M2 and M3 | Concurrent. M2 (client-side tools) and M3 (accounts/entitlements) share no code paths — M2 needs only the engine, M3 needs only the auth skeleton. | M1 done (M2); M0 done (M3, though M3's task 6 gating references tools that benefit from M1/M2 being further along) |
| M4 | Starts once M3 is done; does not need M2 to be finished, only started (M4's UI reuses M3's paywall component). | M3 done |
| M5 | Starts once M1 and M4 are done. | M1, M4 done |
| M6, M7, M8 | Concurrent with each other. All three depend only on M5's queue/storage/encryption infrastructure, not on each other. M7 has a soft dependency on M6 only for batches that include an Office conversion tool — pure-OCR or pure-PDF-tool batches do not need M6 at all. | M5 done |
| M9 | Depends on M5, M6, M7, M8 all being functionally complete, since the API surfaces all four. In practice, M9's route-tree scaffolding (task 1–5) can start as soon as M5 lands, and each resource's routes (tasks 6–9) land incrementally as M6/M7/M8 complete. | M5 done (partial start); M6, M7, M8 done (full) |
| M10 | Cannot meaningfully start until M2, M6, M7, M8, M9 are all functionally complete, since it hardens the whole surface. Some sub-tasks (security review scoping, DR drill runbook drafting) can start earlier as a soft overlap. | M2, M6, M7, M8, M9 done |
Minimum critical path (the longest dependency chain, assuming unlimited engineers and perfect parallelization of everything else): M0 → M1 → M3 → M4 → M5 → M8 (or M6/M7, all roughly equal length) → M9 → M10, which is 2 + 4 + 3 + 2.5 + 4 + 4.5 + 4 + 3 = 27 engineer-weeks of critical-path duration. Note M2 (5 weeks) runs fully in parallel with M3+M4 (5.5 weeks combined) and does not extend the critical path; it is, however, a hard prerequisite for M10 and must finish before M10 starts regardless of when M9 finishes.
With a team of three to four engineers sharing the critical-path milestones and taking M2/M6/M7/M8 in parallel where the graph allows, wall-clock duration of roughly 16–18 weeks (four months) is achievable from an empty repository to a hardened launch, against a raw sum of all milestone effort estimates of approximately 38 engineer-weeks.
22.4 Risk register #
| # | Risk | Likelihood | Impact | Early warning signal | Mitigation |
|---|---|---|---|---|---|
| 1 | In-PDF text editing (Section 8.1) fidelity disappoints users on non-trivial layouts (multi-column, non-embedded fonts, complex kerning). | High | Medium | Support tickets or user feedback citing "broken formatting" after using the edit-text tool. | Scope the tool explicitly to embedded-font, single-column-friendly editing at launch; state the limitation in the tool's own UI copy rather than over-promising; expand fidelity in a post-launch iteration guided by real usage data (Section 22.5). |
| 2 | PDF-to-Office conversion fidelity (Section 9.9) falls short of user expectations for complex source documents. | High | Medium | Golden-file corpus structural-diff pass rate below 95% on realistic (not synthetic) documents during M6. | Fidelity-tier badges set expectations per result; the file-format support matrix (Section 23.4) documents known limitations candidly instead of implying universal fidelity. |
| 3 | WASM performance on low-end mobile devices misses the performance budgets in Section 18.6. | Medium | Medium | Lighthouse or field performance data from a low-end Android reference device during M10 task 8 showing LCP or interactivity budgets missed by more than 50%. | Document a distinct, honest mobile performance expectation in the browser support matrix (Section 15) rather than silently shipping a degraded, unlabeled experience; consider a reduced-feature "lite" path for the lowest-end devices in a future iteration. |
| 4 | Cross-origin isolation (COEP: credentialless) breaks a third-party embed (analytics, chat, an ad pixel) added after the isolation boundary is established. |
Medium | Low | A third-party script silently fails to load or throws a console error referencing credentialless or SharedArrayBuffer. |
Every new third-party script is checked against isolation compatibility before being added to an isolated route; incompatible scripts move to a non-isolated route segment (Section 3.8), never trigger disabling isolation on a tool route. |
| 5 | OCR quality expectations are not met for low-resolution scans, handwriting, or non-Latin scripts. | High | Low | Tesseract mean confidence score below a documented threshold on a meaningful fraction of jobs. | Document minimum recommended scan quality (200 DPI) and language-pack selection requirements per Section 9.8; surface a low-confidence warning to the user rather than silently returning poor-quality text. |
| 6 | Competitor price pressure (iLovePDF, Smallpdf, Sejda) undercuts the pricing model shortly after launch. | Medium | Medium | Falling trial-to-paid conversion rate or rising churn correlated with a competitor price change, tracked from day one of the first-90-days plan (Section 22.5). | The privacy differentiator (Section 1.1, Section 6) is the primary defense against pure price competition; pricing itself is reviewed quarterly against the metrics in Section 22.5, not fixed permanently at launch values. |
| 7 | SEO dependence: the marketing strategy in Section 21 assumes organic search as a primary acquisition channel, and rankings underperform. | Medium | High | Organic traffic materially below the plan's assumed volume 60 days post-launch. | Section 21 owns diversification of acquisition channels as a strategic concern; the first-90-days plan (Section 22.5) tracks organic traffic explicitly as an early leading indicator, not a lagging one discovered at quarter's end. |
| 8 | The legal exposure of an e-signature product without PKI-based signatures is misunderstood by customers or challenged in a specific jurisdiction. | Low | High | A customer support inquiry or legal challenge questioning enforceability in a jurisdiction with stricter qualified-signature requirements (certain eIDAS-regulated use cases). | Product copy and the certificate of completion state the ESIGN/UETA/eIDAS simple-and-advanced scope plainly (Section 10); a qualified-signature offering is explicitly out of scope and documented as such (Section 2.8's scope fence) rather than implied to exist; the daily audit-chain anchoring described in Section 10.5 gives the tamper-evidence claim an externally verifiable backstop beyond database-internal hash chaining, which narrows — without closing — the gap with PKI-based schemes. |
| 9 | Envelope encryption key management failure (KMS outage, wrapped-key corruption) causes data loss or a processing outage. | Low | High | KMS API error rate alert crossing a threshold in the observability dashboards (Section 18). | Uploads fail closed (no unencrypted fallback) on a KMS wrapping failure (Section 3.9); KMS calls are retried with backoff; a KMS regional failover plan is part of the infrastructure runbook (Section 20). |
| 10 | Redaction verification has a false negative — content presented as removed is in fact recoverable. | Low | High | The adversarial redaction test corpus (Section 19.3) fails to catch a known bypass technique added after launch. | The verification pass (Section 9.1's final step) fails closed by design; the golden-file corpus is treated as a living document that grows every time a new bypass technique is identified, in test-corpus updates tracked as a standing engineering practice, not a one-time M2 task. |
| 11 | LibreOffice headless hangs or crashes on pathological Office source documents, stalling worker-office capacity. |
Medium | Medium | Rising expired job rate specifically on the convert queue, visible in the job-state dashboards (Section 18). |
Hard wall-clock timeout plus a process-level watchdog that force-kills and reclaims the worker slot (Section 22.2.6); pathological documents that repeatedly trigger this are added to the golden-file corpus as regression fixtures. |
| 12 | Webhook delivery reliability under prolonged subscriber downtime silently loses events for API customers. | Medium | Medium | Rising failed-delivery count in the webhook dashboard (Section 14) for a specific endpoint. | Bounded exponential-backoff retry per the schedule in Section 14.10.6, the failing-state transition and owner notification (Section 22.2.9), and a dashboard view of failed deliveries with a manual-replay action. |
| 13 | Guest and Free-tier abuse (automated scripting against the six guest tools or the 2-server-task Free limit) drives infrastructure cost without revenue. | Medium | Low | Anomalous request volume from a small set of IP ranges or device identifiers in the rate-limiting and quota dashboards. | Guest tools are the six lowest-infrastructure-cost, entirely client-side operations (Section 12.2 footnote), capping the economic exposure by design; server-side abuse is bounded by the same Redis-backed per-key/per-device rate limiter used for legitimate traffic (Section 14). |
| 14 | Stripe webhook double-processing or missed events cause entitlement/billing state to drift from Stripe's actual subscription state. | Low | Medium | The nightly reconciliation job (Section 22.2.4 task 16) reports drift between workspace plan state and Stripe's subscription state. | Idempotent, event-ID-keyed webhook handling (Section 22.2.4) plus the nightly reconciliation job as a backstop that alerts and can be manually or automatically corrected. |
| 15 | The 200-plus-document golden-file corpus becomes a maintenance burden and regression tests slow down CI enough that engineers start skipping or ignoring them. | Medium | Medium | CI run time for the full test suite trending upward past an agreed threshold, or a rising rate of test-skip commits. | Split the corpus into a fast smoke subset run on every pull request and the full corpus run nightly and pre-release, keeping pull-request CI fast while preserving full-corpus coverage before any merge to the main branch reaches production. |
| 16 | Cross-host byte-equality (Section 3.2's core guarantee) regresses silently when the browser and Node builds of pdfcore diverge after an Emscripten toolchain upgrade. |
Low | High | The byte-equality test suite (Section 22.2.1 task 6) fails on a routine dependency bump. | This test runs in CI on every change to packages/pdfcore, not just at M1 exit, treating any byte-equality failure as a release-blocking regression permanently, not a one-time milestone gate. |
| 17 | Team seat billing and per-workspace quota interactions produce an incorrect charge at a plan-change boundary (mid-cycle member removal, annual-to-monthly switch). | Low | Medium | A customer support billing dispute, or the reconciliation job (risk 14's mitigation) flagging a seat-count mismatch. | The seat-change and quota-reset test matrix from Section 22.2.4 is extended with every new billing edge case discovered post-launch, and Stripe remains the single source of truth for what was actually charged. |
22.5 Post-launch: the first 90 days #
What to measure, tracked from day one on the monitoring dashboards established in Section 22.2.10 task 17:
| Metric | Why it matters | Target by day 90 |
|---|---|---|
| Guest → Free signup conversion rate | Validates the guest-limit funnel design (Section 12.2). | Establish a baseline in the first 30 days; no fixed target until baseline exists. |
| Free → Pro upgrade rate | Validates pricing and the value of the 2-server-task/day gate. | 3–5% of active Free workspaces, informed by category benchmarks, revisited monthly. |
| Client-side vs. server-side task mix | Validates the privacy-first positioning is actually being used, and forecasts infrastructure cost. | Directional only — a declining server-side share as users discover client-side tools is a healthy signal. |
API signups and activation (first successful /v1 call within 7 days of key creation) |
Validates the developer funnel independent of the consumer funnel. | 60% activation within 7 days. |
| Webhook delivery success rate | Operational health of the API's most trust-sensitive feature. | 99.5% first-attempt success, tracked per Section 18's availability posture. |
| E-signature envelope completion rate | Validates the signer experience has no undue friction. | 80%+ of sent envelopes reach envelope.completed within their expiry window. |
| OCR mean confidence score distribution | Early warning for risk 5 in Section 22.4. | No fixed target; a downward trend triggers investigation. |
| Job failure rate by tool | Surfaces tool-specific reliability problems fast. | Under 1% for every tool, investigated immediately if exceeded. |
| Infrastructure cost per paying workspace | Validates the pricing model's margin assumption (risk 6, risk 9 in Section 22.4). | Tracked against the cost review conducted in Section 22.2.10 task 14; a workspace costing more to serve than it pays is flagged for plan-fit review. |
| Organic search traffic to the marketing site | Early warning for risk 7 (SEO dependence) in Section 22.4. | Tracked weekly from launch, not discovered retrospectively at day 90. |
Deferred backlog, ordered by value, with the reason each was deferred:
- Additional UI languages beyond English. Highest deferred value — the i18n architecture (Section 16.7) is already built, so this is primarily translation and locale-QA cost, not engineering risk. Deferred because launch scope (Section 11) explicitly limits translated content to English, and the marketing/positioning work in Section 21 needed to validate product-market fit in one language first.
- Expanded in-PDF text-editing fidelity (multi-column, non-embedded-font support). High value given it is the most-flagged limitation in risk 1 (Section 22.4). Deferred because it requires deeper content-stream-reconstruction engineering than the launch timeline supported, and real usage data on which layouts actually appear will sharpen the investment.
- SOC 2 Type II certification completion. High value for Team-tier and API-tier enterprise sales. Deferred because it is explicitly a documented roadmap item with controls designed in from launch (Section 17.6), not a launch blocker, and the certification process itself takes months of evidence collection that only makes sense once the controls have been running in production.
- White-label or reseller portal. Medium value, contingent entirely on inbound demand from Team/API customers. Deferred per the scope fence in Section 11 because it is a distinct product surface (multi-tenant branding, billing pass-through) that was not validated as core to the initial positioning.
- Native mobile apps (iOS/Android) beyond the PWA. Medium value if mobile usage share proves high post-launch. Deferred per Section 2.8's scope fence because the PWA install path was judged sufficient to validate mobile demand before committing to two additional native codebases.
- Built-in cloud storage pickers (Google Drive, Dropbox, OneDrive). Medium value for user convenience. Deferred per Section 2.8's scope fence because each integration is a nontrivial OAuth and API-quota surface, and the drag-and-drop/local-file flow was judged sufficient to validate the core product before adding integration surface area.
- Enterprise SSO/SAML/SCIM. Lower near-term value until Team-tier adoption produces inbound enterprise demand. Deferred per Section 11's scope fence; better-auth's architecture (Section 17.2) does not preclude adding a SAML provider later without a schema migration.
- PDF/A archival compliance certification. Lower near-term value; a specialized compliance need rather than a mainstream one. Deferred per Section 2.8's scope fence.
- PKI-based digital certificate signatures for e-signature. Deferred not for lack of value but because it is a materially different trust and identity-verification product (Section 10) requiring a certificate authority relationship or an AATL/EUTL trust chain; revisit only if enterprise or regulated-industry demand specifically requires qualified signatures (see the revisit criteria below).
- Document management / version history beyond the retention windows in Section 6. Lowest deferred priority; the product's positioning is a toolkit, not a document management system, and expanding into persistent storage would be a significant re-scoping of the privacy-first retention model that is core to the product's identity.
Criteria that would justify revisiting an out-of-scope item:
- Translations: sustained non-English traffic (from the organic-search metric above) exceeding 15% of total sessions from a single non-English-speaking market for two consecutive months.
- PKI-based signatures: three or more enterprise-tier sales opportunities blocked specifically and explicitly on qualified-signature support within a single quarter.
- SSO/SAML/SCIM: the same threshold as PKI signatures — recurring, named, quantified enterprise demand, not a single feature request.
- Native mobile apps: PWA install rate and mobile session share both exceeding 25% of total usage with measurable, unresolved friction specifically attributable to the PWA experience rather than to WASM performance (risk 3, which native apps would not fix either).
- Cloud storage pickers: direct user feedback citing the missing integration as a blocking reason for churn, tracked in exit surveys, exceeding a small single-digit percentage of churn reasons.
23. Appendices #
23.1 Error code catalog #
Every code below is stable, additive-only, snake_case, and has a docs page at
https://docs.pdfworks.io/errors/{code}. The envelope shape (error.type, error.code,
error.message, error.param, error.docsUrl, error.requestId) and the type-to-HTTP-status
mapping are defined once, in Section 14.6, and are not restated here. "Caller action" is deliberately
terse — it is the one-line instruction a developer needs, not a paragraph. A handful of codes below
are marked n/a in the type column: these are informational result codes returned inside the
payload of an otherwise-successful (2xx) response — a verification outcome, a non-fatal quality
warning — rather than values of error.code inside the error envelope, so they carry no HTTP-status
mapping obligation of their own.
| Code | type |
HTTP | Message template | When it occurs | Caller action | Retryable |
|---|---|---|---|---|---|---|
invalid_api_key |
authentication_error | 401 | "The API key provided is not valid." | Key missing, malformed, revoked, or unknown. | Check the key value and its status in the dashboard. | No |
expired_api_key |
authentication_error | 401 | "This API key has been revoked." | Key was explicitly revoked or rotated out. | Issue a new key. | No |
missing_authentication |
authentication_error | 401 | "No authentication credential was provided." | No Authorization header on a route that requires one. |
Add the Authorization: Bearer header. |
No |
session_expired |
authentication_error | 401 | "Your session has expired. Please sign in again." | Session cookie past its 30-day rolling or 90-day absolute limit. | Re-authenticate. | No |
invalid_otp |
authentication_error | 401 | "The verification code is incorrect or expired." | Signer OTP mismatch or past its window. | Request a new code. | No |
mfa_required |
authentication_error | 401 | "Multi-factor authentication is required to continue." | Workspace enforces MFA and the session has not completed it. | Complete the TOTP or recovery-code challenge. | No |
mfa_code_invalid |
authentication_error | 401 | "The authentication code is incorrect." | Wrong TOTP code at login. | Re-enter the current code from the authenticator app. | No |
recovery_code_invalid |
authentication_error | 401 | "This recovery code is invalid or has already been used." | Wrong or already-consumed recovery code. | Use a different unused recovery code, or contact support if exhausted. | No |
permission_denied |
permission_error | 403 | "You do not have permission to perform this action." | Authenticated but lacking the required role or scope. | Request elevated access from a workspace owner. | No |
insufficient_api_key_scope |
permission_error | 403 | "This API key does not have the %s scope." |
Key scope does not cover the called endpoint. | Issue a key with the required scope. | No |
ip_not_allowed |
permission_error | 403 | "This request's IP address is not on the key's allowlist." | Caller IP absent from the key's configured allowlist. | Add the IP to the key's allowlist. | No |
workspace_access_denied |
permission_error | 403 | "You are not a member of this workspace." | Resource belongs to a workspace the caller cannot access. | Confirm the resource ID and workspace context. | No |
signer_link_invalid |
permission_error | 403 | "This signing link is no longer valid." | Signer token voided, expired, or already fully used. | Ask the sender to resend the envelope. | No |
csrf_token_invalid |
permission_error | 403 | "The request could not be verified as originating from this application." | First-party /internal route fails CSRF validation (not applicable to /v1, which is bearer-token authenticated). |
Reload the page and retry. | No |
resource_not_found |
not_found_error | 404 | "No document was found with ID %s." |
Referenced ID does not exist or was hard-deleted. | Verify the ID; do not retry with the same ID. | No |
job_not_found |
not_found_error | 404 | "No job was found with ID %s." |
Job ID unknown or belongs to another workspace. | Verify the ID. | No |
envelope_not_found |
not_found_error | 404 | "No envelope was found with ID %s." |
Envelope ID unknown or soft-deleted. | Verify the ID. | No |
webhook_endpoint_not_found |
not_found_error | 404 | "No webhook endpoint was found with ID %s." |
Endpoint ID unknown or already deleted. | Verify the ID. | No |
route_not_found |
not_found_error | 404 | "No route matches %s %s." |
Unknown path or method. | Check the API reference. | No |
verification_envelope_unknown |
not_found_error | 404 | "UNKNOWN — no completed envelope matches this reference." | /verify called with an envelope ID or hash with no match. |
Confirm the envelope ID or hash. | No |
validation_error |
invalid_request_error | 400 | "The request body failed validation." | Zod schema rejects the request body. | Fix the field(s) named in param. |
No |
missing_required_field |
invalid_request_error | 400 | "The field %s is required." |
Required field absent from body. | Add the field. | No |
invalid_field_type |
invalid_request_error | 400 | "The field %s must be of type %s." |
Field present but wrong type. | Correct the type. | No |
invalid_enum_value |
invalid_request_error | 400 | "The value %s is not a valid %s." |
Value outside the enum's allowed set. | Use one of the documented enum values. | No |
invalid_cursor |
invalid_request_error | 400 | "The pagination cursor is invalid or expired." | Malformed or stale cursor query parameter. |
Restart pagination from cursor=null. |
No |
invalid_limit |
invalid_request_error | 400 | "The limit parameter must be between 1 and 100." |
limit outside the documented bounds. |
Clamp to 1–100. | No |
file_too_large |
invalid_request_error | 400 | "The uploaded file is %s. Your plan allows %s." | Upload exceeds the plan's max file size (Section 12.2). | Compress the file or upgrade the plan. | No |
file_empty |
invalid_request_error | 400 | "The uploaded file has zero bytes." | Zero-length upload. | Re-select a non-empty file. | No |
unsupported_file_type |
invalid_request_error | 400 | "The file type %s is not supported by this tool." |
MIME/magic-byte sniff does not match an accepted type. | Check the tool's accepted input formats in Section 23.4. | No |
file_type_mismatch |
invalid_request_error | 400 | "The file extension does not match its detected content." | Magic-byte sniff disagrees with the filename extension. | Re-export the file from its source application. | No |
corrupt_pdf |
processing_error | 422 | "The PDF could not be parsed." | QPDF/PDFium reject the structure entirely, even after repair. | Try the repair tool first, or re-export the source. | No |
encrypted_pdf_no_password |
invalid_request_error | 400 | "This PDF is password-protected. Provide the password to continue." | Encrypted PDF submitted to a tool other than unlock without a password. | Supply the password or use the unlock tool. | No |
incorrect_pdf_password |
invalid_request_error | 400 | "The password provided does not open this PDF." | Wrong password supplied to unlock. | Retry with the correct password. | No |
page_range_out_of_bounds |
invalid_request_error | 400 | "Page %s does not exist in a %s-page document." | Page-range parameter exceeds the document's page count. | Correct the page range. | No |
page_range_empty |
invalid_request_error | 400 | "The selected page range is empty." | Zero pages selected for extract/delete/rotate. | Select at least one page. | No |
too_many_source_files |
invalid_request_error | 400 | "This operation accepts at most %s files." | Merge/batch input exceeds the tool or plan's file-count limit. | Reduce the file count or upgrade the plan. | No |
object_count_exceeded |
invalid_request_error | 400 | "This PDF exceeds the maximum supported object count." | Ingest-time structural limit exceeded (zip-bomb defense). | The file is likely malformed or adversarial; contact support if legitimate. | No |
nesting_depth_exceeded |
invalid_request_error | 400 | "This PDF exceeds the maximum supported nesting depth." | Object-stream or content-stream nesting exceeds the ingest limit. | Same as above. | No |
decompressed_size_exceeded |
invalid_request_error | 400 | "This file expands beyond the maximum supported decompressed size." | Decompression-bomb defense triggered. | Same as above. | No |
ocr_language_not_supported |
invalid_request_error | 400 | "The language %s is not supported by OCR." |
Unsupported Tesseract language code requested. | Choose a supported language from the documented list. | No |
fallback_not_available_for_tool |
invalid_request_error | 400 | "This tool's result cannot be finished on our servers." | The opt-in "Finish this on our servers instead" fallback (Section 6.3) is attempted on redact, protect, unlock, or self-sign — the four tools the fallback excludes by design. | Retry client-side, on a smaller file or with more available device memory. | No |
envelope_source_too_large |
invalid_request_error | 400 | "The source document exceeds the maximum size for e-signature." | Source PDF exceeds the plan's max file size at envelope creation. | Compress the document or upgrade the plan. | No |
idempotency_key_reuse |
conflict_error | 409 | "This idempotency key was already used with a different request body." | Same Idempotency-Key, different body, within the 24-hour window. |
Use a new idempotency key for a genuinely new request. | No |
idempotency_key_in_progress |
conflict_error | 409 | "A request with this idempotency key is already being processed." | Concurrent duplicate request while the first is still running. | Wait for the first request to complete, then re-fetch by ID. | Yes |
envelope_already_completed |
conflict_error | 409 | "This envelope has already been completed and cannot be modified." | Void/edit attempted on a completed envelope. | No action needed on a completed envelope. | No |
envelope_already_voided |
conflict_error | 409 | "This envelope has already been voided." | Sign/void attempted on a voided envelope. | Create a new envelope. | No |
signer_already_signed |
conflict_error | 409 | "This signer has already completed their fields." | Duplicate sign attempt on a completed signer session. | No action needed. | No |
signer_order_violation |
conflict_error | 409 | "This signer cannot act until prior signers in the sequence complete." | Sequential-routing signer attempts to act out of turn. | Wait for the preceding signer to complete. | No |
disclosure_not_accepted |
conflict_error | 409 | "The signer must accept the disclosure before completing fields." | Field-completion attempted before consent event recorded. | Accept the Electronic Record and Signature Disclosure first. | No |
job_already_terminal |
conflict_error | 409 | "This job has already reached a terminal state and cannot be canceled." | Cancel requested on a succeeded/failed/canceled/expired job. |
No action needed. | No |
document_already_deleted |
conflict_error | 409 | "This document has already been deleted." | Operation attempted on a hard-deleted document. | Re-upload if the file is still needed. | No |
upload_session_expired |
conflict_error | 409 | "This resumable upload session has expired." | A multipart/resumable upload session (Section 22.2.5) was left incomplete past its 24-hour window and was discarded. | Start a new upload from byte zero. | No |
email_already_registered |
conflict_error | 409 | "An account with this email already exists." | Sign-up with a duplicate email. | Sign in instead, or use password reset. | No |
rate_limit_exceeded |
rate_limit_error | 429 | "Rate limit exceeded. Retry after %s seconds." | Token bucket for the key/IP is empty. | Back off per Retry-After and the RateLimit-* headers. |
Yes |
concurrent_request_limit_exceeded |
rate_limit_error | 429 | "Too many concurrent requests for this API key." | Per-key in-flight request cap exceeded. | Reduce concurrency and retry with backoff. | Yes |
daily_task_limit_exceeded |
quota_error | 402 | "You have used all %s of your daily tasks. Resume tomorrow or upgrade." | Guest 5/day or Free server-side 2/day cap reached. | Wait for the daily reset or upgrade the plan. | No |
server_task_limit_exceeded |
quota_error | 402 | "You have used your daily server-side task limit." | Free-tier 2 server-side tasks/day cap reached. | Upgrade to Pro/Team for unlimited server-side tasks. | No |
envelope_limit_exceeded |
quota_error | 402 | "You have reached your plan's monthly signature envelope limit." | Monthly envelope cap reached (Section 12.2). | Upgrade the plan or wait for the monthly reset. | No |
batch_not_available_on_plan |
quota_error | 402 | "Batch processing is not available on the Free plan." | Free-tier attempts a batch job. | Upgrade to Pro, Team, or API. | No |
batch_file_count_exceeded |
quota_error | 402 | "This plan allows at most %s files per batch." | Batch file count exceeds the plan's ceiling. | Reduce the batch size or upgrade the plan. | No |
quota_exceeded |
quota_error | 402 | "This API key's configured spend cap has been reached." | Per-key spend cap reached (Section 12.6). | Raise or remove the spend cap in the dashboard. | No |
plan_upgrade_required |
quota_error | 402 | "This feature requires a paid plan." | Free/Guest attempts a Pro/Team-only feature. | Upgrade the plan. | No |
payment_failed |
quota_error | 402 | "Your last payment failed. Please update your payment method." | Workspace is past_due after a failed Stripe invoice. |
Update the payment method in the billing portal. | No |
webhook_endpoint_limit_exceeded |
quota_error | 402 | "This plan allows at most %s webhook endpoints." | Per-workspace endpoint cap reached. | Remove an unused endpoint or upgrade the plan. | No |
workspace_seat_limit_reached |
quota_error | 402 | "Adding this member exceeds your Team plan's seat count." | Team invite would exceed the currently paid seat quantity. | The next Stripe invoice will reflect the added seat automatically; this code only applies if seat auto-scaling is disabled for the workspace. | No |
processing_error |
processing_error | 422 | "The document could not be processed: %s." | Generic, tool-specific processing failure not covered by a more specific code. | Retry, or contact support if the file is not adversarial. | Yes |
redaction_verification_failed |
processing_error | 422 | "Redaction could not be verified as complete. No output was produced." | Section 9.1's verification pass fails. | Try narrowing the redaction regions or contact support. | No |
compression_no_improvement |
processing_error | 422 | "This file could not be compressed further." | Compress tool produces an output not smaller than the input. | The file is already optimally compressed; no action needed. | No |
form_flatten_failed |
processing_error | 422 | "Form flattening failed for this document." | Malformed AcroForm defeats the flatten binding. | Try repair first, then flatten again. | Yes |
watermark_placement_failed |
processing_error | 422 | "The watermark could not be placed on one or more pages." | A page's content stream rejects the new watermark operators. | Try repair first, then re-apply the watermark. | Yes |
signature_image_invalid |
processing_error | 422 | "The uploaded signature image could not be processed." | Signature-upload image fails decode or normalization. | Upload a PNG or JPEG under the documented size limit. | No |
merge_page_size_conflict |
processing_error | 422 | "Source documents have incompatible page sizes for this merge mode." | Merge attempted with an incompatible mixed-size mode selected. | Choose "preserve original sizes" instead of "uniform size." | No |
ocr_no_text_detected |
processing_error | 422 | "OCR completed but no text was detected on this page." | Blank, extremely low-resolution, or non-text page. | Verify the scan quality per Section 9.8's recommendations. | No |
ocr_engine_timeout |
processing_error | 422 | "OCR did not complete within the allotted time." | Worker wall-clock timeout on the ocr queue. |
Retry, or split the document into smaller batches. | Yes |
job_timed_out |
processing_error | 422 | "This job exceeded its maximum allowed processing time." | The worker wall-clock timeout from Section 13.3.8 elapsed before the job completed, driving the running → expired state transition (Section 4.7), for a queue without a more specific timeout code of its own. |
Retry, or reduce the size/complexity of the input. | Yes |
conversion_source_corrupt |
processing_error | 422 | "The source file could not be opened for conversion." | LibreOffice or the HTML renderer cannot open the input. | Re-export the source file and retry. | No |
conversion_unsupported_feature |
processing_error | 422 | "This document uses a feature that cannot be converted: %s." | Macro, embedded OLE object, or similarly unsupported construct. | Remove the unsupported feature and retry. | No |
conversion_engine_timeout |
processing_error | 422 | "Conversion did not complete within the allotted time." | LibreOffice/Chromium wall-clock timeout. | Retry, or simplify the document. | Yes |
html_render_blocked_resource |
processing_error | 422 | "One or more page resources could not be loaded during HTML-to-PDF conversion." | External resource (image, stylesheet, font) unreachable from the sandboxed renderer (no outbound network by design, Section 3.5). | Inline required resources or use only same-document references. | No |
xfa_form_not_supported |
processing_error | 422 | "This document uses XFA forms, which are not supported." | Legacy XFA (as opposed to AcroForm) detected. | Convert the source to a standard AcroForm PDF outside PDFWorks. | No |
unsupported_pdf_version |
processing_error | 422 | "This PDF version is not supported." | PDF version outside PDFium's supported range. | Re-export from the source application at a standard PDF version. | No |
envelope_expired |
processing_error | 410 | "This envelope expired before all signers completed." | Current date past the envelope's configured expiry. | Sender must create a new envelope. | No |
signer_email_undeliverable |
processing_error | 422 | "The invitation email to %s could not be delivered." | Resend reports a hard bounce for a signer's email address. | Correct the signer's email and resend. | No |
otp_send_failed |
processing_error | 422 | "The verification code could not be sent." | Resend delivery failure for the OTP email specifically. | Retry sending the code. | Yes |
certificate_generation_failed |
api_error | 500 | "The certificate of completion could not be generated." | Internal failure assembling the completion certificate. | Retry; contact support if it recurs. | Yes |
ocr_low_confidence |
n/a (informational, success payload) | 200 | "OCR completed with low confidence on %s pages." | Mean Tesseract confidence below the documented threshold; not a hard failure, returned as a warning alongside a successful job result — not part of the error envelope. | Review the flagged pages manually. | No |
conversion_font_unavailable |
n/a (informational, success payload) | 200 | "One or more embedded fonts could not be preserved; a fallback font was substituted." | Source font not embeddable/licensable in the output; returned as a warning alongside a successful conversion result — not part of the error envelope. | Review output rendering for the affected text. | No |
verification_hash_mismatch |
n/a (informational, success payload) | 200 | "ALTERED — the provided document does not match the recorded hash." | /verify/{envelopeId} recomputation disagrees with the stored hash; returned as a normal ALTERED verification result — not part of the error envelope. |
Treat the document as untrusted. | No |
webhook_endpoint_url_invalid |
invalid_request_error | 400 | "The webhook URL must be a public HTTPS endpoint." | Non-HTTPS or non-routable URL supplied. | Provide a publicly reachable HTTPS URL. | No |
webhook_signature_invalid |
invalid_request_error | 400 | "The webhook signature could not be verified." | Returned by the customer's own endpoint per the documented spec; included here for reference since the docs page documents both sides. | Recompute the HMAC per Section 17.4's algorithm. | No |
webhook_delivery_failed |
api_error | 500 | "Webhook delivery failed after %s attempts." | All retry attempts to a subscriber endpoint exhausted (Section 14.10.6); the endpoint's lifecycle state is set to failing (Section 22.2.9). |
Check the endpoint's availability; manually replay from the dashboard. | No |
feature_not_available_on_plan |
permission_error | 403 | "This feature is not included on your plan." | A plan-gated feature (e.g., shared workspace) accessed by an ineligible plan. | Upgrade to a plan that includes this feature. | No |
api_access_not_enabled |
permission_error | 403 | "Public API access is not enabled for this workspace." | Non-API-tier workspace calls /v1. |
Subscribe to an API plan. | No |
unsupported_api_version |
invalid_request_error | 400 | "API version %s is not supported." |
Deprecated or unknown version path segment. | Migrate to the current /v1 path. |
No |
internal_error |
api_error | 500 | "An unexpected error occurred. Our team has been notified." | Unhandled exception anywhere in the request path. | Retry with backoff; contact support with the requestId if it recurs. |
Yes |
service_unavailable |
api_error | 503 | "The service is temporarily unavailable." | Planned maintenance or a dependency outage (database, Redis, storage). | Retry with backoff; check status.pdfworks.io. |
Yes |
worker_unavailable |
api_error | 503 | "No workers are currently available to process this job." | Queue backlog or worker pool scaled to zero during an incident. | Retry with backoff; the job remains queued. | Yes |
storage_unavailable |
api_error | 503 | "The storage backend is temporarily unavailable." | Object storage provider outage. | Retry with backoff. | Yes |
kms_unavailable |
api_error | 503 | "The encryption key service is temporarily unavailable." | KMS outage prevents envelope-key wrapping (Section 3.5). | Retry with backoff; uploads fail closed rather than storing unencrypted. | Yes |
database_unavailable |
api_error | 503 | "A database error occurred." | Postgres connection failure or timeout. | Retry with backoff. | Yes |
deadline_exceeded |
api_error | 504 | "The request took too long to process." | Upstream timeout (gateway-level, distinct from a job-level expired state). |
Retry with backoff; for long operations, use the async job endpoints instead of a synchronous call. | Yes |
body_too_large |
invalid_request_error | 413 | "The request body exceeds the maximum allowed size." | Non-file JSON body exceeds the API's body-size limit. | Reduce the payload size; use the multipart upload endpoint for files. | No |
unsupported_media_type |
invalid_request_error | 415 | "The Content-Type header is not supported for this endpoint." |
Wrong Content-Type for the route. |
Use the documented Content-Type for this endpoint. |
No |
malformed_json |
invalid_request_error | 400 | "The request body is not valid JSON." | JSON parse failure. | Fix the request body's syntax. | No |
password_too_weak |
invalid_request_error | 400 | "Choose a password at least 12 characters long." | Password under the 12-character minimum from Section 17.2. | Choose a longer password. | No |
password_compromised |
invalid_request_error | 400 | "This password has appeared in a known data breach. Choose a different one." | Have I Been Pwned k-anonymity check flags the password. | Choose a different password. | No |
guest_device_blocked |
permission_error | 403 | "This device has been temporarily blocked for unusual activity." | Anti-abuse threshold exceeded for a guest device identifier. | Create a free account to continue. | No |
23.2 Environment variable specification #
This subsection is the single canonical source for every boot-time environment variable used anywhere in this specification. Every other section that references an environment variable name, default, or validation rule cites this subsection by number rather than restating it, and if any other section appears to name a variable differently, the name and shape given here are the ones the executor implements.
Every variable below is read through the boot-time validation schema in packages/config/src/env.ts
(reproduced at the end of this subsection), which fails the process fast with a descriptive error
if a required variable is missing or malformed, per the "no silent misconfiguration" rule in Section
23.6. Example values are placeholders and never real secrets.
Core application (apps/web, apps/api) #
| Name | Service | Type | Required | Default | Example | Validation | Description |
|---|---|---|---|---|---|---|---|
NODE_ENV |
web, api, workers | enum | Yes | development |
production |
development | test | production |
Runtime mode; gates verbose logging and dev-only routes. |
APP_URL |
web | url | Yes | — | https://app.pdfworks.io |
Valid absolute HTTPS URL in production | Canonical origin used for absolute link generation and CORS. |
API_URL |
web, sdk-js | url | Yes | — | https://api.pdfworks.io |
Valid absolute HTTPS URL in production | Public API base URL. |
DOCS_URL |
web, api | url | Yes | — | https://docs.pdfworks.io |
Valid absolute HTTPS URL | Used in error-envelope docsUrl generation. |
SIGNER_URL |
web, api | url | Yes | — | https://sign.pdfworks.io |
Valid absolute HTTPS URL | Signer-portal base URL used in envelope invitation emails. |
PORT |
web, api | integer | No | 3000 (web), 8080 (api) |
8080 |
1–65535 | HTTP listen port. |
LOG_LEVEL |
web, api, workers | enum | No | info |
debug |
trace|debug|info|warn|error|fatal |
Pino minimum log level. |
SESSION_SIGNING_SECRET |
api | secret string | Yes | — | [REDACTED] |
32+ bytes, base64 | better-auth session-signing key. |
COOKIE_DOMAIN |
api | string | Yes | — | .pdfworks.io |
Valid domain, leading dot for subdomain sharing | Cookie domain shared across app. and sign. subdomains. |
Database #
| Name | Service | Type | Required | Default | Example | Validation | Description |
|---|---|---|---|---|---|---|---|
DATABASE_URL |
api, workers, db migrations | secret url | Yes | — | postgres://pdfworks:[REDACTED]@db.internal:5432/pdfworks |
Valid postgres:// URL |
Primary PostgreSQL 18 connection string. |
DATABASE_POOL_MIN |
api | integer | No | 2 |
2 |
0–50 | Drizzle/pg pool minimum connections. |
DATABASE_POOL_MAX |
api | integer | No | 10 |
20 |
1–100, ≥ DATABASE_POOL_MIN |
Drizzle/pg pool maximum connections. |
DATABASE_SSL |
api, workers | boolean | No | true |
true |
true|false |
Enforce TLS on the database connection; false only permitted outside production. |
Redis / queues #
| Name | Service | Type | Required | Default | Example | Validation | Description |
|---|---|---|---|---|---|---|---|
REDIS_URL |
api, workers | secret url | Yes | — | rediss://default:[REDACTED]@redis.internal:6379 |
Valid redis:// or rediss:// URL |
Backs BullMQ queues, rate limiting, and the guest task counter. |
QUEUE_CONCURRENCY_OCR |
worker-media | integer | No | 4 |
4 |
1–64 | BullMQ concurrency for the ocr queue per worker instance. |
QUEUE_CONCURRENCY_CONVERT |
worker-office | integer | No | 2 |
2 |
1–64 | BullMQ concurrency for the convert queue (lower default; LibreOffice is memory-heavy). |
QUEUE_CONCURRENCY_ESIGN |
api | integer | No | 8 |
8 |
1–64 | Concurrency for the lightweight esign queue. |
QUEUE_CONCURRENCY_BATCH |
api | integer | No | 4 |
4 |
1–64 | Concurrency for the batch fan-out queue. |
QUEUE_CONCURRENCY_WEBHOOK |
api | integer | No | 16 |
16 |
1–128 | Concurrency for the webhook delivery queue. |
JOB_WALL_CLOCK_TIMEOUT_MS |
workers | integer | No | 600000 |
600000 |
≥ 1000 | Hard per-job timeout before transition to expired (Section 4.7 defines the state transition; Section 13.3.8 owns the canonical timeout value this default is set to match). |
WEBHOOK_ENDPOINT_FAILURE_THRESHOLD |
api | integer | No | 20 |
20 |
≥ 1 | Consecutive delivery failures after which a webhook endpoint transitions from active to failing (Section 22.2.9) and the workspace owner is notified. |
Object storage #
| Name | Service | Type | Required | Default | Example | Validation | Description |
|---|---|---|---|---|---|---|---|
S3_BUCKET_DOCUMENTS |
api, workers | string | Yes | — | pdfworks-prod-documents |
Non-empty | S3-compatible bucket for tool-job source and output documents (uploads, OCR, conversions, batches). |
S3_BUCKET_ENVELOPES |
api, workers | string | Yes | — | pdfworks-prod-envelopes |
Non-empty | S3-compatible bucket for e-signature source PDFs, completed signed PDFs, and Certificates of Completion, kept separate from S3_BUCKET_DOCUMENTS because envelope retention (Section 6) follows its own schedule. |
STORAGE_REGION |
api, workers | string | Yes | — | us-east-1 |
Non-empty | Region shared by both S3-compatible buckets. |
STORAGE_ENDPOINT |
api, workers | url | No | AWS default | https://<account>.r2.cloudflarestorage.com |
Valid URL | Custom endpoint for R2 or other S3-compatible providers; omitted for AWS S3. |
STORAGE_ACCESS_KEY_ID |
api, workers | secret string | Yes | — | [REDACTED] |
Non-empty | AWS SDK v3 credential. |
STORAGE_SECRET_ACCESS_KEY |
api, workers | secret string | Yes | — | [REDACTED] |
Non-empty | AWS SDK v3 credential. |
S3_BUCKET_DOCUMENTS_EU |
api, workers | string | No | — | pdfworks-prod-documents-eu |
Non-empty when Team EU-residency is enabled | EU-region counterpart to S3_BUCKET_DOCUMENTS for the EU data-residency option (Section 17.6). |
S3_BUCKET_ENVELOPES_EU |
api, workers | string | No | — | pdfworks-prod-envelopes-eu |
Non-empty when Team EU-residency is enabled | EU-region counterpart to S3_BUCKET_ENVELOPES for the EU data-residency option (Section 17.6). |
Key management (KMS) #
| Name | Service | Type | Required | Default | Example | Validation | Description |
|---|---|---|---|---|---|---|---|
KMS_MASTER_KEY_ARN |
api, workers | secret string | Yes | — | arn:aws:kms:us-east-1:[REDACTED]:key/[REDACTED] |
Non-empty; KMS key ARN or ID format | Master key used to wrap per-job data keys (Section 3.9). |
KMS_REGION |
api, workers | string | Yes | — | us-east-1 |
Non-empty | KMS region, may differ from STORAGE_REGION. |
Stripe #
| Name | Service | Type | Required | Default | Example | Validation | Description |
|---|---|---|---|---|---|---|---|
STRIPE_SECRET_KEY |
api | secret string | Yes | — | [REDACTED] |
Starts with sk_ |
Stripe server SDK key. |
STRIPE_WEBHOOK_SECRET |
api | secret string | Yes | — | [REDACTED] |
Starts with whsec_ |
Verifies inbound Stripe webhook signatures. |
STRIPE_PUBLISHABLE_KEY |
web | string | Yes | — | pk_live_51... |
Starts with pk_ |
Used only to bootstrap Stripe.js for Checkout redirect, never Elements (Section 3.4). |
STRIPE_PRICE_PRO_MONTHLY |
api | string | Yes | — | price_1Pro... |
Starts with price_ |
Price ID for Pro monthly. |
STRIPE_PRICE_PRO_ANNUAL |
api | string | Yes | — | price_1ProY... |
Starts with price_ |
Price ID for Pro annual. |
STRIPE_PRICE_TEAM_MONTHLY |
api | string | Yes | — | price_1Team... |
Starts with price_ |
Price ID for Team monthly, per-seat. |
STRIPE_PRICE_TEAM_ANNUAL |
api | string | Yes | — | price_1TeamY... |
Starts with price_ |
Price ID for Team annual, per-seat. |
STRIPE_METER_API_OPERATIONS |
api | string | Yes | — | mtr_operations |
Non-empty | Stripe Meters event name for API operation overage billing. |
STRIPE_METER_OCR_PAGES |
api | string | Yes | — | mtr_ocr_pages |
Non-empty | Stripe Meters event name for OCR page overage billing. |
Email #
| Name | Service | Type | Required | Default | Example | Validation | Description |
|---|---|---|---|---|---|---|---|
RESEND_API_KEY |
api | secret string | Yes | — | [REDACTED] |
Starts with re_ |
Resend transactional email API key. |
EMAIL_FROM_ADDRESS |
api | string | Yes | — | notifications@pdfworks.io |
Valid email address | Default From address for all transactional email. |
EMAIL_FROM_ADDRESS_SIGNER |
api | string | Yes | — | sign@pdfworks.io |
Valid email address | From address specifically for e-signature invitations, distinct for deliverability reputation isolation. |
Authentication #
| Name | Service | Type | Required | Default | Example | Validation | Description |
|---|---|---|---|---|---|---|---|
BETTER_AUTH_SECRET |
api | secret string | Yes | — | [REDACTED] |
32+ bytes | better-auth signing secret (distinct from SESSION_SIGNING_SECRET if the executor chooses to separate them; may be the same value). |
HIBP_API_ENABLED |
api | boolean | No | true |
true |
true|false |
Toggles the Have I Been Pwned k-anonymity password check (Section 17.2); disable only in offline test environments. |
MFA_ISSUER_NAME |
api | string | No | PDFWorks |
PDFWorks |
Non-empty | TOTP issuer label shown in authenticator apps. |
OpenTelemetry / Sentry #
| Name | Service | Type | Required | Default | Example | Validation | Description |
|---|---|---|---|---|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
web, api, workers | url | No | disabled | https://otel-collector.internal:4318 |
Valid URL | OTLP collector endpoint; tracing is a no-op if unset. |
OTEL_SERVICE_NAME |
web, api, workers | string | No | package name | pdfworks-api |
Non-empty | Service name attached to every span. |
SENTRY_DSN_WEB |
web | secret url | No | disabled | https://[REDACTED]@o0.ingest.sentry.io/1 |
Valid Sentry DSN URL | Error reporting for apps/web; disabled if unset (e.g., local development). |
SENTRY_DSN_API |
api | secret url | No | disabled | https://[REDACTED]@o0.ingest.sentry.io/2 |
Valid Sentry DSN URL | Error reporting for apps/api; disabled if unset. |
SENTRY_DSN_WORKER_MEDIA |
worker-media | secret url | No | disabled | https://[REDACTED]@o0.ingest.sentry.io/3 |
Valid Sentry DSN URL | Error reporting for the worker-media container; disabled if unset. |
SENTRY_DSN_WORKER_OFFICE |
worker-office | secret url | No | disabled | https://[REDACTED]@o0.ingest.sentry.io/4 |
Valid Sentry DSN URL | Error reporting for the worker-office container; disabled if unset. |
SENTRY_ENVIRONMENT |
web, api | string | No | NODE_ENV value |
staging |
Non-empty | Sentry environment tag. |
SENTRY_TRACES_SAMPLE_RATE |
web, api | float | No | 0.1 |
0.1 |
0.0–1.0 | Fraction of transactions traced. |
Feature flags and limits #
| Name | Service | Type | Required | Default | Example | Validation | Description |
|---|---|---|---|---|---|---|---|
GUEST_DAILY_TASK_LIMIT |
api | integer | No | 5 |
5 |
≥ 0 | Overridable copy of the Section 12.2 guest limit, for staged rollout changes without a deploy. |
FREE_SERVER_TASK_DAILY_LIMIT |
api | integer | No | 2 |
2 |
≥ 0 | Overridable copy of the Free-tier server-side daily cap. |
MAX_UPLOAD_BYTES_DEFAULT |
api | integer | No | 26214400 |
26214400 |
≥ 1 | Fallback max upload size (25 MB) when a plan-specific limit is not resolvable. |
FEATURE_FLAGS_CACHE_TTL_MS |
api | integer | No | 30000 |
30000 |
≥ 0 | In-memory cache TTL for the feature_flags table accessor (Section 4). |
Worker tuning #
| Name | Service | Type | Required | Default | Example | Validation | Description |
|---|---|---|---|---|---|---|---|
WORKER_TMPFS_SIZE_MB |
worker-media, worker-office | integer | No | 2048 |
2048 |
≥ 128 | Per-job tmpfs mount size inside the gVisor sandbox. |
WORKER_MAX_OUTPUT_BYTES |
worker-media, worker-office | integer | No | 1073741824 |
1073741824 |
≥ 1 | Hard cap on a single job's output size (1 GB), independent of plan limits, as a resource-exhaustion backstop. |
LIBREOFFICE_TIMEOUT_MS |
worker-office | integer | No | 120000 |
120000 |
≥ 1000 | Watchdog timeout for a single LibreOffice headless invocation (Section 22.2.6). |
TESSERACT_LANGUAGES |
worker-media | string | No | eng |
eng+fra+deu |
+-separated Tesseract language codes |
Language packs installed and enabled for OCR. |
Boot-time validation schema #
Every service validates its environment at process start using a shared Zod schema in
packages/config/src/env.ts, imported by apps/web, apps/api, worker-media (via its Node
supervisor), and worker-office (via a Python port of the same rules, kept in lockstep by a shared
test fixture). A representative excerpt:
// packages/config/src/env.ts
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
APP_URL: z.string().url(),
API_URL: z.string().url(),
DOCS_URL: z.string().url(),
SIGNER_URL: z.string().url(),
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
.default("info"),
SESSION_SIGNING_SECRET: z.string().min(32),
COOKIE_DOMAIN: z.string().min(1),
DATABASE_URL: z.string().url(),
DATABASE_POOL_MIN: z.coerce.number().int().min(0).max(50).default(2),
DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(100).default(10),
DATABASE_SSL: z.coerce.boolean().default(true),
REDIS_URL: z.string().url(),
S3_BUCKET_DOCUMENTS: z.string().min(1),
S3_BUCKET_ENVELOPES: z.string().min(1),
STORAGE_REGION: z.string().min(1),
STORAGE_ENDPOINT: z.string().url().optional(),
STORAGE_ACCESS_KEY_ID: z.string().min(1),
STORAGE_SECRET_ACCESS_KEY: z.string().min(1),
S3_BUCKET_DOCUMENTS_EU: z.string().min(1).optional(),
S3_BUCKET_ENVELOPES_EU: z.string().min(1).optional(),
KMS_MASTER_KEY_ARN: z.string().min(1),
KMS_REGION: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
STRIPE_PUBLISHABLE_KEY: z.string().startsWith("pk_"),
RESEND_API_KEY: z.string().startsWith("re_"),
EMAIL_FROM_ADDRESS: z.string().email(),
SENTRY_DSN_WEB: z.string().url().optional(),
SENTRY_DSN_API: z.string().url().optional(),
SENTRY_DSN_WORKER_MEDIA: z.string().url().optional(),
SENTRY_DSN_WORKER_OFFICE: z.string().url().optional(),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
})
.superRefine((env, ctx) => {
if (env.DATABASE_POOL_MAX < env.DATABASE_POOL_MIN) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["DATABASE_POOL_MAX"],
message: "DATABASE_POOL_MAX must be >= DATABASE_POOL_MIN",
});
}
if (env.NODE_ENV === "production" && !env.APP_URL.startsWith("https://")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["APP_URL"],
message: "APP_URL must be HTTPS in production",
});
}
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
const parsed = envSchema.safeParse(source);
if (!parsed.success) {
// eslint-disable-next-line no-console
console.error("Environment validation failed:");
for (const issue of parsed.error.issues) {
console.error(` ${issue.path.join(".")}: ${issue.message}`);
}
process.exit(1);
}
return parsed.data;
}Every service calls loadEnv() exactly once, at the top of its entry point, before any other
module that might read process.env directly executes. No module outside packages/config reads
process.env directly — this is enforced by an ESLint rule (no-process-env) in the shared preset
from Section 4.
23.3 Glossary #
Written for a reader who has not built a PDF product before.
PDF terms
| Term | Definition |
|---|---|
| AcroForm | The original, widely supported PDF forms technology: a dictionary of form field objects (text, checkbox, radio, dropdown, signature) tied to appearance streams. PDFWorks's fill-forms and create-fillable-forms tools (Section 8.3, 8.4) read and write AcroForm exclusively. |
| XFA | XML Forms Architecture, a legacy Adobe forms technology embedded alongside or instead of AcroForm in some PDFs. PDFWorks does not support XFA (Section 23.1 catalog, xfa_form_not_supported); it is explicitly out of scope. |
| Cross-reference table (xref) | The index at the end of a PDF file mapping object numbers to their byte offsets, letting a reader jump directly to any object without parsing the whole file. A rewritten (non-incrementally-saved) PDF has exactly one xref section, a property the redaction pipeline (Section 9.1) asserts explicitly. |
| Content stream | A sequence of drawing operators (text placement, path drawing, image painting) associated with a page, written in a small PostScript-like operator language. Redaction and text-editing tools parse and rewrite content streams directly. |
| XObject | An external, reusable graphical object embedded in a PDF — most commonly an image (Image XObject) or a self-contained group of drawing operations (Form XObject). Redaction clips or removes image XObjects that intersect a redaction region. |
| Optional content group (OCG) | A named "layer" in a PDF that can be shown or hidden independently, such as a translation layer or a markup layer. Redaction removes an OCG entirely if all of its content falls inside a redacted region. |
| Linearization | A specific byte ordering of a PDF (also called "fast web view") that lets a viewer render the first page before the whole file has downloaded. PDFWorks's golden-file corpus (Section 19.3) includes linearized fixtures to guard against tools that accidentally break this ordering. |
| Incremental update | A PDF-editing technique that appends changes to the end of a file rather than rewriting it, leaving the original bytes intact and recoverable. The redaction pipeline (Section 9.1) explicitly forbids incremental saves because they defeat the purpose of redaction. |
| Subsetting | Embedding only the glyphs of a font that are actually used in a document, rather than the whole font program. The redaction pipeline re-subsets fonts after removing text so that deleted glyphs are not recoverable from the embedded font. |
| MediaBox | The PDF page-geometry rectangle defining the full physical page size, before any cropping is applied. |
| CropBox | The PDF page-geometry rectangle defining the visible region of a page, which may be smaller than the MediaBox. The crop tool (Section 7.8) modifies the CropBox rather than discarding content outside it, preserving the ability to un-crop. |
| Appearance stream | The rendering instructions for how a form field or annotation should look on the page, independent of its underlying data. Flattening (Section 9.7) bakes appearance streams into permanent page content. |
Product terms
| Term | Definition |
|---|---|
| Envelope | A single e-signature transaction: one or more source documents, one or more signers, and the routing and field configuration that governs how they sign. Identified by an env_-prefixed ID (Section 4). |
| Signer | A person who completes fields in an envelope. Signers never need a PDFWorks account (Section 10.5). |
| Field | A single interactive placement in a fillable form or a signature envelope — signature, initials, date-signed, text, checkbox, radio group, dropdown, attachment request, or one of the four signer-identity fields (full_name, email, title, company) that auto-populate from the signer's own supplied information rather than requiring manual entry, per the field-type enumeration owned by Section 5. |
| Batch | A single request to run one tool across many files at once, subject to the plan-scaled file-count limits in Section 12.2 and the routing rule in Section 1.2. |
| Job | The unit of work tracked through the state machine in Section 4.7 (queued → running → succeeded/failed/canceled/expired), whether client-side or server-side. |
in_progress |
The mid-flight status of an e-signature envelope: creation is complete and at least one signer has been notified, but not every required signer has completed their fields yet. Owned by the envelope data model in Section 5; referenced by Section 10's audit event stream and by Section 22.2.4's downgrade/cancellation handling, where an envelope already in_progress is allowed to complete rather than being interrupted by a plan change. |
exhausted |
The terminal status of a single webhook delivery attempt once every retry in the schedule owned by Section 14.10.6 has been made without a 2xx response. An endpoint that accumulates enough consecutive exhausted deliveries transitions from active to failing (Section 22.2.9); exhausted describes one delivery attempt, not the endpoint's own lifecycle state. |
| Daily audit anchor | The scheduled, externally verifiable digest published once per day covering every e-signature envelope's hash chain that advanced that day, letting retroactive tampering with a completed chain be detected independently of PDFWorks's own database (Section 10.5). Each envelope covered by a given day's anchor has its Certificate of Completion record a reference to it (Section 22.2.8). |
| Artifact | Any file produced by a job — a converted document, a merged PDF, a redaction verification report — as distinct from the source document that was uploaded or opened. |
| Workspace | The billing and membership boundary for a Team account: it holds the subscription, the seats, and shared resources like templates and the shared audit log (Section 11). A personal (Free/Pro) account is a workspace with exactly one member. |
| Entitlement | The resolved set of permissions and limits a workspace or device currently has, computed from its plan by the single accessor described in Section 22.2.3, never hand-checked ad hoc. |
| Quota | A specific numeric limit within an entitlement — a task count, a file-size ceiling, an envelope count — that resets on a defined cadence (daily, monthly) or is a hard ceiling (max file size). |
| Processing location | Whether a given operation runs on-device (client-side, in the browser, file never uploaded) or on PDFWorks's servers (uploaded, encrypted, time-limited). Communicated to the user by the Processing Location Indicator (Section 6.2). |
Standards terms
| Term | Definition |
|---|---|
| ESIGN | The U.S. Electronic Signatures in Global and National Commerce Act (2000), the federal law establishing that an electronic signature carries the same legal weight as a handwritten one for most transactions. PDFWorks's e-signature system is built to satisfy ESIGN's consent, attribution, and record-retention requirements. |
| UETA | The Uniform Electronic Transactions Act, the U.S. state-level counterpart to ESIGN, adopted (with minor variation) by nearly every state. Referenced alongside ESIGN throughout Section 10. |
| eIDAS | The EU regulation (electronic IDentification, Authentication and trust Services) defining three tiers of electronic signature: simple, advanced, and qualified. PDFWorks supports simple and advanced electronic signatures; qualified signatures require a PKI trust chain and are explicitly out of scope (Section 2.8). |
| WCAG 2.2 AA | Web Content Accessibility Guidelines version 2.2, conformance level AA — the specific, testable accessibility standard PDFWorks commits to meeting non-negotiably (Section 16), covering keyboard operability, color contrast, and screen-reader compatibility. |
| GDPR | The EU General Data Protection Regulation, governing how personal data of EU residents is collected, processed, and stored. PDFWorks's DSAR export/delete flows and EU data-residency option (Section 17.6) exist to satisfy it. |
| CCPA | The California Consumer Privacy Act, the U.S. state-level analog to GDPR for California residents, addressed by the same DSAR tooling. |
| SOC 2 Type II | An independent audit attesting that a company's security controls (around availability, confidentiality, and processing integrity) are not just documented but operating effectively over an observation period. PDFWorks treats this as a post-launch roadmap item with controls designed in from day one (Section 17.6). |
| OPFS | Origin Private File System, a browser storage API providing a sandboxed, high-performance file system scoped to a single origin. PDFWorks stages every client-side input, intermediate artifact, and output in OPFS, never in localStorage or a base64 data URL (Section 3.7). |
| WASM | WebAssembly, a portable, near-native-speed binary instruction format that runs in the browser (and in Node). The pdfcore engine (Section 3.2) is compiled to a single WASM artifact that runs identically in both hosts. |
| COOP | Cross-Origin-Opener-Policy, an HTTP response header that isolates a browsing context from cross-origin windows that would otherwise share a process, a prerequisite for enabling SharedArrayBuffer. |
| COEP | Cross-Origin-Embedder-Policy, an HTTP response header that requires every cross-origin resource a page loads to explicitly opt in to being embedded, the second prerequisite for SharedArrayBuffer. PDFWorks uses the credentialless mode (Section 3.8), which relaxes this requirement for resources that do not need to carry credentials. |
| SharedArrayBuffer | A JavaScript object allowing multiple Web Workers to read and write the same block of memory directly, the mechanism pdfcore's multithreaded WASM build relies on for parallelism, gated behind COOP/COEP isolation. |
23.4 File-format support matrix #
| Input format | Output format | Direction | Execution location | Fidelity expectation | Known limitations |
|---|---|---|---|---|---|
| merge, split, extract, organize, rotate, delete/insert pages, crop, compress, watermark, page numbers, protect, unlock, flatten, redact, repair, edit metadata | Client-side | Lossless for structure-preserving operations; compress is lossy by design (image recompression) | Compress cannot improve already-optimized files (compression_no_improvement); repair cannot recover a file with no valid xref anywhere in its byte stream |
||
| edit text & images, annotate & shapes, fill forms, create fillable forms, self-sign | Client-side | High for embedded-font, standard-layout documents; degraded for non-embedded fonts or complex multi-column layouts | Text reflow is not supported — edits replace existing glyph runs in place, they do not re-flow surrounding text (Section 8.1) | ||
| JPG, PNG | PDF → JPG/PNG | Client-side | High; rasterization at a user-selected DPI (default 150) | Very high DPI selections on very large page counts are memory-bounded by the OPFS streaming behavior in Section 3.7 | |
| JPG, PNG | JPG/PNG → PDF | Client-side | High; one image per page at its native resolution unless the user selects a fit-to-page-size option | No OCR is performed in this path; the output PDF is image-only unless separately run through OCR | |
| PDF (searchable) | OCR | Server-side | High for 200+ DPI Latin-script scans; degraded below that or for handwriting | Handwriting recognition is not supported; non-Latin scripts require explicit language-pack selection (ocr_language_not_supported otherwise) |
|
| Plain text | OCR (text-extraction mode) | Server-side | Same as above | Layout (columns, tables) is not preserved in plain-text output; use PDF → DOCX for layout-aware extraction | |
| DOCX, XLSX, PPTX | Office → PDF | Server-side | High; LibreOffice headless rendering closely matches Microsoft Office for standard documents | Macros, embedded OLE objects, and some advanced typography (complex script shaping) are not supported (conversion_unsupported_feature) |
|
| DOCX | PDF → DOCX | Server-side | Medium; text and images map well, complex tables and multi-column layouts are approximated | Not a guaranteed pixel-perfect round trip; see the "layout-reconstructed" fidelity tier badge in Section 22.2.6 | |
| XLSX | PDF → XLSX | Server-side | Medium; strong for documents with clear tabular gridlines, weaker for text-only or irregularly spaced tables | Table-detection heuristics may miss borderless tables, falling back to a single text-block worksheet | |
| PPTX | PDF → PPTX | Server-side | Medium; each page becomes one slide with a background image and overlaid text boxes where confidently positioned | Text boxes are not guaranteed independently editable if position confidence is low; the underlying page image is always preserved as a fallback | |
| HTML | HTML → PDF | Server-side | High for standard CSS, including @media print |
JavaScript-rendered content requiring interaction (not just page-load execution) is not captured; no outbound network access means external resources not inlined at request time will fail (html_render_blocked_resource) |
|
| HTML | PDF → HTML | Server-side | High for tagged PDFs; medium for untagged PDFs | Untagged PDFs fall back to absolutely positioned <div> elements over an image background, which is not meaningfully reflow-responsive |
23.5 Keyboard shortcut reference #
Shortcuts use Mod to mean Ctrl on Windows/Linux and Cmd on macOS, consistent with the platform
conventions documented in Section 16. Every shortcut below has a corresponding accessible-name
announcement for screen-reader users, per the WCAG 2.2 AA bar in Section 16.
Global (every tool page)
| Shortcut | Action |
|---|---|
Mod+O |
Open a file (triggers the OS file picker) |
Mod+S |
Download/save the current result, once a job has succeeded |
Mod+Z |
Undo the last local edit (available anywhere an undo stack exists) |
Mod+Shift+Z |
Redo |
Esc |
Cancel the current in-progress action or close an open dialog |
? |
Open the keyboard shortcut help overlay |
Mod+K |
Open the command palette (tool search and navigation) |
Page organizer (organize/reorder, delete pages, insert blank pages, extract, split)
| Shortcut | Action |
|---|---|
Arrow keys |
Move focus between page thumbnails |
Space |
Pick up the focused thumbnail for keyboard-driven reordering |
Arrow keys (while a thumbnail is picked up) |
Move the picked-up thumbnail; a live region announces its new position |
Space (while a thumbnail is picked up) |
Drop the thumbnail in its new position |
Esc (while a thumbnail is picked up) |
Cancel the reorder and return the thumbnail to its original position |
Delete / Backspace |
Delete the focused (or multi-selected) page(s) |
Mod+A |
Select all pages |
Shift+Arrow keys |
Extend the selection to an adjacent page |
R |
Rotate the focused/selected page(s) 90° clockwise |
Shift+R |
Rotate the focused/selected page(s) 90° counterclockwise |
Enter |
Insert a blank page after the focused page (insert-blank-pages tool only) |
Annotation canvas (annotate & shapes, redaction, fill forms, create fillable forms)
| Shortcut | Action |
|---|---|
P |
Enter keyboard-driven placement mode for the currently selected tool (satisfies the pointer-free placement requirement in Section 16) |
Arrow keys (in placement mode) |
Move the placement cursor across the page in fixed increments |
Shift+Arrow keys (in placement mode) |
Move the placement cursor in larger increments |
Enter (in placement mode) |
Place the current tool's element (annotation, redaction region, form field) at the cursor position |
Esc (in placement mode) |
Exit placement mode without placing an element |
H |
Select the highlight tool |
U |
Select the underline tool |
S |
Select the strikeout tool |
N |
Select the sticky-note tool |
L |
Select the freehand draw tool |
Shift+R |
Select the rectangle shape tool (distinct from the organizer's Shift+R, scoped to the annotation canvas context) |
E |
Select the ellipse shape tool |
V |
Select the selection/move tool (default) |
Delete / Backspace |
Delete the focused annotation, shape, redaction region, or field |
Mod+D |
Duplicate the focused element |
Tab / Shift+Tab |
Move focus between placed elements in placement order |
+ / - |
Zoom the page in or out |
Page Up / Page Down |
Navigate to the previous/next page |
E-signature signer portal (sign.pdfworks.io)
| Shortcut | Action |
|---|---|
Tab / Shift+Tab |
Move between fields in the sender-defined field order |
Enter |
Open the currently focused field for completion (e.g., open the signature pad) |
Esc |
Close the open field-completion dialog without saving |
Mod+Enter |
Submit/finish signing once all required fields are complete |
23.6 Executor instructions — how to build this document #
This subsection is addressed directly to you, the AI agent or engineering team building PDFWorks from this specification. Read it before writing a line of code.
Read the whole document first. Every section references others; a change made against Section 7 without having read Section 5's data model, or Section 14's HTTP contract, will produce a tool that works in isolation and breaks the moment it is wired into the API or the job history view. Do not start coding from Section 1 onward without having read through Section 23 at least once.
Build order. Follow Section 22 exactly: M0 through M10, in the sequence and with the dependencies given in Section 22.1. Do not begin server-side work (M5 onward) before the client-side engine (M1) and billing (M4) are done — every server-side job that runs in production must already be attributable to a quota-checked, paying-or-free-tier-limited workspace, with no exceptions.
One owning section per concern. Every cross-cutting concern in this specification — the error envelope, the pagination shape, the plan and limits table, the job state machine, the retention rules, the dependency version table, the design tokens, the entitlement-computation logic — is defined exactly once, in one numbered section, and referenced by number everywhere else it is used. If you find yourself about to write a second definition of any of these, stop: you have either misread a cross-reference or found a genuine contradiction, and the resolution order below tells you what to do next.
When this document is silent. You will encounter implementation questions this document does
not answer — the debounce delay for a client-side search-as-you-type input, a specific icon choice
within an already-specified design token set, a helper function's internal argument order. When
that happens: decide, using the same judgment and defaults a senior engineer familiar with this
whole specification would use; record the decision in a DECISIONS.md file at the repository root,
one entry per decision, dated, with a one-paragraph rationale; and keep going. Never stall waiting
for clarification, and never insert a placeholder. A DECISIONS.md entry looks like this:
## 2026-09-03 — Argument order for the shared `formatBytes` helper
`packages/ui` needs a shared byte-size formatting helper (used by the compress tool's before/after
comparison, the upload size-limit error copy, and the file-size column in job history), but this
specification does not dictate the signature of internal, non-exported helper functions. Decision:
`formatBytes(bytes: number, locale: string): string`, value first, to match the argument order
already used by the rest of `packages/ui`'s formatting helpers (`formatDate(date, locale)`,
`formatCurrency(minorUnits, currencyCode, locale)`). Rationale: consistency with existing helpers in
the same package outweighs any other convention, and this is exactly the kind of internal decision
this document defers to the executor's judgment.When this document appears to contradict itself. It should not, but if two sections appear to
give different answers to the same question, the owning section wins, and if it is unclear which
section owns the concern, resolve in this fixed order: Section 5 wins for anything about data
(schema, identifiers, retention columns); Section 14 wins for anything about the HTTP contract
(paths, payload shape, status codes, headers); Section 12 wins for anything about limits
(quotas, plan boundaries, pricing); Section 17 wins for anything about security (encryption,
authentication, authorization, compliance posture). Record the contradiction and its resolution in
DECISIONS.md using the same format as above, so the next person (human or agent) understands why
the code does not match a literal reading of the losing section.
Definition of done, at three scales.
- A task (the granularity used throughout Section 22.2's task lists) is done when: the named files/packages exist and compile; unit tests exist for any non-trivial logic and pass; the relevant lint and type checks pass; and, where the task touches a user-facing surface, it is reachable in the running application, not merely present in source.
- A milestone is done when its own Definition of Done checklist (Section 22.2, each milestone's own subsection) is fully satisfied — every bullet, not most of them — and its demo script can be performed live against a running instance of the application.
- The project is done when M10's Definition of Done is satisfied, the go-live checklist (Section 22.2.10, task 16) has been executed successfully in production, and every quality bar in Section 16, 18, and 19 holds true simultaneously across the whole application, not just within the milestone that originally introduced each bar.
Quality gates before any merge. No pull request merges to the main branch unless, at minimum:
lint and type-check pass with zero errors; the unit test suite passes, including the 80%
line-coverage floor on packages/pdfcore bindings, packages/contracts, and entitlement/quota
logic specifically (Section 19); the fast golden-file-corpus smoke subset passes (Section 22.4,
risk 15's mitigation); any Playwright E2E test touching the changed surface passes; and, for any
change to packages/pdfcore, the cross-host byte-equality test (Section 22.2.1, task 6) passes.
Merges that skip a gate by disabling or deleting a failing test rather than fixing the underlying
issue are a defect in the change itself, not a shortcut.
The version rule. For every dependency in the version table in Section 3's technology stack
discussion: install the current stable release at the time you write the code (pnpm add <package>@latest, or the ecosystem-appropriate equivalent), confirm the resolved major version
still matches the line cited in that table, and let the lockfile record the exact patch version
actually installed. If a dependency's current stable major version has moved past the cited line,
treat that as a signal to re-verify compatibility before proceeding, and record the finding in
DECISIONS.md rather than silently pinning to a stale major version.
No user-visible string is hardcoded. Every piece of text a user reads — button labels, error
messages, empty states, email subject lines, the Processing Location Indicator's copy — is a
message key resolved through the next-intl catalogue set up in Section 16.7, even though English
is the only shipped locale at launch (Section 2.8's scope fence). Hardcoding a string directly into a
component is a defect, not a shortcut, because it silently forecloses the deferred internationalization
work described in Section 22.5.
A client-side entitlement check is never trusted. Every quota, plan-gate, or permission check that appears in the browser UI exists to give the user fast, friendly feedback — it never substitutes for the corresponding server-side check. The pattern established in Section 22.2.3 (task 14) applies everywhere: the client optimistically reflects what it believes the user's entitlements to be, and the server independently re-verifies before doing any work that costs money or spends a quota. A tool, route, or job handler that trusts a client-supplied entitlement claim without server-side re-verification is a security defect, reported and fixed with the same priority as an authentication bypass.
The Processing Location Indicator must match reality. The indicator shown on a tool page, a tool card, a batch job row, or a confirmation dialog is a contractual claim about where a user's file is about to go, not a decorative label (Section 6.2). Every time a tool's actual execution location is implemented or changed, the indicator's configuration is updated in the same pull request, and the CI assertion sweep from Section 22.2.10 (task 9) is treated as a release-blocking gate, not an advisory one. There is no acceptable version of this product in which the indicator says "On your device" while a file is uploaded, even temporarily, even for a fallback path — the explicit-click "Finish this on our servers instead" flow (Section 6.3) exists precisely so this never happens silently, and the four tools that flow excludes by design (redact, protect, unlock, self-sign) never render the flow's affordance at all.
Final pre-launch verification checklist. Before executing the go-live checklist in Section 22.2.10 (task 16), confirm every item below independently, even where a milestone's own Definition of Done already claimed it:
- Every tool listed in Section 1.1 and Section 1.2 is implemented and reachable in the production build.
- The Processing Location Indicator is correct on every tool page, tool card, batch row, and confirmation dialog, verified by the automated sweep, not by spot-checking.
- Zero unresolved WCAG 2.2 AA violations, automated or manual, across the entire application.
- Every performance budget in Section 18.6 is met against production infrastructure.
- The external security review's medium-or-above findings are all closed and verified.
- The disaster-recovery drill has been performed at least once against production-equivalent infrastructure within the RTO/RPO documented in Section 20.
- GDPR/CCPA DSAR export and delete flows work end to end against production data structures.
- Every environment variable in Section 23.2 is set correctly in the production environment, verified by the boot-time validation schema actually starting the service without error.
- Stripe is in live mode (not test mode) with all price IDs, webhook secrets, and meter names matching Section 23.2's production values.
- The e-signature verification endpoint (
GET /verify/{envelopeId}) correctly reportsMATCHfor an untampered completed envelope andALTEREDfor a deliberately modified copy of the same file, tested against production. - All five production domains (Section 1's placeholder list, replaced with the executor's real domains) resolve, serve valid TLS certificates, and route to the correct service.
- The status page is live and connected to real uptime monitoring, not a placeholder.
-
DECISIONS.mdis up to date with every decision made during the build that this document did not explicitly specify, so that the next engineer to touch the codebase has the same context you did.
When every box is checked, execute the go-live checklist. The product is done when a real user, with no knowledge of how any of this was built, can merge a PDF, redact a paragraph, convert a report to a slide deck, collect two people's signatures on a lease, and never once wonder whether their file was safe.
Generated at GenerateSpecs.com — one idea in, one buildable spec out.
Licensed under CC BY 4.0. Use it for anything — just credit GenerateSpecs.com.