Vibecode Fillout
track this build5 phases, 15 steps, beginner friendly0%The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Fillout, build a polished form that writes to a user-owned database and supports simple branching. The hard boundary is excellent airtable and notion integrations, scheduling, payments, and hosted polish, plus integrations, deliverability, and workflow depth.
You are building a lean indie version of Fillout. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Fillout · indie build A form builder where the interesting part is the logic engine: questions that appear based on earlier answers, validation that runs the same rules on both sides, partial responses that survive a closed tab, and a submission pipeline that never loses an answer because a webhook failed. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | forms are a database problem with a rendering layer, not the other way around | | Logic | A declarative rule tree evaluated identically on client and server | logic that only runs in the browser is a suggestion | | Storage | Answers as rows keyed by question, not a JSON blob | questions change and a blob makes every historical response unreadable | | Payments | Stripe Checkout for paid forms | taking card details yourself turns a form builder into a compliance project | | Delivery | An outbox with retries for webhooks and notifications | a submission is accepted before anything downstream is attempted | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and shares the logic evaluator with the client. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Versioned questions, keyed answers and an outbox all want a real relational database. - Get it: Docker or your package manager. - Verify: psql connects and creates a table - [ ] **A Stripe account in test mode** (optional) · free to test; per-transaction in production - Why: Phase 4 only. Hosted Checkout keeps card details off your server entirely. - Get it: Sign up, use test keys, and configure a webhook endpoint with its signing secret. - Verify: A test Checkout session completes and the webhook is received and verified - [ ] **A way to receive webhooks locally** · free - Why: Both payment and delivery testing need real webhooks reaching your machine. - Get it: The Stripe CLI forwards events, or use a tunnelling tool. - Verify: A test event arrives at your local endpoint and verifies - [ ] **Your retention period and what forms will collect** · free - Why: Retention is stated on the form and enforced by a job, and the two have to match. Some question types make a form collect data with a much higher duty of care. - Get it: Decide the period, decide what you will refuse to collect, and write both down before building. - Verify: A written note states the period and the categories you will not collect ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The visual builder that makes forms pleasant to construct. - Templates and themes. - Integrations with dozens of destinations. - Certified accessibility work. - excellent Airtable and Notion integrations, scheduling, payments, and hosted polish - advanced branching - payment and CRM integrations - large template libraries - enterprise compliance If one of those is essential to you, that is the reason to keep paying for Fillout, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Fillout The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me conditional forms with logic, payments and a place the answers actually go to replace Fillout. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · forms are a database problem with a rendering layer, not the other way around - Logic: A declarative rule tree evaluated identically on client and server · logic that only runs in the browser is a suggestion - Storage: Answers as rows keyed by question, not a JSON blob · questions change and a blob makes every historical response unreadable - Payments: Stripe Checkout for paid forms · taking card details yourself turns a form builder into a compliance project - Delivery: An outbox with retries for webhooks and notifications · a submission is accepted before anything downstream is attempted ### Data model (create this before Phase 1) - forms(id, slug, title, status, version, published_at) · a published version is immutable and edits create a new version - questions(id, form_id, form_version, key, type, label, required, config_json, ordinal) · key is stable across versions, id is not - rules(id, form_id, form_version, target_question_id, condition_json, action) · action is show, hide or require - responses(id, form_id, form_version, started_at, submitted_at, resume_token_hash, ip_country) - answers(response_id, question_key, value_json, at) · keyed by the stable key so a response stays readable after the form changes - outbox(id, response_id, kind, payload_json, attempts, next_attempt_at, delivered_at, last_error) - A response records the form version it was submitted against, because reading old answers against a new form is how reporting goes wrong. ### Phase 1 · Questions, answers and versions Build: A form with several question types, rendering and submitting to storage. Answers keyed by a stable question key. Publishing snapshots an immutable version and a response records which version it answered. Trap: Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. Done when: A form with five question types collects and stores answers. Editing a published form creates a new version and old responses still read correctly, verified by editing a question label after a submission. Deleting a question leaves prior answers readable. A response names its form version. Do not build yet: No logic, validation or payments. ### Phase 2 · Conditional logic that runs on both sides Build: A declarative rule tree with conditions over answered values and show, hide or require actions. Evaluate the same rules on the client for rendering and on the server for acceptance. Detect rules that can never fire and cycles at publish time. Trap: Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. Done when: A question appears only when its condition is met. A direct post containing an answer to a hidden question is rejected by the server, tested with a crafted request. A rule that can never fire is reported at publish time. A cycle between two rules is refused rather than looping. Do not build yet: No partial saves or payments yet. ### Phase 3 · Partial responses that survive a closed tab Build: Save answers as they are given, issue a resume link with an unguessable token, and restore progress on return. Store only a hash of the token. Expire partials after a stated period and say so on the form. Trap: A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. Done when: Closing the tab and returning via the resume link restores every answer including hidden-question state. Only a hash of the token is in the database, checked by inspection. An expired token is refused with a clear message. A completed response invalidates its resume token. Do not build yet: No payments or delivery yet. ### Phase 4 · Taking payment inside a form Build: Use hosted Checkout so card details never reach you. Treat the response as submitted when the webhook confirms payment, not when the browser returns. Handle the respondent closing the tab after paying. Trap: Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. Done when: A paid form completes through hosted Checkout with no card details reaching your server. Killing the browser immediately after payment still produces a complete response, tested directly. A duplicate webhook does not create two responses. A failed payment leaves the response resumable rather than lost. Do not build yet: No delivery or exports yet. ### Phase 5 · Getting answers where they need to go Build: Write deliveries to an outbox in the same transaction as the response. A separate worker delivers with backoff. Sign outgoing webhooks. Show the delivery state per response and allow a manual retry. Trap: Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. Done when: A submission commits with its deliveries in one transaction. A failing webhook endpoint retries with backoff and the response is unaffected, tested against an endpoint returning errors. Outgoing webhooks are signed and verifiable. A permanently failed delivery is visible and manually retryable. Do not build yet: Exports and retention come last. ### Phase 6 · Exports, retention and what you promised Build: Export responses to CSV, handling questions that were added or removed across versions. Retention deleting responses past a period. A deletion request removing a response, its answers and its partials together. Trap: A CSV export that only includes the current version's questions silently drops answers to questions that were removed, and nobody notices because the file looks complete. Export the union of questions across the versions in range, with empty cells where a question did not exist. Done when: Export includes the union of questions across form versions with empty cells where a question did not exist, verified against a form edited mid-collection. Retention deletes past the period. A deletion request removes response, answers, partials and outbox rows. The form states its retention period to respondents. Do not build yet: Nothing after this. ### Out of scope (and why) - The visual builder that makes forms pleasant to construct, which is most of the hosted product's engineering and none of its interesting logic. - Templates and themes, which are volume work rather than difficulty. - Integrations with dozens of destinations, where each is small and the collection is enormous. - Accessibility work to a certified standard, which is real work you should do and which the hosted product has already paid for. ### README must contain - That answers are keyed by a stable question key so they survive form changes - That logic runs server-side on submission and a crafted post is rejected - That resume tokens are bearer credentials, stored hashed and expiring - That a paid response completes on the webhook rather than the redirect - That exports cover the union of questions across form versions ===== AGENTS.md ===== # Agent instructions · Fillout indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, A declarative rule tree evaluated identically on client and server, Answers as rows keyed by question, not a JSON blob, Stripe Checkout for paid forms, An outbox with retries for webhooks and notifications. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. - Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. - A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. - Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. - Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. ===== BUILD_PLAN.md ===== # Build plan · Fillout A form builder where the interesting part is the logic engine: questions that appear based on earlier answers, validation that runs the same rules on both sides, partial responses that survive a closed tab, and a submission pipeline that never loses an answer because a webhook failed. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Questions, answers and versions A form collects answers that stay readable after the form changes. ### Steps 1. Stable keys, changing ids The key is what an answer refers to and it must not change when the question is edited. Files: `src/schema.sql` 2. Publish as an immutable version Editing a live form mid-collection is otherwise how responses become inconsistent. Files: `src/versions.ts` 3. Record the version on the response Reading old answers against a new form is a reporting bug waiting to happen. Files: `src/responses.ts` ### Done when - [ ] Five question types collect and store answers - [ ] Editing a published form creates a new version - [ ] Old responses read correctly after an edit - [ ] A response records its form version ### Watch out - Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. ## Phase 2 · Conditional logic that runs on both sides Questions appear based on earlier answers, and the server agrees. ### Steps 1. One evaluator, both sides Shared module. Two implementations diverge and the divergence is a security hole. Files: `src/logic/evaluate.ts`, `src/logic/evaluate.test.ts` 2. Enforce visibility on submission Reject answers to hidden questions. Test with a crafted post, not the browser. Files: `src/submit.ts` 3. Detect dead rules and cycles at publish Static analysis of the rule tree. A cycle at runtime hangs a respondent. Files: `src/logic/analyse.ts` ### Done when - [ ] A question appears only when its condition is met - [ ] A crafted post with a hidden answer is rejected - [ ] An unreachable rule is reported at publish - [ ] A rule cycle is refused ### Watch out - Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. ## Phase 3 · Partial responses that survive a closed tab Someone answers half a long form, comes back tomorrow and continues. ### Steps 1. Save progressively Per answer, not per page. A long form loses more than a page's worth otherwise. Files: `src/partial.ts` 2. Hash the resume token It is a bearer credential for personal information. Treat it like one. Files: `src/resume.ts` 3. Restore logic state too Which questions were visible, not just the values. Files: `src/partial.ts` ### Done when - [ ] A resume link restores answers and visibility - [ ] Only a token hash is stored - [ ] An expired token is refused clearly - [ ] Completion invalidates the token ### Watch out - A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. ## Phase 4 · Taking payment inside a form A form can charge, and a paid submission is never lost. ### Steps 1. Hosted Checkout only Card details on your server is a different project with a compliance scope. Files: `src/payments.ts` 2. Complete on the webhook Not the redirect. Test by killing the browser after paying. Files: `src/webhooks.ts` 3. Make webhook handling idempotent Keyed on the event id. Retries are normal. Files: `src/webhooks.ts`, `tests/webhook-idempotency.test.ts` ### Done when - [ ] Payment completes with no card details on your server - [ ] Killing the browser after paying still completes the response - [ ] A duplicate webhook creates one response - [ ] A failed payment leaves the response resumable ### Watch out - Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. ## Phase 5 · Getting answers where they need to go Notifications and webhooks fire reliably, and a failure never loses a submission. ### Steps 1. Outbox in the submission transaction The response and the intent to deliver commit together or not at all. Files: `src/outbox.ts` 2. Deliver in a worker with backoff Never in the request path. The respondent should not wait on someone else's server. Files: `src/worker.ts` 3. Sign outgoing webhooks A timestamped signature the receiver can verify. Files: `src/sign.ts` ### Done when - [ ] Response and deliveries commit atomically - [ ] A failing endpoint retries without affecting the response - [ ] Outgoing webhooks are signed and verifiable - [ ] A dead delivery is visible and retryable ### Watch out - Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. ## Not in this build - The visual builder that makes forms pleasant to construct. - Templates and themes. - Integrations with dozens of destinations. - Certified accessibility work. ## After v1, if you want it - Scheduling and quotas, which close a form on a condition rather than by hand - Response analytics per question, which is where abandonment becomes actionable - Multi-page forms with saved progress per page, once partials are trustworthy ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Contains respondents' answers. Treat the connection string and backups accordingly. DATABASE_URL=postgres://localhost:5432/forms # Required. How long a resume link works. It is a bearer credential, so shorter is safer. RESUME_TOKEN_TTL_HOURS=72 # Required. Must match what the form tells respondents. RESPONSE_RETENTION_DAYS=365 # Optional · secret. Only for paid forms. Test keys until you are certain the webhook path is right. STRIPE_SECRET_KEY=sk_test_... # Optional · secret. Verifies incoming events. An unverified webhook endpoint is an open endpoint. STRIPE_WEBHOOK_SECRET=whsec_... # Required · secret. Signs your outgoing webhooks so receivers can verify them. WEBHOOK_SIGNING_KEY=a random 32-byte hex string
You are building a lean indie version of Fillout. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Fillout · indie build A form builder where the interesting part is the logic engine: questions that appear based on earlier answers, validation that runs the same rules on both sides, partial responses that survive a closed tab, and a submission pipeline that never loses an answer because a webhook failed. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | forms are a database problem with a rendering layer, not the other way around | | Logic | A declarative rule tree evaluated identically on client and server | logic that only runs in the browser is a suggestion | | Storage | Answers as rows keyed by question, not a JSON blob | questions change and a blob makes every historical response unreadable | | Payments | Stripe Checkout for paid forms | taking card details yourself turns a form builder into a compliance project | | Delivery | An outbox with retries for webhooks and notifications | a submission is accepted before anything downstream is attempted | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and shares the logic evaluator with the client. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Versioned questions, keyed answers and an outbox all want a real relational database. - Get it: Docker or your package manager. - Verify: psql connects and creates a table - [ ] **A Stripe account in test mode** (optional) · free to test; per-transaction in production - Why: Phase 4 only. Hosted Checkout keeps card details off your server entirely. - Get it: Sign up, use test keys, and configure a webhook endpoint with its signing secret. - Verify: A test Checkout session completes and the webhook is received and verified - [ ] **A way to receive webhooks locally** · free - Why: Both payment and delivery testing need real webhooks reaching your machine. - Get it: The Stripe CLI forwards events, or use a tunnelling tool. - Verify: A test event arrives at your local endpoint and verifies - [ ] **Your retention period and what forms will collect** · free - Why: Retention is stated on the form and enforced by a job, and the two have to match. Some question types make a form collect data with a much higher duty of care. - Get it: Decide the period, decide what you will refuse to collect, and write both down before building. - Verify: A written note states the period and the categories you will not collect ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The visual builder that makes forms pleasant to construct. - Templates and themes. - Integrations with dozens of destinations. - Certified accessibility work. - excellent Airtable and Notion integrations, scheduling, payments, and hosted polish - advanced branching - payment and CRM integrations - large template libraries - enterprise compliance If one of those is essential to you, that is the reason to keep paying for Fillout, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Fillout The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me conditional forms with logic, payments and a place the answers actually go to replace Fillout. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · forms are a database problem with a rendering layer, not the other way around - Logic: A declarative rule tree evaluated identically on client and server · logic that only runs in the browser is a suggestion - Storage: Answers as rows keyed by question, not a JSON blob · questions change and a blob makes every historical response unreadable - Payments: Stripe Checkout for paid forms · taking card details yourself turns a form builder into a compliance project - Delivery: An outbox with retries for webhooks and notifications · a submission is accepted before anything downstream is attempted ### Data model (create this before Phase 1) - forms(id, slug, title, status, version, published_at) · a published version is immutable and edits create a new version - questions(id, form_id, form_version, key, type, label, required, config_json, ordinal) · key is stable across versions, id is not - rules(id, form_id, form_version, target_question_id, condition_json, action) · action is show, hide or require - responses(id, form_id, form_version, started_at, submitted_at, resume_token_hash, ip_country) - answers(response_id, question_key, value_json, at) · keyed by the stable key so a response stays readable after the form changes - outbox(id, response_id, kind, payload_json, attempts, next_attempt_at, delivered_at, last_error) - A response records the form version it was submitted against, because reading old answers against a new form is how reporting goes wrong. ### Phase 1 · Questions, answers and versions Build: A form with several question types, rendering and submitting to storage. Answers keyed by a stable question key. Publishing snapshots an immutable version and a response records which version it answered. Trap: Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. Done when: A form with five question types collects and stores answers. Editing a published form creates a new version and old responses still read correctly, verified by editing a question label after a submission. Deleting a question leaves prior answers readable. A response names its form version. Do not build yet: No logic, validation or payments. ### Phase 2 · Conditional logic that runs on both sides Build: A declarative rule tree with conditions over answered values and show, hide or require actions. Evaluate the same rules on the client for rendering and on the server for acceptance. Detect rules that can never fire and cycles at publish time. Trap: Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. Done when: A question appears only when its condition is met. A direct post containing an answer to a hidden question is rejected by the server, tested with a crafted request. A rule that can never fire is reported at publish time. A cycle between two rules is refused rather than looping. Do not build yet: No partial saves or payments yet. ### Phase 3 · Partial responses that survive a closed tab Build: Save answers as they are given, issue a resume link with an unguessable token, and restore progress on return. Store only a hash of the token. Expire partials after a stated period and say so on the form. Trap: A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. Done when: Closing the tab and returning via the resume link restores every answer including hidden-question state. Only a hash of the token is in the database, checked by inspection. An expired token is refused with a clear message. A completed response invalidates its resume token. Do not build yet: No payments or delivery yet. ### Phase 4 · Taking payment inside a form Build: Use hosted Checkout so card details never reach you. Treat the response as submitted when the webhook confirms payment, not when the browser returns. Handle the respondent closing the tab after paying. Trap: Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. Done when: A paid form completes through hosted Checkout with no card details reaching your server. Killing the browser immediately after payment still produces a complete response, tested directly. A duplicate webhook does not create two responses. A failed payment leaves the response resumable rather than lost. Do not build yet: No delivery or exports yet. ### Phase 5 · Getting answers where they need to go Build: Write deliveries to an outbox in the same transaction as the response. A separate worker delivers with backoff. Sign outgoing webhooks. Show the delivery state per response and allow a manual retry. Trap: Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. Done when: A submission commits with its deliveries in one transaction. A failing webhook endpoint retries with backoff and the response is unaffected, tested against an endpoint returning errors. Outgoing webhooks are signed and verifiable. A permanently failed delivery is visible and manually retryable. Do not build yet: Exports and retention come last. ### Phase 6 · Exports, retention and what you promised Build: Export responses to CSV, handling questions that were added or removed across versions. Retention deleting responses past a period. A deletion request removing a response, its answers and its partials together. Trap: A CSV export that only includes the current version's questions silently drops answers to questions that were removed, and nobody notices because the file looks complete. Export the union of questions across the versions in range, with empty cells where a question did not exist. Done when: Export includes the union of questions across form versions with empty cells where a question did not exist, verified against a form edited mid-collection. Retention deletes past the period. A deletion request removes response, answers, partials and outbox rows. The form states its retention period to respondents. Do not build yet: Nothing after this. ### Out of scope (and why) - The visual builder that makes forms pleasant to construct, which is most of the hosted product's engineering and none of its interesting logic. - Templates and themes, which are volume work rather than difficulty. - Integrations with dozens of destinations, where each is small and the collection is enormous. - Accessibility work to a certified standard, which is real work you should do and which the hosted product has already paid for. ### README must contain - That answers are keyed by a stable question key so they survive form changes - That logic runs server-side on submission and a crafted post is rejected - That resume tokens are bearer credentials, stored hashed and expiring - That a paid response completes on the webhook rather than the redirect - That exports cover the union of questions across form versions ===== AGENTS.md ===== # Agent instructions · Fillout indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, A declarative rule tree evaluated identically on client and server, Answers as rows keyed by question, not a JSON blob, Stripe Checkout for paid forms, An outbox with retries for webhooks and notifications. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. - Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. - A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. - Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. - Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. ===== BUILD_PLAN.md ===== # Build plan · Fillout A form builder where the interesting part is the logic engine: questions that appear based on earlier answers, validation that runs the same rules on both sides, partial responses that survive a closed tab, and a submission pipeline that never loses an answer because a webhook failed. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Questions, answers and versions A form collects answers that stay readable after the form changes. ### Steps 1. Stable keys, changing ids The key is what an answer refers to and it must not change when the question is edited. Files: `src/schema.sql` 2. Publish as an immutable version Editing a live form mid-collection is otherwise how responses become inconsistent. Files: `src/versions.ts` 3. Record the version on the response Reading old answers against a new form is a reporting bug waiting to happen. Files: `src/responses.ts` ### Done when - [ ] Five question types collect and store answers - [ ] Editing a published form creates a new version - [ ] Old responses read correctly after an edit - [ ] A response records its form version ### Watch out - Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. ## Phase 2 · Conditional logic that runs on both sides Questions appear based on earlier answers, and the server agrees. ### Steps 1. One evaluator, both sides Shared module. Two implementations diverge and the divergence is a security hole. Files: `src/logic/evaluate.ts`, `src/logic/evaluate.test.ts` 2. Enforce visibility on submission Reject answers to hidden questions. Test with a crafted post, not the browser. Files: `src/submit.ts` 3. Detect dead rules and cycles at publish Static analysis of the rule tree. A cycle at runtime hangs a respondent. Files: `src/logic/analyse.ts` ### Done when - [ ] A question appears only when its condition is met - [ ] A crafted post with a hidden answer is rejected - [ ] An unreachable rule is reported at publish - [ ] A rule cycle is refused ### Watch out - Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. ## Phase 3 · Partial responses that survive a closed tab Someone answers half a long form, comes back tomorrow and continues. ### Steps 1. Save progressively Per answer, not per page. A long form loses more than a page's worth otherwise. Files: `src/partial.ts` 2. Hash the resume token It is a bearer credential for personal information. Treat it like one. Files: `src/resume.ts` 3. Restore logic state too Which questions were visible, not just the values. Files: `src/partial.ts` ### Done when - [ ] A resume link restores answers and visibility - [ ] Only a token hash is stored - [ ] An expired token is refused clearly - [ ] Completion invalidates the token ### Watch out - A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. ## Phase 4 · Taking payment inside a form A form can charge, and a paid submission is never lost. ### Steps 1. Hosted Checkout only Card details on your server is a different project with a compliance scope. Files: `src/payments.ts` 2. Complete on the webhook Not the redirect. Test by killing the browser after paying. Files: `src/webhooks.ts` 3. Make webhook handling idempotent Keyed on the event id. Retries are normal. Files: `src/webhooks.ts`, `tests/webhook-idempotency.test.ts` ### Done when - [ ] Payment completes with no card details on your server - [ ] Killing the browser after paying still completes the response - [ ] A duplicate webhook creates one response - [ ] A failed payment leaves the response resumable ### Watch out - Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. ## Phase 5 · Getting answers where they need to go Notifications and webhooks fire reliably, and a failure never loses a submission. ### Steps 1. Outbox in the submission transaction The response and the intent to deliver commit together or not at all. Files: `src/outbox.ts` 2. Deliver in a worker with backoff Never in the request path. The respondent should not wait on someone else's server. Files: `src/worker.ts` 3. Sign outgoing webhooks A timestamped signature the receiver can verify. Files: `src/sign.ts` ### Done when - [ ] Response and deliveries commit atomically - [ ] A failing endpoint retries without affecting the response - [ ] Outgoing webhooks are signed and verifiable - [ ] A dead delivery is visible and retryable ### Watch out - Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. ## Not in this build - The visual builder that makes forms pleasant to construct. - Templates and themes. - Integrations with dozens of destinations. - Certified accessibility work. ## After v1, if you want it - Scheduling and quotas, which close a form on a condition rather than by hand - Response analytics per question, which is where abandonment becomes actionable - Multi-page forms with saved progress per page, once partials are trustworthy ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Contains respondents' answers. Treat the connection string and backups accordingly. DATABASE_URL=postgres://localhost:5432/forms # Required. How long a resume link works. It is a bearer credential, so shorter is safer. RESUME_TOKEN_TTL_HOURS=72 # Required. Must match what the form tells respondents. RESPONSE_RETENTION_DAYS=365 # Optional · secret. Only for paid forms. Test keys until you are certain the webhook path is right. STRIPE_SECRET_KEY=sk_test_... # Optional · secret. Verifies incoming events. An unverified webhook endpoint is an open endpoint. STRIPE_WEBHOOK_SECRET=whsec_... # Required · secret. Signs your outgoing webhooks so receivers can verify them. WEBHOOK_SIGNING_KEY=a random 32-byte hex string
You are building a production product version of Fillout. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Fillout · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Fillout, build a polished form that writes to a user-owned database and supports simple branching. The hard boundary is excellent airtable and notion integrations, scheduling, payments, and hosted polish, plus integrations, deliverability, and workflow depth. ## Product outcome A form builder whose logic is enforced where it matters, whose answers survive the form changing, and where a payment or a failing webhook never costs you a submission. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - Resend API key or SMTP credentials - self-hosted deploy target ## Explicit non-goals for v1 - The visual builder that makes forms pleasant to construct. - Templates and themes. - Integrations with dozens of destinations. - Certified accessibility work. - excellent Airtable and Notion integrations, scheduling, payments, and hosted polish - advanced branching - payment and CRM integrations - large template libraries - enterprise compliance ## Success criteria - A crafted post with a hidden-question answer is confirmed rejected - Old responses are confirmed readable after a form edit - Killing the browser after payment is confirmed to still complete the response - A failing webhook endpoint is confirmed not to affect submissions - Export is confirmed to include removed questions' answers ===== BRIEF.md ===== # Build brief · Fillout The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me conditional forms with logic, payments and a place the answers actually go to replace Fillout. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · forms are a database problem with a rendering layer, not the other way around - Logic: A declarative rule tree evaluated identically on client and server · logic that only runs in the browser is a suggestion - Storage: Answers as rows keyed by question, not a JSON blob · questions change and a blob makes every historical response unreadable - Payments: Stripe Checkout for paid forms · taking card details yourself turns a form builder into a compliance project - Delivery: An outbox with retries for webhooks and notifications · a submission is accepted before anything downstream is attempted ### Data model (create this before Phase 1) - forms(id, slug, title, status, version, published_at) · a published version is immutable and edits create a new version - questions(id, form_id, form_version, key, type, label, required, config_json, ordinal) · key is stable across versions, id is not - rules(id, form_id, form_version, target_question_id, condition_json, action) · action is show, hide or require - responses(id, form_id, form_version, started_at, submitted_at, resume_token_hash, ip_country) - answers(response_id, question_key, value_json, at) · keyed by the stable key so a response stays readable after the form changes - outbox(id, response_id, kind, payload_json, attempts, next_attempt_at, delivered_at, last_error) - A response records the form version it was submitted against, because reading old answers against a new form is how reporting goes wrong. ### Phase 1 · Questions, answers and versions Build: A form with several question types, rendering and submitting to storage. Answers keyed by a stable question key. Publishing snapshots an immutable version and a response records which version it answered. Trap: Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. Done when: A form with five question types collects and stores answers. Editing a published form creates a new version and old responses still read correctly, verified by editing a question label after a submission. Deleting a question leaves prior answers readable. A response names its form version. Do not build yet: No logic, validation or payments. ### Phase 2 · Conditional logic that runs on both sides Build: A declarative rule tree with conditions over answered values and show, hide or require actions. Evaluate the same rules on the client for rendering and on the server for acceptance. Detect rules that can never fire and cycles at publish time. Trap: Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. Done when: A question appears only when its condition is met. A direct post containing an answer to a hidden question is rejected by the server, tested with a crafted request. A rule that can never fire is reported at publish time. A cycle between two rules is refused rather than looping. Do not build yet: No partial saves or payments yet. ### Phase 3 · Partial responses that survive a closed tab Build: Save answers as they are given, issue a resume link with an unguessable token, and restore progress on return. Store only a hash of the token. Expire partials after a stated period and say so on the form. Trap: A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. Done when: Closing the tab and returning via the resume link restores every answer including hidden-question state. Only a hash of the token is in the database, checked by inspection. An expired token is refused with a clear message. A completed response invalidates its resume token. Do not build yet: No payments or delivery yet. ### Phase 4 · Taking payment inside a form Build: Use hosted Checkout so card details never reach you. Treat the response as submitted when the webhook confirms payment, not when the browser returns. Handle the respondent closing the tab after paying. Trap: Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. Done when: A paid form completes through hosted Checkout with no card details reaching your server. Killing the browser immediately after payment still produces a complete response, tested directly. A duplicate webhook does not create two responses. A failed payment leaves the response resumable rather than lost. Do not build yet: No delivery or exports yet. ### Phase 5 · Getting answers where they need to go Build: Write deliveries to an outbox in the same transaction as the response. A separate worker delivers with backoff. Sign outgoing webhooks. Show the delivery state per response and allow a manual retry. Trap: Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. Done when: A submission commits with its deliveries in one transaction. A failing webhook endpoint retries with backoff and the response is unaffected, tested against an endpoint returning errors. Outgoing webhooks are signed and verifiable. A permanently failed delivery is visible and manually retryable. Do not build yet: Exports and retention come last. ### Phase 6 · Exports, retention and what you promised Build: Export responses to CSV, handling questions that were added or removed across versions. Retention deleting responses past a period. A deletion request removing a response, its answers and its partials together. Trap: A CSV export that only includes the current version's questions silently drops answers to questions that were removed, and nobody notices because the file looks complete. Export the union of questions across the versions in range, with empty cells where a question did not exist. Done when: Export includes the union of questions across form versions with empty cells where a question did not exist, verified against a form edited mid-collection. Retention deletes past the period. A deletion request removes response, answers, partials and outbox rows. The form states its retention period to respondents. Do not build yet: Nothing after this. ### Out of scope (and why) - The visual builder that makes forms pleasant to construct, which is most of the hosted product's engineering and none of its interesting logic. - Templates and themes, which are volume work rather than difficulty. - Integrations with dozens of destinations, where each is small and the collection is enormous. - Accessibility work to a certified standard, which is real work you should do and which the hosted product has already paid for. ### README must contain - That answers are keyed by a stable question key so they survive form changes - That logic runs server-side on submission and a crafted post is rejected - That resume tokens are bearer credentials, stored hashed and expiring - That a paid response completes on the webhook rather than the redirect - That exports cover the union of questions across form versions ===== ARCHITECTURE.md ===== # Architecture · Fillout ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | forms are a database problem with a rendering layer, not the other way around | | Logic | A declarative rule tree evaluated identically on client and server | logic that only runs in the browser is a suggestion | | Storage | Answers as rows keyed by question, not a JSON blob | questions change and a blob makes every historical response unreadable | | Payments | Stripe Checkout for paid forms | taking card details yourself turns a form builder into a compliance project | | Delivery | An outbox with retries for webhooks and notifications | a submission is accepted before anything downstream is attempted | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Schema | Versioned forms and keyed answers. | Not swappable for a JSON blob, which is the shortcut that breaks reporting later. | | Logic | One evaluator for client and server. | Any implementation, provided both sides share it rather than reimplementing. | | Partials | Progressive saving and resume tokens. | Any store. The hashing and expiry are the requirements. | | Payments | Hosted Checkout and webhook completion. | Any processor with hosted checkout. Completing on the redirect is the failure mode to avoid. | | Outbox | Deliveries committed with the response. | Any queue, provided the intent commits in the submission transaction. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Contains respondents' answers. Treat the connection string and backups accordingly. - `RESUME_TOKEN_TTL_HOURS` · required · How long a resume link works. It is a bearer credential, so shorter is safer. - `RESPONSE_RETENTION_DAYS` · required · Must match what the form tells respondents. - `STRIPE_SECRET_KEY` · optional, secret · Only for paid forms. Test keys until you are certain the webhook path is right. - `STRIPE_WEBHOOK_SECRET` · optional, secret · Verifies incoming events. An unverified webhook endpoint is an open endpoint. - `WEBHOOK_SIGNING_KEY` · required, secret · Signs your outgoing webhooks so receivers can verify them. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Fillout product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, A declarative rule tree evaluated identically on client and server, Answers as rows keyed by question, not a JSON blob, Stripe Checkout for paid forms, An outbox with retries for webhooks and notifications. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. - Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. - A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. - Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. - Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. - A CSV export that only includes the current version's questions silently drops answers to questions that were removed, and nobody notices because the file looks complete. Export the union of questions across the versions in range, with empty cells where a question did not exist. ===== MILESTONES.md ===== # Delivery milestones · Fillout Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Questions, answers and versions A form collects answers that stay readable after the form changes. ### Steps 1. Stable keys, changing ids The key is what an answer refers to and it must not change when the question is edited. Files: `src/schema.sql` 2. Publish as an immutable version Editing a live form mid-collection is otherwise how responses become inconsistent. Files: `src/versions.ts` 3. Record the version on the response Reading old answers against a new form is a reporting bug waiting to happen. Files: `src/responses.ts` ### Done when - [ ] Five question types collect and store answers - [ ] Editing a published form creates a new version - [ ] Old responses read correctly after an edit - [ ] A response records its form version ### Watch out - Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. ## M2 · Conditional logic that runs on both sides Questions appear based on earlier answers, and the server agrees. ### Steps 1. One evaluator, both sides Shared module. Two implementations diverge and the divergence is a security hole. Files: `src/logic/evaluate.ts`, `src/logic/evaluate.test.ts` 2. Enforce visibility on submission Reject answers to hidden questions. Test with a crafted post, not the browser. Files: `src/submit.ts` 3. Detect dead rules and cycles at publish Static analysis of the rule tree. A cycle at runtime hangs a respondent. Files: `src/logic/analyse.ts` ### Done when - [ ] A question appears only when its condition is met - [ ] A crafted post with a hidden answer is rejected - [ ] An unreachable rule is reported at publish - [ ] A rule cycle is refused ### Watch out - Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. ## M3 · Partial responses that survive a closed tab Someone answers half a long form, comes back tomorrow and continues. ### Steps 1. Save progressively Per answer, not per page. A long form loses more than a page's worth otherwise. Files: `src/partial.ts` 2. Hash the resume token It is a bearer credential for personal information. Treat it like one. Files: `src/resume.ts` 3. Restore logic state too Which questions were visible, not just the values. Files: `src/partial.ts` ### Done when - [ ] A resume link restores answers and visibility - [ ] Only a token hash is stored - [ ] An expired token is refused clearly - [ ] Completion invalidates the token ### Watch out - A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. ## M4 · Taking payment inside a form A form can charge, and a paid submission is never lost. ### Steps 1. Hosted Checkout only Card details on your server is a different project with a compliance scope. Files: `src/payments.ts` 2. Complete on the webhook Not the redirect. Test by killing the browser after paying. Files: `src/webhooks.ts` 3. Make webhook handling idempotent Keyed on the event id. Retries are normal. Files: `src/webhooks.ts`, `tests/webhook-idempotency.test.ts` ### Done when - [ ] Payment completes with no card details on your server - [ ] Killing the browser after paying still completes the response - [ ] A duplicate webhook creates one response - [ ] A failed payment leaves the response resumable ### Watch out - Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. ## M5 · Getting answers where they need to go Notifications and webhooks fire reliably, and a failure never loses a submission. ### Steps 1. Outbox in the submission transaction The response and the intent to deliver commit together or not at all. Files: `src/outbox.ts` 2. Deliver in a worker with backoff Never in the request path. The respondent should not wait on someone else's server. Files: `src/worker.ts` 3. Sign outgoing webhooks A timestamped signature the receiver can verify. Files: `src/sign.ts` ### Done when - [ ] Response and deliveries commit atomically - [ ] A failing endpoint retries without affecting the response - [ ] Outgoing webhooks are signed and verifiable - [ ] A dead delivery is visible and retryable ### Watch out - Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. ## M6 · Exports, retention and what you promised (production only) Answers come out cleanly and are deleted when they should be. ### Steps 1. Export the union across versions Empty cells where a question did not exist. Dropping columns silently loses data. Files: `src/export.ts` 2. Delete completely Answers, partials, outbox payloads. A payload in the outbox is a copy of the response. Files: `src/deletion.ts` 3. State retention on the form If you tell respondents how long you keep answers, the job has to actually run. Files: `src/retention.ts` ### Done when - [ ] Export covers the union of questions across versions - [ ] Retention deletes past the period - [ ] Deletion removes answers, partials and outbox rows - [ ] The stated retention matches what the job does ### Watch out - A CSV export that only includes the current version's questions silently drops answers to questions that were removed, and nobody notices because the file looks complete. Export the union of questions across the versions in range, with empty cells where a question did not exist. ===== OPERATIONS.md ===== # Operations · Fillout ## Backup Responses are irreplaceable and often personal. Encrypted backups, tested restores, and retention applied to backups too rather than keeping data you promised to delete. ## Restore Restore and confirm answers read correctly against their recorded form versions, and that retention resumes. A restore that reinstates deleted responses undoes a deletion request. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Outbox depth and oldest undelivered age, partial abandonment rate, and payment webhook failures. A rising abandonment rate on one question usually means that question is broken rather than that respondents lost interest. ## Incident checklist A paid submission with no response record is the worst case and points at redirect-based completion. Webhook deliveries failing to one endpoint should never affect submissions, and if they do, the delivery is still in the request path. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A crafted post with a hidden-question answer is confirmed rejected - [ ] Old responses are confirmed readable after a form edit - [ ] Killing the browser after payment is confirmed to still complete the response - [ ] A failing webhook endpoint is confirmed not to affect submissions - [ ] Export is confirmed to include removed questions' answers ## Launch constraint Do not market omitted Fillout capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Contains respondents' answers. Treat the connection string and backups accordingly. DATABASE_URL=postgres://localhost:5432/forms # Required. How long a resume link works. It is a bearer credential, so shorter is safer. RESUME_TOKEN_TTL_HOURS=72 # Required. Must match what the form tells respondents. RESPONSE_RETENTION_DAYS=365 # Optional · secret. Only for paid forms. Test keys until you are certain the webhook path is right. STRIPE_SECRET_KEY=sk_test_... # Optional · secret. Verifies incoming events. An unverified webhook endpoint is an open endpoint. STRIPE_WEBHOOK_SECRET=whsec_... # Required · secret. Signs your outgoing webhooks so receivers can verify them. WEBHOOK_SIGNING_KEY=a random 32-byte hex string
# Fillout · indie build A form builder where the interesting part is the logic engine: questions that appear based on earlier answers, validation that runs the same rules on both sides, partial responses that survive a closed tab, and a submission pipeline that never loses an answer because a webhook failed. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | forms are a database problem with a rendering layer, not the other way around | | Logic | A declarative rule tree evaluated identically on client and server | logic that only runs in the browser is a suggestion | | Storage | Answers as rows keyed by question, not a JSON blob | questions change and a blob makes every historical response unreadable | | Payments | Stripe Checkout for paid forms | taking card details yourself turns a form builder into a compliance project | | Delivery | An outbox with retries for webhooks and notifications | a submission is accepted before anything downstream is attempted | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and shares the logic evaluator with the client. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Versioned questions, keyed answers and an outbox all want a real relational database. - Get it: Docker or your package manager. - Verify: psql connects and creates a table - [ ] **A Stripe account in test mode** (optional) · free to test; per-transaction in production - Why: Phase 4 only. Hosted Checkout keeps card details off your server entirely. - Get it: Sign up, use test keys, and configure a webhook endpoint with its signing secret. - Verify: A test Checkout session completes and the webhook is received and verified - [ ] **A way to receive webhooks locally** · free - Why: Both payment and delivery testing need real webhooks reaching your machine. - Get it: The Stripe CLI forwards events, or use a tunnelling tool. - Verify: A test event arrives at your local endpoint and verifies - [ ] **Your retention period and what forms will collect** · free - Why: Retention is stated on the form and enforced by a job, and the two have to match. Some question types make a form collect data with a much higher duty of care. - Get it: Decide the period, decide what you will refuse to collect, and write both down before building. - Verify: A written note states the period and the categories you will not collect ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The visual builder that makes forms pleasant to construct. - Templates and themes. - Integrations with dozens of destinations. - Certified accessibility work. - excellent Airtable and Notion integrations, scheduling, payments, and hosted polish - advanced branching - payment and CRM integrations - large template libraries - enterprise compliance If one of those is essential to you, that is the reason to keep paying for Fillout, and the README should say so rather than pretend.
# Build brief · Fillout The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me conditional forms with logic, payments and a place the answers actually go to replace Fillout. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · forms are a database problem with a rendering layer, not the other way around - Logic: A declarative rule tree evaluated identically on client and server · logic that only runs in the browser is a suggestion - Storage: Answers as rows keyed by question, not a JSON blob · questions change and a blob makes every historical response unreadable - Payments: Stripe Checkout for paid forms · taking card details yourself turns a form builder into a compliance project - Delivery: An outbox with retries for webhooks and notifications · a submission is accepted before anything downstream is attempted ### Data model (create this before Phase 1) - forms(id, slug, title, status, version, published_at) · a published version is immutable and edits create a new version - questions(id, form_id, form_version, key, type, label, required, config_json, ordinal) · key is stable across versions, id is not - rules(id, form_id, form_version, target_question_id, condition_json, action) · action is show, hide or require - responses(id, form_id, form_version, started_at, submitted_at, resume_token_hash, ip_country) - answers(response_id, question_key, value_json, at) · keyed by the stable key so a response stays readable after the form changes - outbox(id, response_id, kind, payload_json, attempts, next_attempt_at, delivered_at, last_error) - A response records the form version it was submitted against, because reading old answers against a new form is how reporting goes wrong. ### Phase 1 · Questions, answers and versions Build: A form with several question types, rendering and submitting to storage. Answers keyed by a stable question key. Publishing snapshots an immutable version and a response records which version it answered. Trap: Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. Done when: A form with five question types collects and stores answers. Editing a published form creates a new version and old responses still read correctly, verified by editing a question label after a submission. Deleting a question leaves prior answers readable. A response names its form version. Do not build yet: No logic, validation or payments. ### Phase 2 · Conditional logic that runs on both sides Build: A declarative rule tree with conditions over answered values and show, hide or require actions. Evaluate the same rules on the client for rendering and on the server for acceptance. Detect rules that can never fire and cycles at publish time. Trap: Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. Done when: A question appears only when its condition is met. A direct post containing an answer to a hidden question is rejected by the server, tested with a crafted request. A rule that can never fire is reported at publish time. A cycle between two rules is refused rather than looping. Do not build yet: No partial saves or payments yet. ### Phase 3 · Partial responses that survive a closed tab Build: Save answers as they are given, issue a resume link with an unguessable token, and restore progress on return. Store only a hash of the token. Expire partials after a stated period and say so on the form. Trap: A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. Done when: Closing the tab and returning via the resume link restores every answer including hidden-question state. Only a hash of the token is in the database, checked by inspection. An expired token is refused with a clear message. A completed response invalidates its resume token. Do not build yet: No payments or delivery yet. ### Phase 4 · Taking payment inside a form Build: Use hosted Checkout so card details never reach you. Treat the response as submitted when the webhook confirms payment, not when the browser returns. Handle the respondent closing the tab after paying. Trap: Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. Done when: A paid form completes through hosted Checkout with no card details reaching your server. Killing the browser immediately after payment still produces a complete response, tested directly. A duplicate webhook does not create two responses. A failed payment leaves the response resumable rather than lost. Do not build yet: No delivery or exports yet. ### Phase 5 · Getting answers where they need to go Build: Write deliveries to an outbox in the same transaction as the response. A separate worker delivers with backoff. Sign outgoing webhooks. Show the delivery state per response and allow a manual retry. Trap: Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. Done when: A submission commits with its deliveries in one transaction. A failing webhook endpoint retries with backoff and the response is unaffected, tested against an endpoint returning errors. Outgoing webhooks are signed and verifiable. A permanently failed delivery is visible and manually retryable. Do not build yet: Exports and retention come last. ### Phase 6 · Exports, retention and what you promised Build: Export responses to CSV, handling questions that were added or removed across versions. Retention deleting responses past a period. A deletion request removing a response, its answers and its partials together. Trap: A CSV export that only includes the current version's questions silently drops answers to questions that were removed, and nobody notices because the file looks complete. Export the union of questions across the versions in range, with empty cells where a question did not exist. Done when: Export includes the union of questions across form versions with empty cells where a question did not exist, verified against a form edited mid-collection. Retention deletes past the period. A deletion request removes response, answers, partials and outbox rows. The form states its retention period to respondents. Do not build yet: Nothing after this. ### Out of scope (and why) - The visual builder that makes forms pleasant to construct, which is most of the hosted product's engineering and none of its interesting logic. - Templates and themes, which are volume work rather than difficulty. - Integrations with dozens of destinations, where each is small and the collection is enormous. - Accessibility work to a certified standard, which is real work you should do and which the hosted product has already paid for. ### README must contain - That answers are keyed by a stable question key so they survive form changes - That logic runs server-side on submission and a crafted post is rejected - That resume tokens are bearer credentials, stored hashed and expiring - That a paid response completes on the webhook rather than the redirect - That exports cover the union of questions across form versions
# Agent instructions · Fillout indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, A declarative rule tree evaluated identically on client and server, Answers as rows keyed by question, not a JSON blob, Stripe Checkout for paid forms, An outbox with retries for webhooks and notifications. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. - Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. - A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. - Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. - Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately.
# Build plan · Fillout A form builder where the interesting part is the logic engine: questions that appear based on earlier answers, validation that runs the same rules on both sides, partial responses that survive a closed tab, and a submission pipeline that never loses an answer because a webhook failed. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Questions, answers and versions A form collects answers that stay readable after the form changes. ### Steps 1. Stable keys, changing ids The key is what an answer refers to and it must not change when the question is edited. Files: `src/schema.sql` 2. Publish as an immutable version Editing a live form mid-collection is otherwise how responses become inconsistent. Files: `src/versions.ts` 3. Record the version on the response Reading old answers against a new form is a reporting bug waiting to happen. Files: `src/responses.ts` ### Done when - [ ] Five question types collect and store answers - [ ] Editing a published form creates a new version - [ ] Old responses read correctly after an edit - [ ] A response records its form version ### Watch out - Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. ## Phase 2 · Conditional logic that runs on both sides Questions appear based on earlier answers, and the server agrees. ### Steps 1. One evaluator, both sides Shared module. Two implementations diverge and the divergence is a security hole. Files: `src/logic/evaluate.ts`, `src/logic/evaluate.test.ts` 2. Enforce visibility on submission Reject answers to hidden questions. Test with a crafted post, not the browser. Files: `src/submit.ts` 3. Detect dead rules and cycles at publish Static analysis of the rule tree. A cycle at runtime hangs a respondent. Files: `src/logic/analyse.ts` ### Done when - [ ] A question appears only when its condition is met - [ ] A crafted post with a hidden answer is rejected - [ ] An unreachable rule is reported at publish - [ ] A rule cycle is refused ### Watch out - Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. ## Phase 3 · Partial responses that survive a closed tab Someone answers half a long form, comes back tomorrow and continues. ### Steps 1. Save progressively Per answer, not per page. A long form loses more than a page's worth otherwise. Files: `src/partial.ts` 2. Hash the resume token It is a bearer credential for personal information. Treat it like one. Files: `src/resume.ts` 3. Restore logic state too Which questions were visible, not just the values. Files: `src/partial.ts` ### Done when - [ ] A resume link restores answers and visibility - [ ] Only a token hash is stored - [ ] An expired token is refused clearly - [ ] Completion invalidates the token ### Watch out - A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. ## Phase 4 · Taking payment inside a form A form can charge, and a paid submission is never lost. ### Steps 1. Hosted Checkout only Card details on your server is a different project with a compliance scope. Files: `src/payments.ts` 2. Complete on the webhook Not the redirect. Test by killing the browser after paying. Files: `src/webhooks.ts` 3. Make webhook handling idempotent Keyed on the event id. Retries are normal. Files: `src/webhooks.ts`, `tests/webhook-idempotency.test.ts` ### Done when - [ ] Payment completes with no card details on your server - [ ] Killing the browser after paying still completes the response - [ ] A duplicate webhook creates one response - [ ] A failed payment leaves the response resumable ### Watch out - Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. ## Phase 5 · Getting answers where they need to go Notifications and webhooks fire reliably, and a failure never loses a submission. ### Steps 1. Outbox in the submission transaction The response and the intent to deliver commit together or not at all. Files: `src/outbox.ts` 2. Deliver in a worker with backoff Never in the request path. The respondent should not wait on someone else's server. Files: `src/worker.ts` 3. Sign outgoing webhooks A timestamped signature the receiver can verify. Files: `src/sign.ts` ### Done when - [ ] Response and deliveries commit atomically - [ ] A failing endpoint retries without affecting the response - [ ] Outgoing webhooks are signed and verifiable - [ ] A dead delivery is visible and retryable ### Watch out - Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. ## Not in this build - The visual builder that makes forms pleasant to construct. - Templates and themes. - Integrations with dozens of destinations. - Certified accessibility work. ## After v1, if you want it - Scheduling and quotas, which close a form on a condition rather than by hand - Response analytics per question, which is where abandonment becomes actionable - Multi-page forms with saved progress per page, once partials are trustworthy
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Contains respondents' answers. Treat the connection string and backups accordingly. DATABASE_URL=postgres://localhost:5432/forms # Required. How long a resume link works. It is a bearer credential, so shorter is safer. RESUME_TOKEN_TTL_HOURS=72 # Required. Must match what the form tells respondents. RESPONSE_RETENTION_DAYS=365 # Optional · secret. Only for paid forms. Test keys until you are certain the webhook path is right. STRIPE_SECRET_KEY=sk_test_... # Optional · secret. Verifies incoming events. An unverified webhook endpoint is an open endpoint. STRIPE_WEBHOOK_SECRET=whsec_... # Required · secret. Signs your outgoing webhooks so receivers can verify them. WEBHOOK_SIGNING_KEY=a random 32-byte hex string
# Fillout · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Fillout, build a polished form that writes to a user-owned database and supports simple branching. The hard boundary is excellent airtable and notion integrations, scheduling, payments, and hosted polish, plus integrations, deliverability, and workflow depth. ## Product outcome A form builder whose logic is enforced where it matters, whose answers survive the form changing, and where a payment or a failing webhook never costs you a submission. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - Resend API key or SMTP credentials - self-hosted deploy target ## Explicit non-goals for v1 - The visual builder that makes forms pleasant to construct. - Templates and themes. - Integrations with dozens of destinations. - Certified accessibility work. - excellent Airtable and Notion integrations, scheduling, payments, and hosted polish - advanced branching - payment and CRM integrations - large template libraries - enterprise compliance ## Success criteria - A crafted post with a hidden-question answer is confirmed rejected - Old responses are confirmed readable after a form edit - Killing the browser after payment is confirmed to still complete the response - A failing webhook endpoint is confirmed not to affect submissions - Export is confirmed to include removed questions' answers
# Build brief · Fillout The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me conditional forms with logic, payments and a place the answers actually go to replace Fillout. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · forms are a database problem with a rendering layer, not the other way around - Logic: A declarative rule tree evaluated identically on client and server · logic that only runs in the browser is a suggestion - Storage: Answers as rows keyed by question, not a JSON blob · questions change and a blob makes every historical response unreadable - Payments: Stripe Checkout for paid forms · taking card details yourself turns a form builder into a compliance project - Delivery: An outbox with retries for webhooks and notifications · a submission is accepted before anything downstream is attempted ### Data model (create this before Phase 1) - forms(id, slug, title, status, version, published_at) · a published version is immutable and edits create a new version - questions(id, form_id, form_version, key, type, label, required, config_json, ordinal) · key is stable across versions, id is not - rules(id, form_id, form_version, target_question_id, condition_json, action) · action is show, hide or require - responses(id, form_id, form_version, started_at, submitted_at, resume_token_hash, ip_country) - answers(response_id, question_key, value_json, at) · keyed by the stable key so a response stays readable after the form changes - outbox(id, response_id, kind, payload_json, attempts, next_attempt_at, delivered_at, last_error) - A response records the form version it was submitted against, because reading old answers against a new form is how reporting goes wrong. ### Phase 1 · Questions, answers and versions Build: A form with several question types, rendering and submitting to storage. Answers keyed by a stable question key. Publishing snapshots an immutable version and a response records which version it answered. Trap: Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. Done when: A form with five question types collects and stores answers. Editing a published form creates a new version and old responses still read correctly, verified by editing a question label after a submission. Deleting a question leaves prior answers readable. A response names its form version. Do not build yet: No logic, validation or payments. ### Phase 2 · Conditional logic that runs on both sides Build: A declarative rule tree with conditions over answered values and show, hide or require actions. Evaluate the same rules on the client for rendering and on the server for acceptance. Detect rules that can never fire and cycles at publish time. Trap: Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. Done when: A question appears only when its condition is met. A direct post containing an answer to a hidden question is rejected by the server, tested with a crafted request. A rule that can never fire is reported at publish time. A cycle between two rules is refused rather than looping. Do not build yet: No partial saves or payments yet. ### Phase 3 · Partial responses that survive a closed tab Build: Save answers as they are given, issue a resume link with an unguessable token, and restore progress on return. Store only a hash of the token. Expire partials after a stated period and say so on the form. Trap: A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. Done when: Closing the tab and returning via the resume link restores every answer including hidden-question state. Only a hash of the token is in the database, checked by inspection. An expired token is refused with a clear message. A completed response invalidates its resume token. Do not build yet: No payments or delivery yet. ### Phase 4 · Taking payment inside a form Build: Use hosted Checkout so card details never reach you. Treat the response as submitted when the webhook confirms payment, not when the browser returns. Handle the respondent closing the tab after paying. Trap: Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. Done when: A paid form completes through hosted Checkout with no card details reaching your server. Killing the browser immediately after payment still produces a complete response, tested directly. A duplicate webhook does not create two responses. A failed payment leaves the response resumable rather than lost. Do not build yet: No delivery or exports yet. ### Phase 5 · Getting answers where they need to go Build: Write deliveries to an outbox in the same transaction as the response. A separate worker delivers with backoff. Sign outgoing webhooks. Show the delivery state per response and allow a manual retry. Trap: Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. Done when: A submission commits with its deliveries in one transaction. A failing webhook endpoint retries with backoff and the response is unaffected, tested against an endpoint returning errors. Outgoing webhooks are signed and verifiable. A permanently failed delivery is visible and manually retryable. Do not build yet: Exports and retention come last. ### Phase 6 · Exports, retention and what you promised Build: Export responses to CSV, handling questions that were added or removed across versions. Retention deleting responses past a period. A deletion request removing a response, its answers and its partials together. Trap: A CSV export that only includes the current version's questions silently drops answers to questions that were removed, and nobody notices because the file looks complete. Export the union of questions across the versions in range, with empty cells where a question did not exist. Done when: Export includes the union of questions across form versions with empty cells where a question did not exist, verified against a form edited mid-collection. Retention deletes past the period. A deletion request removes response, answers, partials and outbox rows. The form states its retention period to respondents. Do not build yet: Nothing after this. ### Out of scope (and why) - The visual builder that makes forms pleasant to construct, which is most of the hosted product's engineering and none of its interesting logic. - Templates and themes, which are volume work rather than difficulty. - Integrations with dozens of destinations, where each is small and the collection is enormous. - Accessibility work to a certified standard, which is real work you should do and which the hosted product has already paid for. ### README must contain - That answers are keyed by a stable question key so they survive form changes - That logic runs server-side on submission and a crafted post is rejected - That resume tokens are bearer credentials, stored hashed and expiring - That a paid response completes on the webhook rather than the redirect - That exports cover the union of questions across form versions
# Architecture · Fillout ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | forms are a database problem with a rendering layer, not the other way around | | Logic | A declarative rule tree evaluated identically on client and server | logic that only runs in the browser is a suggestion | | Storage | Answers as rows keyed by question, not a JSON blob | questions change and a blob makes every historical response unreadable | | Payments | Stripe Checkout for paid forms | taking card details yourself turns a form builder into a compliance project | | Delivery | An outbox with retries for webhooks and notifications | a submission is accepted before anything downstream is attempted | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Schema | Versioned forms and keyed answers. | Not swappable for a JSON blob, which is the shortcut that breaks reporting later. | | Logic | One evaluator for client and server. | Any implementation, provided both sides share it rather than reimplementing. | | Partials | Progressive saving and resume tokens. | Any store. The hashing and expiry are the requirements. | | Payments | Hosted Checkout and webhook completion. | Any processor with hosted checkout. Completing on the redirect is the failure mode to avoid. | | Outbox | Deliveries committed with the response. | Any queue, provided the intent commits in the submission transaction. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Contains respondents' answers. Treat the connection string and backups accordingly. - `RESUME_TOKEN_TTL_HOURS` · required · How long a resume link works. It is a bearer credential, so shorter is safer. - `RESPONSE_RETENTION_DAYS` · required · Must match what the form tells respondents. - `STRIPE_SECRET_KEY` · optional, secret · Only for paid forms. Test keys until you are certain the webhook path is right. - `STRIPE_WEBHOOK_SECRET` · optional, secret · Verifies incoming events. An unverified webhook endpoint is an open endpoint. - `WEBHOOK_SIGNING_KEY` · required, secret · Signs your outgoing webhooks so receivers can verify them. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Fillout product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, A declarative rule tree evaluated identically on client and server, Answers as rows keyed by question, not a JSON blob, Stripe Checkout for paid forms, An outbox with retries for webhooks and notifications. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. - Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. - A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. - Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. - Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. - A CSV export that only includes the current version's questions silently drops answers to questions that were removed, and nobody notices because the file looks complete. Export the union of questions across the versions in range, with empty cells where a question did not exist.
# Delivery milestones · Fillout Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Questions, answers and versions A form collects answers that stay readable after the form changes. ### Steps 1. Stable keys, changing ids The key is what an answer refers to and it must not change when the question is edited. Files: `src/schema.sql` 2. Publish as an immutable version Editing a live form mid-collection is otherwise how responses become inconsistent. Files: `src/versions.ts` 3. Record the version on the response Reading old answers against a new form is a reporting bug waiting to happen. Files: `src/responses.ts` ### Done when - [ ] Five question types collect and store answers - [ ] Editing a published form creates a new version - [ ] Old responses read correctly after an edit - [ ] A response records its form version ### Watch out - Storing answers as a JSON blob against the current form means renaming or deleting a question silently breaks every historical response, and this is discovered months later when someone runs a report. Store answers as rows keyed by a stable key and record the form version. ## M2 · Conditional logic that runs on both sides Questions appear based on earlier answers, and the server agrees. ### Steps 1. One evaluator, both sides Shared module. Two implementations diverge and the divergence is a security hole. Files: `src/logic/evaluate.ts`, `src/logic/evaluate.test.ts` 2. Enforce visibility on submission Reject answers to hidden questions. Test with a crafted post, not the browser. Files: `src/submit.ts` 3. Detect dead rules and cycles at publish Static analysis of the rule tree. A cycle at runtime hangs a respondent. Files: `src/logic/analyse.ts` ### Done when - [ ] A question appears only when its condition is met - [ ] A crafted post with a hidden answer is rejected - [ ] An unreachable rule is reported at publish - [ ] A rule cycle is refused ### Watch out - Logic evaluated only in the browser means a submitted response can contain answers to questions that should have been hidden, or omit ones that should have been required, because anyone can post directly to the endpoint. Share one evaluator and run it server-side on submission. ## M3 · Partial responses that survive a closed tab Someone answers half a long form, comes back tomorrow and continues. ### Steps 1. Save progressively Per answer, not per page. A long form loses more than a page's worth otherwise. Files: `src/partial.ts` 2. Hash the resume token It is a bearer credential for personal information. Treat it like one. Files: `src/resume.ts` 3. Restore logic state too Which questions were visible, not just the values. Files: `src/partial.ts` ### Done when - [ ] A resume link restores answers and visibility - [ ] Only a token hash is stored - [ ] An expired token is refused clearly - [ ] Completion invalidates the token ### Watch out - A resume link is a bearer credential granting access to whatever has already been answered, which on many forms is personal information. Store a hash rather than the token, keep the expiry short and stated, and never put the token in a page the respondent might share. ## M4 · Taking payment inside a form A form can charge, and a paid submission is never lost. ### Steps 1. Hosted Checkout only Card details on your server is a different project with a compliance scope. Files: `src/payments.ts` 2. Complete on the webhook Not the redirect. Test by killing the browser after paying. Files: `src/webhooks.ts` 3. Make webhook handling idempotent Keyed on the event id. Retries are normal. Files: `src/webhooks.ts`, `tests/webhook-idempotency.test.ts` ### Done when - [ ] Payment completes with no card details on your server - [ ] Killing the browser after paying still completes the response - [ ] A duplicate webhook creates one response - [ ] A failed payment leaves the response resumable ### Watch out - Marking the response complete on the browser redirect loses submissions whenever someone pays and closes the tab before the redirect completes, which is a paid customer with no record of their answers. The webhook is the source of truth and the redirect is only a convenience. ## M5 · Getting answers where they need to go Notifications and webhooks fire reliably, and a failure never loses a submission. ### Steps 1. Outbox in the submission transaction The response and the intent to deliver commit together or not at all. Files: `src/outbox.ts` 2. Deliver in a worker with backoff Never in the request path. The respondent should not wait on someone else's server. Files: `src/worker.ts` 3. Sign outgoing webhooks A timestamped signature the receiver can verify. Files: `src/sign.ts` ### Done when - [ ] Response and deliveries commit atomically - [ ] A failing endpoint retries without affecting the response - [ ] Outgoing webhooks are signed and verifiable - [ ] A dead delivery is visible and retryable ### Watch out - Calling a webhook inside the submission request means a slow or failing endpoint either delays the respondent or loses the delivery entirely when the request times out. Commit the response and the delivery intent together, then deliver separately. ## M6 · Exports, retention and what you promised (production only) Answers come out cleanly and are deleted when they should be. ### Steps 1. Export the union across versions Empty cells where a question did not exist. Dropping columns silently loses data. Files: `src/export.ts` 2. Delete completely Answers, partials, outbox payloads. A payload in the outbox is a copy of the response. Files: `src/deletion.ts` 3. State retention on the form If you tell respondents how long you keep answers, the job has to actually run. Files: `src/retention.ts` ### Done when - [ ] Export covers the union of questions across versions - [ ] Retention deletes past the period - [ ] Deletion removes answers, partials and outbox rows - [ ] The stated retention matches what the job does ### Watch out - A CSV export that only includes the current version's questions silently drops answers to questions that were removed, and nobody notices because the file looks complete. Export the union of questions across the versions in range, with empty cells where a question did not exist.
# Operations · Fillout ## Backup Responses are irreplaceable and often personal. Encrypted backups, tested restores, and retention applied to backups too rather than keeping data you promised to delete. ## Restore Restore and confirm answers read correctly against their recorded form versions, and that retention resumes. A restore that reinstates deleted responses undoes a deletion request. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Outbox depth and oldest undelivered age, partial abandonment rate, and payment webhook failures. A rising abandonment rate on one question usually means that question is broken rather than that respondents lost interest. ## Incident checklist A paid submission with no response record is the worst case and points at redirect-based completion. Webhook deliveries failing to one endpoint should never affect submissions, and if they do, the delivery is still in the request path. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A crafted post with a hidden-question answer is confirmed rejected - [ ] Old responses are confirmed readable after a form edit - [ ] Killing the browser after payment is confirmed to still complete the response - [ ] A failing webhook endpoint is confirmed not to affect submissions - [ ] Export is confirmed to include removed questions' answers ## Launch constraint Do not market omitted Fillout capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Contains respondents' answers. Treat the connection string and backups accordingly. DATABASE_URL=postgres://localhost:5432/forms # Required. How long a resume link works. It is a bearer credential, so shorter is safer. RESUME_TOKEN_TTL_HOURS=72 # Required. Must match what the form tells respondents. RESPONSE_RETENTION_DAYS=365 # Optional · secret. Only for paid forms. Test keys until you are certain the webhook path is right. STRIPE_SECRET_KEY=sk_test_... # Optional · secret. Verifies incoming events. An unverified webhook endpoint is an open endpoint. STRIPE_WEBHOOK_SECRET=whsec_... # Required · secret. Signs your outgoing webhooks so receivers can verify them. WEBHOOK_SIGNING_KEY=a random 32-byte hex string
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Fillout because a basic form is trivial; people pay when the form feeds a dependable business workflow and never loses a response. The recurring cost buys spam control, email delivery, storage, exports, accessibility, conditional logic, and integrations, not just the visible interface.
xexcellent Airtable and Notion integrations, scheduling, payments, and hosted polish
xadvanced branching
xpayment and CRM integrations
xlarge template libraries
xenterprise compliance
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Fillout pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | Unlimited forms, seats, questions and file uploads; 1,000 responses/month; 20MB maximum file size; Fillout branding |
| starter | $19/workspace | $15/workspace | 2,000 responses/month and 20MB maximum file size; unlimited forms, seats, questions and uploads |
| pro | $49/workspace | $40/workspace | 5,000 responses/month; unlimited forms and seats |
| business | $89/workspace | $75/workspace | Unlimited responses, at least 1GB maximum file size, custom domain, unlimited forms and seats |
| enterprise | custom | — | 1 million+ database records, unlimited workflow runs, unlimited forms, seats and responses |
free tierUnlimited forms, seats, questions and file uploads; 1,000 responses/month; 20MB maximum file size; Fillout branding
billingmonthly + annual prepaid (-20%)
hidden costsFillout says responses continue to be collected after the monthly cap, but the account must upgrade, buy unpublished bulk-submission capacity or wait for reset to use them; Conversion Kit is a separately sold add-on with no public rate.
verified 2026-08-12 · source ↗
Vibecode Fillout
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Fillout replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Fillout cost?
Fillout costs about $19/month (Starter, checked 2026-07-31), which is $228 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Fillout?
Honestly: excellent Airtable and Notion integrations, scheduling, payments, and hosted polish; advanced branching; payment and CRM integrations; large template libraries; enterprise compliance. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Fillout?
Yes: Baserow (Airtable's database half with public forms and your own disk underneath) The prompt is for when you want it exactly your way.