<?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: ZoomApps</title>
    <description>The latest articles on DEV Community by ZoomApps (@zoomapps).</description>
    <link>https://dev.to/zoomapps</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%2F4209265%2F3203cc66-ada1-406d-a5ce-4a9f5d037262.png</url>
      <title>DEV Community: ZoomApps</title>
      <link>https://dev.to/zoomapps</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zoomapps"/>
    <language>en</language>
    <item>
      <title>Building a WhatsApp AI Chatbot That Hands Off to a Human: Architecture and Pitfalls</title>
      <dc:creator>ZoomApps</dc:creator>
      <pubDate>Mon, 02 Nov 2026 06:01:21 +0000</pubDate>
      <link>https://dev.to/zoomapps/building-a-whatsapp-ai-chatbot-that-hands-off-to-a-human-architecture-and-pitfalls-23le</link>
      <guid>https://dev.to/zoomapps/building-a-whatsapp-ai-chatbot-that-hands-off-to-a-human-architecture-and-pitfalls-23le</guid>
      <description>&lt;p&gt;Getting a language model to answer WhatsApp messages takes an afternoon. Getting it to stop answering at the right moment, pass the conversation to a person with enough context, and never talk over that person afterwards takes considerably longer. Most of the bugs we have seen in production chatbots are not about answer quality. They are about state: who owns the conversation right now, which messages have already been processed, and what happens when the phone, the model or the manager goes quiet.&lt;/p&gt;

&lt;p&gt;This post walks through an architecture that holds up under real traffic, with the parts that usually break.&lt;/p&gt;

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

&lt;p&gt;At a high level there are five components:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Transport.&lt;/strong&gt; Receives incoming messages and sends replies. This is either the official WhatsApp Business Platform with webhooks, or a session linked to an existing number through Linked Devices (the same mechanism WhatsApp on a computer uses).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inbox queue.&lt;/strong&gt; Every incoming event goes into a durable queue before anything else touches it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conversation store.&lt;/strong&gt; One record per chat: owner (bot or human), recent history, flags, timestamps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assistant worker.&lt;/strong&gt; Pulls from the queue, builds the prompt from the knowledge base and the history, decides between "answer" and "hand off".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operator side.&lt;/strong&gt; Wherever managers actually reply: the phone itself, a shared inbox, or a CRM.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rule that saves you later: the transport layer does nothing except normalise events and enqueue them. No model calls inside a webhook handler. Webhook senders retry on slow responses, and an LLM call that takes eight seconds will happily produce duplicate replies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalise and deduplicate first
&lt;/h2&gt;

&lt;p&gt;Whatever transport you use, convert events into one internal shape:&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;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InboundEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;      &lt;span class="c1"&gt;# provider's ID, used for dedup
&lt;/span&gt;    &lt;span class="n"&gt;chat_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;         &lt;span class="c1"&gt;# stable per customer
&lt;/span&gt;    &lt;span class="n"&gt;from_me&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;        &lt;span class="c1"&gt;# sent from the business number
&lt;/span&gt;    &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;            &lt;span class="c1"&gt;# "text", "voice", "image", "document"
&lt;/span&gt;    &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;media_ref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;              &lt;span class="c1"&gt;# provider timestamp, seconds
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then deduplicate on &lt;code&gt;message_id&lt;/code&gt; before enqueueing. A Redis &lt;code&gt;SET NX&lt;/code&gt; with a TTL of a day is enough:&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;def&lt;/span&gt; &lt;span class="nf"&gt;accept&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;InboundEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&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;seen:&lt;/span&gt;&lt;span class="si"&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_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&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;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;          &lt;span class="c1"&gt;# duplicate delivery, drop it
&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;push&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="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Webhook retries, reconnects of a linked session and your own worker restarts will all deliver the same message twice. Without dedup the customer gets two slightly different answers to one question.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;from_me&lt;/code&gt; field. On a linked number, messages the manager types on the phone arrive as events too. You need them, because they are the most reliable signal that a human has taken over (more on that below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Debounce bursts before calling the model
&lt;/h2&gt;

&lt;p&gt;People do not write in single messages. They write "hi", then "how much is", then "delivery to Almaty?" within ten seconds. If the worker answers each fragment, you get three replies, the first two of them useless.&lt;/p&gt;

&lt;p&gt;A simple debounce: when a message arrives for a chat, schedule processing a few seconds later and reset the timer on every new message.&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;DEBOUNCE_SEC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_event&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;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append_pending&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;chat_id&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;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="o"&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;reply:&lt;/span&gt;&lt;span class="si"&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;chat_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;run_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;DEBOUNCE_SEC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;replace_existing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the job fires, it takes all pending messages for that chat as one turn. Three to five seconds is the usual sweet spot: shorter and you still split sentences, longer and the bot feels sluggish. Voice notes need special handling here, because transcription adds its own delay; start the debounce timer from the moment the transcript is ready, not from the moment the audio arrived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Voice notes are text with a confidence problem
&lt;/h2&gt;

&lt;p&gt;A large share of WhatsApp traffic is voice. The pipeline is straightforward: download the media, transcribe it, treat the transcript as a text message. The pitfall is noise. A voice note recorded on a busy street produces a transcript that is grammatically fine and semantically wrong, and the model will confidently answer the wrong question.&lt;/p&gt;

&lt;p&gt;Two mitigations that work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep the per-segment confidence or average log-probability from the speech model and, below a threshold, have the assistant ask the customer to clarify instead of answering.&lt;/li&gt;
&lt;li&gt;Store the original audio reference with the transcript, so a manager who takes over can listen to it rather than trust the text.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conversation ownership is a state machine
&lt;/h2&gt;

