How I Built a Micro-SaaS in a Weekend (And the Mistakes I Made)

12 min read Original article ↗

In my last blog post, I wrote about why I decided to build FormBeep. I felt really good writing the intro post and thought why not write on how I decided to build it.

Learning from Past Mistakes

When I built my other project called TrackMonk (which is still in beta!), I had decided to go with the traditional self-hosting route: a VPS at Hetzner, backend built with FastAPI, frontend with Next.js.

While I love the flexibility, the whole process is really complicated for a one-person project. Managing Docker containers, setting up CI/CD pipelines, configuring Nginx reverse proxies, SSL certificate renewals, database backups, monitoring, security patches - it’s such a nightmare when you’re also trying to build the actual product.

Every time I wanted to ship a new feature, I’d spend more time debugging deployment issues than writing code.

When I had the idea for FormBeep, I wanted something different. I wanted to build something fast over a weekend - something I could do end-to-end to see and live the entire product lifecycle, and learn from it.

No DevOps. No infrastructure management. Just code and ship.

Why I Chose Cloudflare

I made one critical decision early: use Cloudflare for everything.

Here’s why:

1. I was already using Cloudflare

I use Cloudflare as DNS for all my projects and Cloudflare Pages for hosting my blog and other static sites. I knew the dashboard, I trusted the platform, and I didn’t want to learn another service.

2. I had experience with Workers

While building plugins for TRMNL, I used Cloudflare Workers extensively. I knew how fast I could deploy and debug. wrangler dev for local testing, wrangler deploy for production - done in seconds. No CI/CD pipelines, no deployment scripts.

3. Zero cost to start

Cloudflare Workers has a generous free tier: 100,000 requests per day. For an MVP with zero users, that’s perfect. No credit card required and no bill anxiety unlike Vercel or Netlify. I could build, launch, and scale without worrying about costs unless I actually reached scale.

4. Edge computing in one place

Workers run at the edge, close to users. Form submissions from India, Brazil, or the US all get processed in milliseconds. I didn’t have to think about regions, CDNs, or load balancers. Cloudflare handles it.

5. Everything integrated

  • Workers for the API
  • D1 for the database
  • KV for rate limiting
  • Pages for the dashboard
  • R2 if I need object storage later

One vendor, one CLI (wrangler), one mental model. When you’re building solo over a weekend, this is much easier.

The Architecture

The MVP was simple: accept form submissions, send WhatsApp notifications, manage user config. I also decided to go for a mono-repo structure since maintaining multiple repos each for docs, landing, dashboard and backend was too much for a weekend project.

