Vibecode Heyflow
track this build4 phases, 12 steps, beginner friendly0%The visible interactive form flows loop is buildable, but a credible replacement needs more than the first screen. Heyflow earns its keep through delivery, integrations, compliance, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Heyflow. 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 ===== # Heyflow · indie build A multi-step form builder whose hard problems are immutable published versions so old responses keep their meaning, branching that cannot dead-end, partial answers that survive a closed tab, and delivery that happens once per response. Estimated effort: **weekend**. 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 | Next.js 15 with TypeScript, one process, server-rendered public flow | the public form must work before any client script runs | | Storage | SQLite with responses written in a single transaction | the response is the product and the database is where it lands first | | Versioning | Published form versions are immutable rows, drafts are separate | a response means what it meant on the day it was given | | Logic | Branch conditions evaluated on the server with a reachability check at publish | a dead end in a flow is a form nobody can finish | | Delivery | A queue with retries, signatures and an idempotency key per response | an integration failing must never cost you the answer | ## 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 builder and the public flow in one process. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A small host with a domain and TLS** · a few pounds a month - Why: The public flow has to be reachable from a phone on a mobile network, and a form only tested on localhost has not been tested. - Get it: Any small virtual machine or platform that terminates TLS for you. - Verify: The published form loads on a phone over mobile data - [ ] **An SMTP or transactional email credential** (optional) · free tier for low volume - Why: Notification and confirmation email is the one integration people notice immediately when it stops. - Get it: Any provider you already use, or a local test server while developing. - Verify: A test submission produces a notification you receive - [ ] **A webhook receiver you control** · free - Why: Delivering once through timeouts and retries is only testable against a receiver you can make fail on purpose. - Get it: A tiny endpoint that logs calls and can be told to time out or return an error. - Verify: It records exactly one call for one submission after a forced timeout - [ ] **Retention for partials, uploads and responses** · free - Why: Each is enforced by a job and asserted by a test, and deciding it after launch means deciding it under pressure. - Get it: Write down how long partials, submitted responses and uploaded files are kept, and what deletion means. - Verify: All three appear in configuration and in the README ## 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: - Payments inside the flow. - A template and integration catalogue. - Team accounts and approvals. - Any deliverability guarantee. - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic - spam and abuse defense at scale If one of those is essential to you, that is the reason to keep paying for Heyflow, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Heyflow 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 a multi-step form flow that keeps partial answers and delivers every submission once to replace Heyflow. 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. ### Rule zero A response that reaches your server is never lost and never reinterpreted. Write the answers to the database in one transaction before you send a webhook, before you send an email, before you render a thank-you page. Every integration that runs afterwards is allowed to fail, and none of them may take the response with them. Reinterpreted is the subtler half. A response answered in March under one set of questions, read back in December after the form has been edited, must still mean what the person said. So a published form is immutable: editing creates a new version, every response records the version it was answered under, and the response view renders the questions from that version rather than from the current one. Without this a renamed field turns yesterday's answers into confident nonsense, and nobody notices because the table still looks full. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, one process, server-rendered public flow · the public form must work before any client script runs - Storage: SQLite with responses written in a single transaction · the response is the product and the database is where it lands first - Versioning: Published form versions are immutable rows, drafts are separate · a response means what it meant on the day it was given - Logic: Branch conditions evaluated on the server with a reachability check at publish · a dead end in a flow is a form nobody can finish - Delivery: A queue with retries, signatures and an idempotency key per response · an integration failing must never cost you the answer ### Data model (create this before Phase 1) - forms(id, name, created_at, archived_at) - form_versions(id, form_id, version, schema_json, published_at, published_by) · immutable once published - fields(version_id, key, ordinal, kind, label, help, required, validation_json, step) · part of the frozen schema - branches(version_id, from_step, condition_json, to_step) · conditions evaluated server side - responses(id, form_id, version_id, started_at, submitted_at, answers_json, partial, source_ref, ip_hash) - deliveries(id, response_id, target, idempotency_key, state, attempts, last_error, delivered_at) - uploads(id, response_id, field_key, path, sha256, mime, bytes, scanned_state) - The version_id on every response is what makes an old answer readable: the response page renders labels from that version, never from the current draft. ### Phase 1 · A schema you can freeze Build: Build the field schema, the editor that produces it, and a publish action that writes an immutable version row. Editing a published form starts a new draft. Keep field keys stable across versions so exports line up, and refuse to reuse a key for a different kind. Trap: Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. Done when: Publishing twice produces two version rows and the first is unchanged, verified by hash. A response records its version and renders with that version's labels after the form is edited, tested directly. Reusing a field key for a different kind is refused at publish. An export names columns by field key, so two versions of the same form still line up. Do not build yet: No public flow, branching, partial saves or delivery. ### Phase 2 · A flow with no dead ends Build: Evaluate branch conditions on the server when a step is submitted, and return the next step. At publish, walk the graph: report any step no path reaches and any path that terminates without a submit. Support going back without losing answers already given. Trap: Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. Done when: A form with an unreachable step is refused at publish with the step named, tested with a deliberate graph. A path that ends without reaching submit is refused the same way. Going back and changing an answer re-evaluates the branch and discards answers that are no longer on the path, visibly. A crafted request that skips a required step is rejected server side, tested. Do not build yet: No partial saves, uploads or delivery yet. ### Phase 3 · Answers that survive a closed tab Build: Save answers to the server at each step transition, not at the end. Keep a resume token in the browser so a returning visitor continues where they stopped. Mark partial rows clearly and never mix them into submitted counts. Trap: Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. Done when: Closing the tab mid-flow and returning with the resume token restores the answers already given, tested end to end. A partial row is never counted as a submission anywhere in the interface or the export, asserted in the suite. Answers are saved at each step transition rather than only at the end, verified by inspecting the network calls. A partial older than the configured retention is deleted and the deletion logged. Do not build yet: No delivery, uploads or abuse controls yet. ### Phase 4 · Delivery that happens once Build: Queue one delivery row per response and target with an idempotency key. Sign the payload with a shared secret and a timestamp. Retry with backoff on failures that happened before the receiver could have accepted, and mark an unknown outcome for review rather than retrying blindly. Show failures in the interface, not only in a log. Trap: Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. Done when: A receiver that times out after accepting produces one delivery, not two, tested with a fake receiver that records calls. The submit request completes without waiting for any integration, tested by delaying the receiver. Every payload carries a signature and a timestamp the receiver can verify, with the verification documented. Failed deliveries are visible in the interface with their error and attempt count. Do not build yet: No spam controls, uploads or reporting yet. ### Phase 5 · Uploads, abuse and getting the data out Build: Bound uploads by size and type, store them outside the web root with generated names, and never trust the client-supplied name or type. Rate limit per address with a hashed key, add a timed honeypot, and record why a submission was rejected. Export responses and uploads together, and delete a response and its files for real when asked. Trap: Storing an upload under the name the browser supplied puts an attacker-chosen path on your disk and an attacker-chosen extension in front of your web server. Generate the stored name, keep the original name as a label only, and serve downloads through a handler that sets the type yourself. Done when: An upload with a crafted name is stored under a generated name and the original is kept only as a label, tested with a traversal attempt. Files over the configured size or outside the allowed types are rejected before writing, with the limit shown to the visitor. Deleting a response removes its upload files from disk, tested by checking the path afterwards. The export contains every response with its version and its uploads. Do not build yet: Nothing after this. ### Out of scope (and why) - Payments inside the flow, which drags in a processor, refunds and tax handling and deserves its own project. - A template and integration catalogue, which is the part of the subscription that is really an ongoing maintenance contract. - Team accounts and approvals, which multiply every screen here by a permission model. - Guaranteed email delivery to any inbox, which is an operational commitment a self-hosted form cannot honestly make. ### README must contain - That published form versions are immutable and every response records the version it was answered under - That branch conditions are evaluated on the server and publish refuses unreachable steps - That answers are saved at each step, and why an unload-time save cannot be relied on - How webhook payloads are signed and how a receiver verifies the signature and idempotency key - That uploads are stored under generated names and deleting a response deletes its files ===== AGENTS.md ===== # Agent instructions · Heyflow indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, one process, server-rendered public flow, SQLite with responses written in a single transaction, Published form versions are immutable rows, drafts are separate, Branch conditions evaluated on the server with a reachability check at publish, A queue with retries, signatures and an idempotency key per response. 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 - Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. - Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. - Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. - Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. ===== BUILD_PLAN.md ===== # Build plan · Heyflow A multi-step form builder whose hard problems are immutable published versions so old responses keep their meaning, branching that cannot dead-end, partial answers that survive a closed tab, and delivery that happens once per response. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A schema you can freeze Publishing a form produces an immutable version that responses can point at. ### Steps 1. Make publish write an immutable row Drafts are mutable, published versions are not. Files: `src/lib/versions.ts`, `tests/version-immutable.test.ts` 2. Keep field keys stable, kinds fixed The key is the contract with every export that already exists. Files: `src/lib/schema.ts` 3. Render responses through their own version This is the difference between a record and a guess. Files: `src/app/responses/[id]/page.tsx` ### Done when - [ ] Two publishes produce two rows and the first is unchanged - [ ] An old response renders with its own labels - [ ] A reused key with a new kind is refused - [ ] Exports name columns by field key ### Watch out - Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. ## Phase 2 · A flow with no dead ends Every step is reachable and every path reaches the end. ### Steps 1. Decide the next step on the server The browser asks, the server answers. Files: `src/lib/flow.ts` 2. Walk the graph at publish Unreachable steps and paths with no ending are publish-time errors. Files: `src/lib/reachability.ts`, `tests/reachability.test.ts` 3. Re-evaluate on back navigation Answers off the new path are discarded in view, not silently kept. Files: `src/lib/flow.ts` ### Done when - [ ] An unreachable step blocks publish - [ ] A path with no ending blocks publish - [ ] Back navigation re-evaluates and shows what was dropped - [ ] A skipped required step is rejected server side ### Watch out - Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. ## Phase 3 · Answers that survive a closed tab A half-finished flow can be resumed, and the partial is stored as a partial. ### Steps 1. Persist at every step transition The page being open is the only time you can rely on. Files: `src/app/api/step/route.ts` 2. Keep partials separate from submissions A completion rate built on mixed rows is a made-up number. Files: `src/lib/responses.ts`, `tests/partial-counts.test.ts` 3. Expire partials on a stated retention Half-finished answers are still personal data. Files: `src/lib/retention.ts` ### Done when - [ ] A resumed flow restores prior answers - [ ] Partials never appear in submitted counts - [ ] Answers persist at each step, not only at the end - [ ] Partials expire on the stated retention ### Watch out - Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. ## Phase 4 · Delivery that happens once A webhook or email goes out once per response, or fails visibly. ### Steps 1. Queue, do not call, inside submit The response is saved before any integration is attempted. Files: `src/lib/deliveries.ts` 2. Carry an idempotency key and a signature One key per response and target, sent on every attempt. Files: `src/lib/webhook.ts`, `tests/delivery-once.test.ts` 3. Surface failures where the user looks A dead webhook that only shows up in a log is a lost week of leads. Files: `src/app/deliveries/page.tsx` ### Done when - [ ] A timed-out receiver gets one delivery - [ ] Submit does not wait on integrations - [ ] Payloads carry signature and timestamp - [ ] Failures are visible in the interface ### Watch out - Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. ## Not in this build - Payments inside the flow. - A template and integration catalogue. - Team accounts and approvals. - Any deliverability guarantee. ## After v1, if you want it - Scheduled exports to a spreadsheet, which reuse the delivery queue rather than adding a second path - A second delivery target such as a database row, added as a target kind with its own idempotency key - Per-step drop-off reporting computed from partials, which the data already supports ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and deliveries. DATABASE_PATH=./data/forms.db # Required. Outside the web root, generated names only. UPLOAD_DIR=./data/uploads # Required. Enforced before writing, and shown to the visitor as a limit. MAX_UPLOAD_BYTES=10485760 # Optional · secret. Signs outgoing payloads. The README documents the verification. WEBHOOK_SECRET=a long random string # Optional · secret. Notifications. Unset means notifications are off and the form still works. SMTP_URL=smtp://user:pass@host:587 # Required. Half-finished answers are deleted after this, and the deletion is logged. PARTIAL_RETENTION_DAYS=30 # Required. Per hashed address. Rejections are counted, not silent. RATE_LIMIT_PER_HOUR=20
You are building a lean indie version of Heyflow. 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 ===== # Heyflow · indie build A multi-step form builder whose hard problems are immutable published versions so old responses keep their meaning, branching that cannot dead-end, partial answers that survive a closed tab, and delivery that happens once per response. Estimated effort: **weekend**. 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 | Next.js 15 with TypeScript, one process, server-rendered public flow | the public form must work before any client script runs | | Storage | SQLite with responses written in a single transaction | the response is the product and the database is where it lands first | | Versioning | Published form versions are immutable rows, drafts are separate | a response means what it meant on the day it was given | | Logic | Branch conditions evaluated on the server with a reachability check at publish | a dead end in a flow is a form nobody can finish | | Delivery | A queue with retries, signatures and an idempotency key per response | an integration failing must never cost you the answer | ## 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 builder and the public flow in one process. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A small host with a domain and TLS** · a few pounds a month - Why: The public flow has to be reachable from a phone on a mobile network, and a form only tested on localhost has not been tested. - Get it: Any small virtual machine or platform that terminates TLS for you. - Verify: The published form loads on a phone over mobile data - [ ] **An SMTP or transactional email credential** (optional) · free tier for low volume - Why: Notification and confirmation email is the one integration people notice immediately when it stops. - Get it: Any provider you already use, or a local test server while developing. - Verify: A test submission produces a notification you receive - [ ] **A webhook receiver you control** · free - Why: Delivering once through timeouts and retries is only testable against a receiver you can make fail on purpose. - Get it: A tiny endpoint that logs calls and can be told to time out or return an error. - Verify: It records exactly one call for one submission after a forced timeout - [ ] **Retention for partials, uploads and responses** · free - Why: Each is enforced by a job and asserted by a test, and deciding it after launch means deciding it under pressure. - Get it: Write down how long partials, submitted responses and uploaded files are kept, and what deletion means. - Verify: All three appear in configuration and in the README ## 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: - Payments inside the flow. - A template and integration catalogue. - Team accounts and approvals. - Any deliverability guarantee. - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic - spam and abuse defense at scale If one of those is essential to you, that is the reason to keep paying for Heyflow, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Heyflow 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 a multi-step form flow that keeps partial answers and delivers every submission once to replace Heyflow. 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. ### Rule zero A response that reaches your server is never lost and never reinterpreted. Write the answers to the database in one transaction before you send a webhook, before you send an email, before you render a thank-you page. Every integration that runs afterwards is allowed to fail, and none of them may take the response with them. Reinterpreted is the subtler half. A response answered in March under one set of questions, read back in December after the form has been edited, must still mean what the person said. So a published form is immutable: editing creates a new version, every response records the version it was answered under, and the response view renders the questions from that version rather than from the current one. Without this a renamed field turns yesterday's answers into confident nonsense, and nobody notices because the table still looks full. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, one process, server-rendered public flow · the public form must work before any client script runs - Storage: SQLite with responses written in a single transaction · the response is the product and the database is where it lands first - Versioning: Published form versions are immutable rows, drafts are separate · a response means what it meant on the day it was given - Logic: Branch conditions evaluated on the server with a reachability check at publish · a dead end in a flow is a form nobody can finish - Delivery: A queue with retries, signatures and an idempotency key per response · an integration failing must never cost you the answer ### Data model (create this before Phase 1) - forms(id, name, created_at, archived_at) - form_versions(id, form_id, version, schema_json, published_at, published_by) · immutable once published - fields(version_id, key, ordinal, kind, label, help, required, validation_json, step) · part of the frozen schema - branches(version_id, from_step, condition_json, to_step) · conditions evaluated server side - responses(id, form_id, version_id, started_at, submitted_at, answers_json, partial, source_ref, ip_hash) - deliveries(id, response_id, target, idempotency_key, state, attempts, last_error, delivered_at) - uploads(id, response_id, field_key, path, sha256, mime, bytes, scanned_state) - The version_id on every response is what makes an old answer readable: the response page renders labels from that version, never from the current draft. ### Phase 1 · A schema you can freeze Build: Build the field schema, the editor that produces it, and a publish action that writes an immutable version row. Editing a published form starts a new draft. Keep field keys stable across versions so exports line up, and refuse to reuse a key for a different kind. Trap: Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. Done when: Publishing twice produces two version rows and the first is unchanged, verified by hash. A response records its version and renders with that version's labels after the form is edited, tested directly. Reusing a field key for a different kind is refused at publish. An export names columns by field key, so two versions of the same form still line up. Do not build yet: No public flow, branching, partial saves or delivery. ### Phase 2 · A flow with no dead ends Build: Evaluate branch conditions on the server when a step is submitted, and return the next step. At publish, walk the graph: report any step no path reaches and any path that terminates without a submit. Support going back without losing answers already given. Trap: Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. Done when: A form with an unreachable step is refused at publish with the step named, tested with a deliberate graph. A path that ends without reaching submit is refused the same way. Going back and changing an answer re-evaluates the branch and discards answers that are no longer on the path, visibly. A crafted request that skips a required step is rejected server side, tested. Do not build yet: No partial saves, uploads or delivery yet. ### Phase 3 · Answers that survive a closed tab Build: Save answers to the server at each step transition, not at the end. Keep a resume token in the browser so a returning visitor continues where they stopped. Mark partial rows clearly and never mix them into submitted counts. Trap: Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. Done when: Closing the tab mid-flow and returning with the resume token restores the answers already given, tested end to end. A partial row is never counted as a submission anywhere in the interface or the export, asserted in the suite. Answers are saved at each step transition rather than only at the end, verified by inspecting the network calls. A partial older than the configured retention is deleted and the deletion logged. Do not build yet: No delivery, uploads or abuse controls yet. ### Phase 4 · Delivery that happens once Build: Queue one delivery row per response and target with an idempotency key. Sign the payload with a shared secret and a timestamp. Retry with backoff on failures that happened before the receiver could have accepted, and mark an unknown outcome for review rather than retrying blindly. Show failures in the interface, not only in a log. Trap: Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. Done when: A receiver that times out after accepting produces one delivery, not two, tested with a fake receiver that records calls. The submit request completes without waiting for any integration, tested by delaying the receiver. Every payload carries a signature and a timestamp the receiver can verify, with the verification documented. Failed deliveries are visible in the interface with their error and attempt count. Do not build yet: No spam controls, uploads or reporting yet. ### Phase 5 · Uploads, abuse and getting the data out Build: Bound uploads by size and type, store them outside the web root with generated names, and never trust the client-supplied name or type. Rate limit per address with a hashed key, add a timed honeypot, and record why a submission was rejected. Export responses and uploads together, and delete a response and its files for real when asked. Trap: Storing an upload under the name the browser supplied puts an attacker-chosen path on your disk and an attacker-chosen extension in front of your web server. Generate the stored name, keep the original name as a label only, and serve downloads through a handler that sets the type yourself. Done when: An upload with a crafted name is stored under a generated name and the original is kept only as a label, tested with a traversal attempt. Files over the configured size or outside the allowed types are rejected before writing, with the limit shown to the visitor. Deleting a response removes its upload files from disk, tested by checking the path afterwards. The export contains every response with its version and its uploads. Do not build yet: Nothing after this. ### Out of scope (and why) - Payments inside the flow, which drags in a processor, refunds and tax handling and deserves its own project. - A template and integration catalogue, which is the part of the subscription that is really an ongoing maintenance contract. - Team accounts and approvals, which multiply every screen here by a permission model. - Guaranteed email delivery to any inbox, which is an operational commitment a self-hosted form cannot honestly make. ### README must contain - That published form versions are immutable and every response records the version it was answered under - That branch conditions are evaluated on the server and publish refuses unreachable steps - That answers are saved at each step, and why an unload-time save cannot be relied on - How webhook payloads are signed and how a receiver verifies the signature and idempotency key - That uploads are stored under generated names and deleting a response deletes its files ===== AGENTS.md ===== # Agent instructions · Heyflow indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, one process, server-rendered public flow, SQLite with responses written in a single transaction, Published form versions are immutable rows, drafts are separate, Branch conditions evaluated on the server with a reachability check at publish, A queue with retries, signatures and an idempotency key per response. 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 - Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. - Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. - Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. - Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. ===== BUILD_PLAN.md ===== # Build plan · Heyflow A multi-step form builder whose hard problems are immutable published versions so old responses keep their meaning, branching that cannot dead-end, partial answers that survive a closed tab, and delivery that happens once per response. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A schema you can freeze Publishing a form produces an immutable version that responses can point at. ### Steps 1. Make publish write an immutable row Drafts are mutable, published versions are not. Files: `src/lib/versions.ts`, `tests/version-immutable.test.ts` 2. Keep field keys stable, kinds fixed The key is the contract with every export that already exists. Files: `src/lib/schema.ts` 3. Render responses through their own version This is the difference between a record and a guess. Files: `src/app/responses/[id]/page.tsx` ### Done when - [ ] Two publishes produce two rows and the first is unchanged - [ ] An old response renders with its own labels - [ ] A reused key with a new kind is refused - [ ] Exports name columns by field key ### Watch out - Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. ## Phase 2 · A flow with no dead ends Every step is reachable and every path reaches the end. ### Steps 1. Decide the next step on the server The browser asks, the server answers. Files: `src/lib/flow.ts` 2. Walk the graph at publish Unreachable steps and paths with no ending are publish-time errors. Files: `src/lib/reachability.ts`, `tests/reachability.test.ts` 3. Re-evaluate on back navigation Answers off the new path are discarded in view, not silently kept. Files: `src/lib/flow.ts` ### Done when - [ ] An unreachable step blocks publish - [ ] A path with no ending blocks publish - [ ] Back navigation re-evaluates and shows what was dropped - [ ] A skipped required step is rejected server side ### Watch out - Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. ## Phase 3 · Answers that survive a closed tab A half-finished flow can be resumed, and the partial is stored as a partial. ### Steps 1. Persist at every step transition The page being open is the only time you can rely on. Files: `src/app/api/step/route.ts` 2. Keep partials separate from submissions A completion rate built on mixed rows is a made-up number. Files: `src/lib/responses.ts`, `tests/partial-counts.test.ts` 3. Expire partials on a stated retention Half-finished answers are still personal data. Files: `src/lib/retention.ts` ### Done when - [ ] A resumed flow restores prior answers - [ ] Partials never appear in submitted counts - [ ] Answers persist at each step, not only at the end - [ ] Partials expire on the stated retention ### Watch out - Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. ## Phase 4 · Delivery that happens once A webhook or email goes out once per response, or fails visibly. ### Steps 1. Queue, do not call, inside submit The response is saved before any integration is attempted. Files: `src/lib/deliveries.ts` 2. Carry an idempotency key and a signature One key per response and target, sent on every attempt. Files: `src/lib/webhook.ts`, `tests/delivery-once.test.ts` 3. Surface failures where the user looks A dead webhook that only shows up in a log is a lost week of leads. Files: `src/app/deliveries/page.tsx` ### Done when - [ ] A timed-out receiver gets one delivery - [ ] Submit does not wait on integrations - [ ] Payloads carry signature and timestamp - [ ] Failures are visible in the interface ### Watch out - Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. ## Not in this build - Payments inside the flow. - A template and integration catalogue. - Team accounts and approvals. - Any deliverability guarantee. ## After v1, if you want it - Scheduled exports to a spreadsheet, which reuse the delivery queue rather than adding a second path - A second delivery target such as a database row, added as a target kind with its own idempotency key - Per-step drop-off reporting computed from partials, which the data already supports ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and deliveries. DATABASE_PATH=./data/forms.db # Required. Outside the web root, generated names only. UPLOAD_DIR=./data/uploads # Required. Enforced before writing, and shown to the visitor as a limit. MAX_UPLOAD_BYTES=10485760 # Optional · secret. Signs outgoing payloads. The README documents the verification. WEBHOOK_SECRET=a long random string # Optional · secret. Notifications. Unset means notifications are off and the form still works. SMTP_URL=smtp://user:pass@host:587 # Required. Half-finished answers are deleted after this, and the deletion is logged. PARTIAL_RETENTION_DAYS=30 # Required. Per hashed address. Rejections are counted, not silent. RATE_LIMIT_PER_HOUR=20
You are building a production product version of Heyflow. 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 ===== # Heyflow · product brief ## Problem The visible interactive form flows loop is buildable, but a credible replacement needs more than the first screen. Heyflow earns its keep through delivery, integrations, compliance, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A form that keeps a response the moment it arrives, still reads correctly a year after the questions changed, resumes for a visitor who closed the tab, and delivers to a webhook exactly once. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - deploy target - SMTP or transactional email key in .env ## Explicit non-goals for v1 - Payments inside the flow. - A template and integration catalogue. - Team accounts and approvals. - Any deliverability guarantee. - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic - spam and abuse defense at scale ## Success criteria - An edited form is confirmed not to change an old response's labels - An unreachable step is confirmed to block publish - A resumed flow is confirmed to restore prior answers - A timed-out receiver is confirmed to get one delivery - A crafted upload name is confirmed unable to escape the folder ===== BRIEF.md ===== # Build brief · Heyflow 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 a multi-step form flow that keeps partial answers and delivers every submission once to replace Heyflow. 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. ### Rule zero A response that reaches your server is never lost and never reinterpreted. Write the answers to the database in one transaction before you send a webhook, before you send an email, before you render a thank-you page. Every integration that runs afterwards is allowed to fail, and none of them may take the response with them. Reinterpreted is the subtler half. A response answered in March under one set of questions, read back in December after the form has been edited, must still mean what the person said. So a published form is immutable: editing creates a new version, every response records the version it was answered under, and the response view renders the questions from that version rather than from the current one. Without this a renamed field turns yesterday's answers into confident nonsense, and nobody notices because the table still looks full. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, one process, server-rendered public flow · the public form must work before any client script runs - Storage: SQLite with responses written in a single transaction · the response is the product and the database is where it lands first - Versioning: Published form versions are immutable rows, drafts are separate · a response means what it meant on the day it was given - Logic: Branch conditions evaluated on the server with a reachability check at publish · a dead end in a flow is a form nobody can finish - Delivery: A queue with retries, signatures and an idempotency key per response · an integration failing must never cost you the answer ### Data model (create this before Phase 1) - forms(id, name, created_at, archived_at) - form_versions(id, form_id, version, schema_json, published_at, published_by) · immutable once published - fields(version_id, key, ordinal, kind, label, help, required, validation_json, step) · part of the frozen schema - branches(version_id, from_step, condition_json, to_step) · conditions evaluated server side - responses(id, form_id, version_id, started_at, submitted_at, answers_json, partial, source_ref, ip_hash) - deliveries(id, response_id, target, idempotency_key, state, attempts, last_error, delivered_at) - uploads(id, response_id, field_key, path, sha256, mime, bytes, scanned_state) - The version_id on every response is what makes an old answer readable: the response page renders labels from that version, never from the current draft. ### Phase 1 · A schema you can freeze Build: Build the field schema, the editor that produces it, and a publish action that writes an immutable version row. Editing a published form starts a new draft. Keep field keys stable across versions so exports line up, and refuse to reuse a key for a different kind. Trap: Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. Done when: Publishing twice produces two version rows and the first is unchanged, verified by hash. A response records its version and renders with that version's labels after the form is edited, tested directly. Reusing a field key for a different kind is refused at publish. An export names columns by field key, so two versions of the same form still line up. Do not build yet: No public flow, branching, partial saves or delivery. ### Phase 2 · A flow with no dead ends Build: Evaluate branch conditions on the server when a step is submitted, and return the next step. At publish, walk the graph: report any step no path reaches and any path that terminates without a submit. Support going back without losing answers already given. Trap: Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. Done when: A form with an unreachable step is refused at publish with the step named, tested with a deliberate graph. A path that ends without reaching submit is refused the same way. Going back and changing an answer re-evaluates the branch and discards answers that are no longer on the path, visibly. A crafted request that skips a required step is rejected server side, tested. Do not build yet: No partial saves, uploads or delivery yet. ### Phase 3 · Answers that survive a closed tab Build: Save answers to the server at each step transition, not at the end. Keep a resume token in the browser so a returning visitor continues where they stopped. Mark partial rows clearly and never mix them into submitted counts. Trap: Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. Done when: Closing the tab mid-flow and returning with the resume token restores the answers already given, tested end to end. A partial row is never counted as a submission anywhere in the interface or the export, asserted in the suite. Answers are saved at each step transition rather than only at the end, verified by inspecting the network calls. A partial older than the configured retention is deleted and the deletion logged. Do not build yet: No delivery, uploads or abuse controls yet. ### Phase 4 · Delivery that happens once Build: Queue one delivery row per response and target with an idempotency key. Sign the payload with a shared secret and a timestamp. Retry with backoff on failures that happened before the receiver could have accepted, and mark an unknown outcome for review rather than retrying blindly. Show failures in the interface, not only in a log. Trap: Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. Done when: A receiver that times out after accepting produces one delivery, not two, tested with a fake receiver that records calls. The submit request completes without waiting for any integration, tested by delaying the receiver. Every payload carries a signature and a timestamp the receiver can verify, with the verification documented. Failed deliveries are visible in the interface with their error and attempt count. Do not build yet: No spam controls, uploads or reporting yet. ### Phase 5 · Uploads, abuse and getting the data out Build: Bound uploads by size and type, store them outside the web root with generated names, and never trust the client-supplied name or type. Rate limit per address with a hashed key, add a timed honeypot, and record why a submission was rejected. Export responses and uploads together, and delete a response and its files for real when asked. Trap: Storing an upload under the name the browser supplied puts an attacker-chosen path on your disk and an attacker-chosen extension in front of your web server. Generate the stored name, keep the original name as a label only, and serve downloads through a handler that sets the type yourself. Done when: An upload with a crafted name is stored under a generated name and the original is kept only as a label, tested with a traversal attempt. Files over the configured size or outside the allowed types are rejected before writing, with the limit shown to the visitor. Deleting a response removes its upload files from disk, tested by checking the path afterwards. The export contains every response with its version and its uploads. Do not build yet: Nothing after this. ### Out of scope (and why) - Payments inside the flow, which drags in a processor, refunds and tax handling and deserves its own project. - A template and integration catalogue, which is the part of the subscription that is really an ongoing maintenance contract. - Team accounts and approvals, which multiply every screen here by a permission model. - Guaranteed email delivery to any inbox, which is an operational commitment a self-hosted form cannot honestly make. ### README must contain - That published form versions are immutable and every response records the version it was answered under - That branch conditions are evaluated on the server and publish refuses unreachable steps - That answers are saved at each step, and why an unload-time save cannot be relied on - How webhook payloads are signed and how a receiver verifies the signature and idempotency key - That uploads are stored under generated names and deleting a response deletes its files ===== ARCHITECTURE.md ===== # Architecture · Heyflow ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, one process, server-rendered public flow | the public form must work before any client script runs | | Storage | SQLite with responses written in a single transaction | the response is the product and the database is where it lands first | | Versioning | Published form versions are immutable rows, drafts are separate | a response means what it meant on the day it was given | | Logic | Branch conditions evaluated on the server with a reachability check at publish | a dead end in a flow is a form nobody can finish | | Delivery | A queue with retries, signatures and an idempotency key per response | an integration failing must never cost you the answer | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Versions | Immutable published schemas. | Any storage, provided published rows are never edited. | | Flow | Server-side branch evaluation and reachability. | Any evaluator, provided the next step is decided on the server. | | Responses | Partials, submissions and the version link. | Any store, provided partials never count as submissions. | | Deliveries | Queue, signature, idempotency and retries. | Any transport, provided one response yields one delivery. | | Uploads | Generated names, bounds and real deletion. | Any object store, provided client names never reach the filesystem. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Forms, versions, responses and deliveries. - `UPLOAD_DIR` · required · Outside the web root, generated names only. - `MAX_UPLOAD_BYTES` · required · Enforced before writing, and shown to the visitor as a limit. - `WEBHOOK_SECRET` · optional, secret · Signs outgoing payloads. The README documents the verification. - `SMTP_URL` · optional, secret · Notifications. Unset means notifications are off and the form still works. - `PARTIAL_RETENTION_DAYS` · required · Half-finished answers are deleted after this, and the deletion is logged. - `RATE_LIMIT_PER_HOUR` · required · Per hashed address. Rejections are counted, not silent. ## 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 · Heyflow product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, one process, server-rendered public flow, SQLite with responses written in a single transaction, Published form versions are immutable rows, drafts are separate, Branch conditions evaluated on the server with a reachability check at publish, A queue with retries, signatures and an idempotency key per response. - 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 - Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. - Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. - Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. - Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. - Storing an upload under the name the browser supplied puts an attacker-chosen path on your disk and an attacker-chosen extension in front of your web server. Generate the stored name, keep the original name as a label only, and serve downloads through a handler that sets the type yourself. ===== MILESTONES.md ===== # Delivery milestones · Heyflow Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A schema you can freeze Publishing a form produces an immutable version that responses can point at. ### Steps 1. Make publish write an immutable row Drafts are mutable, published versions are not. Files: `src/lib/versions.ts`, `tests/version-immutable.test.ts` 2. Keep field keys stable, kinds fixed The key is the contract with every export that already exists. Files: `src/lib/schema.ts` 3. Render responses through their own version This is the difference between a record and a guess. Files: `src/app/responses/[id]/page.tsx` ### Done when - [ ] Two publishes produce two rows and the first is unchanged - [ ] An old response renders with its own labels - [ ] A reused key with a new kind is refused - [ ] Exports name columns by field key ### Watch out - Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. ## M2 · A flow with no dead ends Every step is reachable and every path reaches the end. ### Steps 1. Decide the next step on the server The browser asks, the server answers. Files: `src/lib/flow.ts` 2. Walk the graph at publish Unreachable steps and paths with no ending are publish-time errors. Files: `src/lib/reachability.ts`, `tests/reachability.test.ts` 3. Re-evaluate on back navigation Answers off the new path are discarded in view, not silently kept. Files: `src/lib/flow.ts` ### Done when - [ ] An unreachable step blocks publish - [ ] A path with no ending blocks publish - [ ] Back navigation re-evaluates and shows what was dropped - [ ] A skipped required step is rejected server side ### Watch out - Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. ## M3 · Answers that survive a closed tab A half-finished flow can be resumed, and the partial is stored as a partial. ### Steps 1. Persist at every step transition The page being open is the only time you can rely on. Files: `src/app/api/step/route.ts` 2. Keep partials separate from submissions A completion rate built on mixed rows is a made-up number. Files: `src/lib/responses.ts`, `tests/partial-counts.test.ts` 3. Expire partials on a stated retention Half-finished answers are still personal data. Files: `src/lib/retention.ts` ### Done when - [ ] A resumed flow restores prior answers - [ ] Partials never appear in submitted counts - [ ] Answers persist at each step, not only at the end - [ ] Partials expire on the stated retention ### Watch out - Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. ## M4 · Delivery that happens once A webhook or email goes out once per response, or fails visibly. ### Steps 1. Queue, do not call, inside submit The response is saved before any integration is attempted. Files: `src/lib/deliveries.ts` 2. Carry an idempotency key and a signature One key per response and target, sent on every attempt. Files: `src/lib/webhook.ts`, `tests/delivery-once.test.ts` 3. Surface failures where the user looks A dead webhook that only shows up in a log is a lost week of leads. Files: `src/app/deliveries/page.tsx` ### Done when - [ ] A timed-out receiver gets one delivery - [ ] Submit does not wait on integrations - [ ] Payloads carry signature and timestamp - [ ] Failures are visible in the interface ### Watch out - Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. ## M5 · Uploads, abuse and getting the data out (production only) The public form survives contact with the internet and the data can leave. ### Steps 1. Generate stored names, keep the original as a label The client-supplied name never touches the filesystem. Files: `src/lib/uploads.ts`, `tests/upload-names.test.ts` 2. Rate limit and honeypot, and record rejections A rejection you cannot count is a form you cannot tune. Files: `src/lib/abuse.ts` 3. Make deletion delete the files too A row removed while the file stays is not deletion. Files: `src/lib/responses.ts` ### Done when - [ ] A crafted filename cannot escape the upload folder - [ ] Oversized and wrong-type uploads are rejected with the limit shown - [ ] Deleting a response removes its files - [ ] Export includes version and uploads ### Watch out - Storing an upload under the name the browser supplied puts an attacker-chosen path on your disk and an attacker-chosen extension in front of your web server. Generate the stored name, keep the original name as a label only, and serve downloads through a handler that sets the type yourself. ===== OPERATIONS.md ===== # Operations · Heyflow ## Backup The database and the upload folder together, since a response referencing a file that is gone is an incomplete record. Back up before every schema migration as well, because form versions are the part you cannot reconstruct. ## Restore Restore both, then open the oldest response you have and confirm it renders with its own version's labels. That single check proves the versioning survived the restore. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Failed deliveries, the age of the oldest queued delivery, partial volume against submissions, and rejection counts by reason. A webhook that has been failing for a week is the usual quiet disaster. ## Incident checklist A missing lead is almost always a delivery that failed, not a response that vanished, because the response is written first. Check the deliveries table before anything else, and replay by idempotency key rather than resubmitting. 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 - [ ] An edited form is confirmed not to change an old response's labels - [ ] An unreachable step is confirmed to block publish - [ ] A resumed flow is confirmed to restore prior answers - [ ] A timed-out receiver is confirmed to get one delivery - [ ] A crafted upload name is confirmed unable to escape the folder ## Launch constraint Do not market omitted Heyflow 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. Forms, versions, responses and deliveries. DATABASE_PATH=./data/forms.db # Required. Outside the web root, generated names only. UPLOAD_DIR=./data/uploads # Required. Enforced before writing, and shown to the visitor as a limit. MAX_UPLOAD_BYTES=10485760 # Optional · secret. Signs outgoing payloads. The README documents the verification. WEBHOOK_SECRET=a long random string # Optional · secret. Notifications. Unset means notifications are off and the form still works. SMTP_URL=smtp://user:pass@host:587 # Required. Half-finished answers are deleted after this, and the deletion is logged. PARTIAL_RETENTION_DAYS=30 # Required. Per hashed address. Rejections are counted, not silent. RATE_LIMIT_PER_HOUR=20
# Heyflow · indie build A multi-step form builder whose hard problems are immutable published versions so old responses keep their meaning, branching that cannot dead-end, partial answers that survive a closed tab, and delivery that happens once per response. Estimated effort: **weekend**. 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 | Next.js 15 with TypeScript, one process, server-rendered public flow | the public form must work before any client script runs | | Storage | SQLite with responses written in a single transaction | the response is the product and the database is where it lands first | | Versioning | Published form versions are immutable rows, drafts are separate | a response means what it meant on the day it was given | | Logic | Branch conditions evaluated on the server with a reachability check at publish | a dead end in a flow is a form nobody can finish | | Delivery | A queue with retries, signatures and an idempotency key per response | an integration failing must never cost you the answer | ## 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 builder and the public flow in one process. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A small host with a domain and TLS** · a few pounds a month - Why: The public flow has to be reachable from a phone on a mobile network, and a form only tested on localhost has not been tested. - Get it: Any small virtual machine or platform that terminates TLS for you. - Verify: The published form loads on a phone over mobile data - [ ] **An SMTP or transactional email credential** (optional) · free tier for low volume - Why: Notification and confirmation email is the one integration people notice immediately when it stops. - Get it: Any provider you already use, or a local test server while developing. - Verify: A test submission produces a notification you receive - [ ] **A webhook receiver you control** · free - Why: Delivering once through timeouts and retries is only testable against a receiver you can make fail on purpose. - Get it: A tiny endpoint that logs calls and can be told to time out or return an error. - Verify: It records exactly one call for one submission after a forced timeout - [ ] **Retention for partials, uploads and responses** · free - Why: Each is enforced by a job and asserted by a test, and deciding it after launch means deciding it under pressure. - Get it: Write down how long partials, submitted responses and uploaded files are kept, and what deletion means. - Verify: All three appear in configuration and in the README ## 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: - Payments inside the flow. - A template and integration catalogue. - Team accounts and approvals. - Any deliverability guarantee. - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic - spam and abuse defense at scale If one of those is essential to you, that is the reason to keep paying for Heyflow, and the README should say so rather than pretend.
# Build brief · Heyflow 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 a multi-step form flow that keeps partial answers and delivers every submission once to replace Heyflow. 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. ### Rule zero A response that reaches your server is never lost and never reinterpreted. Write the answers to the database in one transaction before you send a webhook, before you send an email, before you render a thank-you page. Every integration that runs afterwards is allowed to fail, and none of them may take the response with them. Reinterpreted is the subtler half. A response answered in March under one set of questions, read back in December after the form has been edited, must still mean what the person said. So a published form is immutable: editing creates a new version, every response records the version it was answered under, and the response view renders the questions from that version rather than from the current one. Without this a renamed field turns yesterday's answers into confident nonsense, and nobody notices because the table still looks full. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, one process, server-rendered public flow · the public form must work before any client script runs - Storage: SQLite with responses written in a single transaction · the response is the product and the database is where it lands first - Versioning: Published form versions are immutable rows, drafts are separate · a response means what it meant on the day it was given - Logic: Branch conditions evaluated on the server with a reachability check at publish · a dead end in a flow is a form nobody can finish - Delivery: A queue with retries, signatures and an idempotency key per response · an integration failing must never cost you the answer ### Data model (create this before Phase 1) - forms(id, name, created_at, archived_at) - form_versions(id, form_id, version, schema_json, published_at, published_by) · immutable once published - fields(version_id, key, ordinal, kind, label, help, required, validation_json, step) · part of the frozen schema - branches(version_id, from_step, condition_json, to_step) · conditions evaluated server side - responses(id, form_id, version_id, started_at, submitted_at, answers_json, partial, source_ref, ip_hash) - deliveries(id, response_id, target, idempotency_key, state, attempts, last_error, delivered_at) - uploads(id, response_id, field_key, path, sha256, mime, bytes, scanned_state) - The version_id on every response is what makes an old answer readable: the response page renders labels from that version, never from the current draft. ### Phase 1 · A schema you can freeze Build: Build the field schema, the editor that produces it, and a publish action that writes an immutable version row. Editing a published form starts a new draft. Keep field keys stable across versions so exports line up, and refuse to reuse a key for a different kind. Trap: Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. Done when: Publishing twice produces two version rows and the first is unchanged, verified by hash. A response records its version and renders with that version's labels after the form is edited, tested directly. Reusing a field key for a different kind is refused at publish. An export names columns by field key, so two versions of the same form still line up. Do not build yet: No public flow, branching, partial saves or delivery. ### Phase 2 · A flow with no dead ends Build: Evaluate branch conditions on the server when a step is submitted, and return the next step. At publish, walk the graph: report any step no path reaches and any path that terminates without a submit. Support going back without losing answers already given. Trap: Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. Done when: A form with an unreachable step is refused at publish with the step named, tested with a deliberate graph. A path that ends without reaching submit is refused the same way. Going back and changing an answer re-evaluates the branch and discards answers that are no longer on the path, visibly. A crafted request that skips a required step is rejected server side, tested. Do not build yet: No partial saves, uploads or delivery yet. ### Phase 3 · Answers that survive a closed tab Build: Save answers to the server at each step transition, not at the end. Keep a resume token in the browser so a returning visitor continues where they stopped. Mark partial rows clearly and never mix them into submitted counts. Trap: Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. Done when: Closing the tab mid-flow and returning with the resume token restores the answers already given, tested end to end. A partial row is never counted as a submission anywhere in the interface or the export, asserted in the suite. Answers are saved at each step transition rather than only at the end, verified by inspecting the network calls. A partial older than the configured retention is deleted and the deletion logged. Do not build yet: No delivery, uploads or abuse controls yet. ### Phase 4 · Delivery that happens once Build: Queue one delivery row per response and target with an idempotency key. Sign the payload with a shared secret and a timestamp. Retry with backoff on failures that happened before the receiver could have accepted, and mark an unknown outcome for review rather than retrying blindly. Show failures in the interface, not only in a log. Trap: Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. Done when: A receiver that times out after accepting produces one delivery, not two, tested with a fake receiver that records calls. The submit request completes without waiting for any integration, tested by delaying the receiver. Every payload carries a signature and a timestamp the receiver can verify, with the verification documented. Failed deliveries are visible in the interface with their error and attempt count. Do not build yet: No spam controls, uploads or reporting yet. ### Phase 5 · Uploads, abuse and getting the data out Build: Bound uploads by size and type, store them outside the web root with generated names, and never trust the client-supplied name or type. Rate limit per address with a hashed key, add a timed honeypot, and record why a submission was rejected. Export responses and uploads together, and delete a response and its files for real when asked. Trap: Storing an upload under the name the browser supplied puts an attacker-chosen path on your disk and an attacker-chosen extension in front of your web server. Generate the stored name, keep the original name as a label only, and serve downloads through a handler that sets the type yourself. Done when: An upload with a crafted name is stored under a generated name and the original is kept only as a label, tested with a traversal attempt. Files over the configured size or outside the allowed types are rejected before writing, with the limit shown to the visitor. Deleting a response removes its upload files from disk, tested by checking the path afterwards. The export contains every response with its version and its uploads. Do not build yet: Nothing after this. ### Out of scope (and why) - Payments inside the flow, which drags in a processor, refunds and tax handling and deserves its own project. - A template and integration catalogue, which is the part of the subscription that is really an ongoing maintenance contract. - Team accounts and approvals, which multiply every screen here by a permission model. - Guaranteed email delivery to any inbox, which is an operational commitment a self-hosted form cannot honestly make. ### README must contain - That published form versions are immutable and every response records the version it was answered under - That branch conditions are evaluated on the server and publish refuses unreachable steps - That answers are saved at each step, and why an unload-time save cannot be relied on - How webhook payloads are signed and how a receiver verifies the signature and idempotency key - That uploads are stored under generated names and deleting a response deletes its files
# Agent instructions · Heyflow indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, one process, server-rendered public flow, SQLite with responses written in a single transaction, Published form versions are immutable rows, drafts are separate, Branch conditions evaluated on the server with a reachability check at publish, A queue with retries, signatures and an idempotency key per response. 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 - Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. - Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. - Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. - Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it.
# Build plan · Heyflow A multi-step form builder whose hard problems are immutable published versions so old responses keep their meaning, branching that cannot dead-end, partial answers that survive a closed tab, and delivery that happens once per response. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A schema you can freeze Publishing a form produces an immutable version that responses can point at. ### Steps 1. Make publish write an immutable row Drafts are mutable, published versions are not. Files: `src/lib/versions.ts`, `tests/version-immutable.test.ts` 2. Keep field keys stable, kinds fixed The key is the contract with every export that already exists. Files: `src/lib/schema.ts` 3. Render responses through their own version This is the difference between a record and a guess. Files: `src/app/responses/[id]/page.tsx` ### Done when - [ ] Two publishes produce two rows and the first is unchanged - [ ] An old response renders with its own labels - [ ] A reused key with a new kind is refused - [ ] Exports name columns by field key ### Watch out - Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. ## Phase 2 · A flow with no dead ends Every step is reachable and every path reaches the end. ### Steps 1. Decide the next step on the server The browser asks, the server answers. Files: `src/lib/flow.ts` 2. Walk the graph at publish Unreachable steps and paths with no ending are publish-time errors. Files: `src/lib/reachability.ts`, `tests/reachability.test.ts` 3. Re-evaluate on back navigation Answers off the new path are discarded in view, not silently kept. Files: `src/lib/flow.ts` ### Done when - [ ] An unreachable step blocks publish - [ ] A path with no ending blocks publish - [ ] Back navigation re-evaluates and shows what was dropped - [ ] A skipped required step is rejected server side ### Watch out - Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. ## Phase 3 · Answers that survive a closed tab A half-finished flow can be resumed, and the partial is stored as a partial. ### Steps 1. Persist at every step transition The page being open is the only time you can rely on. Files: `src/app/api/step/route.ts` 2. Keep partials separate from submissions A completion rate built on mixed rows is a made-up number. Files: `src/lib/responses.ts`, `tests/partial-counts.test.ts` 3. Expire partials on a stated retention Half-finished answers are still personal data. Files: `src/lib/retention.ts` ### Done when - [ ] A resumed flow restores prior answers - [ ] Partials never appear in submitted counts - [ ] Answers persist at each step, not only at the end - [ ] Partials expire on the stated retention ### Watch out - Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. ## Phase 4 · Delivery that happens once A webhook or email goes out once per response, or fails visibly. ### Steps 1. Queue, do not call, inside submit The response is saved before any integration is attempted. Files: `src/lib/deliveries.ts` 2. Carry an idempotency key and a signature One key per response and target, sent on every attempt. Files: `src/lib/webhook.ts`, `tests/delivery-once.test.ts` 3. Surface failures where the user looks A dead webhook that only shows up in a log is a lost week of leads. Files: `src/app/deliveries/page.tsx` ### Done when - [ ] A timed-out receiver gets one delivery - [ ] Submit does not wait on integrations - [ ] Payloads carry signature and timestamp - [ ] Failures are visible in the interface ### Watch out - Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. ## Not in this build - Payments inside the flow. - A template and integration catalogue. - Team accounts and approvals. - Any deliverability guarantee. ## After v1, if you want it - Scheduled exports to a spreadsheet, which reuse the delivery queue rather than adding a second path - A second delivery target such as a database row, added as a target kind with its own idempotency key - Per-step drop-off reporting computed from partials, which the data already supports
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and deliveries. DATABASE_PATH=./data/forms.db # Required. Outside the web root, generated names only. UPLOAD_DIR=./data/uploads # Required. Enforced before writing, and shown to the visitor as a limit. MAX_UPLOAD_BYTES=10485760 # Optional · secret. Signs outgoing payloads. The README documents the verification. WEBHOOK_SECRET=a long random string # Optional · secret. Notifications. Unset means notifications are off and the form still works. SMTP_URL=smtp://user:pass@host:587 # Required. Half-finished answers are deleted after this, and the deletion is logged. PARTIAL_RETENTION_DAYS=30 # Required. Per hashed address. Rejections are counted, not silent. RATE_LIMIT_PER_HOUR=20
# Heyflow · product brief ## Problem The visible interactive form flows loop is buildable, but a credible replacement needs more than the first screen. Heyflow earns its keep through delivery, integrations, compliance, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A form that keeps a response the moment it arrives, still reads correctly a year after the questions changed, resumes for a visitor who closed the tab, and delivers to a webhook exactly once. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - deploy target - SMTP or transactional email key in .env ## Explicit non-goals for v1 - Payments inside the flow. - A template and integration catalogue. - Team accounts and approvals. - Any deliverability guarantee. - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic - spam and abuse defense at scale ## Success criteria - An edited form is confirmed not to change an old response's labels - An unreachable step is confirmed to block publish - A resumed flow is confirmed to restore prior answers - A timed-out receiver is confirmed to get one delivery - A crafted upload name is confirmed unable to escape the folder
# Build brief · Heyflow 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 a multi-step form flow that keeps partial answers and delivers every submission once to replace Heyflow. 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. ### Rule zero A response that reaches your server is never lost and never reinterpreted. Write the answers to the database in one transaction before you send a webhook, before you send an email, before you render a thank-you page. Every integration that runs afterwards is allowed to fail, and none of them may take the response with them. Reinterpreted is the subtler half. A response answered in March under one set of questions, read back in December after the form has been edited, must still mean what the person said. So a published form is immutable: editing creates a new version, every response records the version it was answered under, and the response view renders the questions from that version rather than from the current one. Without this a renamed field turns yesterday's answers into confident nonsense, and nobody notices because the table still looks full. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, one process, server-rendered public flow · the public form must work before any client script runs - Storage: SQLite with responses written in a single transaction · the response is the product and the database is where it lands first - Versioning: Published form versions are immutable rows, drafts are separate · a response means what it meant on the day it was given - Logic: Branch conditions evaluated on the server with a reachability check at publish · a dead end in a flow is a form nobody can finish - Delivery: A queue with retries, signatures and an idempotency key per response · an integration failing must never cost you the answer ### Data model (create this before Phase 1) - forms(id, name, created_at, archived_at) - form_versions(id, form_id, version, schema_json, published_at, published_by) · immutable once published - fields(version_id, key, ordinal, kind, label, help, required, validation_json, step) · part of the frozen schema - branches(version_id, from_step, condition_json, to_step) · conditions evaluated server side - responses(id, form_id, version_id, started_at, submitted_at, answers_json, partial, source_ref, ip_hash) - deliveries(id, response_id, target, idempotency_key, state, attempts, last_error, delivered_at) - uploads(id, response_id, field_key, path, sha256, mime, bytes, scanned_state) - The version_id on every response is what makes an old answer readable: the response page renders labels from that version, never from the current draft. ### Phase 1 · A schema you can freeze Build: Build the field schema, the editor that produces it, and a publish action that writes an immutable version row. Editing a published form starts a new draft. Keep field keys stable across versions so exports line up, and refuse to reuse a key for a different kind. Trap: Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. Done when: Publishing twice produces two version rows and the first is unchanged, verified by hash. A response records its version and renders with that version's labels after the form is edited, tested directly. Reusing a field key for a different kind is refused at publish. An export names columns by field key, so two versions of the same form still line up. Do not build yet: No public flow, branching, partial saves or delivery. ### Phase 2 · A flow with no dead ends Build: Evaluate branch conditions on the server when a step is submitted, and return the next step. At publish, walk the graph: report any step no path reaches and any path that terminates without a submit. Support going back without losing answers already given. Trap: Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. Done when: A form with an unreachable step is refused at publish with the step named, tested with a deliberate graph. A path that ends without reaching submit is refused the same way. Going back and changing an answer re-evaluates the branch and discards answers that are no longer on the path, visibly. A crafted request that skips a required step is rejected server side, tested. Do not build yet: No partial saves, uploads or delivery yet. ### Phase 3 · Answers that survive a closed tab Build: Save answers to the server at each step transition, not at the end. Keep a resume token in the browser so a returning visitor continues where they stopped. Mark partial rows clearly and never mix them into submitted counts. Trap: Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. Done when: Closing the tab mid-flow and returning with the resume token restores the answers already given, tested end to end. A partial row is never counted as a submission anywhere in the interface or the export, asserted in the suite. Answers are saved at each step transition rather than only at the end, verified by inspecting the network calls. A partial older than the configured retention is deleted and the deletion logged. Do not build yet: No delivery, uploads or abuse controls yet. ### Phase 4 · Delivery that happens once Build: Queue one delivery row per response and target with an idempotency key. Sign the payload with a shared secret and a timestamp. Retry with backoff on failures that happened before the receiver could have accepted, and mark an unknown outcome for review rather than retrying blindly. Show failures in the interface, not only in a log. Trap: Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. Done when: A receiver that times out after accepting produces one delivery, not two, tested with a fake receiver that records calls. The submit request completes without waiting for any integration, tested by delaying the receiver. Every payload carries a signature and a timestamp the receiver can verify, with the verification documented. Failed deliveries are visible in the interface with their error and attempt count. Do not build yet: No spam controls, uploads or reporting yet. ### Phase 5 · Uploads, abuse and getting the data out Build: Bound uploads by size and type, store them outside the web root with generated names, and never trust the client-supplied name or type. Rate limit per address with a hashed key, add a timed honeypot, and record why a submission was rejected. Export responses and uploads together, and delete a response and its files for real when asked. Trap: Storing an upload under the name the browser supplied puts an attacker-chosen path on your disk and an attacker-chosen extension in front of your web server. Generate the stored name, keep the original name as a label only, and serve downloads through a handler that sets the type yourself. Done when: An upload with a crafted name is stored under a generated name and the original is kept only as a label, tested with a traversal attempt. Files over the configured size or outside the allowed types are rejected before writing, with the limit shown to the visitor. Deleting a response removes its upload files from disk, tested by checking the path afterwards. The export contains every response with its version and its uploads. Do not build yet: Nothing after this. ### Out of scope (and why) - Payments inside the flow, which drags in a processor, refunds and tax handling and deserves its own project. - A template and integration catalogue, which is the part of the subscription that is really an ongoing maintenance contract. - Team accounts and approvals, which multiply every screen here by a permission model. - Guaranteed email delivery to any inbox, which is an operational commitment a self-hosted form cannot honestly make. ### README must contain - That published form versions are immutable and every response records the version it was answered under - That branch conditions are evaluated on the server and publish refuses unreachable steps - That answers are saved at each step, and why an unload-time save cannot be relied on - How webhook payloads are signed and how a receiver verifies the signature and idempotency key - That uploads are stored under generated names and deleting a response deletes its files
# Architecture · Heyflow ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, one process, server-rendered public flow | the public form must work before any client script runs | | Storage | SQLite with responses written in a single transaction | the response is the product and the database is where it lands first | | Versioning | Published form versions are immutable rows, drafts are separate | a response means what it meant on the day it was given | | Logic | Branch conditions evaluated on the server with a reachability check at publish | a dead end in a flow is a form nobody can finish | | Delivery | A queue with retries, signatures and an idempotency key per response | an integration failing must never cost you the answer | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Versions | Immutable published schemas. | Any storage, provided published rows are never edited. | | Flow | Server-side branch evaluation and reachability. | Any evaluator, provided the next step is decided on the server. | | Responses | Partials, submissions and the version link. | Any store, provided partials never count as submissions. | | Deliveries | Queue, signature, idempotency and retries. | Any transport, provided one response yields one delivery. | | Uploads | Generated names, bounds and real deletion. | Any object store, provided client names never reach the filesystem. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Forms, versions, responses and deliveries. - `UPLOAD_DIR` · required · Outside the web root, generated names only. - `MAX_UPLOAD_BYTES` · required · Enforced before writing, and shown to the visitor as a limit. - `WEBHOOK_SECRET` · optional, secret · Signs outgoing payloads. The README documents the verification. - `SMTP_URL` · optional, secret · Notifications. Unset means notifications are off and the form still works. - `PARTIAL_RETENTION_DAYS` · required · Half-finished answers are deleted after this, and the deletion is logged. - `RATE_LIMIT_PER_HOUR` · required · Per hashed address. Rejections are counted, not silent. ## 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 · Heyflow product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, one process, server-rendered public flow, SQLite with responses written in a single transaction, Published form versions are immutable rows, drafts are separate, Branch conditions evaluated on the server with a reachability check at publish, A queue with retries, signatures and an idempotency key per response. - 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 - Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. - Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. - Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. - Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. - Storing an upload under the name the browser supplied puts an attacker-chosen path on your disk and an attacker-chosen extension in front of your web server. Generate the stored name, keep the original name as a label only, and serve downloads through a handler that sets the type yourself.
# Delivery milestones · Heyflow Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A schema you can freeze Publishing a form produces an immutable version that responses can point at. ### Steps 1. Make publish write an immutable row Drafts are mutable, published versions are not. Files: `src/lib/versions.ts`, `tests/version-immutable.test.ts` 2. Keep field keys stable, kinds fixed The key is the contract with every export that already exists. Files: `src/lib/schema.ts` 3. Render responses through their own version This is the difference between a record and a guess. Files: `src/app/responses/[id]/page.tsx` ### Done when - [ ] Two publishes produce two rows and the first is unchanged - [ ] An old response renders with its own labels - [ ] A reused key with a new kind is refused - [ ] Exports name columns by field key ### Watch out - Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version. ## M2 · A flow with no dead ends Every step is reachable and every path reaches the end. ### Steps 1. Decide the next step on the server The browser asks, the server answers. Files: `src/lib/flow.ts` 2. Walk the graph at publish Unreachable steps and paths with no ending are publish-time errors. Files: `src/lib/reachability.ts`, `tests/reachability.test.ts` 3. Re-evaluate on back navigation Answers off the new path are discarded in view, not silently kept. Files: `src/lib/flow.ts` ### Done when - [ ] An unreachable step blocks publish - [ ] A path with no ending blocks publish - [ ] Back navigation re-evaluates and shows what was dropped - [ ] A skipped required step is rejected server side ### Watch out - Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken. ## M3 · Answers that survive a closed tab A half-finished flow can be resumed, and the partial is stored as a partial. ### Steps 1. Persist at every step transition The page being open is the only time you can rely on. Files: `src/app/api/step/route.ts` 2. Keep partials separate from submissions A completion rate built on mixed rows is a made-up number. Files: `src/lib/responses.ts`, `tests/partial-counts.test.ts` 3. Expire partials on a stated retention Half-finished answers are still personal data. Files: `src/lib/retention.ts` ### Done when - [ ] A resumed flow restores prior answers - [ ] Partials never appear in submitted counts - [ ] Answers persist at each step, not only at the end - [ ] Partials expire on the stated retention ### Watch out - Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive. ## M4 · Delivery that happens once A webhook or email goes out once per response, or fails visibly. ### Steps 1. Queue, do not call, inside submit The response is saved before any integration is attempted. Files: `src/lib/deliveries.ts` 2. Carry an idempotency key and a signature One key per response and target, sent on every attempt. Files: `src/lib/webhook.ts`, `tests/delivery-once.test.ts` 3. Surface failures where the user looks A dead webhook that only shows up in a log is a lost week of leads. Files: `src/app/deliveries/page.tsx` ### Done when - [ ] A timed-out receiver gets one delivery - [ ] Submit does not wait on integrations - [ ] Payloads carry signature and timestamp - [ ] Failures are visible in the interface ### Watch out - Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it. ## M5 · Uploads, abuse and getting the data out (production only) The public form survives contact with the internet and the data can leave. ### Steps 1. Generate stored names, keep the original as a label The client-supplied name never touches the filesystem. Files: `src/lib/uploads.ts`, `tests/upload-names.test.ts` 2. Rate limit and honeypot, and record rejections A rejection you cannot count is a form you cannot tune. Files: `src/lib/abuse.ts` 3. Make deletion delete the files too A row removed while the file stays is not deletion. Files: `src/lib/responses.ts` ### Done when - [ ] A crafted filename cannot escape the upload folder - [ ] Oversized and wrong-type uploads are rejected with the limit shown - [ ] Deleting a response removes its files - [ ] Export includes version and uploads ### Watch out - Storing an upload under the name the browser supplied puts an attacker-chosen path on your disk and an attacker-chosen extension in front of your web server. Generate the stored name, keep the original name as a label only, and serve downloads through a handler that sets the type yourself.
# Operations · Heyflow ## Backup The database and the upload folder together, since a response referencing a file that is gone is an incomplete record. Back up before every schema migration as well, because form versions are the part you cannot reconstruct. ## Restore Restore both, then open the oldest response you have and confirm it renders with its own version's labels. That single check proves the versioning survived the restore. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Failed deliveries, the age of the oldest queued delivery, partial volume against submissions, and rejection counts by reason. A webhook that has been failing for a week is the usual quiet disaster. ## Incident checklist A missing lead is almost always a delivery that failed, not a response that vanished, because the response is written first. Check the deliveries table before anything else, and replay by idempotency key rather than resubmitting. 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 - [ ] An edited form is confirmed not to change an old response's labels - [ ] An unreachable step is confirmed to block publish - [ ] A resumed flow is confirmed to restore prior answers - [ ] A timed-out receiver is confirmed to get one delivery - [ ] A crafted upload name is confirmed unable to escape the folder ## Launch constraint Do not market omitted Heyflow 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. Forms, versions, responses and deliveries. DATABASE_PATH=./data/forms.db # Required. Outside the web root, generated names only. UPLOAD_DIR=./data/uploads # Required. Enforced before writing, and shown to the visitor as a limit. MAX_UPLOAD_BYTES=10485760 # Optional · secret. Signs outgoing payloads. The README documents the verification. WEBHOOK_SECRET=a long random string # Optional · secret. Notifications. Unset means notifications are off and the form still works. SMTP_URL=smtp://user:pass@host:587 # Required. Half-finished answers are deleted after this, and the deletion is logged. PARTIAL_RETENTION_DAYS=30 # Required. Per hashed address. Rejections are counted, not silent. RATE_LIMIT_PER_HOUR=20
$ choose a build depth, inspect the files, then open the complete pack in your agent
Heyflow: Customers pay for dependable collection, spam control, notifications, integrations, and confidence that responses will not disappear.
xlarge integration and template catalog
xcompliance controls and guaranteed delivery
xenterprise workflow logic
xspam and abuse defense at scale
Don't feel like building it? These folks already made it free.
all 3 free alternatives to Heyflow →· no votes, no pay-to-list · just what's real
Heyflow pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| growth (lead generation) | $102.71/workspace | — | 250 responses/month, 10 live funnels and 2 seats; includes Core, Design and Conversion bundles |
| scale (lead generation) | $275.81/workspace | — | 1,000 responses/month, 20 live funnels and 4 seats; includes all feature bundles |
| enterprise (lead generation) | $1269.40/workspace | — | Starts above 5,000 responses/month with unlimited live funnels; seats and other limits are custom |
| prime (e-commerce) | $229.65/workspace | — | 25,000 visitors/month, 10 quiz funnels and 4 seats |
| custom (e-commerce) | custom | — | Unlimited visitors, quiz funnels and responses; seats and commercial terms are custom |
free tierno free tier; 14-day trial with no card
billingmonthly + annual; annual discount is advertised as up to 31% on the plan table but up to 51% in the FAQ, and exact annual tier prices are not exposed; annual commitments are billed upfront unless invoicing terms are negotiated
hidden costsLead-generation overages/add-ons: responses from €0.15 ($0.17) each, extra funnel €8 ($9.23), Design/Conversion/Insights/Advanced Integrations €40 ($46.16) each/month, Coding €90 ($103.86), Security €30 ($34.62), Success €100 ($115.40), and extra seat €35 ($40.39). E-commerce Prime overages are €10 ($11.54) per 1,000 visitors, €80 ($92.32) per 25,000 visitors, €100 ($115.40) per 10 extra funnels, and €35 ($40.39) per seat. VAT is extra.
verified 2026-08-12 · source ↗
Vibecode Heyflow
Kinda. The core of Heyflow is buildable in a weekend with the prompt on this page, but there are real gaps: large integration and template catalog, compliance controls and guaranteed delivery. Read the honest list above before committing.
How much does Heyflow cost?
Heyflow costs about $102.71/month (Growth (Lead Generation), checked 2026-08-12), which is $1232.52 per year.
What do I lose by replacing Heyflow?
Honestly: large integration and template catalog; compliance controls and guaranteed delivery; enterprise workflow logic; spam and abuse defense at scale. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Heyflow?
Yes: Typebot (Conversational landing flows with branching, calculations, payments, and analytics; the licence is fair-source, not open-source) Tally (Multi-page forms, calculations, conditional logic, and result pages are free; removing the branding is not) Forminator (Forms, quizzes, calculators, and payments for free, provided the landing page lives in WordPress) All 3 curated free alternatives are at vibecodeit.com/heyflow/alternatives. The prompt is for when you want it exactly your way.