&lt;p&gt;This is the core of the handoff design. Each chat has exactly one owner at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;            customer asks for human / trigger fires
   BOT  ───────────────────────────────────────────▶  PENDING_HUMAN
    ▲                                                     │
    │                                     manager replies │
    │                                                     ▼
    └──────────── idle timeout or manual release ─────  HUMAN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BOT&lt;/strong&gt;: the assistant answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PENDING_HUMAN&lt;/strong&gt;: the assistant has told the customer a person will reply, and stays silent. Managers are notified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HUMAN&lt;/strong&gt;: a manager is active. The assistant is fully muted for this chat.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store it explicitly and check it at the last possible moment, right before sending, not only when the job starts:&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;def&lt;/span&gt; &lt;span class="nf"&gt;send_reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chat_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generated_at_version&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;conv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&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="n"&gt;chat_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;conv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BOT&lt;/span&gt;&lt;span class="sh"&gt;"&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;# a human took over while the model was thinking
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;conv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;generated_at_version&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;# new messages arrived, this answer is stale
&lt;/span&gt;    &lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chat_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version check matters more than it looks. A model call can take several seconds; during that time the customer may add a message or a manager may jump in. Sending a reply generated from an outdated state is the single most common source of "the bot interrupted me" complaints from managers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What triggers a handoff
&lt;/h2&gt;

&lt;p&gt;Hard-coding "if the message contains 'operator'" catches only a fraction of cases. A better approach is to let the model return a structured decision alongside the answer:&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;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"handoff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"custom_terms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Wants 40 units with installation in Shymkent, asks for a discount on volume."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reply_to_customer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Thanks, I'm passing this to a manager who can confirm volume terms."&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;p&gt;Typical handoff reasons worth defining up front:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the customer explicitly asks for a person;&lt;/li&gt;
&lt;li&gt;the customer is ready to buy and the next step needs a human (payment, contract, scheduling);&lt;/li&gt;
&lt;li&gt;non-standard terms: discounts outside the price list, unusual quantities, custom deadlines;&lt;/li&gt;
&lt;li&gt;complaints and negative sentiment;&lt;/li&gt;
&lt;li&gt;the question is outside the knowledge base after one clarification attempt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Make the list configurable per business rather than baked into the prompt. A clinic and a furniture shop draw the line in very different places.&lt;/p&gt;

&lt;p&gt;Equally important are hard boundaries that never depend on the model's judgement. The assistant should not promise discounts beyond the price list, quote deadlines that depend on workload, or give professional conclusions. Encode these both in the system prompt and as post-generation checks: a regex for percentages and currency amounts that do not appear in the knowledge base catches a surprising number of invented prices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting that a human has taken over
&lt;/h2&gt;

&lt;p&gt;If managers reply from a CRM or a shared inbox, you know when they send something. If they reply from the phone itself, which is common with small teams, you only know through &lt;code&gt;from_me&lt;/code&gt; events from the transport.&lt;/p&gt;

&lt;p&gt;The rule: any &lt;code&gt;from_me&lt;/code&gt; message that was not sent by the bot moves the chat to &lt;code&gt;HUMAN&lt;/code&gt;. To tell the bot's own messages apart, record the provider message IDs of everything the bot sends:&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;def&lt;/span&gt; &lt;span class="nf"&gt;on_from_me&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sismember&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;bot_sent:&lt;/span&gt;&lt;span class="si"&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;chat_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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_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;# our own echo
&lt;/span&gt;    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_owner&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;chat_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HUMAN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;since&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;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not rely on comparing message text; managers copy bot phrases more often than you would expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Giving the manager context
&lt;/h2&gt;

&lt;p&gt;A handoff that dumps the customer on a person with no context forces them to ask everything again, which defeats the purpose. Send the manager:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the model's one-paragraph summary from the handoff decision;&lt;/li&gt;
&lt;li&gt;the reason code;&lt;/li&gt;
&lt;li&gt;the last N messages verbatim, including transcripts of voice notes;&lt;/li&gt;
&lt;li&gt;anything already collected: name, city, items and quantities mentioned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The summary is generated once at handoff time and stored, so it stays consistent if several managers look at the chat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Releasing the chat back to the bot
&lt;/h2&gt;

&lt;p&gt;The forgotten transition is HUMAN back to BOT. If you never release, the bot slowly goes silent across your whole customer base. If you release too eagerly, the bot answers in the middle of a human negotiation.&lt;/p&gt;

&lt;p&gt;What works in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An idle timeout measured from the last &lt;strong&gt;manager&lt;/strong&gt; message, not the last customer message. Something between 2 and 12 hours depending on the business.&lt;/li&gt;
&lt;li&gt;A manual release command from the operator side.&lt;/li&gt;
&lt;li&gt;On release, the bot does not proactively write anything. It simply answers the next incoming message, with the human part of the conversation included in its history so it does not contradict what the manager promised.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is easy to miss. If the manager agreed to a delivery date, the bot needs to see that agreement, or it will cheerfully quote the standard terms on the next question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowledge base instead of a decision tree
&lt;/h2&gt;

&lt;p&gt;The reason menu-driven bots age badly is maintenance: every new service or price change means editing the tree. With an LLM, the price list and service descriptions become the knowledge base, and a new line in the price list is available immediately.&lt;/p&gt;

&lt;p&gt;For a typical small business catalogue (a few hundred items) you often do not need vector search at all. The whole price list fits in the context window, and putting it there directly removes a class of retrieval failures where the right item simply is not fetched. Move to retrieval when the catalogue grows past what fits comfortably, and even then keep exact-match lookup for SKUs and product names alongside embeddings.&lt;/p&gt;

