Rajtík's blog

← Back to home

Business 10 min read

My first SaaS

How my first SaaS project came about. From the first idea to the design of my own proof-of-work algorithm.

How captchaapi.eu came about - from a German client through my own proof-of-work algorithm to weeks spent over GDPR.


I was working on a job for a long-term German client when he asked whether I knew any sensible CAPTCHA package for Laravel. I automatically offered reCAPTCHA or Cloudflare Turnstile - what I would deploy myself. But he calmly explained why that was out: his customers' data must not leave the EU and he does not want to hear about a cookie banner because of a CAPTCHA layer.

So I started looking. I assumed I would find dozens of EU-based alternatives - GDPR has been here for six years, someone must have solved this. Reality was different. EU CAPTCHA services exist, but almost all of them aim at enterprise clientele with a matching price. For a freelancer, a small agency or a five-person startup that wants to put a CAPTCHA on a contact form at a reasonable price, the offer was weak.

That is when it hit me:

This could be a gap in the market.

And so I decided to build my own CAPTCHA SaaS.

What I knew and what I did not

I knew almost nothing about GDPR. I did not know what Schrems II was or what Article 28 meant. I did not know how to write a DPA. What I could do: Laravel, PostgreSQL, Redis, and I knew I could build the foundations of a project that would work.

That was the starting point: a solid technical base and a willingness to chew through the compliance layer. That it would take almost as much time as the code itself, I found out only later.

Choosing the algorithm

The first decision: which type of CAPTCHA to build. I dropped the image puzzle (select all traffic lights) quickly. From my own experience I know what a pain it is to click on blurry images and guess whether the pole with a lamp is a traffic light or not. I dropped behavioural ML scoring too, but for a different reason: to run it I would have to collect data about my customer's clients - exactly what I wanted to avoid from the start.

That left proof-of-work. The client computes something, the server checks the result, done. No cookies, no fingerprinting, no cross-site data. Simple to implement, GDPR-clean by design.

The first iteration was naive: look for a hash that starts with a certain number of zeros. It works. I was surprised how little code it takes. PHP hash('sha256', $input) in a loop, check the beginning of the string, done.

Then came the problem of scaling the difficulty.

Why counting zeros does not work

The logic "more zeros, harder" sounds reasonable, but the maths is unpleasant: every additional required zero shrinks the space of possible solutions sixteen times (each hex position has 16 values, zero is only one of them). Difficulty does not grow linearly but exponentially.

In practice it looked like this: 4 zeros are found practically instantly. 5 zeros take a few seconds. 6 zeros take tens of seconds. And that on an M-series MacBook.

I cannot imagine what such an algorithm would do on a five-year-old Android with a slow CPU. The visitor would wait a minute for a contact form to send. As a CAPTCHA for real websites? Unusable.

I needed smooth scaling, not exponential.

Second iteration: a numeric target

The solution came from bitcoin mining, which has been solving the same problem for 15 years. Instead of counting zeros, you compare the numeric value of the first few bytes of the hash against a target value.

The server issues:

  • A random 32B token (stored in the Redis cache together with a hash of the IP address, TTL 2 minutes)
  • A target - a 32-bit integer

The client looks for a solution value such that:

hexdec(substr(sha256(token + solution), 0, 8)) <= target

The lower the target, the fewer hashes pass - the harder the computation. The key difference: the target can be any number between 1 and 2³²-1. I do not add zeros in jumps, I lower the numeric value smoothly. Difficulty now scales continuously, not in exponential steps.

In practice that means:

  • Free tier: target 0x000FFFFF down to 0x0000FFFF (4,096-65,536 expected hashes)
  • Business tier: target 0x003FFFFF down to 0x0003FFFF (1,024-16,384 hashes, four times faster)

A free tier visitor pays a few tens of milliseconds of CPU work in a Web Worker. A business tier visitor a bit over ten milliseconds. A botnet that wants to send a million submits pays that same price a million times - and that is exactly the point.

From challenge-verify to local HMAC verify

Originally I worked the classic way: the server issues a challenge, the client sends the solution, the server verifies and returns "OK/not-OK". Three round trips on every form submission and a potential bottleneck under heavier load.

Today the flow is different:

  1. The widget (JS on the client) requests a challenge and verifies the solution against our API
  2. The API returns a signed attestation token - base64url(payload).base64url(HMAC-SHA256(payload, secret))
  3. The integrator's backend receives the attestation in a hidden input on form submit and verifies the HMAC itself locally using the secret key

The integrator's backend never contacts captchaapi.eu - the whole verification runs locally, it is a pure cryptographic operation. No HTTP round trip. Sub-millisecond verification.

In format the token resembles a simplified JWT: it has a payload and a signature, separated by a dot. But it is not a JWT - there is no header, no alg field, it is a custom lightweight format built on the minimum of necessary fields. For that use case (a single-purpose attestation with a short TTL) a JWT header would be pointless overhead.

