<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Paul</title>
    <description>The latest articles on Forem by Paul (@paull_).</description>
    <link>https://forem.com/paull_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3889978%2Fac904654-974e-464f-a047-dd9435837bff.png</url>
      <title>Forem: Paul</title>
      <link>https://forem.com/paull_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/paull_"/>
    <language>en</language>
    <item>
      <title>How I stopped my API from going down on a €5 VPS</title>
      <dc:creator>Paul</dc:creator>
      <pubDate>Mon, 27 Apr 2026 12:38:31 +0000</pubDate>
      <link>https://forem.com/paull_/how-i-stopped-my-api-from-going-down-on-a-eu5-vps-36ag</link>
      <guid>https://forem.com/paull_/how-i-stopped-my-api-from-going-down-on-a-eu5-vps-36ag</guid>
      <description>&lt;p&gt;&lt;strong&gt;My server went down three times in one day. The first time I didn't even notice.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That last part is the real problem. No alerts, no health checks, no monitoring. I only found out because I happened to check the dashboard. By then it had already happened twice more.&lt;/p&gt;

&lt;h2&gt;
  
  
  First lesson: build a health check that lives somewhere else
&lt;/h2&gt;

&lt;p&gt;If your health check runs on the same server that crashes, it crashes with it. Obvious in hindsight. I set up an external uptime monitor that pings a &lt;code&gt;/health&lt;/code&gt; endpoint every minute and sends a notification when it does not respond. Free tier on most uptime tools covers this. Do it before anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually causing the crashes
&lt;/h2&gt;

&lt;p&gt;After looking at the logs, the cause was simple. A single customer was sending 3,000 requests per second. No malicious intent, they were just running a batch job and did not know there were limits. The API docs said "unlimited" and they took that literally.&lt;/p&gt;

&lt;p&gt;I had tried to add rate limiting earlier using the built-in API key management from my auth library. It never worked reliably. The bigger issue was that subscription state got cached, so when a user upgraded their plan the old limits stayed active for a while. It created subtle bugs that were hard to track down, so I eventually ripped it out.&lt;/p&gt;

&lt;p&gt;This time I wanted to build something I actually understood.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solution: two-layer rate limiting with Redis
&lt;/h2&gt;

&lt;p&gt;After some research I landed on a two-layer approach: a daily quota and a per-minute burst limit, both backed by Redis atomic counters.&lt;/p&gt;

&lt;p&gt;The logic runs like this. Every request first checks the daily quota. If that passes, it checks the burst limit. If either fails, the request is blocked and returns a 429.&lt;/p&gt;

&lt;p&gt;Tier definitions live in one file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RATE_LIMITS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;anonymous&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;windowSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;free&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;windowSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;trial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;windowSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;pro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;windowSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BURST_LIMITS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;anonymous&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;windowSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;free&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;windowSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;trial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;windowSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;pro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;windowSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The daily check uses a Redis key scoped to the current calendar date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dailyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`ratelimit:daily:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;identityKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dateKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dailyCurrent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dailyKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dailyCurrent&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dailyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dailyCurrent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only requests that pass the daily check hit the burst counter. The burst key is scoped to a fixed 60-second window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;burstKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`ratelimit:burst:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;identityKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;windowStart&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;burstKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;burstKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;windowSeconds&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail worth keeping: if the burst check fails, the daily counter gets decremented back. A blocked request should not eat into the user's daily budget.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;burst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dailyKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;burstLimited&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every response also sends back standard rate limit headers so clients can self-throttle instead of just hitting 429 and retrying blind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-RateLimit-Limit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-RateLimit-Remaining&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-RateLimit-Reset&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resetAt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this design
&lt;/h2&gt;

&lt;p&gt;Fixed windows are simpler than sliding windows and good enough for this use case. Atomic INCR means no race conditions and no Lua scripts. Daily quota runs before burst so a request that is already over the daily limit does not waste a burst slot.&lt;/p&gt;

&lt;p&gt;The tier is resolved from the user's subscription status, which is cached in Redis with a short TTL. This avoids a database call on every request and also fixes the plan-upgrade bug I had before, because the cache expires fast enough to reflect changes quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing was harder than building
&lt;/h2&gt;

&lt;p&gt;Getting the implementation right took one failed attempt. The first version had an off-by-one in the window calculation that only showed up under concurrent load. I wrote a small test script that fires requests in parallel across different tiers and checks that the right ones get blocked. Only after that ran cleanly did I trust the implementation in production.&lt;/p&gt;

&lt;p&gt;If you are building something similar: write the test script before you deploy, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding Cloudflare in front of everything
&lt;/h2&gt;

&lt;p&gt;Rate limiting at the application layer is good but it still means traffic hits your server first. On a €5 VPS with limited bandwidth, even blocked requests have a cost if they arrive at thousands per second.&lt;/p&gt;

&lt;p&gt;I added Cloudflare as a proxy in front of the server and set a request rate rule that drops anything above 50 requests per second per IP before it reaches the VPS. Cloudflare absorbs the traffic spike. The application rate limiting handles the finer-grained per-tier logic.&lt;/p&gt;