&lt;p&gt;Feed corrections back from real conversations. Log every handoff with reason &lt;code&gt;outside_knowledge_base&lt;/code&gt;; reviewing that list weekly is the cheapest way to improve answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The linked phone goes offline.&lt;/strong&gt; With Linked Devices the link runs through the phone. If it loses connectivity, the session stops receiving messages. Monitor last-event time per number and alert when it exceeds a sensible window during working hours. When the link comes back, the replayed backlog hits your dedup layer, which is another reason that layer has to exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bulk messaging gets numbers banned.&lt;/strong&gt; Answering inbound messages on a business number is normal usage. Sending to cold lists is what triggers restrictions. Keep the bot strictly reactive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Late answers.&lt;/strong&gt; If processing fails and recovers an hour later, do not answer an hour-old question as if nothing happened. Drop or rephrase replies to messages older than a cutoff, and route those chats to a human instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Several numbers.&lt;/strong&gt; Branches often have different prices. Key the conversation store and the knowledge base by number, and allow a shared base with per-number overrides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logging and privacy.&lt;/strong&gt; Decide retention up front and mask phone numbers in application logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist before going live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Dedup on provider message ID before the queue&lt;/li&gt;
&lt;li&gt;Debounce of 3 to 5 seconds per chat, started after transcription for voice&lt;/li&gt;
&lt;li&gt;Explicit owner state per chat, checked again right before sending&lt;/li&gt;
&lt;li&gt;Version check to drop stale model answers&lt;/li&gt;
&lt;li&gt;Structured handoff decision with reason and summary&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;from_me&lt;/code&gt; detection with a record of bot-sent IDs&lt;/li&gt;
&lt;li&gt;Idle-based release measured from the last manager message&lt;/li&gt;
&lt;li&gt;Hard checks against invented prices and deadlines&lt;/li&gt;
&lt;li&gt;Alert on silent numbers, cutoff for late answers&lt;/li&gt;
&lt;li&gt;A sandbox chat where answers are reviewed before the first real customer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these pieces is complicated on its own. The difficulty is that skipping any one of them produces failures that look like "the AI said something weird", when the real cause is a race between a model call and a manager's thumb. If you would rather start with a setup that already handles connection, voice notes and handoff, and test it on your own price list first, take a look at our &lt;a href="https://zoomapps.kz/en/whatsapp-ai-chatbot" rel="noopener noreferrer"&gt;WhatsApp AI chatbot that works on your existing number&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>chatbot</category>
      <category>architecture</category>
      <category>ai</category>
    </item>
    <item>
      <title>Bitrix24 REST API in Practice: Webhooks, Batch Calls and Rate Limits</title>
      <dc:creator>ZoomApps</dc:creator>
      <pubDate>Thu, 29 Oct 2026 06:02:09 +0000</pubDate>
      <link>https://dev.to/zoomapps/bitrix24-rest-api-in-practice-webhooks-batch-calls-and-rate-limits-49i0</link>
      <guid>https://dev.to/zoomapps/bitrix24-rest-api-in-practice-webhooks-batch-calls-and-rate-limits-49i0</guid>
      <description>&lt;p&gt;The Bitrix24 REST API looks simple on day one. You create an inbound webhook, call &lt;code&gt;crm.deal.list&lt;/code&gt;, get JSON back and the prototype works. The trouble starts on the first bulk sync, when a few thousand records need to move and the portal answers with &lt;code&gt;QUERY_LIMIT_EXCEEDED&lt;/code&gt;, or a week later, when someone notices that deals updated over the weekend never reached the warehouse system.&lt;/p&gt;

&lt;p&gt;This post collects what we have learned building exchanges between Bitrix24 and external systems: how the two kinds of webhooks behave, how to use &lt;code&gt;batch&lt;/code&gt; without shooting yourself in the foot, how the rate limits actually work, and the handful of field-level quirks that cause silent data corruption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of webhooks, two different jobs
&lt;/h2&gt;

&lt;p&gt;Bitrix24 uses the word "webhook" for two unrelated things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inbound webhook.&lt;/strong&gt; A URL with a secret embedded in it that lets your code call REST methods on behalf of a specific user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://yourcompany.bitrix24.kz/rest/1/x7k2abc9def0ghij/crm.deal.get.json?id=42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;1&lt;/code&gt; is the user ID, the next segment is the secret. Everything this URL does runs with that user's permissions and is limited to the scopes you ticked when creating it (&lt;code&gt;crm&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt;, &lt;code&gt;task&lt;/code&gt; and so on). Two practical consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a dedicated technical user for integrations. If the webhook belongs to a sales manager who later leaves the company and gets deactivated, the integration dies with their account.&lt;/li&gt;
&lt;li&gt;Grant the minimum scopes. The URL is a bearer credential. It ends up in logs, environment files and occasionally in screenshots sent to a chat.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Outbound webhook (event handler).&lt;/strong&gt; Bitrix24 sends a POST to your URL when something happens: &lt;code&gt;ONCRMDEALADD&lt;/code&gt;, &lt;code&gt;ONCRMDEALUPDATE&lt;/code&gt;, &lt;code&gt;ONCRMCONTACTDELETE&lt;/code&gt; and many more. The body is form-encoded, not JSON, and looks roughly like this once parsed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;event=ONCRMDEALUPDATE
data[FIELDS][ID]=1287
ts=1761712345
auth[domain]=yourcompany.bitrix24.kz
auth[application_token]=k4q9...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what is missing: the deal itself. You get an ID and nothing else. Your handler has to call &lt;code&gt;crm.deal.get&lt;/code&gt; to find out what changed, which means every event costs at least one API request.&lt;/p&gt;

&lt;p&gt;A minimal handler in Python (Flask) that does the right things:&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;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&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;abort&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;queue_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;enqueue&lt;/span&gt;  &lt;span class="c1"&gt;# Redis, RabbitMQ, a DB table, anything durable
&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;APP_TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B24_APP_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&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;/b24/events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;b24_event&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;form&lt;/span&gt; &lt;span class="o"&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;form&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;form&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auth[application_token]&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="n"&gt;APP_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&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="n"&gt;form&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;form&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data[FIELDS][ID]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&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="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;form&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules are baked in here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check &lt;code&gt;application_token&lt;/code&gt;.&lt;/strong&gt; The handler URL is public; without the check anyone can push fake events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not call the API inside the handler.&lt;/strong&gt; Acknowledge fast and queue the work. A slow or failing handler loses events, and Bitrix24 keeps no replay log for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store only the ID.&lt;/strong&gt; By the time the worker runs, the deal may have changed again, and you want the latest state anyway.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Events are a hint, not a guarantee
&lt;/h3&gt;