Speed and parallel load

The second bogeyman after difficulty: the speed of the challenge-issuing endpoint. If someone launches a parallel attack on that endpoint, I can lose the product before it even gets going.

The first profiling showed the database as the bottleneck - every request ran several SQL queries to look up the project, verify it, record the request. Step by step I moved everything to the Redis cache: project metadata, per-IP rate limiting, challenge state. The database in the hot path today sees one SQL query - incrementing a daily counter for quota tracking. The rest is Redis.

Latency dropped from tens of milliseconds to single digits. If the load rises, I scale the Hetzner instance vertically - for a single-region EU service that is far simpler than a distributed solution.

Then came the legals

Programming was the fun part. I churned out code, saw results, refactored. I had an MVP that worked.

And then I opened an editor and started writing the privacy policy.

This was a completely different discipline. I started with GDPR Article 28 (processor obligations), continued with Article 13 (duty to inform), got lost in Article 4(4) (the definition of profiling), ran into Schrems II (CJEU C-311/18), found out that the EU-U.S. Data Privacy Framework exists and is now being challenged before the EU General Court (case T-553/23) - and it dawned on me that this layer may be more important than the code itself.

The specific questions I kept running into:

  • How exactly to phrase the legitimate interest in the privacy policy? (Article 13 requires a specific legitimate interest, not a generic "for security")
  • What is the difference between a sub-processor and an independent controller? (Lemon Squeezy as Merchant of Record is technically the second, not the first)
  • How long should the retention period for IP hashes be? (I ended up at 2 minutes in Redis - the minimum needed for rate limiting, nothing more)
  • Which transfer mechanism to use for Stripe? (DPF certification + Standard Contractual Clauses as a fallback)

I spent several weeks reading, not writing. EU legal sites, law blogs, the Schrems II text literally (90 pages), GDPR enforcement decisions, comparisons of how other EU SaaS companies phrase their DPA.

That result - an Article 28 DPA with a signature block, a sub-processors page with specific certifications and audit report dates, a retention table in the privacy policy with milliseconds for IP hashes - I value today more than the code itself. Compliance-as-code instead of compliance-as-PDF. In the EU SaaS scene that is strangely rare and paradoxically it is my biggest competitive advantage today.

What I learned

A few things I would tell myself three months ago:

1. Compliance is not overhead, it is a feature. If you target privacy-conscious EU customers, your DPA, sub-processors page and retention table are a marketing asset, not a necessary burden. Most of the competition has boilerplate; yours can be better.

2. The PoW algorithm is trivial. Difficulty scaling is not. The hours I spent refactoring from "count zeros" to "numeric target" were an investment that paid off with every further line of code. A bad algorithm cannot be saved by a good implementation.

3. Single-region EU deployment is a superpower, not a limitation. If I do not have to deal with cross-region replication, I have simpler code, a clearer compliance story, predictable performance. Hetzner Nuremberg, one datacenter, end of story.

4. Stateless local HMAC verify is a load-bearing decision. Without it the product would need a central verify endpoint, latency would rise, dependence on uptime would worsen and the privacy claim ("we do not log your requests") would collapse. One design decision that holds up the whole rest.

5. Writing legals is worse than writing code, but it was worth it. The investment paid back in the form of material that serves me today as a trust signal for every procurement reviewer. Without it I would not have a product - I would only have an API.

Tech stack

  • Laravel 13 - backend
  • Livewire 4 - dashboard and interactivity
  • FluxUI Pro - components
  • Tailwind CSS 4 - styling
  • PostgreSQL 18 - persistence (account data, projects, billing)
  • Redis - challenge state, rate limiting, quota counters
  • Hetzner Nuremberg - hosting (single region, EU only)

No Kubernetes, no microservices, no multi-region magic. PHP/Laravel + Redis + Postgres on a single Hetzner server.

The project uses no non-EU service except the payment gateway (Stripe Payments Europe Ltd. - Ireland, with ancillary US transfers under DPF + SCCs) and the MoR solution for consumer payments (Lemon Squeezy LLC - US, as an independent controller under DPF + SCCs Module 1). Both are explicitly documented on the sub-processors page.

What next

Right now I am polishing distribution and preparing the launch. captchaapi.eu has a free tier (5,000 challenges/month), paid plans from €9/month. The widget is open-source - minified, not obfuscated, you can read exactly what runs in your clients' browsers.

If you target EU customers and are looking for a CAPTCHA without cookies, take a look. If you have feedback - from bug reports through architectural remarks to "this is badly worded" - send me an email. I read them myself.

And if you are considering building your own SaaS as a solo developer: it is worth it. But expect that half of the work is something other than programming, and you cannot get around that half.