graph LR A[Website Form] -->|POST /v1/submit/:apiKey| B[Cloudflare Worker] B -->|Validate & Store| C[D1 Database] B -->|Check Rate Limit| D[KV Store] B -->|Send Template| E[WhatsApp API] E -->|View Details Button| F[User's Phone] F -->|Tap Button| G[Webhook Handler] G -->|Fetch Data| C G -->|Send Full Message| E G -->|Delete Data| C

What I Built

Here’s the full repository structure:

formbeep/
├── worker/          # Backend (Cloudflare Worker)
│   ├── src/
│   │   ├── index.js
│   │   ├── handlers/
│   │   ├── lib/
│   │   └── middleware/
│   └── wrangler.toml
├── dashboard/       # Plain HTML/CSS/JS
│   ├── home.html
│   ├── domains.html
│   ├── numbers.html
│   ├── logs.html
│   └── shared.js
├── docs/            # Hugo docs site
│   └── content/
└── landing/         # Hugo marketing site

Core features:

  1. Onboarding flow - QR code signup where users verify their WhatsApp number by sending a message with a verification code
  2. Form submission API - POST /v1/submit/:apiKey accepts form data, validates origin, sends WhatsApp notification
  3. Two-message pattern - Template message with “View Details” button (works outside 24h window) → user taps button → free-form message with full form data
  4. Dashboard - API key management, domain allowlist, usage tracking, billing integration

Tech stack:

  • Backend: Cloudflare Worker + Hono for routing (~2,000 lines)
  • Auth: Clerk for JWT verification (RS256 + JWKS)
  • Database: Cloudflare D1 (SQLite at the edge)
  • Frontend: Plain HTML/CSS/JS (no React, no build step)
  • Payments: Stripe (via webhooks)
  • Notifications: Meta WhatsApp Business API. Tried Twilio, I was not able to get my number verified so finally decided to use Meta API directly.

For documentation, I initially looked at Material for MkDocs but got confused since the team was building another SSG called Zensical. I didn’t want to spend time figuring out another SSG, so I went with good old Hugo and the Hugo Book theme. Used AI to keep consistent styling across landing, dashboard, and docs.

Total time: ~20 hours spread across a weekend. Most of it worked on the first deploy.

The WhatsApp Problem: 24-Hour Window

Here’s a problem I ran into that completely changed my design.

In my first design, the flow was simple: you get a form submission → you receive the full message instantly on WhatsApp with all the form data. This worked perfectly in my testing setup on my phone.

But there was a catch.

Meta doesn’t allow you to send free-form messages directly to a user unless they’ve texted you in the last 24 hours. This is called the “24-hour customer service window.”

I never realised it becauase, during signup the user sends a verification message which indirectly initiates the 24-hour window. So it always worked at my end, but say a user hasn’t replied or sent any texts to the number in 24 hours, all messages would get rejected by Whatsapp API.

The idea is to prevent spam - businesses can only send promotional or transactional messages if the user recently initiated contact. Outside that window, you can only send pre-approved template messages.

For FormBeep, this was a problem. A user might get a form submission at any time - days or weeks after their last message. I couldn’t just send the form data directly because it would be outside the 24-hour window.

The Solution: Two-Message Pattern

I had to redesign the flow:

  1. Template message - When a form is submitted, send a pre-approved template message that just says “New form submission from [domain]” with a “View Details” quick reply button. Template messages work anytime, no 24-hour window required.

  2. User taps button - When the user taps “View Details”, WhatsApp sends a webhook to my Worker. This interaction opens a new 24-hour service window.

  3. Free-form message - Now I can send the actual form data (name, email, message, etc.) as a free-form message. After sending, I delete the form data from D1.

This pattern solved the 24-hour window problem while keeping data storage minimal. The form data only lives in the database until the user views it, then it’s permanently deleted. If it’s not viewed within 7 days, it gets auto-deleted anyway.

Pro tip: The message template took me 3-4 days to get approval from Meta, which delayed the project. If you’re building anything with WhatsApp Business API, finalize your message templates and send them for approval as soon as possible. Don’t wait until everything else is ready - template approval is a bottleneck.

The Mistake: Choosing KV for User Config

Here’s where I screwed up.

I wanted something simple, so in my mind Cloudflare KV seemed perfect for storing user configuration - plan limits, WhatsApp numbers, API keys, allowed domains. Just a key-value store. Easy.

But I wasn’t aware of the latency issues of KV.

Cloudflare KV is designed for eventually consistent data. Writes can take up to 60 seconds to propagate globally across Cloudflare’s edge network.

That’s fine for caching static assets or storing session tokens. It’s terrible for user configuration that needs to be read immediately.

The problem: Eventual consistency killed UX

The flow looked like this:

  1. User completes onboarding, verifies WhatsApp number
  2. I write their config to KV: kv.put(userId, config)
  3. Redirect them to dashboard with embed snippet
  4. User excitedly pastes the code on their website and submits a test form
  5. My Worker reads from KV: kv.get(apiKey)returns null
  6. Error: User config not found

The data was written. It just hadn’t propagated to the edge location processing their request yet.

Same problem after every config change:

  • User adds a new domain → waits up to 60 seconds before it works
  • User upgrades their plan → waits 60 seconds before new limits apply
  • User regenerates their API key → old key still valid for 60 seconds, new key doesn’t work yet

For a product where you paste one line of code and expect instant results, this was a terrible experience.

I tried adding retries with exponential backoff. I tried caching strategies. Nothing worked cleanly. The fundamental problem was using eventually consistent storage for data that needed strong consistency.

The second problem: No SQL = No analytics

I also realized KV made data analysis a nightmare. Want to answer simple questions like:

  • How many users signed up this week?
  • Which domains are generating the most submissions?
  • What’s the average time between signup and first submission?

With KV, you’d have to iterate over every key in the namespace, parse the JSON, aggregate manually. No indexes. No queries. Just brute force.

With SQL, it’s one query:

SELECT COUNT(*) FROM users
WHERE created_at > date('now', '-7 days');

I needed a real database.

The Fix: Moving to D1

After a day of fighting KV propagation issues, I made the switch to Cloudflare D1 - SQLite at the edge.

The migration took about 2 hours. I created three tables:

-- User accounts and configuration
CREATE TABLE users (
  user_id TEXT PRIMARY KEY,
  api_key TEXT UNIQUE NOT NULL,
  plan TEXT DEFAULT 'free',
  whatsapp_numbers TEXT DEFAULT '[]',  -- JSON array
  allowed_domains TEXT DEFAULT '[]',   -- JSON array
  stripe_customer_id TEXT,
  subscription_status TEXT,
  subscription_expires_at INTEGER,     -- Unix ms
  created_at INTEGER NOT NULL
);
CREATE INDEX idx_users_api_key ON users(api_key);

-- Message counters (atomic increments per billing period)
CREATE TABLE message_counts (
  user_id TEXT NOT NULL,
  period_key TEXT NOT NULL,
  count INTEGER DEFAULT 0,
  PRIMARY KEY (user_id, period_key)
);

-- Submission logs (for dashboard + debugging)
CREATE TABLE logs (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id TEXT NOT NULL,
  domain TEXT,
  field_names TEXT,              -- JSON array (never values)
  recipients_count INTEGER,
  delivery_status TEXT,
  submission_ref TEXT,           -- Short ref shown in WhatsApp
  submission_data TEXT,          -- Form values (nulled after viewing)
  viewed_count INTEGER DEFAULT 0,
  created_at INTEGER
);
CREATE INDEX idx_logs_user_id ON logs(user_id);
CREATE INDEX idx_logs_submission_ref ON logs(submission_ref);

What D1 gave me:

1. Strong read-after-write consistency User signs up → config is written → next request reads it. No 60-second wait. No propagation delays. It just works.

2. Atomic operations Message counter increments are atomic with ON CONFLICT upserts:

INSERT INTO message_counts (user_id, period_key, count)
VALUES (?, ?, 1)
ON CONFLICT(user_id, period_key)
DO UPDATE SET count = count + 1;

No race conditions. No lost counts.

3. SQL queries for analytics Want to know how many users signed up this week?

SELECT COUNT(*) FROM users
WHERE created_at > unixepoch('now', '-7 days') * 1000;

Want to see which domains generate the most submissions?

SELECT domain, COUNT(*) as submissions
FROM logs
GROUP BY domain
ORDER BY submissions DESC
LIMIT 10;

With KV, these queries would require iterating over every key. With D1, it’s instant.

4. Indexes and foreign keys Looking up a user by API key? Indexed. No manual secondary KV keys needed.

5. Still at the edge D1 is replicated globally. Read queries are served from nearby replicas, writes go to the primary. For this use case (mostly reads after initial write), it’s fast enough.

The migration took 4 hours. I kept all the same Worker code, just swapped kv.get() calls with db.query() calls.

// Before: KV with manual retries
const config = await kv.get(`user:${apiKey}`, 'json');
if (!config) {
  await sleep(1000);
  config = await kv.get(`user:${apiKey}`, 'json');
}

// After: D1 with strong consistency
const result = await env.FORMBEEP_DB.prepare(
  'SELECT * FROM users WHERE api_key = ?'
).bind(apiKey).first();

I kept KV only for rate limiting (where eventual consistency is perfectly fine - if a user sneaks past the limit by 1-2 requests during propagation, it has no major effect on the system).

Deployment: 5 Seconds to Production

By Sunday night, I had:

  • ✅ Working onboarding flow (QR code verification)
  • ✅ Form submission endpoint
  • ✅ WhatsApp template notifications
  • ✅ Dashboard (domains, API keys, logs, billing)
  • ✅ Clerk authentication
  • ✅ Stripe payment integration
  • ✅ Rate limiting

I deployed to production:

Five seconds later, FormBeep dashboard was live at app.formbeep.com.

FormBeep design

No Docker builds. No CI/CD pipelines. No Kubernetes manifests. Just wrangler deploy.

I really loved how fast I could build and ship FormBeep using Cloudflare.

What I Learned

1. Start simple, migrate when it hurts I could have spent days researching the “right” database solution upfront. Instead, I started with KV, hit the consistency wall after 24 hours, and migrated to D1 in 4 hours. Total time lost: 2 hours. Time saved by not overthinking: probably 2 days.

2. Edge computing removes entire categories of problems No regions to configure. No CDN to set up. No load balancers. No deployment pipelines. Workers run everywhere by default. For a solo project, this eliminated weeks of DevOps work.

3. WhatsApp Business API has sharp edges The 24-hour messaging window forced a complete redesign of the notification flow. If you’re building with WhatsApp, get your message templates approved early - it’s a 3-4 day bottleneck I didn’t anticipate. Also worth knowing: Meta charges per template message, not per conversation — rates vary significantly by country, so model your costs before you scale.

4. Cloudflare’s free tier is legitimately generous 100k Worker requests/day, 5GB D1 storage, 100k KV reads/day - all free. I deployed to production without a credit card. The product runs at $0/month until it actually gets traction.


The platform works. Users can sign up, verify their WhatsApp number via QR code, add domains, and get instant notifications on form submissions.

Now I need to figure out if anyone actually wants this. That’s a different kind of problem than the ones I’m good at solving.

And if you have ideas (especially on achitecture or security) on how to make it better, I’m all ears! Reach out at hello[at]formbeep.com

With love, Rishi