&lt;p&gt;Treat outbound events as "something probably changed, go look". Deliveries get lost during your downtime, network blips or a deploy that broke the route. Two mitigations we use together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offline events.&lt;/strong&gt; For applications, Bitrix24 can store events on its side; you pull them with &lt;code&gt;event.offline.get&lt;/code&gt; and confirm processing. Push becomes pull and survives your downtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A periodic reconciliation pass.&lt;/strong&gt; Every N minutes, request entities with &lt;code&gt;DATE_MODIFY&lt;/code&gt; after your last checkpoint and run them through the same worker. Cheap, and it catches whatever the event stream dropped.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The echo loop
&lt;/h3&gt;

&lt;p&gt;If your integration writes back to the same entity it listens to, you will create a loop: you update deal 1287, Bitrix24 fires &lt;code&gt;ONCRMDEALUPDATE&lt;/code&gt;, your worker fetches it, sees a "change", pushes it to the external system, which pushes a status back, which updates the deal again.&lt;/p&gt;

&lt;p&gt;Break it explicitly. Options that work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compare a hash of the fields you care about with the last hash you synced. If nothing relevant changed, stop.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;MODIFY_BY_ID&lt;/code&gt; (or &lt;code&gt;updatedBy&lt;/code&gt; in &lt;code&gt;crm.item.*&lt;/code&gt;) and ignore changes made by your technical integration user.&lt;/li&gt;
&lt;li&gt;Write a sync marker into a custom field, but only if the business is fine with an extra field on the card.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first option is the most robust, because it also filters out updates that touched fields you do not sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  batch: up to 50 calls in one request
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;batch&lt;/code&gt; takes a set of named commands and executes them on the server in one HTTP round trip. The limit is 50 commands per batch.&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;requests&lt;/span&gt;

&lt;span class="n"&gt;WEBHOOK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://yourcompany.bitrix24.kz/rest/1/x7k2abc9def0ghij/&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;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&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="bp"&gt;None&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WEBHOOK&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="ow"&gt;or&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="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&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;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# read the body even on 503: the error code says which limit you hit
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error_description&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;

&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&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;d&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;deal_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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crm.deal.get?id=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;deal_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;deal_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;102&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;103&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;batch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;halt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cmd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&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="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="c1"&gt;# successful commands
&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result_error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# failed commands, keyed the same way
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Things to know before relying on it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check &lt;code&gt;result_error&lt;/code&gt; every time.&lt;/strong&gt; With &lt;code&gt;halt: 0&lt;/code&gt; the batch continues past failures, and the top level of the response looks successful. A batch of 50 updates where 7 failed returns HTTP 200. If you only read &lt;code&gt;result&lt;/code&gt;, those 7 are lost silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;halt: 1&lt;/code&gt; is not a transaction.&lt;/strong&gt; It stops at the first error, but the commands that already ran are not rolled back. There are no transactions in the REST API. Design updates so that re-running them is safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commands are query strings.&lt;/strong&gt; Each value in &lt;code&gt;cmd&lt;/code&gt; is a URL-encoded string, not JSON. Hand-built strings break on &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;=&lt;/code&gt; or Cyrillic. Use an encoder for PHP-style nested arrays (&lt;code&gt;fields[TITLE]=...&lt;/code&gt;, &lt;code&gt;filter[&amp;gt;ID]=...&lt;/code&gt;); plain &lt;code&gt;urlencode&lt;/code&gt; on a nested dict gets it wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results can feed later commands.&lt;/strong&gt; You can reference an earlier result inside the same batch:&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;cmd&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;contact&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;crm.contact.add?fields[NAME]=Aigerim&amp;amp;fields[PHONE][0][VALUE]=%2B77001234567&amp;amp;fields[PHONE][0][VALUE_TYPE]=WORK&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;deal&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;crm.deal.add?fields[TITLE]=Order%20123&amp;amp;fields[CONTACT_ID]=$result[contact]&lt;/span&gt;&lt;span class="sh"&gt;"&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;Handy, but it makes the batch order-dependent and harder to retry. We use it for create-and-link pairs and avoid it everywhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A batch is one request for the rate limiter, but not free.&lt;/strong&gt; It counts as one hit against the request frequency limit, but every command inside still consumes server execution time, which is limited separately (see below). Fifty heavy &lt;code&gt;list&lt;/code&gt; calls with complex filters in one batch can exhaust the time budget faster than fifty simple &lt;code&gt;get&lt;/code&gt; calls spread over a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the rate limits actually work
&lt;/h2&gt;

&lt;p&gt;There are two independent limits, and integrations usually discover them in this order.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Request frequency: a leaky bucket
&lt;/h3&gt;

&lt;p&gt;Each portal has a request counter that behaves like a leaky bucket. Requests fill it, and it drains at a fixed rate per second. While the bucket has room, requests go through instantly. When it is full, you get HTTP 503 with error &lt;code&gt;QUERY_LIMIT_EXCEEDED&lt;/code&gt;. The common published figures are a drain rate of 2 requests per second with a burst capacity of 50 on standard plans, and higher values on top plans and on-premise installations. Check the current numbers for your plan, they have changed over the years.&lt;/p&gt;

&lt;p&gt;A script firing 200 requests in a tight loop gets the first 50 through, then hits errors, and a naive retry loop only adds more requests. The fix is a client-side limiter that mirrors the server's bucket, plus backoff when the server disagrees:&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;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LeakyBucket&lt;/span&gt;&lt;span class="p"&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&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="o"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rate&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;capacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capacity&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;level&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;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&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;monotonic&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;lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;acquire&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="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;with&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;lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&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;monotonic&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;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.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;level&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&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;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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&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;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
                &lt;span class="k"&gt;if&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;level&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;capacity&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;level&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;return&lt;/span&gt;
                &lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;=&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;level&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&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;capacity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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&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;wait&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LeakyBucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# stay below the real limit
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_limited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;acquire&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="k"&gt;return&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;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;RuntimeError&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="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;QUERY_LIMIT_EXCEEDED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;str&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="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OPERATION_TIME_LIMIT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;str&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="k"&gt;raise&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="nf"&gt;min&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="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: rate limited after &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter. The limiter runs below the real limit, because telephony, marketplace apps and other scripts share the same bucket. And it must be shared across workers: four processes with their own in-memory bucket send four times the allowed rate. Beyond one process, keep the counter in Redis.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Execution time per method
&lt;/h3&gt;

