Author: Sarthak Pal Status: Draft v1.0 Companion doc: PRD_SignFlow.md Last updated: July 8, 2026
| Layer | Choice | Reasoning |
|---|---|---|
| Backend framework | FastAPI (Python) | Async-native, so it handles calls out to Setu (upload, create signature, poll status) without blocking. Built-in request/response validation via Pydantic means malformed uploads or signer payloads get rejected before they ever reach Setu. Auto-generated OpenAPI docs give you a free, always-current API reference to hand to a reviewer. It's also the stack your backend experience is strongest in, so decisions here come from real understanding, not a tutorial you followed once. |
| Frontend framework | Next.js (React, TypeScript) | Gives you file-based routing for the three MVP screens (Landing, Upload, Status) with minimal boilerplate. TypeScript catches shape mismatches between what the backend returns and what the UI expects — important when you're juggling documentId, signatureId, and status across screens. Deploys trivially to Vercel with zero server config. |
| Database | PostgreSQL | Relational data (documents → signature requests → signers) with real foreign-key relationships; you want referential integrity here, not a document store. Postgres is also explicitly named in the assignment's bonus suggestion, is free-tier available on Railway/Render/Supabase, and is a stack most interviewers will recognize instantly. |
| ORM / migrations | SQLAlchemy + Alembic | SQLAlchemy pairs naturally with FastAPI. Alembic gives you versioned schema migrations, so your README can say "run alembic upgrade head" instead of "manually create these tables," which reads as more production-minded. |
| File storage (uploaded PDFs) | Local disk for MVP, swappable to S3-compatible storage later | You don't need object storage complexity for a single-signer MVP handling PDFs under a few MB. Keep the storage layer behind a small interface (storage.py) so swapping to S3/Supabase Storage later is a one-file change, not a rewrite. |
| Background status checks | None in MVP — status is fetched on demand | Matches the MVP scope decision in the PRD (manual refresh, not polling/webhooks). Avoids needing a task queue (Celery/Redis) for v1. |
| Auth | Clerk (single Owner account, passwordless magic-link login) in MVP; team roles deferred | Reconciled with the Security and Access Doc: MVP needs exactly one logged-in identity so documents can be scoped to someone, and the row-level security rules have something to check against. Multi-user/Team Member roles stay deferred per the PRD — this is one account, not an org system yet — but it's a real login from day one rather than "no auth," which would leave every document ownerless. |
| Deployment — backend | Render or Railway | Both support Python + Postgres out of the box, have generous free tiers, and give you a public HTTPS URL with zero DevOps work — you want your engineering time going into the Setu integration, not into learning Kubernetes. |
| Deployment — frontend | Vercel | Zero-config Next.js deploys, automatic preview URLs per commit — useful when you're iterating and want a shareable link for each stage of the assignment. |
| Secrets management (production notes only) | Environment variables in MVP; a dedicated secrets manager (AWS Secrets Manager / Doppler / Render's built-in secret store) in production | The assignment specifically asks you to document this even if you don't implement it — see Section 4. |
signflow/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI app entrypoint, CORS setup, router registration
│ │ ├── config.py # Loads and validates env vars (pydantic Settings)
│ │ ├── database.py # SQLAlchemy engine/session setup
│ │ │
│ │ ├── models/
│ │ │ ├── __init__.py
│ │ │ ├── document.py # Document ORM model
│ │ │ ├── signature_request.py # SignatureRequest ORM model
│ │ │ └── signer.py # Signer ORM model
│ │ │
│ │ ├── schemas/
│ │ │ ├── __init__.py
│ │ │ ├── document.py # Pydantic request/response schemas for documents
│ │ │ └── signature.py # Pydantic request/response schemas for signatures
│ │ │
│ │ ├── routers/
│ │ │ ├── __init__.py
│ │ │ ├── upload.py # POST /api/upload-contract
│ │ │ ├── signature_status.py # GET /api/signature-status/{id}
│ │ │ └── download.py # GET /api/download/{id}
│ │ │
│ │ ├── services/
│ │ │ ├── __init__.py
│ │ │ ├── setu_client.py # All outbound HTTP calls to Setu, credentials injected here only
│ │ │ └── storage.py # File save/retrieve abstraction (local disk now, S3-ready later)
│ │ │
│ │ └── utils/
│ │ ├── __init__.py
│ │ └── validators.py # File type/size validation helpers
│ │
│ ├── alembic/
│ │ ├── versions/ # Auto-generated migration files
│ │ └── env.py
│ │
│ ├── tests/
│ │ ├── test_upload.py
│ │ ├── test_signature_status.py
│ │ └── test_download.py
│ │
│ ├── .env.example # Documented env vars, no real values
│ ├── requirements.txt
│ ├── alembic.ini
│ └── README.md
│
├── frontend/
│ ├── app/
│ │ ├── page.tsx # Landing page
│ │ ├── upload/
│ │ │ └── page.tsx # Upload Contract page
│ │ ├── status/
│ │ │ └── page.tsx # Status page (enter/select a request)
│ │ └── layout.tsx
│ │
│ ├── components/
│ │ ├── UploadDropzone.tsx
│ │ ├── StatusBadge.tsx
│ │ ├── DocumentCard.tsx
│ │ └── ui/ # Shared low-level UI primitives (button, input, etc.)
│ │
│ ├── lib/
│ │ ├── api.ts # Typed fetch wrapper for calling the backend
│ │ └── types.ts # Shared TS types matching backend Pydantic schemas
│ │
│ ├── .env.local.example
│ ├── package.json
│ └── README.md
│
├── docs/
│ ├── PRD_SignFlow.md
│ ├── Technical_Architecture_SignFlow.md
│ ├── system-architecture-diagram.png
│ └── sequence-diagram.png
│
└── README.md # Top-level: setup, architecture overview, security notes
Why this shape: routers/ stays thin (HTTP concerns only), services/ holds the actual Setu integration and file handling, and models//schemas/ are kept separate so your database representation (models) never leaks directly into your API contract (schemas) — a reviewer looking at this structure can tell you understand separation of concerns without you having to explain it out loud.
Three tables, reflecting the real-world relationship: one document can have one signature request, and one signature request has one or more signers.
| Field | Type | Notes |
|---|---|---|
id |
UUID (PK) | Internal primary key |
setu_document_id |
VARCHAR | The documentId returned by Setu after upload — this is what you'll reference in later Setu API calls |
owner_id |
VARCHAR | The Clerk user ID of the logged-in Owner who uploaded this document — added because MVP now includes single-account auth; this is the field every query filters on, and the field that becomes organization_id when team accounts ship |
original_filename |
VARCHAR | The name of the file as uploaded, for display purposes |
file_path |
VARCHAR | Where the PDF is stored (local path in MVP, S3 key later) |
file_size_bytes |
INTEGER | Used for validation and display |
uploaded_at |
TIMESTAMP | When the upload happened |
In plain English: this table is your record of "a PDF exists in our system," independent of whether anyone has been asked to sign it yet. Keeping it separate from signature_requests means you could, in theory, upload a document without immediately sending it for signature — matching how the assignment's Stage 2 flow separates "upload document" from "create signature request" as two backend steps.
| Field | Type | Notes |
|---|---|---|
id |
UUID (PK) | Internal primary key |
document_id |
UUID (FK → documents.id) | Which document this request is for |
setu_signature_id |
VARCHAR | The id Setu returns from POST /api/signature — this is the value used in every later status/download call |
status |
VARCHAR (enum: pending, signed, expired, failed) |
Our simplified internal status. Mapped from Setu's real enum on every refresh: sign_initiated/sign_pending/sign_in_progress → pending, sign_complete → signed (see Frontend_Specification_SignFlow.md, Section 7.1, for the full mapping) |
redirect_url |
TEXT | Where Setu redirects the signer after signing, if configured |
created_at |
TIMESTAMP | When the request was created |
updated_at |
TIMESTAMP | Last time status was refreshed |
Correction from an earlier draft: signer_url was removed from this table — Setu returns a separate signing link per signer, not one link per request, so that field now lives on signers instead (below).
In plain English: this is the row that represents "someone has been asked to sign this document." It's a separate table from documents because a single document could theoretically be re-sent for signature (a new request) without needing a whole new upload — the relationship is one-to-many from documents to signature_requests, even though MVP usage will mostly be one-to-one.
| Field | Type | Notes |
|---|---|---|
id |
UUID (PK) | Internal primary key |
signature_request_id |
UUID (FK → signature_requests.id) | Which request this signer belongs to |
setu_signer_id |
VARCHAR | The signer's own id, returned inside the signers array from Setu — needed to match status updates back to the right signer |
identifier |
VARCHAR | Correction from an earlier draft: Setu's eSign is Aadhaar OTP-based, so a signer is identified by their Aadhaar-linked mobile number, not an email address. This replaces the email field from the original schema |
display_name |
VARCHAR (nullable) | Signer's name, if collected — passed to Setu as displayName and optionally validated against Aadhaar OTP data |
signer_url |
TEXT | This signer's individual signing link, returned by Setu as url inside their entry in the signers array |
status |
VARCHAR (enum: pending, in_progress, signed) |
Mirrors Setu's real per-signer status enum directly — no translation needed here, unlike the request-level status |
signed_at |
TIMESTAMP (nullable) | Filled in once Setu confirms this signer has signed (inferred from status flipping to signed, since Setu's response doesn't include a literal timestamp field — store the time your backend observed the change) |
In plain English: even though MVP only supports one signer per document, this table is still separate from signature_requests rather than being a single column on that table. That's a deliberate, cheap-now decision: multi-signer support (a v1.x "nice-to-have" from the PRD) becomes "add more rows," not "redesign the schema and write a migration that reshapes existing data" — and it turns out to be the correct shape anyway, since Setu itself returns one URL and one status per signer, not one per request.
documents (1) ──── (1) signature_requests (1) ──── (many) signers
- One document has one active signature request in MVP (schema allows more, for future re-sends)
- One signature request has one signer in MVP (schema allows more, for future multi-signer support)
| Variable | Purpose | Notes |
|---|---|---|
SETU_CLIENT_ID |
Setu's x-client-id |
Sandbox value during development; never logged, never returned in any API response |
SETU_CLIENT_SECRET |
Setu's x-client-secret |
Same handling as above — this is the one credential that would cause real damage if leaked |
SETU_PRODUCT_INSTANCE_ID |
Setu's x-product-instance-id |
Identifies which Setu product instance you're calling |
SETU_BASE_URL |
Setu API base URL | Keep this configurable rather than hardcoded, so switching between sandbox and production is a config change, not a code change |
DATABASE_URL |
Postgres connection string | Format: postgresql://user:password@host:port/dbname |
FRONTEND_URL |
The deployed frontend's URL | Used to configure CORS — only this origin should be allowed to call the backend |
MAX_UPLOAD_SIZE_MB |
File size limit | Enforced server-side, not just in the frontend dropzone — never trust client-side validation alone |
ENVIRONMENT |
development / production |
Lets you toggle things like verbose error messages (fine in dev, a security risk in prod) |
| Variable | Purpose | Notes |
|---|---|---|
NEXT_PUBLIC_API_BASE_URL |
Your backend's URL | This is the only Setu-adjacent config the frontend ever sees — no client IDs, no secrets. NEXT_PUBLIC_ prefix means it's bundled client-side, so double-check nothing sensitive ever gets this prefix by accident |
- Never let a Setu credential exist in any file under
frontend/. The moment a variable meant for the backend gets prefixedNEXT_PUBLIC_in Next.js, it ships to the browser. This is the single most likely accidental security mistake in this stack — worth a one-line comment inconfig.pywarning future-you about it. - CORS should allow exactly one origin (
FRONTEND_URL), not*. A wildcard origin on a backend that handles file uploads and signature data is a bigger opening than it looks. .env.examplefiles should be committed;.envand.env.localshould not. Add both to.gitignorebefore your first commit, not after.- Production secrets belong in a secrets manager, not a
.envfile on a server. For this project's scale, Render's and Railway's built-in encrypted environment variable storage is a reasonable "production-lite" answer — worth naming explicitly in your README since the assignment asks for it, even though a full Vault/AWS Secrets Manager setup would be overkill here. - Rotate the Setu sandbox credentials if this repo is ever made public with real values committed anywhere in its git history — a
.envcommitted once and later removed still exists in old commits unless the history is rewritten. - Webhook signing secret (future, not MVP): if you add Setu webhook support later, it will come with its own secret used to verify that incoming webhook calls actually came from Setu and not a forged request. Worth a placeholder line (
SETU_WEBHOOK_SECRET=) in.env.examplenow, even unused, so the README's "how you'd extend this" section has something concrete to point to.