&lt;p&gt;Since then: zero downtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently from day one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;External health check with notifications before anything else&lt;/li&gt;
&lt;li&gt;Redis on the same VPS, rate limiting from the start&lt;/li&gt;
&lt;li&gt;Cloudflare proxy from the first public launch&lt;/li&gt;
&lt;li&gt;Write test scripts for the rate limiter, not just unit tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The crashes were not a scaling problem. The VPS can handle the load fine with proper caching and controlled concurrency. The problem was that nothing was protecting it.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>devops</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>How I turned a boring government database into a developer API (and got my first paying customer)</title>
      <dc:creator>Paul</dc:creator>
      <pubDate>Tue, 21 Apr 2026 03:33:54 +0000</pubDate>
      <link>https://forem.com/paull_/how-i-turned-a-boring-government-database-into-a-developer-api-and-got-my-first-paying-customer-543k</link>
      <guid>https://forem.com/paull_/how-i-turned-a-boring-government-database-into-a-developer-api-and-got-my-first-paying-customer-543k</guid>
      <description>&lt;p&gt;&lt;strong&gt;I built a REST API for the Austrian company register - here's what happened&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Austria has a public company register called the Firmenbuch. It lists around 500,000 registered companies: addresses, managing directors, shareholders, legal form, and annual financial statements going back years. Useful stuff if you want to research a company before a job application, check a business partner, or just satisfy your curiosity.&lt;/p&gt;

&lt;p&gt;The problem: the official way to access this data is painful. You pay €3–7 per company lookup. The UI looks like it was built in 2003. And there is no API, just a SOAP-based web service that nobody would choose to use voluntarily.&lt;/p&gt;

&lt;p&gt;I needed to look up company data for a personal project and got frustrated enough to build a wrapper. That was the start of firmafind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The technical nightmare nobody warns you about&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;SOAP in 2026 is already bad enough. But the Austrian Firmenbuch takes it further.&lt;/p&gt;

&lt;p&gt;Annual financial statements are stored in XML where every field is mapped to a cryptographic code. There is an official "decode key", it is an Excel spreadsheet. A very old Excel spreadsheet. AI tools are basically useless here because the encoding is too obscure and too specific to get right without manually cross-referencing the sheet for every single field.&lt;/p&gt;

&lt;p&gt;Getting the parsing right took way longer than building the actual API endpoints. If you ever need to work with Austrian Firmenbuch XML data, expect to spend a good amount of time just figuring out what field HS2 means (it is the equity, by the way).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From web search to API wrapper&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The free search tool was getting used but it was a dead end for monetization. I didn't want to put it behind a paywall the whole point was that this data should be accessible to everyone.&lt;/p&gt;

&lt;p&gt;So I thought about who else might need this. Developers building CRMs, compliance tools, onboarding flows anyone who needs to look up Austrian company data programmatically and doesn't want to deal with a SOAP API from 2003. I already had all the parsing logic from the search tool, so I rewrote it into clean REST endpoints and added caching to make it actually fast. The government API is slow. With caching, response times dropped significantly.&lt;/p&gt;

&lt;p&gt;I made the endpoints available for free at first to see if anyone would use them. They did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The overengineered detour&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then the API started getting hammered with requests and Vercel's free tier couldn't keep up. I needed to monetize. My first idea was a credit system users start with 500 credits, each request costs some, buy more when you run out. Seemed logical.&lt;/p&gt;

&lt;p&gt;It was a mistake. Transaction handling, credit balances, edge cases everywhere. I spent weeks on infrastructure that had nothing to do with the actual product. Eventually I scrapped the whole thing and switched to simple monthly subscriptions. Should have done that from day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First paying customer and a pivot&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I did almost no marketing. A few posts on niche Austrian subreddits that barely got seen. No ads, no outreach campaigns.&lt;/p&gt;

&lt;p&gt;Then last week I got my first paying subscriber. What surprised me: they are not Austrian. They are an international company that needed to look up Austrian business partners. The product was in German. They subscribed anyway.&lt;/p&gt;

&lt;p&gt;That was enough signal for me. I translated everything to English and shifted focus toward international customers, compliance teams, legal firms, fintechs operating in Austria who need programmatic access to Austrian company data. The data is local but the problem is not.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What's next&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The roadmap right now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More European countries (same API structure, different registers)&lt;/li&gt;
&lt;li&gt;Credit scoring based on annual financial statements&lt;/li&gt;
&lt;li&gt;KYC API with modular Austrian register coverage: insolvency registry, beneficial ownership register, entity mapping&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The free search tool stays free. The API is at &lt;a href="https://firmafind.at" rel="noopener noreferrer"&gt;firmafind.at&lt;/a&gt; if you ever need Austrian company data in your stack.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Lessons learned: don't overbuild your billing system, post in the right communities, and pay attention when a customer finds you instead of the other way around - they are telling you something.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