&lt;p&gt;Newer versions of the platform also limit how much server time a single method may consume over a sliding window. Responses carry a &lt;code&gt;time&lt;/code&gt; block, with fields such as &lt;code&gt;operating&lt;/code&gt; and &lt;code&gt;operating_reset_at&lt;/code&gt; telling you how much time the method has used. When a method exceeds its budget, it is blocked for a while with &lt;code&gt;OPERATION_TIME_LIMIT&lt;/code&gt;, while other methods keep working.&lt;/p&gt;

&lt;p&gt;This bites bulk exports. &lt;code&gt;crm.deal.list&lt;/code&gt; filtering on a custom field with a large &lt;code&gt;select&lt;/code&gt; is expensive, and a loop of them eats the budget. Log the &lt;code&gt;time&lt;/code&gt; block during test runs to see which calls are heavy before production does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pagination that does not get slower on every page
&lt;/h2&gt;

&lt;p&gt;List methods return 50 records per page with &lt;code&gt;next&lt;/code&gt; and &lt;code&gt;total&lt;/code&gt;. The obvious loop passes &lt;code&gt;start=0, 50, 100...&lt;/code&gt;. On large tables this degrades, because the server still counts and offsets through everything before your page, and it also computes &lt;code&gt;total&lt;/code&gt; on every call.&lt;/p&gt;

&lt;p&gt;The faster pattern: sort by &lt;code&gt;ID&lt;/code&gt;, filter by &lt;code&gt;&amp;gt;ID&lt;/code&gt; from the last record you saw, and pass &lt;code&gt;start=-1&lt;/code&gt; to turn off the count.&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;def&lt;/span&gt; &lt;span class="nf"&gt;iter_deals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;select&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;last_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_limited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crm.deal.list&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;ID&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;ASC&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;filter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;&amp;gt;ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;last_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;select&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&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="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;
        &lt;span class="n"&gt;last_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&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="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each request is now an indexed range scan, and page 2,000 costs the same as page 1. On exports of hundreds of thousands of records this decides whether the job finishes overnight at all.&lt;/p&gt;

&lt;p&gt;Always pass &lt;code&gt;select&lt;/code&gt;. Without it you get the default field set, which excludes custom &lt;code&gt;UF_CRM_*&lt;/code&gt; fields, and you will wonder why they are empty. With &lt;code&gt;["*", "UF_*"]&lt;/code&gt; you get everything, which is expensive. List the fields you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Field-level pitfalls
&lt;/h2&gt;

&lt;p&gt;These do not throw errors. They corrupt data quietly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-fields append by default.&lt;/strong&gt; &lt;code&gt;PHONE&lt;/code&gt;, &lt;code&gt;EMAIL&lt;/code&gt;, &lt;code&gt;WEB&lt;/code&gt; and &lt;code&gt;IM&lt;/code&gt; are arrays of &lt;code&gt;{ID, VALUE, VALUE_TYPE}&lt;/code&gt;. If you call &lt;code&gt;crm.contact.update&lt;/code&gt; with &lt;code&gt;PHONE: [{"VALUE": "+77001234567"}]&lt;/code&gt;, the number is added, not replaced. Run that on every sync and a contact collects fifty copies of the same phone. To change a value, pass the existing item's &lt;code&gt;ID&lt;/code&gt;; to delete one, pass its &lt;code&gt;ID&lt;/code&gt; with an empty &lt;code&gt;VALUE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two naming conventions.&lt;/strong&gt; &lt;code&gt;crm.deal.*&lt;/code&gt; uses &lt;code&gt;UPPER_SNAKE&lt;/code&gt; (&lt;code&gt;STAGE_ID&lt;/code&gt;, &lt;code&gt;ASSIGNED_BY_ID&lt;/code&gt;). The newer universal &lt;code&gt;crm.item.*&lt;/code&gt; methods with &lt;code&gt;entityTypeId&lt;/code&gt; (2 for deals, 3 for contacts, 4 for companies, plus IDs for smart processes) use &lt;code&gt;camelCase&lt;/code&gt; (&lt;code&gt;stageId&lt;/code&gt;, &lt;code&gt;assignedById&lt;/code&gt;) and wrap results in &lt;code&gt;item&lt;/code&gt; or &lt;code&gt;items&lt;/code&gt;. Mixing them in one codebase leads to fields that are "set" but never saved. Pick one family per entity and stick to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage IDs depend on the pipeline.&lt;/strong&gt; In the default pipeline a stage is &lt;code&gt;NEW&lt;/code&gt; or &lt;code&gt;WON&lt;/code&gt;; in additional pipelines it becomes &lt;code&gt;C3:NEW&lt;/code&gt;, &lt;code&gt;C3:WON&lt;/code&gt;, where 3 is the category ID. Hardcoding &lt;code&gt;WON&lt;/code&gt; works until sales creates a second pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dates come with a timezone offset.&lt;/strong&gt; Values look like &lt;code&gt;2026-10-29T11:00:00+05:00&lt;/code&gt;. Parse them as aware datetimes. Comparing them as strings or stripping the offset produces off-by-hours bugs in checkpoints, which means missed or duplicated records in reconciliation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;List fields store IDs, not labels.&lt;/strong&gt; A custom enum field returns &lt;code&gt;"45"&lt;/code&gt;, not &lt;code&gt;"Wholesale"&lt;/code&gt;. Load the field definitions with &lt;code&gt;crm.deal.userfield.list&lt;/code&gt; once and map both ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to log
&lt;/h2&gt;

