<?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>DEV Community: Boris Kl</title>
    <description>The latest articles on DEV Community by Boris Kl (@lamas51).</description>
    <link>https://dev.to/lamas51</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3942385%2F8d8793b0-7612-4b5a-a70c-1d4a8b562b8a.png</url>
      <title>DEV Community: Boris Kl</title>
      <link>https://dev.to/lamas51</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lamas51"/>
    <language>en</language>
    <item>
      <title>Your Telegram bot isn't crashing randomly. It's on a timer.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Mon, 20 Jul 2026 22:23:35 +0000</pubDate>
      <link>https://dev.to/lamas51/your-telegram-bot-isnt-crashing-randomly-its-on-a-timer-1igj</link>
      <guid>https://dev.to/lamas51/your-telegram-bot-isnt-crashing-randomly-its-on-a-timer-1igj</guid>
      <description>&lt;p&gt;The ticket always reads the same way: "the bot keeps crashing randomly, please fix."&lt;/p&gt;

&lt;p&gt;So you pull the logs. And it's not ra&lt;br&gt;
ndom at all. The bot dies on a schedule — every few hours, almost to the minute. Like clockwork.&lt;/p&gt;

&lt;p&gt;That rhythm is a gift. Random failures are the hard ones. They smell like memory leaks, race conditions, bad hardware. A failure that lands on a schedule means something &lt;em&gt;expires&lt;/em&gt;. A socket. A token. A connection some box in the middle decided was idle. You're not hunting a ghost. You're looking for a timer.&lt;/p&gt;

&lt;p&gt;Here are the three timers I find over and over in Telegram bots that "crash randomly." Between them they cover most of the rescues I get called in for.&lt;/p&gt;
&lt;h2&gt;
  
  
  Timer #1: the connection that quietly went stale
&lt;/h2&gt;

&lt;p&gt;A long-polling bot holds one HTTP connection open to Telegram and waits. The code looks alive. The process is running. But between your VPS and Telegram sits a NAT table or a proxy. It has an idle timeout. After a fixed window, it drops the connection and tells no one.&lt;/p&gt;

&lt;p&gt;No error on your side. No error on Telegram's side. The socket is just... gone. Your bot keeps waiting on a connection that no longer exists. Depending on the library and its settings, it throws two hours later. Or it hangs forever.&lt;/p&gt;

&lt;p&gt;The fix isn't a bigger server and it isn't switching libraries. It's assuming the connection &lt;em&gt;will&lt;/em&gt; die and reconnecting like you mean it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;poll_forever&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.telegram.org/bot&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/getUpdates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timeout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&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="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# connect, read — never infinite
&lt;/span&gt;            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;upd&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
                &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;upd&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestException&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poll failed: %s — reconnecting in %ss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things matter here. A real read timeout, set a bit above the long-poll window. Backoff that grows, so a Telegram hiccup doesn't turn into a hammering loop. And a log line every single time it happens. That last one is the sleeper. If reconnects are silent, you'll never know if they fire once a week or once a minute.&lt;/p&gt;

&lt;p&gt;Frameworks like aiogram and python-telegram-bot handle much of this for you. But I keep meeting bots where someone turned retries off, or wrapped the library in their own loop that swallows the one exception that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timer #2: two transports fighting over one bot
&lt;/h2&gt;

&lt;p&gt;Telegram gives you two ways to receive updates: &lt;code&gt;getUpdates&lt;/code&gt; polling or a webhook. One bot token gets exactly one. If a webhook is set and something calls &lt;code&gt;getUpdates&lt;/code&gt;, Telegram answers with &lt;code&gt;409 Conflict&lt;/code&gt;, and plenty of homegrown loops treat that as a fatal crash.&lt;/p&gt;

&lt;p&gt;How do you end up here without noticing? Easier than you'd think. Someone tested a webhook months ago and never deleted it. A deploy script starts a second copy before the first one dies. Or systemd restarts the service, the old process lingers for a minute still holding the connection, and the new one comes up screaming.&lt;/p&gt;

&lt;p&gt;The symptoms look mystical: duplicate messages, updates arriving twice, the bot answering some users and ghosting others, crashes that only happen after a deploy. The cause is boring: two consumers, one queue.&lt;/p&gt;

&lt;p&gt;The check takes ten seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/getWebhookInfo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're polling and that response shows a URL — there's your bug. Clear it with &lt;code&gt;deleteWebhook&lt;/code&gt; and pick &lt;em&gt;one&lt;/em&gt; transport for good. Then make sure only one copy of the bot can run. On systemd that's the default, unless your unit file fights it. The ghost-process case usually comes down to &lt;code&gt;KillMode&lt;/code&gt; and &lt;code&gt;TimeoutStopSec&lt;/code&gt; that don't match how the bot really shuts down. I've watched a service restart-loop for hours because a dead process refused to let go of port and token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timer #3: the request with no timeout
&lt;/h2&gt;

&lt;p&gt;This one doesn't kill the process. It's worse — the bot stays green in every dashboard while doing absolutely nothing.&lt;/p&gt;

&lt;p&gt;Somewhere in a handler there's a call to an outside API. Weather, payments, a CRM, your own backend. It's written as &lt;code&gt;requests.get(url)&lt;/code&gt;, no timeout, because it worked fine in testing. Then one day that API stops answering but keeps the TCP connection open. The default timeout in &lt;code&gt;requests&lt;/code&gt; is &lt;em&gt;none&lt;/em&gt;. So the handler blocks forever. And in a single-threaded polling loop, that means the whole bot is now a very quiet piece of furniture.&lt;/p&gt;

&lt;p&gt;Users say "the bot stopped replying." Monitoring says everything's fine. The process has been "up" for nine days.&lt;/p&gt;

&lt;p&gt;Every outbound call gets a timeout. No exceptions, including the calls to Telegram itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&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="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# 5s to connect, 30s to read
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if the bot does real work, handlers shouldn't share one thread with the poller at all. Even a small thread pool changes the math. One dead upstream API costs you one worker, not the whole bot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the next failure introduce itself
&lt;/h2&gt;

&lt;p&gt;The pattern behind all three: the bot had no way to tell you what was going on. So the fix that outlasts any single bug is a heartbeat. One log line per minute: poll count, last-update time. Plus an alert when the process restarts more than a couple of times an hour. It's cheap to add. And it turns the next "it crashes randomly" into a two-minute read, because now the logs show the rhythm.&lt;/p&gt;

&lt;p&gt;Uptime for a bot isn't a feature you bolt on at the end. It's a mindset. Assume every connection dies. Assume every API hangs. Assume every deploy leaves a ghost behind. Then write the ten extra lines that expect all of it.&lt;/p&gt;

&lt;p&gt;I build and rescue Telegram bots for clients, and this trio is where I look first, before reading a line of business logic.&lt;/p&gt;

&lt;p&gt;What's the strangest clockwork failure you've traced back to a timeout? And do you run bots on webhooks or polling — and why? Genuinely curious what breaks for other people.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>telegram</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I've shipped production code since 1999. AI didn't make me faster where it counts.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Fri, 17 Jul 2026 10:39:05 +0000</pubDate>
      <link>https://dev.to/lamas51/ive-shipped-production-code-since-1999-ai-didnt-make-me-faster-where-it-counts-1jnb</link>
      <guid>https://dev.to/lamas51/ive-shipped-production-code-since-1999-ai-didnt-make-me-faster-where-it-counts-1jnb</guid>
      <description>&lt;p&gt;I wrote my first paid line of code in 1999. Servers you could hear. Deploys over FTP. If something broke at 2am, you SSH'd in and read logs until your eyes hurt.&lt;/p&gt;

&lt;p&gt;So when people tell me AI made them 10x faster, I want to believe it. I use it every day — I build bots on Claude and GPT, I've got AI running in production on my own projects. It's the best tool I've picked up in years.&lt;/p&gt;

&lt;p&gt;But faster? Where it actually counts, no. And I think a lot of us are measuring the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Typing was never the bottleneck
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part. The slow bit of this job was never the typing.&lt;/p&gt;

&lt;p&gt;I can bang out a CRUD endpoint in ten minutes with or without AI. That was never what ate my week. What ate my week was understanding &lt;em&gt;why&lt;/em&gt; the payment webhook fired twice, or why a site behind a CDN was fast in the test and dead for real users, or which of forty plugins was quietly holding the main thread hostage.&lt;/p&gt;

&lt;p&gt;AI is fantastic at producing code. It's much weaker at understanding a system it can't see — your traffic, your data shape, the three weird decisions someone made in 2021 that everything now depends on. That understanding is the job. The code is just what falls out at the end.&lt;/p&gt;

&lt;p&gt;So when AI writes the endpoint in 30 seconds, I didn't save a week. I saved ten minutes. The week is still there, waiting, in the part AI can't do for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap I catch myself in
&lt;/h2&gt;

&lt;p&gt;There's a subtler cost, and it's the one I actually worry about.&lt;/p&gt;

&lt;p&gt;When a tool hands you working code instantly, it's really tempting to skip the understanding step. It runs, tests are green, ship it. I've done it. I've shipped something an AI wrote, felt great about the speed, and then spent two days a week later untangling a problem I'd have seen in five minutes if I'd read the thing properly the first time.&lt;/p&gt;

&lt;p&gt;The code "worked." That's the trap. Working and understood are not the same state, and production only cares about the second one.&lt;/p&gt;

&lt;p&gt;I saw this on a real job — I'll keep it vague. A bot was sending conversion events to an ad platform and the numbers were off. The generated code was clean. It sent the events, got a 200 back, looked perfect in every test. The actual problem was two layers up in how an ID got passed between systems, somewhere no amount of "make the code correct" would ever fix, because the code &lt;em&gt;was&lt;/em&gt; correct. You only find that by understanding the whole path. No model was going to hand me that. I had to sit and think.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually got faster
&lt;/h2&gt;

