How to build a Tally alternative
How to build a Tally alternative with a block builder, a response table, and a signed webhook. Follow the prompts, check each step, then skip payments.
You can learn how to build a Tally alternative as a document of blocks, a table of answers, and a signed webhook. Tally is that product. The catalog line is “A clean document-like form builder with the exports you need.”
You pay $29–$89 a month. That is $348–$1,068 a year. The verdict is “Forms are wonderfully finite”. The first estimate is 8 hours: block builder, response table, csv export.
There is no standalone teardown yet. The Typeform post already names Tally as the document-style cousin. This guide is the walk.
The audience is a small team publishing lead, feedback, and application forms. If you take payments in the form, keep paying. The same v1 also replaces Fillout and Paperform.
Typeform is the one-question cousin, $29–$99 a month, about 2 hours. I walked that as How to build a Typeform alternative. SurveyMonkey is $39–$75 a person a month, about 4 hours, when you want charts more than a builder.
How to build a Tally alternative without payments
The Tally page splits the work into three rows.
The first row is the one you want. A document-style builder. A public page that shows every block at once. A table you can filter. A CSV. That is the 8-hour v1.
The card also lists email plus a signed outbound webhook. Ship the hook with the table. Leave file uploads and conditional visibility on the 3-day row.
The third row is 1 month and up. Payments, quizzes, a template gallery, an integration marketplace. Skip it. If you tell an agent to “clone Tally”, it aims there. Pin every prompt to the first row, plus the webhook.
v1 does this. Flavio stacks blocks, publishes a slug, and a stranger fills the live page. One row lands. An email goes out. A receiver can check a signature. No card number, no quiz score, no Zapier product.
The parts and where a request goes
Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Tally is Astro, HTMX, and D1. This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. Pick one stack and stay on it.
The stack post is which tab you copy. The prompt post uses Tally as the 8-hour pin. The scope post is why the marketplace waits.
Open Build your own Tally. The guardrail is the useful sentence: validate every answer against the published form version, rate-limit public submissions, and sign webhook deliveries. The non-goal sits next to it. No payments, quizzes, collaborative editing, an integration marketplace, advanced branching, or a public template gallery.
A save, a publish, and a submit are ordinary form posts. Email and the webhook start after the insert commits. A slow receiver cannot stall the thank-you page. R2 waits until you allow file blocks.
The data the form carries
The card lists Form, Block, Option, LogicRule, Submission, and Answer. Tonight you need those minus LogicRule, plus a frozen Version. The version is what the visitor filled.
Flavio owns a form: title, slug, colors, thank-you, optional close date, pointer to the live version. Publishing writes a numbered snapshot of blocks and options. Old answers keep pointing at the version they saw. A block is one field, short text, email, long text, choice, rating, or date. Choice blocks grow options. A submission is one accepted fill with a one-time token. Each answer keys to a block id, so a later rename does not scramble the table. The webhook URL and secret live on the form. Delivery attempts hang off the submission, so you can see which row failed.
forms.slug is unique per owner. submissions.token is unique. A delivery key is unique per submission and endpoint, so a retry is the same message twice, not two messages.
The screens you ship
Four surfaces. Keep them boxes and labels. If the agent starts matching Tally’s live UI, send it back.
The forms list is home. New form, title, slug, published or draft, a reply count.
The builder is a stacked document. Each block is a row you can drag. Type, label, required, and options sit on the block. Slug, colors, thank-you, and close date sit at the top. Publish is not save.
The public form is that document with the chrome stripped. Intro, every live block, send. No progress stepper. That stepper is the Typeform v1.
The response table is Flavio’s inbox. Newest first. Columns follow the published labels. CSV downloads the current filter. A mark shows whether the webhook landed.
The publish and submit workflow
Flavio edits a draft. The live URL still serves the last published version. Publish freezes the current blocks as version 3 and points the public page at it.
Ada opens /rome-js with a one-time token in the HTML. She fills the page and hits send. The server checks the token, the close date, the honeypot, the rate limit, and every required block on the version she loaded. First insert wins. Mail and the webhook start after that insert.
If Flavio published version 3 while she still had version 2 open, her submit still validates against version 2. That is the first mini-prompt on the card.
A waitlist with a confirm link is a different job. That walkthrough is How to build a waitlist.
Prompt 1. Scaffold Formlet and stop
Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it.
Ship the 8-hour row, plus a signed
webhook after each accepted row.
Project name: Formlet.
Public URL is https://forms.flaviocopes.com.
Owner is Flavio.
Seed form: Rome JS workshop,
slug rome-js.
Blocks: name (short text, required),
email (required), session (choice:
Sat morning, Sat afternoon),
notes (long, optional).
Thank-you: You're on the list.
I'll email the room details.
Do not add file uploads,
conditional visibility, payments,
quizzes, a template gallery, or
an integration marketplace.
This is a document, not a
one-question stepper.
When the plan is done, stop.
Do not write code until I say go.
The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.
You want / for the list, /forms/rome-js/edit for the builder, /rome-js for the public document, /forms/rome-js/replies for the table, and a CSV on that same path. Tables for forms, versions, blocks, options, submissions, answers, webhook endpoints, and deliveries. No payments, no quiz_scores, no integrations.
If the plan includes Stripe, a question-by-question stepper, or a template marketplace, send it back. When the plan is narrow, say go.
What to check after the scaffold
Start the app. Sign in as Flavio. You should see an empty list and a new-form action. submissions has a unique token. forms has a unique slug per owner. There is a versions table. There is no amount column.
If the README is a placeholder, stop. It has to name the published-version rule, the honeypot, the rate limit, and how a receiver checks the webhook.
Prompt 2. Stack the document of blocks
The builder is the 8-hour difference from Typeform. All blocks on one canvas. Reorder. Mark required. Choice blocks grow options.
Build the Formlet builder.
Flavio opens /forms/rome-js/edit.
The page is a stacked document,
not a stepper.
Block types tonight: short text,
email, long text, single choice,
rating 1-5, date.
Each block has a label, required
flag, and position.
Choice blocks have ordered options.
Add, delete, and reorder blocks
on the draft. Save writes the
draft only. The public /rome-js
page must not change on save.
Slug is lowercase, unique for
Flavio. Reject a second rome-js.
Colors, thank-you text, and an
optional close date live on the
form. Preview shows the draft.
Leave Publish saying not yet.
Do not add page breaks, hidden
fields, or file blocks.
Done when I can add a notes
block, drag session above email,
reload, and /rome-js is still
the empty public shell.
What to check after the builder works
Create Rome JS workshop. Add notes. Drag session above email. Reload. The order held. Rename the title to Rome JS workshop June. Slug stays rome-js. A second form named rome-js is refused.
Open /rome-js in a private window. You should see a not-published state, not the dirty draft. If the agent built one question per screen, stop. That is How to build a Typeform alternative. This app is a page.
Prompt 3. Publish a version and take one response
The first mini-prompt on the card is “Publish version 3, validate against version 3”. The third is “One click, one row”. Tonight those are one publish button and one public submit.
Build publish and submit for
Formlet.
Publish copies the current draft
into an immutable numbered
version and points the public
page at it. Edits after that
touch the draft only.
/rome-js serves the latest
published version. Embed the
version number and a one-time
submission token in the page.
Submit checks, in this order:
token unused, form not closed,
close date still ahead, honeypot
empty, under 20 posts an hour
from that IP, then every required
block on the version the token
was issued for.
Accept a submit that started on
version 2 even if version 3 is
live. Store the answers against
version 2.
A used token returns the same
thank-you and inserts nothing.
A closed form stores nothing.
Honeypot filled: same thank-you,
no row, no mail, no webhook.
Rate limit: a clear try-later
message, no row.
Do not send email yet.
Do not call a webhook yet.
Done when I publish v1, fill as
Ada, publish v2 that deletes
notes, and Ada's old row still
shows the notes label she saw.
Hammering send still leaves
one row.
What to check after a real row lands
Publish. Open /rome-js logged out. All four blocks on one page. Submit as Ada Rossi, [email protected], Sat morning. Thank-you shows the seed sentence. The table has one row. Hit send again on that tab. Still one row.
Delete the notes block. Publish. Ada’s row still shows the notes label she saw. A new visitor does not see notes. Set a close date in the past. A new submit is rejected. Fill the honeypot in the page source. Thank-you appears. No row.
If the agent rewrites old answers when you rename a label, you skipped versioning.
Prompt 4. Table, CSV, and a signed webhook
The table is how you cancel Tally. The webhook is how the answer reaches a tool you already run. The fourth mini-prompt is “The webhook the receiver can trust”.
Build replies, CSV, email, and
a signed webhook for Formlet.
/forms/rome-js/replies lists
submissions newest first.
Columns follow the selected
version's labels. Filter by
session and by date.
CSV downloads the current
filter. Prefix values that
look like =, +, -, or @ with
a quote so a spreadsheet
does not run them.
After the insert commits,
enqueue one email to Flavio
and one webhook delivery.
Do not send them inside the
submit request.
Webhook body is JSON: form
slug, version, submission id,
submitted time, answers.
Sign the exact bytes with
HMAC-SHA256 and the endpoint
secret. Send
X-Formlet-Signature,
X-Formlet-Timestamp, and
X-Formlet-Idempotency-Key.
The key is stable per
submission and endpoint.
Retries reuse body and key.
Timeouts and 5xx retry.
4xx stops. Cap at 5 attempts.
Show delivered, pending, or
failed on the row.
Email is a bonus. If mail
fails, the row still exists.
Done when Ada's row appears,
the CSV opens in a sheet,
and a request bin receives a
verifiable signature.
First attempt can ride waitUntil after the insert. Later retries need a sweep. A five-minute cron is enough.
crons = ["*/5 * * * *"]
That line only looks at deliveries still pending. It does not fetch Ada’s browser.
What to check after the table fills
Open replies. Ada is first. Session says Sat morning. Download the CSV. Ada’s email is one cell. A notes value of =2+2 stays text.
Point the endpoint at a request bin you control. Submit Luca. The bin shows the three headers and a body you can HMAC yourself. Unplug the bin. Submit again. The row is still there. The mark says pending, then failed after the cap. Manual redelivery uses the same key. Kill the mail provider. The row still appears.
If the thank-you spins until a webhook 200, the agent sent the hook inside the request. Move it behind the insert.
Failures the agent ships anyway
The live page mutates while Ada types
Save and publish got glued together. You notice when a rename changes yesterday’s column title. Published versions stay frozen, and drafts stay off the live URL.
Two rows for one person
The button disabled in JavaScript, and that was the whole plan. A double click inserts twice. The unique token is the rule. The disabled button is politeness.
The webhook blocks the thank-you
A slow receiver makes /rome-js feel broken. Store, respond, then deliver. You notice this when a down hook makes visitors retry, and you get five rows.
An unsigned POST to a stranger
The agent posts JSON and calls it a webhook. Anyone who guesses the URL can fake a signup. HMAC over the exact body, a timestamp, and a stable key. A receiver that cannot verify from the README is not done.
A Typeform hiding in the builder
One field, a next button, a progress 2 of 4. That is the other guide. If /rome-js paginates questions, you left this product. A Stripe field is the same miss. Remove it, and any amount column.
Deploy the forms
Formlet wants Astro, HTMX, and D1. Put it on a subdomain you already own, forms.flaviocopes.com. D1 holds drafts, versions, and answers. Mail uses whatever transactional sender you already pay for. The webhook is outbound HTTPS.
The hosting post is that path: the adapter, then the bindings, then the custom domain.
Attach the domain on the Worker. Sign in as Flavio from your phone on cellular. Publish Rome JS. Fill it from a second phone. Confirm the laptop table shows Ada after a refresh. Check the request bin from the same submit.
Ship the workshop form and one form you need this month. Take a D1 backup before you delete the paid account. Use it as the real intake for a week. If you miss file uploads or a field that hides itself, those are the 3-day row. Add them after someone has submitted.
Then cancel Tally, or Fillout, whichever one you pay for the inbox.
What to skip on purpose
Leave out payments, quizzes, collaborative editing, a template gallery, and an integration marketplace. File uploads and conditional visibility stay on the 3-day row. You already took the webhook. The other two wait until a real visitor asks.
A one-question flow you already need tonight is the Typeform guide. A line of emails with a confirm link is the waitlist guide. More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.
Start now
Go to Build your own Tally. Copy the Astro prompt, or TanStack Start if that is what you write.
Tell the agent the first estimate only: block builder, response table, csv export, plus a signed webhook. Eight hours.
Point it at one form you already sent last week. When Ada’s row is in your table, you can cancel.