&lt;p&gt;Log method, entity ID, direction, duration, the &lt;code&gt;time&lt;/code&gt; block, the error code and a correlation ID linking event, queue job and API call. Add one alert on the age of the oldest unprocessed job. Integrations rarely crash; they drift until the numbers stop matching a week later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Dedicated technical user, minimal webhook scopes&lt;/li&gt;
&lt;li&gt;Event handler: token check, enqueue, return 200&lt;/li&gt;
&lt;li&gt;Reconciliation pass by &lt;code&gt;DATE_MODIFY&lt;/code&gt; to catch lost events&lt;/li&gt;
&lt;li&gt;Echo-loop protection by field hash or author check&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;batch&lt;/code&gt; with &lt;code&gt;result_error&lt;/code&gt; checked on every call&lt;/li&gt;
&lt;li&gt;Shared client-side limiter below the published rate, backoff on 503&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;gt;ID&lt;/code&gt; pagination with &lt;code&gt;start=-1&lt;/code&gt; and explicit &lt;code&gt;select&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Multi-fields updated by &lt;code&gt;ID&lt;/code&gt;, stage IDs resolved per pipeline&lt;/li&gt;
&lt;li&gt;Logs with timing data and an alert on queue age&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The REST API itself is the same for cloud and on-premise portals; only authorisation and access setup differ, so these patterns carry over. If you would rather hand the exchange to a team that builds queues, retries and monitoring into every project, here is how we approach &lt;a href="https://zoomapps.kz/en/bitrix24-rest-api" rel="noopener noreferrer"&gt;Bitrix24 REST API integration with warehouse, logistics and banking systems&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>bitrix24</category>
      <category>api</category>
      <category>python</category>
      <category>integration</category>
    </item>
    <item>
      <title>Sizing VRAM for a Self-Hosted LLM: 7B to 70B on a Rented GPU Server</title>
      <dc:creator>ZoomApps</dc:creator>
      <pubDate>Tue, 27 Oct 2026 18:29:47 +0000</pubDate>
      <link>https://dev.to/zoomapps/sizing-vram-for-a-self-hosted-llm-7b-to-70b-on-a-rented-gpu-server-m53</link>
      <guid>https://dev.to/zoomapps/sizing-vram-for-a-self-hosted-llm-7b-to-70b-on-a-rented-gpu-server-m53</guid>
      <description>&lt;p&gt;Most "will it fit" questions about self-hosted models get answered with a rule of thumb: two bytes per parameter, add some headroom, done. That rule tells you whether the weights load. It does not tell you whether the server survives fifty concurrent users with long prompts, which is the question that actually decides what card you rent.&lt;/p&gt;

&lt;p&gt;This post walks through the arithmetic I use before picking hardware for inference and fine-tuning, with a small Python calculator, a working vLLM launch config and the mistakes that cost the most time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things that live in VRAM
&lt;/h2&gt;

&lt;p&gt;During inference, GPU memory is split between:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Model weights.&lt;/strong&gt; Fixed size, determined by parameter count and precision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KV cache.&lt;/strong&gt; Grows with context length and with the number of sequences served in parallel. This is the part people forget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Activations and temporary buffers.&lt;/strong&gt; Relatively small for inference, but spikes during prefill of long prompts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime overhead.&lt;/strong&gt; CUDA context, cuBLAS workspaces, NCCL buffers if you use several cards. Budget roughly 1 to 2 GB per GPU.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Training adds gradients and optimizer state on top, which changes the picture completely. We will get to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Weights: the easy part
&lt;/h2&gt;

&lt;p&gt;Bytes per parameter by precision:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Precision&lt;/th&gt;
&lt;th&gt;Bytes/param&lt;/th&gt;
&lt;th&gt;7B&lt;/th&gt;
&lt;th&gt;13B&lt;/th&gt;
&lt;th&gt;34B&lt;/th&gt;
&lt;th&gt;70B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FP16 / BF16&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;14 GB&lt;/td&gt;
&lt;td&gt;26 GB&lt;/td&gt;
&lt;td&gt;68 GB&lt;/td&gt;
&lt;td&gt;140 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FP8 / INT8&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7 GB&lt;/td&gt;
&lt;td&gt;13 GB&lt;/td&gt;
&lt;td&gt;34 GB&lt;/td&gt;
&lt;td&gt;70 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-bit (AWQ, GPTQ)&lt;/td&gt;
&lt;td&gt;~0.55&lt;/td&gt;
&lt;td&gt;~4 GB&lt;/td&gt;
&lt;td&gt;~7 GB&lt;/td&gt;
&lt;td&gt;~19 GB&lt;/td&gt;
&lt;td&gt;~39 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 4-bit column is not exactly 0.5 because quantized formats store scales and zero points per group of weights, and some layers (embeddings, the output head) often stay in higher precision. Check the actual size of the safetensors files rather than trusting the multiplication.&lt;/p&gt;

&lt;p&gt;So a 70B model in BF16 does not fit on one 80 GB card, needs two of them with tensor parallelism, and fits on a single 141 GB card with room for cache. The same model in 4-bit fits on a 48 GB card, but with very little left over for users.&lt;/p&gt;

&lt;h2&gt;
  
  
  KV cache: the part that decides the card
&lt;/h2&gt;

&lt;p&gt;For every token in every active sequence, each transformer layer stores a key and a value vector. The formula:&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;kv_bytes_per_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;num_layers&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;num_kv_heads&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;head_dim&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bytes_per_element&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;2&lt;/code&gt; is key plus value. &lt;code&gt;num_kv_heads&lt;/code&gt; is the important term: models with grouped-query attention (GQA) share key/value heads across several query heads, which shrinks the cache dramatically.&lt;/p&gt;

&lt;p&gt;Real numbers, FP16 cache:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Llama 2 7B&lt;/strong&gt; (32 layers, 32 KV heads, head_dim 128, no GQA): 2 × 32 × 32 × 128 × 2 = &lt;strong&gt;512 KiB per token&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Llama 3 8B&lt;/strong&gt; (32 layers, 8 KV heads, head_dim 128): 2 × 32 × 8 × 128 × 2 = &lt;strong&gt;128 KiB per token&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Llama 3 70B&lt;/strong&gt; (80 layers, 8 KV heads, head_dim 128): 2 × 80 × 8 × 128 × 2 = &lt;strong&gt;320 KiB per token&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now multiply by load. Say you serve Llama 3 70B with an 8,192-token context and want 32 sequences in flight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;320 KiB * 8192 tokens = 2.5 GiB per sequence
2.5 GiB * 32 sequences = 80 GiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 80 GiB of cache on top of 140 GB of weights. The "it fits on two 80 GB cards" plan turns into "it loads on two cards and serves about four users at full context". The model starts, the demo works, and throughput collapses under real traffic.&lt;/p&gt;