&lt;p&gt;I don't want to sound like AI is a toy. It's not. Some things genuinely changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The boring first draft.&lt;/strong&gt; Boilerplate, config, a test harness — AI gets me to something I can react to instead of a blank file. Reacting is faster than starting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unfamiliar territory.&lt;/strong&gt; Dropped into a language or an API I don't use often, AI is a better first guide than skimming docs for an hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rubber duck that talks back.&lt;/strong&gt; Half the value is just explaining the problem out loud to something that pushes back. I catch my own bad assumptions faster.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what those have in common. They speed up the &lt;em&gt;edges&lt;/em&gt; — the start, the unfamiliar, the stuck. None of them touch the core: knowing the system well enough to make the right call. That part still runs at human speed, and I've made peace with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 1999 me would recognize this
&lt;/h2&gt;

&lt;p&gt;The tools have changed beyond recognition. The habit that separated the people who could fix things from the people who could only write things — that hasn't changed at all.&lt;/p&gt;

&lt;p&gt;Back then it was the guy who actually read the manual versus the guy who copy-pasted from a forum until it compiled. Both shipped. One of them could fix it at 2am. AI just made the copy-paste path frictionless and fast, which means the gap between "it runs" and "I understand it" is easier than ever to skip over. The discipline of not skipping it is worth more now, not less.&lt;/p&gt;

&lt;p&gt;That's the thing I'd tell someone starting today. Let AI write the code. Then read every line like you'll be the one paged when it breaks — because you will be. Speed at typing is cheap. Understanding is the whole job, and it's the one thing nobody can generate for you.&lt;/p&gt;




&lt;p&gt;I run production systems and build AI-driven tools for a living — the kind of work where "it ran in the demo" isn't good enough. If your stack has one of those problems where the code looks right but the behavior is wrong, that's the part I actually enjoy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Genuine question for the room:&lt;/strong&gt; where has AI actually made you faster — the core work, or just the edges around it? I'd like to know if I'm the outlier here or if we're all quietly measuring the wrong number.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>career</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Cloudflare's Flexible SSL looks secure. It isn't.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Fri, 03 Jul 2026 10:45:41 +0000</pubDate>
      <link>https://dev.to/lamas51/cloudflares-flexible-ssl-looks-secure-it-isnt-3kl6</link>
      <guid>https://dev.to/lamas51/cloudflares-flexible-ssl-looks-secure-it-isnt-3kl6</guid>
      <description>&lt;p&gt;A client called: "the site has the padlock, we're done with SSL, right?" Pulled up the Cloudflare panel — Flexible mode. The padlock was real. The encryption wasn't.&lt;/p&gt;

&lt;p&gt;This catches more sites than it should, and the fix is straightforward once you see what's happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Cloudflare's SSL modes actually mean
&lt;/h2&gt;

&lt;p&gt;There are four, and only one is what most people &lt;em&gt;think&lt;/em&gt; they have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Off&lt;/strong&gt; — no HTTPS. Plain HTTP both ways. Nobody picks this on purpose anymore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flexible&lt;/strong&gt; — HTTPS between the visitor and Cloudflare. &lt;strong&gt;Plain HTTP between Cloudflare and your origin.&lt;/strong&gt; The padlock is the browser's, but the second half of the trip is naked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full&lt;/strong&gt; — HTTPS both ways. Cloudflare doesn't check the origin's certificate, so a self-signed cert is fine. Encrypted, but not authenticated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full (strict)&lt;/strong&gt; — HTTPS both ways, &lt;em&gt;and&lt;/em&gt; Cloudflare verifies the origin cert against a real CA. This is the one you want.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Flexible is dangerous because the browser shows the same lock as Full Strict. The site &lt;em&gt;feels&lt;/em&gt; secure to the user, to the developer, to whatever scanner pings it from outside. But the traffic between Cloudflare and the origin server is plaintext.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threat model people miss
&lt;/h2&gt;

&lt;p&gt;"Plain HTTP between Cloudflare and origin — so what, that's a private link?"&lt;/p&gt;

&lt;p&gt;It isn't. The path from Cloudflare to your origin crosses public internet. Any network in between — a transit provider, a hosting peer, an exit on a managed VPN, a misconfigured router — can read it. Session cookies, login posts, form data, anything not separately encrypted. You spent the budget on Cloudflare to protect the wire, and the back half of the wire is still wide open.&lt;/p&gt;

&lt;p&gt;This is the part the padlock can't tell you about. The browser only sees its own leg of the connection. Cloudflare's leg to your box is invisible to the user, and that's exactly where the leak is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Move to &lt;strong&gt;Full (strict)&lt;/strong&gt;. This is two steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Put a real cert on your origin.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you control the server and run nginx/Apache, the easy answer is Let's Encrypt with certbot — free, 90-day renewal, automated. It's a 10-minute install on most stacks.&lt;/p&gt;

&lt;p&gt;If you can't reach the origin from the public internet (it's locked to Cloudflare's IPs, or behind a firewall that won't let Let's Encrypt's HTTP-01 challenge through), use &lt;strong&gt;Cloudflare's Origin CA&lt;/strong&gt;. It's a separate cert authority Cloudflare runs specifically to issue long-lived (up to 15-year) certs that only Cloudflare itself trusts. Generate one in the Cloudflare dashboard, install on your origin, done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Switch the SSL/TLS mode in Cloudflare to Full (strict).&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cloudflare dashboard → SSL/TLS → Overview. Flip from Flexible to Full Strict.&lt;/p&gt;

&lt;p&gt;There's an "Always Use HTTPS" toggle nearby — turn it on too. Without it, an attacker can downgrade the first request, the user clicks a plain &lt;code&gt;http://&lt;/code&gt; link, and the connection rides plaintext until Cloudflare upgrades it. Force HTTPS at the edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch out for the redirect loop
&lt;/h2&gt;