&lt;p&gt;In practice not every request uses the full context, and paged attention (vLLM, SGLang, TensorRT-LLM) allocates cache in blocks only for tokens that exist. So size against your real prompt length distribution, not the theoretical maximum. Log prompt plus completion lengths from a pilot and use the 90th percentile.&lt;/p&gt;

&lt;h2&gt;
  
  
  A calculator you can adapt
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="n"&gt;GiB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;params_b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;      &lt;span class="c1"&gt;# billions of parameters
&lt;/span&gt;    &lt;span class="n"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;kv_heads&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;head_dim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;inference_vram_gib&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kv_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="n"&gt;ctx_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;concurrent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="n"&gt;overhead_gib&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;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;activation_frac&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;0.05&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;params_b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1e9&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;weight_bytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;GiB&lt;/span&gt;
    &lt;span class="n"&gt;kv_per_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;layers&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kv_heads&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;head_dim&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;kv_bytes&lt;/span&gt;
    &lt;span class="n"&gt;kv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kv_per_token&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ctx_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;concurrent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;GiB&lt;/span&gt;
    &lt;span class="n"&gt;activations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;activation_frac&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;activations&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;overhead_gib&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;llama3_8b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;llama3_70b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;70.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;inference_vram_gib&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llama3_8b&lt;/span&gt;&lt;span class="p"&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;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;    &lt;span class="c1"&gt;# BF16 weights, FP16 cache
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;inference_vram_gib&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llama3_70b&lt;/span&gt;&lt;span class="p"&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;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;inference_vram_gib&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llama3_70b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;# 4-bit weights, FP8 cache
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(14.9, 16.0, 34.4)
(131.5, 80.0, 224.1)
(36.2, 40.0, 82.0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the last line carefully. Quantizing weights to 4-bit and the cache to FP8 brings a 32-user 70B deployment from "three or four 80 GB cards" down to "one 80 GB card, slightly over budget". Drop concurrency to 24 or context to 6k and it fits. Whether the quality loss from 4-bit weights is acceptable is a separate question that you answer with your own eval set, not with a benchmark table.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;activation_frac&lt;/code&gt; of 5% is a rough allowance. Long-prompt prefill on large batches can need more; vLLM handles this by reserving memory during its profiling run at startup, which is why its numbers differ from naive math.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning the math into a vLLM config
&lt;/h2&gt;

&lt;p&gt;vLLM takes a fraction of GPU memory, loads weights, and gives everything left to the KV cache. The flags that matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm serve meta-llama/Meta-Llama-3-70B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tensor-parallel-size&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.90 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 8192 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-num-seqs&lt;/span&gt; 32 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kv-cache-dtype&lt;/span&gt; fp8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-prefix-caching&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--gpu-memory-utilization 0.90&lt;/code&gt; leaves 10% for the CUDA context and fragmentation. Going to 0.95 works until a monitoring agent or a second process grabs memory, then you get OOM at 3 a.m.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--max-model-len&lt;/code&gt; caps context. If you leave it at the model's native 128k, vLLM may refuse to start because one maximum-length sequence does not fit in the cache, or it starts with very few concurrent slots.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--max-num-seqs&lt;/code&gt; limits how many sequences are scheduled at once. Too high and requests get preempted and recomputed when the cache fills; too low and the GPU idles.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--kv-cache-dtype fp8&lt;/code&gt; halves cache memory. Supported on Hopper and Ada cards; measure output quality before shipping it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--enable-prefix-caching&lt;/code&gt; reuses cache for shared prompt prefixes. If every request starts with the same 2,000-token system prompt, this is a large win.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At startup vLLM logs the number of GPU blocks it allocated for the cache and the maximum concurrency it can sustain at full context. Read that line before running a load test. It is the single best check of whether your sizing was right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fine-tuning needs a different budget
&lt;/h2&gt;

&lt;p&gt;Full fine-tuning with AdamW in mixed precision costs about 16 bytes per parameter before activations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;BF16 weights: 2&lt;/li&gt;
&lt;li&gt;BF16 gradients: 2&lt;/li&gt;
&lt;li&gt;FP32 master weights: 4&lt;/li&gt;
&lt;li&gt;Adam first and second moments in FP32: 8&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a 7B model that is about 112 GB, so full fine-tuning even a small model needs several cards or ZeRO/FSDP sharding plus optimizer offload. Activations come on top and scale with batch size and sequence length; gradient checkpointing trades roughly 30% more compute for a large cut there.&lt;/p&gt;

&lt;p&gt;LoRA freezes the base weights and trains small adapter matrices, so optimizer state almost disappears. A 7B model with LoRA fits on a 24 to 48 GB card. QLoRA loads the base model in 4-bit and trains adapters on top: the original QLoRA work fine-tuned a 65B model on a single 48 GB GPU. For most business use cases (tone, format, domain vocabulary) LoRA on an 8B or 70B base is the sensible starting point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Matching models to cards
&lt;/h2&gt;

&lt;p&gt;Rough mapping for inference with moderate concurrency, assuming you size the cache as above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;7B to 13B, BF16:&lt;/strong&gt; a 48 GB card such as the L40S handles this comfortably with plenty of cache. Good fit for internal assistants and RAG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;30B to 34B, BF16:&lt;/strong&gt; one 80 GB A100 or H100.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;70B, 4-bit:&lt;/strong&gt; one 80 GB card with limited concurrency, or a 48 GB card for a low-traffic internal tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;70B, BF16 or FP8 with real traffic:&lt;/strong&gt; two 80 GB cards with tensor parallelism, or a single H200 with 141 GB, which avoids inter-GPU communication entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Memory bandwidth matters as much as capacity. Token generation is bandwidth-bound: every new token reads all the weights once. A card with more HBM bandwidth generates tokens faster at the same model size, which is a big part of why H100 and H200 are worth the premium for latency-sensitive serving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls that cost real time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Multi-GPU without NVLink.&lt;/strong&gt; Tensor parallelism across PCIe-only cards works, but the all-reduce on every layer eats into throughput. Ask how the cards are connected before you plan a two-card split.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Driver and CUDA mismatch.&lt;/strong&gt; A wheel built for one CUDA version on a host driver that is too old gives cryptic errors. Pin your container base image and check &lt;code&gt;nvidia-smi&lt;/code&gt; for the driver's maximum supported CUDA version first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk and download time.&lt;/strong&gt; A 70B BF16 checkpoint is around 140 GB. On a fresh server, downloading it can take longer than the first experiment. Put model weights on local NVMe, not a network mount, or load times stretch into tens of minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Benchmarking with one user.&lt;/strong&gt; A single-stream test measures latency, not capacity. Use a load generator with your real prompt length distribution and watch time-to-first-token as concurrency grows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignoring data residency.&lt;/strong&gt; If the prompts contain customer records, where the GPU physically sits can be a legal constraint, not a preference. Check it before choosing a provider, not after the security review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rent or buy
&lt;/h2&gt;

&lt;p&gt;The utilisation threshold is the deciding number. A card that is busy less than about half the time is usually cheaper to rent, because owned hardware depreciates while idle. Prototyping, evaluation of several model sizes and periodic fine-tuning runs almost always fall below that line. A model serving production traffic around the clock for several years can justify buying.&lt;/p&gt;

&lt;p&gt;The practical sequence: rent, measure real utilisation and real VRAM use for a month with the calculator above as your starting estimate, then decide. If you need hardware located inside Kazakhstan with CUDA, PyTorch and Docker already set up, billed by the hour for experiments or monthly for long runs, here is how our &lt;a href="https://zoomapps.kz/en/gpu-server-rental" rel="noopener noreferrer"&gt;GPU server rental with H100, H200, A100 and L40S cards&lt;/a&gt; works.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>gpu</category>
      <category>machinelearning</category>
      <category>devops</category>
    </item>
    <item>
      <title>Finding duplicate photos on iPhone without uploading a single one</title>
      <dc:creator>ZoomApps</dc:creator>
      <pubDate>Mon, 26 Oct 2026 17:22:05 +0000</pubDate>
      <link>https://dev.to/zoomapps/finding-duplicate-photos-on-iphone-without-uploading-a-single-one-1okg</link>
      <guid>https://dev.to/zoomapps/finding-duplicate-photos-on-iphone-without-uploading-a-single-one-1okg</guid>
      <description>&lt;p&gt;Most photo cleaners send your library somewhere or lock the useful part behind a weekly subscription. We wanted the opposite for our app, Fast Cleaner: everything runs on the iPhone, and the free tier covers a normal daily cleanup. Here is how the duplicate finder works and what we learned shipping it to 50 languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One embedding per photo with Apple Vision
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;VNGenerateImageFeaturePrintRequest&lt;/code&gt; returns a feature vector for an image. Two photos of the same scene end up close to each other, even with small changes in framing or exposure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;VNGenerateImageFeaturePrintRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;VNImageRequestHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;cgImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;thumb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[:])&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform&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="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;print&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;VNFeaturePrintObservation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We request a 160×160 thumbnail from PhotoKit instead of the full image. Vision resamples to its own input size anyway, and the smaller request loads about 30% faster. Embeddings are cached on the device, so the second scan only processes new photos.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Compare only what can be similar
&lt;/h2&gt;

&lt;p&gt;Comparing every photo with every other one is O(n²). On a 50,000-photo library that is far too slow. We bucket photos by the month they were taken and compare pairs inside a bucket. Duplicates and burst-like series are almost always taken close in time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;computeDistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;unionFind&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&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;A distance threshold of 0.3 gave the best balance between "same moment" and "similar but different". Union-find turns the pairs into groups.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Pick the best shot, offer the rest
&lt;/h2&gt;

&lt;p&gt;Inside each group a simple weighted score picks the photo to keep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;face capture quality (&lt;code&gt;VNDetectFaceCaptureQualityRequest&lt;/code&gt;): 0.35&lt;/li&gt;
&lt;li&gt;sharpness, Laplacian variance on luminance: 0.30&lt;/li&gt;
&lt;li&gt;resolution, log-scaled: 0.15&lt;/li&gt;
&lt;li&gt;open eyes and smile from face landmarks: 0.15&lt;/li&gt;
&lt;li&gt;newer date as a tiebreak: 0.05&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Photos without faces get a neutral face score, so landscapes are not penalised.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Things that bit us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memory.&lt;/strong&gt; Batches of 50 images inside &lt;code&gt;autoreleasepool&lt;/code&gt;, otherwise large libraries run out of memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The simulator lies.&lt;/strong&gt; In the iOS simulator Vision returned the same vector for every image, so all distances were 0 and every photo landed in one group. We now detect "collapsed" embeddings (different sizes, distance exactly 0) and skip such buckets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Main thread.&lt;/strong&gt; Reading the photo library during view rendering caused watchdog kills on big libraries. All PhotoKit reads moved off the main thread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deletion safety.&lt;/strong&gt; Deletions go through PhotoKit, so everything lands in Recently Deleted for 30 days.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pricing without a weekly plan
&lt;/h2&gt;

&lt;p&gt;Scanning, the report, swipe-to-clean and up to 300 MB of cleanup a day are free. Pro removes the limit and adds video compression: $1.99/month (first month $0.99), $9.99/year or $19.99 once.&lt;/p&gt;

&lt;p&gt;Fast Cleaner is on the App Store: &lt;a href="https://apps.apple.com/app/id6772155675" rel="noopener noreferrer"&gt;https://apps.apple.com/app/id6772155675&lt;/a&gt; — more at &lt;a href="https://zoomapps.kz/fastcleaner/" rel="noopener noreferrer"&gt;https://zoomapps.kz/fastcleaner/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>iosswiftprivamobilecy</category>
    </item>
  </channel>
</rss>