&lt;p&gt;A mistake I see: site is on Flexible, the origin redirects HTTP→HTTPS at the app level (WordPress with the &lt;code&gt;siteurl&lt;/code&gt; set to &lt;code&gt;https://&lt;/code&gt;, for example). Cloudflare hits the origin on HTTP, the origin sends back a 301 to &lt;code&gt;https://&lt;/code&gt;, Cloudflare follows it back to itself, and you get an infinite loop.&lt;/p&gt;

&lt;p&gt;Symptom: page never loads, browser eventually shows "too many redirects."&lt;/p&gt;

&lt;p&gt;Fix: don't redirect at the origin when you're on Flexible. Or, better, fix the actual problem — move to Full Strict and let the origin always speak HTTPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check what you have right now
&lt;/h2&gt;

&lt;p&gt;In the Cloudflare dashboard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSL/TLS → Overview shows the current mode in plain words.&lt;/li&gt;
&lt;li&gt;SSL/TLS → Edge Certificates shows the cert Cloudflare presents to visitors.&lt;/li&gt;
&lt;li&gt;Edge → Origin Server → look at the cert source — that's the one to check is real.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From outside, you can also hit your origin's IP directly with &lt;code&gt;curl --resolve&lt;/code&gt; to confirm it serves HTTPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;--resolve&lt;/span&gt; yourdomain.com:443:&amp;lt;origin_ip&amp;gt; https://yourdomain.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that errors with "SSL certificate problem" or "connection refused on 443," your origin isn't speaking HTTPS at all — Flexible was hiding that.&lt;/p&gt;




&lt;p&gt;The padlock is a useful signal but it doesn't tell the whole story. Flexible SSL exists for a reason — early days, lots of origins didn't have certs — but it's a 2014 compromise and shouldn't be running anywhere in 2026. If you're holding client sites behind Cloudflare, audit the SSL mode on each one. It's a five-second check that catches a real problem.&lt;/p&gt;

&lt;p&gt;I do this kind of edge-hardening for agencies running multiple client sites on Cloudflare — if your SSL setup hasn't been looked at in a while, happy to take a look.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>cloudflare</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your Meta CAPI events from a Telegram bot are losing attribution. Here's the fix.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Mon, 29 Jun 2026 19:37:36 +0000</pubDate>
      <link>https://dev.to/lamas51/your-meta-capi-events-from-a-telegram-bot-are-losing-attribution-heres-the-fix-5c07</link>
      <guid>https://dev.to/lamas51/your-meta-capi-events-from-a-telegram-bot-are-losing-attribution-heres-the-fix-5c07</guid>
      <description>&lt;p&gt;A funnel I see all the time: a Meta ad sends people to a landing page, the page says "message us on Telegram," and a bot takes it from there — qualifies the lead, takes the order, whatever. The conversion happens &lt;em&gt;inside the bot&lt;/em&gt;, server-side, so you fire it to Meta with the Conversions API instead of the Pixel.&lt;/p&gt;

&lt;p&gt;Makes sense. Except attribution quietly falls apart, and it took me a while to see why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The click ID is the whole game
&lt;/h2&gt;

&lt;p&gt;Meta matches a server event back to the ad click using &lt;code&gt;fbc&lt;/code&gt;. The format is fixed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fb.1.&amp;lt;timestamp&amp;gt;.&amp;lt;fbclid&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;&amp;lt;fbclid&amp;gt;&lt;/code&gt; comes off the landing URL as &lt;code&gt;?fbclid=...&lt;/code&gt; when someone arrives from the ad. No &lt;code&gt;fbc&lt;/code&gt;, and your CAPI event still lands — but match quality drops and Meta can't tie the conversion to the click. Which is the one thing you actually wanted.&lt;/p&gt;

&lt;p&gt;So the job is: capture &lt;code&gt;fbclid&lt;/code&gt; on the landing page, carry it into the bot, rebuild &lt;code&gt;fbc&lt;/code&gt; there, send it with the event.&lt;/p&gt;

&lt;p&gt;"Carry it into the bot" is where it breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telegram's deep link won't hold it
&lt;/h2&gt;

&lt;p&gt;You move someone from web into a bot with a deep link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://t.me/your_bot?start=&amp;lt;payload&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bot gets &lt;code&gt;&amp;lt;payload&amp;gt;&lt;/code&gt; as the argument to &lt;code&gt;/start&lt;/code&gt;. Perfect place to stash the click ID, right?&lt;/p&gt;

&lt;p&gt;No. Telegram caps that &lt;code&gt;start&lt;/code&gt; payload at &lt;strong&gt;64 characters&lt;/strong&gt;, and only allows &lt;code&gt;A-Z a-z 0-9 _ -&lt;/code&gt;. A real &lt;code&gt;fbclid&lt;/code&gt; runs way past that — the ones I've measured sit around 170+ characters. It does not fit. Truncate it and it's no longer a valid click ID. URL-encode it and it gets &lt;em&gt;longer&lt;/em&gt;. There's no squeezing the real value through that gate.&lt;/p&gt;

&lt;p&gt;This is the part nobody warns you about. The naive build looks fine in testing with a short fake fbclid, then drops attribution in production with real ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: hand over a token, not the value
&lt;/h2&gt;

&lt;p&gt;Don't pass the click ID. Pass a short token that &lt;em&gt;points&lt;/em&gt; to it.&lt;/p&gt;

&lt;p&gt;On the landing page, when the visitor taps through to Telegram:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate a short random token (a dozen URL-safe chars is plenty).&lt;/li&gt;
&lt;li&gt;Store &lt;code&gt;{ fbclid, click timestamp, utm params }&lt;/code&gt; against that token server-side — Redis, a KV store, a tiny table.&lt;/li&gt;
&lt;li&gt;Build the deep link with the token: &lt;code&gt;https://t.me/your_bot?start=ab12cd34ef&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the bot, on &lt;code&gt;/start&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startPayload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// "ab12cd34ef"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;click&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;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// { fbclid, ts, utm }&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;click&lt;/span&gt;&lt;span class="p"&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;fbc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`fb.1.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;click&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&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;click&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fbclid&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendCapiEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;event_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Lead&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;action_source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// it happened in a messaging app, not a website&lt;/span&gt;
      &lt;span class="na"&gt;user_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fbc&lt;/span&gt; &lt;span class="cm"&gt;/* + whatever else you have */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="c1"&gt;// event_id if you also fire a Pixel, so Meta dedups&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// no token? organic /start. Fine — just lower match quality, no fbc.&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token is short, so it sails through the 64-char limit. The real &lt;code&gt;fbclid&lt;/code&gt; never has to travel through Telegram at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two details that bite
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use the click timestamp, not the bot-open time.&lt;/strong&gt; Store &lt;code&gt;ts&lt;/code&gt; when they hit the landing page. People tap an ad now and open the bot tomorrow — if you stamp &lt;code&gt;fbc&lt;/code&gt; with the moment &lt;code&gt;/start&lt;/code&gt; fired, the window's off. Carry the original click time with the token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;action_source&lt;/code&gt; should tell the truth.&lt;/strong&gt; A bot conversion isn't a &lt;code&gt;website&lt;/code&gt; event — it happened in a messaging app, so the value is &lt;code&gt;chat&lt;/code&gt;. (&lt;code&gt;business_messaging&lt;/code&gt; is Meta's own channels like WhatsApp, not Telegram — don't borrow it.) Meta accepts non-website sources; misreporting it just muddies your own data later.&lt;/p&gt;

&lt;p&gt;And give tokens a TTL. A click that never opens the bot leaves a dangling entry — expire them after a day or two.&lt;/p&gt;




&lt;p&gt;None of this is exotic. It's one indirection — a token standing in for a value that's too big to move. But the failure is silent: events keep arriving, dashboards look populated, and attribution is just... soft, with no error to tell you why. If you're running paid traffic into a chat funnel, check whether your click IDs are actually surviving the handoff.&lt;/p&gt;

&lt;p&gt;I build these bot funnels and the tracking behind them — if yours is leaking attribution somewhere, happy to take a look.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>telegram</category>
      <category>python</category>
      <category>programming</category>
    </item>
    <item>
      <title>Approval-driven server ops: how I let contractors restart nginx without ever giving them SSH</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Fri, 26 Jun 2026 23:04:18 +0000</pubDate>
      <link>https://dev.to/lamas51/approval-driven-server-ops-how-i-let-contractors-restart-nginx-without-ever-giving-them-ssh-2fdm</link>
      <guid>https://dev.to/lamas51/approval-driven-server-ops-how-i-let-contractors-restart-nginx-without-ever-giving-them-ssh-2fdm</guid>
      <description>&lt;p&gt;I run a small WordPress + Cloudflare agency. Two recurring pains finally pushed me to build something instead of complaining:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pain 1: contractor access drift.&lt;/strong&gt; I'd hand a contractor root SSH for "just this one task". Three jobs later, I'd realize four ex-contractors still had access I never rotated. The "I'll rotate keys later" lie compounds quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pain 2: 3am Telegram from a client.&lt;/strong&gt; "Site is down" while I'm on the subway with no laptop. Find Wi-Fi, SSH in, &lt;code&gt;systemctl restart nginx&lt;/code&gt;. Five minutes of actual work, ninety minutes of friction.&lt;/p&gt;

&lt;p&gt;The constraint I wanted: &lt;strong&gt;a contractor should be able to restart nginx on a specific server, and nothing else.&lt;/strong&gt; Not "a contractor with a limited shell". Not "a contractor with sudo restricted via sudoers". A contractor with no shell at all, and a chat command that does exactly one thing.&lt;/p&gt;

&lt;p&gt;This article walks the architecture I ended up with. Real components, real grammar — names from the actual source tree. If it's useful, fork the idea — or if you'd rather not build it, link at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the solution
&lt;/h2&gt;

&lt;p&gt;Three components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Operator (Telegram)
  APPROVE: action + nonce + ts (+ TOTP)
        ↓
Python control plane
  - aiogram bot
  - audit hash-chain (SHA-256)
  - policy engine + runtime state (SQLite WAL)
        ↓ mTLS              ↓ HTTPS
   Go agent           Cloudflare API
   (customer's        (under-attack mode,
    server)            challenges, blocks)
   diagnostics +
   allowlisted ops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Go agent sits on the managed server. It speaks one protocol — mTLS HTTP — to the Python control plane. It never accepts shell. It accepts a fixed list of operations declared by a &lt;strong&gt;capability manifest&lt;/strong&gt; loaded at startup.&lt;/p&gt;

&lt;p&gt;The control plane talks to operators via a Telegram bot (aiogram) and to Cloudflare via their REST API. It runs the policy engine, persists state in SQLite (WAL mode), and writes a hash-chained audit log.&lt;/p&gt;

&lt;p&gt;An operator's only interface is a Telegram chat. They never touch the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The APPROVE grammar
&lt;/h2&gt;

&lt;p&gt;Every mutating action goes through this grammar. There is no other path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APPROVE: &amp;lt;action&amp;gt; &amp;lt;client_id&amp;gt; nonce=&amp;lt;16-hex&amp;gt; ts=&amp;lt;unix_epoch&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With TOTP enabled (env var &lt;code&gt;SECMON_TELEGRAM_TOTP_SECRET&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APPROVE: &amp;lt;action&amp;gt; &amp;lt;client_id&amp;gt; otp=&amp;lt;6digits&amp;gt; nonce=&amp;lt;16-hex&amp;gt; ts=&amp;lt;unix_epoch&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules the control plane enforces against the message:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Action allowlist.&lt;/strong&gt; &lt;code&gt;&amp;lt;action&amp;gt;&lt;/code&gt; must be in the canonical &lt;code&gt;ActionID&lt;/code&gt; enum. Today that's &lt;code&gt;restart-web&lt;/code&gt;, &lt;code&gt;restart-php&lt;/code&gt;, &lt;code&gt;restart-db&lt;/code&gt;, &lt;code&gt;cf-under-attack&lt;/code&gt;, &lt;code&gt;cf-managed-challenge&lt;/code&gt;, &lt;code&gt;cf-targeted-block&lt;/code&gt;, &lt;code&gt;cf-pattern-block&lt;/code&gt;, &lt;code&gt;cf-rate-limit&lt;/code&gt;, &lt;code&gt;enable-cf-auto&lt;/code&gt;. New actions require code + manifest + tests, not configuration. That's deliberate friction.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TTL.&lt;/strong&gt; &lt;code&gt;|now - ts| &amp;lt;= 300 seconds&lt;/code&gt;. Stale approvals get rejected, not silently applied.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One-time nonce.&lt;/strong&gt; The 16-hex &lt;code&gt;nonce&lt;/code&gt; is claimed atomically against a SQLite-backed nonce store. Replay = explicit rejection, not silent dedup.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TOTP second factor&lt;/strong&gt; (when configured). Six-digit OTP from a pyotp-compatible secret. The control plane refuses the approval if the TOTP doesn't match.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Telegram sender allowlist.&lt;/strong&gt; &lt;code&gt;SECMON_TELEGRAM_ALLOWED_USER_IDS&lt;/code&gt; is checked before parsing. Unknown sender → message logged, action not even parsed.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any of those fails, the agent never gets a request — the rejection happens in the control plane, gets written to the audit log with reason, and the operator gets a curt Telegram reply explaining what was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The capability manifest
&lt;/h2&gt;

&lt;p&gt;The agent advertises its capabilities via a JSON document, signed at deploy time with HMAC-SHA256.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JSON&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt; &lt;span class="nf"&gt;body &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="nb"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HMAC&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;SHA256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECMON_MANIFEST_SECRET&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the agent comes up, it loads &lt;code&gt;capabilities.manifest.json&lt;/code&gt;. When the control plane dispatches an action, it pulls the agent's manifest, verifies the HMAC, and refuses anything not in the signed list. The agent cannot lie about what it supports — and the control plane cannot accidentally route a &lt;code&gt;restart-db&lt;/code&gt; to a host whose manifest only declared &lt;code&gt;restart-web&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the cheap version of the right idea. The expensive version is signed-everything-everywhere. HMAC-SHA256 with a shared secret is enough for "operator wants X, agent is allowed to do X". For multi-tenant trust I'd reach for asymmetric, but for a self-hosted agent paired with a single control plane, shared HMAC is good enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit log — hash-chained, verifiable offline
&lt;/h2&gt;

&lt;p&gt;Every approval, every dispatch, every Cloudflare auto-mitigation gets a JSON record in an append-only JSONL file. Each record carries &lt;code&gt;prev_hash&lt;/code&gt; and &lt;code&gt;record_hash&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;canonical&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JSON&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;prev_hash&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;record_hash&lt;/span&gt; &lt;span class="n"&gt;stripped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="nb"&gt;sorted&lt;/span&gt;
&lt;span class="n"&gt;record_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev_hash&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;prev_hash&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record_hash&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;preceding&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first record has &lt;code&gt;prev_hash = ""&lt;/code&gt;. Every subsequent record locks in everything before it. Tamper with any line, every line after fails verification.&lt;/p&gt;

&lt;p&gt;Verification has a CLI: &lt;code&gt;secmon-audit-verify&lt;/code&gt;. Walks the chain, returns line numbers of any break.&lt;/p&gt;

&lt;p&gt;The threat model is "compromised SaaS cannot lie about what your servers did, after the fact". I think that's the audit threat model that actually matters for an agency that has to show a client what happened during an incident.&lt;/p&gt;

&lt;p&gt;(The audit log is &lt;strong&gt;separate&lt;/strong&gt; from the licensing JWT, which uses Ed25519. Different concern. Don't conflate them.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately is NOT
&lt;/h2&gt;

&lt;p&gt;This is just as important as the feature list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not a monitoring stack.&lt;/strong&gt; Use Prometheus, UptimeRobot, Grafana. This tool reacts to incidents — it doesn't detect them. The diagnostics module on the agent is read-only triage (&lt;code&gt;/diag&lt;/code&gt;), not continuous monitoring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a remote desktop.&lt;/strong&gt; No raw shell exec. If you want SSH, you want SSH; this isn't a worse SSH.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not DDoS protection.&lt;/strong&gt; Cloudflare is your shield. The control plane &lt;em&gt;can&lt;/em&gt; auto-dispatch a Cloudflare under-attack mode in response to certain patterns — but auto-dispatch has a hard kill-switch (&lt;code&gt;SECMON_AUTO_CF_DISABLED=1&lt;/code&gt;) checked on &lt;strong&gt;every&lt;/strong&gt; dispatch path. Manual &lt;code&gt;APPROVE: cf-under-attack&lt;/code&gt; still works as an explicit operator override, with &lt;code&gt;auto_dispatch_overridden_by=approve&lt;/code&gt; written to audit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a ticketing system.&lt;/strong&gt; No "open ticket for this incident". The audit log is the record, your team's normal tools do the workflow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every feature I didn't add is a feature I don't have to maintain. At my scale, that math dominates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat model: assume the bot token leaks
&lt;/h2&gt;

&lt;p&gt;The Telegram bot token gets passed around. Plan for the leak.&lt;/p&gt;

&lt;p&gt;If the bot token leaks alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Attacker can spam the bot's Telegram channel.&lt;/li&gt;
&lt;li&gt;Attacker &lt;strong&gt;cannot&lt;/strong&gt; invoke an action, because:

&lt;ul&gt;
&lt;li&gt;Their Telegram user ID isn't in &lt;code&gt;SECMON_TELEGRAM_ALLOWED_USER_IDS&lt;/code&gt;. Message gets logged, parsing never starts.&lt;/li&gt;
&lt;li&gt;Even if they were on the allowlist, they don't have the TOTP secret.&lt;/li&gt;
&lt;li&gt;Even if they had the TOTP, they can't reuse a stolen nonce (one-time, atomic claim).&lt;/li&gt;
&lt;li&gt;Even with a fresh nonce, &lt;code&gt;|now - ts| &amp;gt; 300s&lt;/code&gt; kills any captured approval.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Attacker &lt;strong&gt;cannot&lt;/strong&gt; elevate themselves into the allowlist or the TOTP secret — both are env-var on the control plane host. Compromising the host changes the threat model entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To actually run an operation against a server, the attacker would need all of:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bot token (Telegram side)&lt;/li&gt;
&lt;li&gt;TOTP secret (env var on control plane)&lt;/li&gt;
&lt;li&gt;Telegram user ID on the allowlist (env var on control plane)&lt;/li&gt;
&lt;li&gt;A fresh, unused nonce (or write access to the nonce store)&lt;/li&gt;
&lt;li&gt;A timestamp within ±300s of dispatch&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the design point. Each layer is cheap to add and forces an attacker to compromise distinct surfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;A few honest reflections:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TOTP belongs in v1, not v3.&lt;/strong&gt; I shipped the bot + APPROVE grammar first, added nonce/TTL in a "security hardening" sprint, added TOTP in another security hardening sprint. Should have been one combined hardening pass before the first real customer touched it. I got away with it because the first customer was me, but I wouldn't ship a v2 without it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The hash-chain is great until it isn't.&lt;/strong&gt; A corrupt last line used to be swallowed silently — new events would be written with &lt;code&gt;prev_hash=""&lt;/code&gt; and produce a "verifiable but forked" log. Fixed by explicitly propagating &lt;code&gt;AuditChainError&lt;/code&gt; on read failure or JSON parse failure, with an opt-in manual recovery path. Lesson: tamper-evidence is a property of the &lt;em&gt;whole&lt;/em&gt; chain, including the read path, not just the write path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Per-tenant Cloudflare credentials need their own design.&lt;/strong&gt; I had a single CF token at the start. That works for one client. For multi-client it became per-&lt;code&gt;(client_id, zone_id)&lt;/code&gt; rows encrypted at rest with Fernet (&lt;code&gt;SECMON_DATA_KEY&lt;/code&gt;). Earlier this had a one-key-per-deployment shape and I had to migrate — should have started multi-tenant from the first commit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Diagnostics is bigger than ops.&lt;/strong&gt; The agent's read-only diagnostics module (HTTP, web, PHP, DB, disk, SSL, flood patterns) is about 1.5K lines. The mutating-ops surface is smaller. That ratio is honest: you spend much more time looking than fixing, and read-only checks have to be deeply specific or they're noise.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;If you run client infrastructure and the "shared root password" thing makes you wince, the architecture above is buildable. The Go agent is a single binary with no runtime deps. The control plane is Python with SQLite. Nothing exotic in the stack.&lt;/p&gt;

&lt;p&gt;If you'd rather not build it: I packaged this up as &lt;a href="https://secmon.io/" rel="noopener noreferrer"&gt;secmon.io&lt;/a&gt;. Pricing is $49/mo Starter (1 server, 4h response), $199 Pro (5 servers, 1h response), $599 Agency (20 servers, monthly incident review). 7-day pilot on all tiers. The agent source is currently closed; whether to open-source it under AGPL is on the roadmap if there's demand — happy to discuss the trade-offs in comments.&lt;/p&gt;

&lt;p&gt;If you'd like to be one of the first ten pilots, the contact form is on the homepage. Honest feedback is more useful to me right now than your money.&lt;/p&gt;

&lt;p&gt;Critique very welcome — what's broken in the threat model? What's the obvious bigger competitor I missed?&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>linux</category>
      <category>security</category>
    </item>
    <item>
      <title>Your page loads fast but still feels slow? It's INP, not load time</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Tue, 16 Jun 2026 10:27:25 +0000</pubDate>
      <link>https://dev.to/lamas51/your-page-loads-fast-but-still-feels-slow-its-inp-not-load-time-2gkn</link>
      <guid>https://dev.to/lamas51/your-page-loads-fast-but-still-feels-slow-its-inp-not-load-time-2gkn</guid>
      <description>&lt;p&gt;Your Lighthouse report is mostly green. LCP is fine, CLS is fine, the page loads fast. Then the real-world score drops and you can't see why. Nine times out of ten the culprit is INP — and it's the one metric a quick Lighthouse run barely shows you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What INP actually measures
&lt;/h2&gt;

&lt;p&gt;INP, short for Interaction to Next Paint, replaced FID as a Core Web Vital in March 2024. FID only looked at the delay before your &lt;em&gt;first&lt;/em&gt; interaction. INP looks at &lt;em&gt;all&lt;/em&gt; of them, the whole time someone uses the page, and reports close to the worst one.&lt;/p&gt;

&lt;p&gt;So it's not a loading metric. It's a responsiveness metric. It answers a different question: when I tap, click, or type, how long until the screen actually changes? Google's buckets are simple — 200ms or under is good, over 500ms is poor.&lt;/p&gt;

&lt;p&gt;That's why a site can load in a second and still fail. Loading fast and responding fast are two different jobs, done by two different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's invisible in a normal audit
&lt;/h2&gt;

&lt;p&gt;LCP and CLS happen during load, so a lab tool catches them every run. INP only happens when a human interacts. Lighthouse doesn't tap your buttons, so its number is an estimate at best. You can have a green lab report and a red field score at the same time, and that gap is exactly where people get stuck.&lt;/p&gt;

&lt;p&gt;To see the real number, measure interactions as they happen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onINP&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;web-vitals&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;onINP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entries&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;That logs the actual slow interaction and the element behind it. Now you're fixing a real thing instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really slow
&lt;/h2&gt;

&lt;p&gt;INP is almost always one thing: the main thread was busy when the user acted. The browser can't paint the response until the current JavaScript task finishes, so a long task blocks the interaction.&lt;/p&gt;

&lt;p&gt;The usual sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A heavy event handler doing real work on every click or keystroke.&lt;/li&gt;
&lt;li&gt;Third-party scripts like chat widgets, analytics, and tag managers, running long tasks at the wrong moment.&lt;/li&gt;
&lt;li&gt;Layout thrash: reading and writing the DOM in a loop so the browser recalculates over and over.&lt;/li&gt;
&lt;li&gt;Framework hydration waking the whole page up at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fixes that move it
&lt;/h2&gt;

&lt;p&gt;Break up long tasks. If a handler does a lot, let the browser breathe partway through instead of holding the thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;doUrgentPart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// update the UI first&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;yieldToMain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// give the browser a turn to paint&lt;/span&gt;
  &lt;span class="nf"&gt;doExpensivePart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// the rest can wait a tick&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;yieldToMain&lt;/code&gt; is a one-line helper around &lt;code&gt;scheduler.yield()&lt;/code&gt; where it's supported, or a &lt;code&gt;setTimeout(0)&lt;/code&gt; fallback. The trick is to paint the response &lt;em&gt;before&lt;/em&gt; the slow work, not after.&lt;/p&gt;

&lt;p&gt;Beyond that: defer scripts the page doesn't need to react, audit third-party widgets for the ones that run long tasks, debounce expensive handlers, and batch your DOM reads and writes so the browser isn't recalculating layout on every line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part
&lt;/h2&gt;

&lt;p&gt;I won't promise you a magic number — INP depends on your scripts, your theme, and what your users actually click. But it's measurable, and the field data shows the difference plainly once the long tasks are gone.&lt;/p&gt;

&lt;p&gt;I keep my own WordPress sites in the green on Core Web Vitals, and INP is the one I watch most now, because it's the one that quietly fails while everything else looks fine. If your lab report is green but the real score isn't, stop staring at LCP. Go measure an interaction.&lt;/p&gt;

</description>
      <category>webperf</category>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Telegram bot replies twice? It's timing, not a logic bug</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Mon, 15 Jun 2026 13:23:49 +0000</pubDate>
      <link>https://dev.to/lamas51/your-telegram-bot-replies-twice-its-timing-not-a-logic-bug-2f4j</link>
      <guid>https://dev.to/lamas51/your-telegram-bot-replies-twice-its-timing-not-a-logic-bug-2f4j</guid>
      <description>&lt;p&gt;A Telegram bot replies to the same message twice. An n8n flow processes an order, then processes it again ten seconds later. The owner reads the handler code, finds nothing wrong, and assumes the logic is broken.&lt;/p&gt;

&lt;p&gt;It usually isn't. These bugs are almost always about timing, not logic — and once you know the three places timing bites, they stop being mysterious.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The webhook you never answered
&lt;/h2&gt;

&lt;p&gt;Telegram (and most webhook senders) wait for an HTTP 200. If your endpoint does the work first and answers afterward, a slow database call or a third-party API can push you past the timeout. The sender assumes delivery failed and sends the same update again. Now your "double reply" isn't a logic bug — it's the same event arriving twice because you were too slow to say "got it."&lt;/p&gt;

&lt;p&gt;The fix is to acknowledge first, process second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/webhook&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_nowait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# hand off
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# answer immediately
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Return 200 the moment you've safely stored the update. Do the real work in a background task or a worker. The sender stops retrying, and the duplicates dry up.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. No dedup, so retries become real work
&lt;/h2&gt;

&lt;p&gt;Answering fast helps, but retries still happen — network blips, restarts, a sender that's feeling anxious. The honest assumption is: every event can arrive more than once. So make handling it twice harmless.&lt;/p&gt;

&lt;p&gt;Every Telegram update has an &lt;code&gt;update_id&lt;/code&gt;. Every message has a &lt;code&gt;message_id&lt;/code&gt;. Most webhook payloads have some stable id. Key on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;            &lt;span class="c1"&gt;# already handled, do nothing
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;seen&lt;/code&gt; can be Redis, a unique column in your database, anything that's shared across workers. The point is that "process this order" runs once even if the event shows up three times. People call this idempotency; it just means doing it again changes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Two messages, one piece of state, no lock
&lt;/h2&gt;

&lt;p&gt;This is the one that looks the most like a logic bug and isn't. A user double-taps a button. Two updates arrive almost together. Both handlers read "balance: 100", both subtract 30, both write "70". You charged once for two actions, or booked the same slot twice.&lt;/p&gt;

&lt;p&gt;Nothing in the logic is wrong. The two runs just overlapped. The fix is to stop them from overlapping on the same state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;set_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A per-user lock (Redis &lt;code&gt;SET NX&lt;/code&gt;, a database row lock, whatever you have) means update B waits for update A to finish before it touches the same row. In n8n the same idea shows up as a queue or a "wait for previous execution" step instead of letting every webhook fire its own parallel run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that saves you next time
&lt;/h2&gt;

&lt;p&gt;Most of these never get diagnosed because they're invisible. The handler "works" when you test it by hand — you can't tap fast enough to cause the race, and your local webhook answers instantly. It only breaks under real traffic, at 3am, where you're not looking.&lt;/p&gt;

&lt;p&gt;So log the timing, not just the errors. Log the &lt;code&gt;update_id&lt;/code&gt; on the way in and the way out. Log when a lock is contended. The first time you see the same &lt;code&gt;update_id&lt;/code&gt; logged twice, the whole thing stops being a mystery and becomes a one-line fix.&lt;/p&gt;

&lt;p&gt;I run Telegram bots and n8n in production every day, and I've hit all three of these. None of them were in the logic. They were in the gaps between events — and that's almost always where to look first.&lt;/p&gt;

</description>
      <category>python</category>
      <category>telegram</category>
      <category>automation</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Sending Telegram Bot Conversions to Meta? Don't Reach for business_messaging</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Sun, 14 Jun 2026 13:45:21 +0000</pubDate>
      <link>https://dev.to/lamas51/sending-telegram-bot-conversions-to-meta-dont-reach-for-businessmessaging-1ecj</link>
      <guid>https://dev.to/lamas51/sending-telegram-bot-conversions-to-meta-dont-reach-for-businessmessaging-1ecj</guid>
      <description>&lt;p&gt;A bot was firing Subscribe and Purchase events from Telegram straight to Meta's Conversions API, and every call came back with a 400:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"error_user_title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Missing Messaging Channel Parameter"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"error_user_msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A messaging channel parameter is required when provided
                   action source is business_messaging. Valid value could be
                   messenger, whatsapp and instagram."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payload looked fine — &lt;code&gt;event_name&lt;/code&gt;, &lt;code&gt;event_time&lt;/code&gt;, a hashed &lt;code&gt;external_id&lt;/code&gt;, and &lt;code&gt;action_source: 'business_messaging'&lt;/code&gt;. So why the 400?&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;business_messaging&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; a generic "it happened in a chat" source. Meta ties it to its own messaging products, and it demands a companion &lt;code&gt;messaging_channel&lt;/code&gt; whose only valid values are &lt;code&gt;messenger&lt;/code&gt;, &lt;code&gt;whatsapp&lt;/code&gt;, &lt;code&gt;instagram&lt;/code&gt;. Telegram isn't on that list — there's no channel you can hand it — so the request can never validate.&lt;/p&gt;

&lt;p&gt;The instinct is to try &lt;code&gt;app&lt;/code&gt; next. Don't. &lt;code&gt;app&lt;/code&gt; drags in a required &lt;code&gt;app_data&lt;/code&gt; block: the extinfo array, advertiser tracking flags, the whole mobile-SDK surface. You don't have that from a bot, and you don't want to fake it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;For a self-hosted Telegram bot, the right source is plain &lt;strong&gt;&lt;code&gt;other&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action_source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;other&lt;/code&gt; has no extra mandatory fields. You need &lt;code&gt;event_name&lt;/code&gt;, &lt;code&gt;event_time&lt;/code&gt;, &lt;code&gt;action_source&lt;/code&gt;, and a &lt;code&gt;user_data&lt;/code&gt; with at least one identifier. A SHA-256 hashed Telegram user id as &lt;code&gt;external_id&lt;/code&gt; is enough to clear the 400. One-line change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it actually attribute
&lt;/h2&gt;

&lt;p&gt;Not crashing is the low bar. To tie a Subscribe or Purchase back to the ad that caused it, you need the click id:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capture &lt;code&gt;fbc&lt;/code&gt;.&lt;/strong&gt; Your ad sends people to a deep link — &lt;code&gt;t.me/yourbot?start=...&lt;/code&gt;. Meta appends &lt;code&gt;fbclid&lt;/code&gt; to that destination. Pack the &lt;code&gt;fbclid&lt;/code&gt; into the &lt;code&gt;start&lt;/code&gt; payload, read it on &lt;code&gt;/start&lt;/code&gt;, and build &lt;code&gt;fbc = fb.1.[unix_time].[fbclid]&lt;/code&gt;. Send it in &lt;code&gt;user_data&lt;/code&gt; next to &lt;code&gt;external_id&lt;/code&gt;. This is the single biggest lever for matching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Purchase needs money.&lt;/strong&gt; Add &lt;code&gt;custom_data&lt;/code&gt; with &lt;code&gt;value&lt;/code&gt; and &lt;code&gt;currency&lt;/code&gt;, or there's no ROAS to compute later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify before you trust it.&lt;/strong&gt; Events Manager has a Test Events tab — send with a &lt;code&gt;test_event_code&lt;/code&gt; and watch the events land and match before you point real traffic at it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedupe&lt;/strong&gt; if a web pixel fires the same events: same &lt;code&gt;event_id&lt;/code&gt; on both sides and Meta collapses them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 400 is a five-second fix. The attribution is the part that actually pays for itself.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>meta</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Lighthouse Gave My Site 100/100. The Site Was Down.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Thu, 11 Jun 2026 18:56:58 +0000</pubDate>
      <link>https://dev.to/lamas51/lighthouse-gave-my-site-100100-the-site-was-down-3gin</link>
      <guid>https://dev.to/lamas51/lighthouse-gave-my-site-100100-the-site-was-down-3gin</guid>
      <description>&lt;p&gt;Yesterday I ran PageSpeed Insights on a site I manage. Performance: &lt;strong&gt;100/100&lt;/strong&gt;. Green circle, confetti, the works.&lt;/p&gt;

&lt;p&gt;One problem: the screenshot in the report showed a Cloudflare block page — "Sorry, you have been blocked."&lt;/p&gt;

&lt;p&gt;Lighthouse didn't measure my site. It measured the &lt;em&gt;error page&lt;/em&gt; my WAF served to Google's crawler. And error pages are, of course, blazing fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this happens
&lt;/h2&gt;

&lt;p&gt;If you put Cloudflare in front of a site and turn the security dial up (Bot Fight Mode, aggressive WAF rules, country blocks), you'll eventually block more than bots:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PageSpeed Insights / Lighthouse&lt;/strong&gt; — measures a block page, reports nonsense&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uptime monitors&lt;/strong&gt; — see HTTP 403 with a 200-ish body, or vice versa, and lie to you either way&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google's crawler itself&lt;/strong&gt; — and that one quietly costs you rankings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The nasty part is the &lt;em&gt;silence&lt;/em&gt;. Nothing looks broken from your own browser, because you're whitelisted by your own cookies, IP reputation, or login session. The tools just start telling you fairy tales.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-minute audit
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;Cloudflare → Security → Events&lt;/strong&gt;. Filter the last 7 days. Look at what's actually being challenged or blocked — you'll usually find a legit service in there within a minute.&lt;/li&gt;
&lt;li&gt;Check the user agents: &lt;code&gt;Chrome-Lighthouse&lt;/code&gt;, &lt;code&gt;GoogleOther&lt;/code&gt;, &lt;code&gt;Googlebot&lt;/code&gt;, your uptime checker. If they show up here, that traffic never reached your site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify bots properly&lt;/strong&gt;: Cloudflare has a "Verified Bots" category — allow it instead of hand-maintaining user-agent allowlists (user agents are trivially faked; verified-bot checks aren't).&lt;/li&gt;
&lt;li&gt;Re-run your measurement and &lt;em&gt;look at the rendered screenshot&lt;/em&gt;, not just the score. The screenshot is the only part of a Lighthouse report that can't lie to you.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Rules I now follow
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never trust a perfect score.&lt;/strong&gt; 100/100 on a real WordPress/commerce site is a smell, not an achievement. Real sites have real images and real JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the screenshot first&lt;/strong&gt;, score second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After every WAF change, re-test from outside&lt;/strong&gt;: different network, curl with a Googlebot UA, or just PageSpeed Insights — and read the Events log after.&lt;/li&gt;
&lt;li&gt;Monitoring that runs &lt;em&gt;behind&lt;/em&gt; your own allowlist isn't monitoring. It's a mirror.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cloudflare is still the best free thing that ever happened to small sites — I run my own production behind it and it has eaten real attack waves for breakfast. But a security layer you configured and never audited is just a random traffic filter with good branding.&lt;/p&gt;

&lt;p&gt;Five minutes in the Events log. That's the whole tip.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webperf</category>
      <category>devops</category>
      <category>seo</category>
    </item>
    <item>
      <title>I set up Claude Code for a real production project. Here's what actually earned its keep</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Sat, 06 Jun 2026 13:53:19 +0000</pubDate>
      <link>https://dev.to/lamas51/i-set-up-claude-code-for-a-real-production-project-heres-what-actually-earned-its-keep-56i7</link>
      <guid>https://dev.to/lamas51/i-set-up-claude-code-for-a-real-production-project-heres-what-actually-earned-its-keep-56i7</guid>
      <description>&lt;p&gt;Everyone's got a "10 AI coding tricks" post. This isn't that. This is what's left after three weeks of running Claude Code on a real project — a bilingual booking bot for a beauty salon (Telegram + WhatsApp, Postgres, Google Calendar) — once the novelty wore off and only the useful parts survived.&lt;/p&gt;

&lt;p&gt;Out of the box, Claude Code is a very smart intern with amnesia. Every session it shows up brilliant and clueless. The whole game is fixing the clueless part. Four things did that for me: a CLAUDE.md file, two custom agents, one skill, and two hooks. Everything else I tried, I deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLAUDE.md: the file that pays rent every single day
&lt;/h2&gt;

&lt;p&gt;CLAUDE.md sits in your repo root and gets read at the start of every session. Mine started as three lines. It grew every time the assistant did something I had to undo.&lt;/p&gt;

&lt;p&gt;That's the trick, honestly. Don't write CLAUDE.md upfront — grow it from failures. Mine now includes things like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Architecture rules&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Business logic lives in src/core/ and must not know about
  Telegram or WhatsApp. Channel code lives in src/adapters/.
&lt;span class="p"&gt;-&lt;/span&gt; All times stored in UTC; convert only for display.
&lt;span class="p"&gt;-&lt;/span&gt; Booking creation must stay double-booking-safe — never remove
  locks or constraints around it.

&lt;span class="gu"&gt;## Working agreements&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Before "done": run typecheck &amp;amp;&amp;amp; lint &amp;amp;&amp;amp; test and show the result.
&lt;span class="p"&gt;-&lt;/span&gt; Schema changes go through a migration file. Always.
&lt;span class="p"&gt;-&lt;/span&gt; Prefer the smallest diff that does the job.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each of those lines exists because the assistant once did the opposite. It put Telegram-specific code in core logic — new rule. It "fixed" a timezone bug by converting at storage time — new rule. It reported "done" with failing types — new rule.&lt;/p&gt;

&lt;p&gt;Three weeks in, I almost never repeat an instruction. That file is the difference between an assistant and a goldfish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom agents: the reviewer I argue with
&lt;/h2&gt;

&lt;p&gt;Custom agents live in &lt;code&gt;.claude/agents/&lt;/code&gt; as markdown files with a system prompt. You invoke them for a specific job, they do it with their own instructions and tool limits, and they don't pollute your main session's context.&lt;/p&gt;

&lt;p&gt;The one that earns its keep daily is a code reviewer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;code-reviewer&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Reviews&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;changes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bugs&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;security&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;issues"&lt;/span&gt;
  &lt;span class="s"&gt;before they are committed.&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Grep, Glob, Bash&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

You are a strict but practical code reviewer.
Check, in this order: correctness (timezone boundaries,
double-booking windows), security (unvalidated webhook input,
SQL built by concatenation, missing signature checks),
project rules from CLAUDE.md, and whether behavior changed
without a test changing.
Report findings ordered by severity, with file:line and a
concrete fix. If something is fine, don't pad the review.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point isn't that it catches everything. The point is that it's a &lt;em&gt;different context&lt;/em&gt; with one job. The main session wrote the code and is biased toward liking it. The reviewer agent reads it cold. It regularly catches things the main session waved through — a webhook handler that trusted &lt;code&gt;message_id&lt;/code&gt; without checking the signature, a slot calculation that broke across midnight.&lt;/p&gt;

&lt;p&gt;It found the midnight bug before my client's customers did. That one agent paid for the whole setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  A skill that stops me from skipping steps
&lt;/h2&gt;

&lt;p&gt;Skills are reusable workflows — a SKILL.md file describing a procedure the assistant follows when you invoke it. I have exactly one that matters, &lt;code&gt;/add-feature&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Restate what we're building, confirm.&lt;/li&gt;
&lt;li&gt;List files that will change and why. Smallest possible diff.&lt;/li&gt;
&lt;li&gt;Implement, following CLAUDE.md.&lt;/li&gt;
&lt;li&gt;Write tests for the changed units.&lt;/li&gt;
&lt;li&gt;Run the code-reviewer agent on the diff. Fix what it finds.&lt;/li&gt;
&lt;li&gt;Summarize: what changed, how to try it, what I must do manually.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing clever. It's a checklist. But here's the thing about checklists — they work precisely because on the fifth feature of the day, &lt;em&gt;I&lt;/em&gt; would skip the review step. The skill doesn't get tired at 11pm. Pilots figured this out decades ago; we're just catching up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hooks: the two-line insurance policy
&lt;/h2&gt;

&lt;p&gt;Hooks run shell commands on events. I only need two.&lt;/p&gt;

&lt;p&gt;The first blocks any edit to secrets files. The assistant has no business touching &lt;code&gt;.env&lt;/code&gt;, ever, and now it physically can't — a PreToolUse hook checks the file path and exits with an error if it looks like secrets. Cost me five minutes to write. Worth it the first time a refactor tried to "helpfully" update an env var.&lt;/p&gt;

&lt;p&gt;The second runs the typecheck after every file edit and pipes problems straight back into the session. The assistant sees its own type errors immediately instead of discovering them at the end, which means it fixes them while the context is hot. This one change cut my "it said done but nothing compiles" rate to roughly zero.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Edit|Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash .claude/hooks/protect-secrets.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PostToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Edit|Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run --silent typecheck"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I tried and deleted
&lt;/h2&gt;

&lt;p&gt;For honesty's sake: I also built an agent for writing commit messages (the main session does this fine), a skill for deployments (too risky to automate, I want my hands on that), and a hook that auto-ran the full test suite on every edit (made everything crawl — the typecheck is the right granularity; full tests run at review time).&lt;/p&gt;

&lt;p&gt;If a piece of setup doesn't save you something every day, it's not configuration, it's clutter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest summary
&lt;/h2&gt;

&lt;p&gt;Claude Code without setup is a talented freelancer on their first day, every day. With a grown-from-failures CLAUDE.md, one cold-eyed reviewer agent, one checklist skill and two hooks, it's closer to a colleague who's been on the project for a month.&lt;/p&gt;

&lt;p&gt;The setup took me about two hours total, spread over days, mostly as reactions to things that annoyed me. The payback is that I now ship features for a production bot — payments, reminders, a wait-list — in evenings, alone, without the quality dropping.&lt;/p&gt;

&lt;p&gt;Start with CLAUDE.md. Add a reviewer agent the first time you catch a bug you should've caught. Grow the rest from your own failures — they're better teachers than my list anyway.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>automation</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>One year of self-hosted n8n on a $6 Hetzner VPS</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Wed, 27 May 2026 11:49:40 +0000</pubDate>
      <link>https://dev.to/lamas51/one-year-of-self-hosted-n8n-on-a-6-hetzner-vps-4ee7</link>
      <guid>https://dev.to/lamas51/one-year-of-self-hosted-n8n-on-a-6-hetzner-vps-4ee7</guid>
      <description>&lt;h1&gt;
  
  
  One year of self-hosted n8n on a $6 Hetzner VPS
&lt;/h1&gt;

&lt;p&gt;Twelve months ago I moved my workflow automation off Zapier and onto a single Hetzner CX22 — €4.51/mo, 2 vCPU, 4 GB RAM, 40 GB disk. One Docker host, one n8n container, one Postgres, one Caddy reverse proxy. It's run four production workflows continuously since then, with one outage I'll get to below.&lt;/p&gt;

&lt;p&gt;This post is not a "n8n vs Zapier" pitch. It's a year of operating notes — what stayed cheap, what broke, what I'd do differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hetzner Cloud CX22 (Falkenstein)
├── Docker
│   ├── n8n (latest stable)
│   ├── postgres:15
│   └── caddy (with automatic TLS)
├── UFW (22, 80, 443 only)
└── borgbackup → Hetzner Storage Box (€3.81/mo)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Caddy bit matters more than people think. n8n's built-in HTTP is fine for localhost, but webhook receivers need real TLS, and Caddy gives you ACME, HTTP→HTTPS redirect, and per-domain certificates with zero config. Caddyfile is six lines. You don't have to think about it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's running
&lt;/h2&gt;

&lt;p&gt;Four workflows. None of them invented; all real:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Telegram bot dispatcher.&lt;/strong&gt; Inbound webhook → routing logic → either a Postgres write or a downstream service call. About 40 events/day average, occasional 200-event spikes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RSS aggregator → Telegram channel.&lt;/strong&gt; Polls 12 feeds every 15 min, dedupes by URL hash in Postgres, posts new items to a private channel. ~30 posts/day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Form submission → CRM-lite.&lt;/strong&gt; A few WordPress sites hit a webhook on form submit; n8n writes to Postgres, sends an email confirmation, and logs to a Discord channel for me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daily reporting cron.&lt;/strong&gt; Pulls metrics from three internal APIs at 06:00, builds a markdown digest, emails it, also posts it to Slack.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these need millisecond latency. All of them benefit from being one config-pull away from changing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost breakdown (12 months)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;th&gt;Annual&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner CX22&lt;/td&gt;
&lt;td&gt;€4.51&lt;/td&gt;
&lt;td&gt;€54.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage Box (backup)&lt;/td&gt;
&lt;td&gt;€3.81&lt;/td&gt;
&lt;td&gt;€45.72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain (.dev)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;€12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~€9.20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~€112&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Equivalent Zapier seat for the same task volume would have been ~$30-50/month depending on the plan, so we're looking at roughly €350-500 saved over the year. Not life-changing. The real win is something else, which I'll get to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke (the one outage)
&lt;/h2&gt;

&lt;p&gt;Month four. n8n upgraded from v1.x to a major release. I'd been running &lt;code&gt;docker compose pull&lt;/code&gt; weekly without pinning, because "it's been fine." The upgrade introduced a breaking change to how credentials were stored. Container started; UI loaded; every workflow showed "credentials missing" and refused to execute.&lt;/p&gt;

&lt;p&gt;Root cause: I had no version-pin and no upgrade test. The backup was fine (borg snapshots intact), but the restore-and-investigate took me a Saturday afternoon.&lt;/p&gt;

&lt;p&gt;What I changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pinned n8n image to a specific minor version (&lt;code&gt;n8nio/n8n:1.45.x&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Added a "staging" branch on a second Hetzner VPS (€3/mo CX21) that gets the upgrade first.&lt;/li&gt;
&lt;li&gt;Subscribed to the n8n releases RSS feed so I see breaking changes before I pull.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In hindsight: a SaaS would have done the upgrade for me and either Bigger Things would have broken (multi-tenant blast radius) or none of this would have ever happened. Pick your trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual win (it's not the money)
&lt;/h2&gt;

&lt;p&gt;The €350/year doesn't matter. What matters is that &lt;strong&gt;workflows live in a git-tracked YAML I own, on infrastructure I own&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When a workflow changes, I commit the n8n export. When something breaks, I can diff yesterday's export against today's and see what shifted. When the credentials database gets weird, I open psql and look at the rows. When the webhook target changes, I write the new URL in a Caddyfile and reload — no support ticket, no rate limit on changes, no "this requires an upgrade to the Team plan."&lt;/p&gt;

&lt;p&gt;On Zapier, the same change graph is a black box. Some changes are free, some require the next plan tier, and you don't always know which until the click. With n8n on a box you control, the question "can I do this?" reduces to "is it physically possible?" — and the answer is almost always yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I'd do differently if starting today
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pin the image from day one.&lt;/strong&gt; Whatever the cost in "missing the new shiny feature for a week" is dwarfed by the cost of an unscheduled Saturday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use external Postgres, not the docker-compose one.&lt;/strong&gt; Hetzner offers managed Postgres now. €11/mo, automatic backups, no "my container restarted and ate the WAL" risk. I'd take the €11 hit gladly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't put auth on the webhook receivers via n8n itself.&lt;/strong&gt; Put it at Caddy or a separate gateway. n8n's auth model exists, but you can't reuse it for non-n8n endpoints, and you'll regret the coupling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the runbook first, not after the first outage.&lt;/strong&gt; "How do I restore from borg," "how do I roll the credentials key," "where are the env files" — five minutes to write, an hour to rediscover when stressed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't put more than 10 workflows on one box.&lt;/strong&gt; Memory usage scales with concurrent execution, and a runaway loop in one workflow will starve the others. If you go past 10, split into two n8n instances, not one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When NOT to self-host
&lt;/h2&gt;

&lt;p&gt;This setup works because the four workflows are mine, the data is mine, and downtime measured in hours (not minutes) is acceptable. If any of those three change, the calculus changes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a client depends on the webhook receiver having 99.95% uptime, this single-box setup is wrong. Use n8n Cloud or a multi-node deployment.&lt;/li&gt;
&lt;li&gt;If the workflows touch regulated data (HIPAA, PCI, GDPR's stricter applications), don't reach for the cheapest box. Use a vendor who'll sign a DPA and an audit-ready hosting tier.&lt;/li&gt;
&lt;li&gt;If you're a team of more than three and people need fine-grained access, n8n self-host's RBAC is workable but not great. The Cloud tier handles teams better.&lt;/li&gt;
&lt;li&gt;If your time is worth more than €30/month, and the workflows are simple enough that Zapier or Make.com handles them without ceremony, the savings aren't worth the operating load. Pay for the SaaS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The five-line take
&lt;/h2&gt;

&lt;p&gt;Self-hosted n8n on a cheap VPS is one of those rare cases where the "boring" answer is also the cheap one and also the powerful one. Run it for a year before you decide it's not for you. Pin your versions. Write the runbook. Don't put it on the same box as anything else important.&lt;/p&gt;

&lt;p&gt;— Boris (&lt;a href="https://twitter.com/lamastoma" rel="noopener noreferrer"&gt;@lamastoma&lt;/a&gt;)&lt;/p&gt;




&lt;h2&gt;
  
  
  Publishing checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;☐ Set &lt;code&gt;published: true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;☐ Add cover image (1000×420 — Hetzner ANGE + n8n logo composite? or just terminal screenshot)&lt;/li&gt;
&lt;li&gt;☐ Tags: &lt;code&gt;n8n&lt;/code&gt;, &lt;code&gt;selfhosted&lt;/code&gt;, &lt;code&gt;automation&lt;/code&gt;, &lt;code&gt;devops&lt;/code&gt; — Dev.to limits to 4&lt;/li&gt;
&lt;li&gt;☐ Canonical URL: leave blank (Dev.to is canonical)&lt;/li&gt;
&lt;li&gt;☐ Once published, share Fiverr profile URL in bio (not in body of article)&lt;/li&gt;
&lt;li&gt;☐ Comment-engagement plan: monitor for first 24h, reply to every comment, no defensive corrections&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Article #1 (race condition Python Telegram bot) — already published 2026-05-20&lt;/li&gt;
&lt;li&gt;[[devto-article-01]] memory — engagement tracking&lt;/li&gt;
&lt;li&gt;[[twitter-rules]] — no Dev.to URL in Twitter body for first 30 days&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>n8n</category>
      <category>selfhosted</category>
      <category>automation</category>
      <category>devops</category>
    </item>
    <item>
      <title>A Production Python Telegram Bot Was Crashing Every 2 Hours. The Fix Was 18 Lines.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Wed, 20 May 2026 13:28:23 +0000</pubDate>
      <link>https://dev.to/lamas51/a-production-python-telegram-bot-was-crashing-every-2-hours-the-fix-was-18-lines-29di</link>
      <guid>https://dev.to/lamas51/a-production-python-telegram-bot-was-crashing-every-2-hours-the-fix-was-18-lines-29di</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"If you see cascading errors, find the first thing that fails and stop reading the log there. Everything after the first failure is the system reacting to the first failure."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A production Python Telegram bot I was looking after started crashing every 2-3 hours. The traceback was a horror show — &lt;code&gt;TelegramRetryAfter&lt;/code&gt;, then &lt;code&gt;asyncio.TimeoutError&lt;/code&gt;, then &lt;code&gt;sqlite3.OperationalError: database is locked&lt;/code&gt;, then 47 leaked sessions, then the process got OOM-killed, then systemd restarted it. Then it happened again, 140 minutes later, like clockwork.&lt;/p&gt;

&lt;p&gt;The temptation when you see this kind of cascade is to throw the whole architecture out. &lt;em&gt;"SQLite can't handle our scale, let's move to Postgres."&lt;/em&gt; &lt;em&gt;"Bare asyncio is too low-level, let's add a queue."&lt;/em&gt; &lt;em&gt;"Let's rewrite it in Go."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I didn't do any of those things. The fix was 18 lines of code in one middleware file. The bot has been up for weeks since.&lt;/p&gt;

&lt;p&gt;Here's the diagnosis, the fix, and the takeaway. The code is real (anonymized of any client specifics) and the numbers are real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptoms
&lt;/h2&gt;

&lt;p&gt;Stack: &lt;code&gt;Python 3.12&lt;/code&gt;, &lt;code&gt;aiogram 3.x&lt;/code&gt;, &lt;code&gt;SQLite&lt;/code&gt; for user state, &lt;code&gt;asyncio&lt;/code&gt; everywhere. Volume: about 4,000 daily incoming messages. Not high-throughput.&lt;/p&gt;

&lt;p&gt;The log every 140 minutes looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[14:22:01] ERROR  aiogram.TelegramRetryAfter: flood control, retry in 28s
[14:22:03] ERROR  asyncio.TimeoutError in update handler
[14:22:05] WARNING bot.session not closed (47 active)
[14:22:08] ERROR  sqlite3.OperationalError: database is locked
[14:22:14] ERROR  ...same pattern, multiplying...
[14:22:20] ERROR  process killed by OOM
[14:22:21] INFO   systemd: restarted
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Process up ~140 minutes. Then the cascade. Then restart. Repeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  What looked plausible (and was wrong)
&lt;/h2&gt;

&lt;p&gt;When I started looking, the first hypothesis was &lt;em&gt;"SQLite is the bottleneck — it can't handle the concurrency."&lt;/em&gt; That's the most obvious thing to say when you see &lt;code&gt;database is locked&lt;/code&gt; in a log.&lt;/p&gt;

&lt;p&gt;It was wrong. Here's why I dropped it after 30 minutes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4,000 messages a day is nothing for SQLite.&lt;/strong&gt; SQLite handles tens of thousands of writes per second on modest hardware. If we were hitting a SQLite ceiling, we'd be hitting it under steady load, not in sudden bursts. The 140-minute interval was the giveaway — something was &lt;em&gt;accumulating&lt;/em&gt;, not saturating.&lt;/p&gt;

&lt;p&gt;The second hypothesis was &lt;em&gt;"We're hitting Telegram API rate limits."&lt;/em&gt; That's what &lt;code&gt;TelegramRetryAfter&lt;/code&gt; literally says. But again, 4,000 messages a day = roughly 1 message every 20 seconds on average. Telegram's per-bot rate limit is 30 messages per second. We weren't even in the same order of magnitude.&lt;/p&gt;

&lt;p&gt;So whatever was happening was &lt;em&gt;bursty&lt;/em&gt;, not steady-state. And the bot was somehow turning a steady stream of inbound updates into a burst of outbound API calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual root cause
&lt;/h2&gt;

&lt;p&gt;Here's what was happening, step by step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A user sends a message. &lt;code&gt;aiogram&lt;/code&gt; receives it as an update.&lt;/li&gt;
&lt;li&gt;The handler runs, does some work, and sends a reply to Telegram.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normally:&lt;/strong&gt; that reply goes out, the handler returns, the asyncio task ends, the &lt;code&gt;bot.session&lt;/code&gt; HTTP connection is released.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What actually happened:&lt;/strong&gt; &lt;em&gt;no throttle middleware existed.&lt;/em&gt; If 5-10 users happened to message in the same second (which happens during peak hours), the bot fired 5-10 outbound &lt;code&gt;sendMessage&lt;/code&gt; API calls &lt;em&gt;concurrently&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Five or ten outbound requests inside one second pushed us past Telegram's per-second rate limit. Telegram answered with &lt;code&gt;429 Too Many Requests&lt;/code&gt; and a &lt;code&gt;retry_after&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aiogram&lt;/code&gt; raised &lt;code&gt;TelegramRetryAfter&lt;/code&gt;. But the handler that raised it was &lt;em&gt;waiting&lt;/em&gt; on the API response — it couldn't release its HTTP session until the retry window closed (28 seconds in the log above).&lt;/li&gt;
&lt;li&gt;While that handler was waiting, the next inbound update hit the same handler code. Another async task spawned. Another &lt;code&gt;bot.session&lt;/code&gt; connection opened. Another wait.&lt;/li&gt;
&lt;li&gt;Now we have two stuck tasks, each holding a connection, each blocked on &lt;code&gt;retry_after&lt;/code&gt;. Both tasks also need to update the user's row in SQLite. SQLite locks the row for the first writer. The second writer waits. Deadlock potential.&lt;/li&gt;
&lt;li&gt;Multiply this by 10 minutes of bursty traffic. Now you have 47 leaked sessions, an SQLite deadlock, and a Python process eating memory because tasks aren't completing.&lt;/li&gt;
&lt;li&gt;OOM killer hits. Systemd restarts. Cycle resets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cascade had &lt;strong&gt;one&lt;/strong&gt; cause: no rate limit on the bot's &lt;em&gt;inbound&lt;/em&gt; side. Everything downstream was just the system reacting to the upstream pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — 18 lines
&lt;/h2&gt;

&lt;p&gt;A throttle middleware. Drop incoming updates from a user if they already had a message in the last second. That's it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# middleware.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseMiddleware&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Update&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cachetools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TTLCache&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ThrottleMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseMiddleware&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Drop second-message-within-N-seconds per user.

    Without this, bursty inbound traffic translates 1:1 into bursty
    outbound API calls and trips Telegram&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s flood control.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate_limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TTLCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rate_limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__call__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# silently drop — user is over their rate limit
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And wire it up plus a clean shutdown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# main.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dispatcher&lt;/span&gt;

&lt;span class="n"&gt;bot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BOT_TOKEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;dp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Dispatcher&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ThrottleMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rate_limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_shutdown&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Close the bot session explicitly. Otherwise sessions leak
    on graceful shutdown and the next start hits a connection pool
    in a weird state.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;on_shutdown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 18 lines of production code plus one test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# test_middleware.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;middleware&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThrottleMiddleware&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.mark.asyncio&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_throttle_drops_rapid_second_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mocker&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;middleware&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ThrottleMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rate_limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mocker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncMock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;processed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;make_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# helper to build a fake aiogram Update
&lt;/span&gt;
    &lt;span class="c1"&gt;# First message — goes through
&lt;/span&gt;    &lt;span class="n"&gt;result1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;processed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# Second message same user, same second — dropped
&lt;/span&gt;    &lt;span class="n"&gt;result2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result2&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert_called_once&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole patch.&lt;/p&gt;

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

&lt;p&gt;The fix doesn't make SQLite faster. It doesn't add a queue. It doesn't change anything about how the handlers process messages. It just stops the &lt;em&gt;upstream pressure&lt;/em&gt; before it cascades downstream.&lt;/p&gt;

&lt;p&gt;Once incoming updates are rate-limited per-user at 1 per second, the bot never has 10 concurrent outbound API calls. It has at most 1-2. Telegram never gets angry. &lt;code&gt;TelegramRetryAfter&lt;/code&gt; never fires. Handlers never get stuck waiting. Sessions never leak. SQLite never sees concurrent writes for the same row.&lt;/p&gt;

&lt;p&gt;The cascade isn't a chain. It's a tree, and the throttle cuts the tree at the root.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Numbers (real, from production):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First 4 hours after deploy:&lt;/strong&gt; zero &lt;code&gt;TelegramRetryAfter&lt;/code&gt;. Zero &lt;code&gt;TimeoutError&lt;/code&gt;. Session count stable at 1-2 (vs. climbing past 40 every two hours before).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First 24 hours:&lt;/strong&gt; zero errors of any kind in the log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First 7 days:&lt;/strong&gt; zero crashes. Zero systemd restarts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bot has been up continuously since deploy. Same SQLite. Same asyncio. Same handlers. The only thing that changed is the throttle middleware.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell a junior on the team
&lt;/h2&gt;

&lt;p&gt;A few generic takeaways that apply far beyond this specific bug:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Find the first failure in the log and stop reading.&lt;/strong&gt; When you see cascading errors, everything after the first failure is the system reacting to the first failure. Don't try to "fix" the downstream errors. Find the upstream cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Upstream backpressure is the cause about 80% of the time when you see async-Python cascades.&lt;/strong&gt; When the downstream component (SQLite, HTTP client, worker pool) looks stuck, it's almost always waiting for something the upstream is doing too fast. Rate-limit the upstream first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The temptation to rewrite is almost always wrong early in diagnosis.&lt;/strong&gt; "Rewrite in Go" / "switch to Postgres" / "add a queue" are valid responses to &lt;em&gt;real&lt;/em&gt; scale problems. They're not valid responses to "I haven't figured out the bug yet." Spend an hour with the actual logs first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Volume matters less than burstiness.&lt;/strong&gt; A system handling 4k messages/day average can absolutely fall over from 10 messages in one second. The metric you care about is &lt;em&gt;peak concurrency&lt;/em&gt;, not &lt;em&gt;total throughput&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Test the throttle as a unit, not as an integration.&lt;/strong&gt; The fix above has one test (12 lines). It doesn't try to spin up a real bot. It just verifies the middleware behavior in isolation. That's enough — the actual production behavior is downstream of this contract holding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The middleware and the test are public:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://github.com/lamas51/claude-code-templates" rel="noopener noreferrer"&gt;github.com/lamas51/claude-code-templates&lt;/a&gt; (case studies folder)&lt;/p&gt;

&lt;p&gt;Same project also has Claude Code agent/skill/hook templates I deploy across Go, Python, and WordPress projects — feel free to fork.&lt;/p&gt;

&lt;h2&gt;
  
  
  About me
&lt;/h2&gt;

&lt;p&gt;I'm Boris — IT-pro since 1999. I run production code across Go, Python, and React, mostly for small and mid-size businesses. Last 18 months I've been heavy on Claude Code workflow.&lt;/p&gt;

&lt;p&gt;If you have a production Python service throwing similar cascades and want help diagnosing it, I take this kind of work through Fiverr (clean scope, escrow, no off-platform contact):&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://www.fiverr.com/lamastoma" rel="noopener noreferrer"&gt;fiverr.com/lamastoma&lt;/a&gt; — Python / n8n / Telegram bot bug fixing in 24 hours&lt;/p&gt;

&lt;p&gt;Open to questions in the comments — happy to dig into specifics if you're seeing something similar.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Anonymized — no client data, the diagnosis flow and final patch are the actual ones I shipped.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>aiogram</category>
      <category>asyncio</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
