The Bitrix24 REST API looks simple on day one. You create an inbound webhook, call crm.deal.list, 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 QUERY_LIMIT_EXCEEDED, or a week later, when someone notices that deals updated over the weekend never reached the warehouse system.
This post collects what we have learned building exchanges between Bitrix24 and external systems: how the two kinds of webhooks behave, how to use batch without shooting yourself in the foot, how the rate limits actually work, and the handful of field-level quirks that cause silent data corruption.
Two kinds of webhooks, two different jobs
Bitrix24 uses the word "webhook" for two unrelated things.
Inbound webhook. A URL with a secret embedded in it that lets your code call REST methods on behalf of a specific user:
https://yourcompany.bitrix24.kz/rest/1/x7k2abc9def0ghij/crm.deal.get.json?id=42
The 1 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 (crm, user, task and so on). Two practical consequences:
- 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.
- 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.
Outbound webhook (event handler). Bitrix24 sends a POST to your URL when something happens: ONCRMDEALADD, ONCRMDEALUPDATE, ONCRMCONTACTDELETE and many more. The body is form-encoded, not JSON, and looks roughly like this once parsed:
event=ONCRMDEALUPDATE
data[FIELDS][ID]=1287
ts=1761712345
auth[domain]=yourcompany.bitrix24.kz
auth[application_token]=k4q9...
Notice what is missing: the deal itself. You get an ID and nothing else. Your handler has to call crm.deal.get to find out what changed, which means every event costs at least one API request.
A minimal handler in Python (Flask) that does the right things:
import os
from flask import Flask, request, abort
from queue_client import enqueue # Redis, RabbitMQ, a DB table, anything durable
app = Flask(__name__)
APP_TOKEN = os.environ["B24_APP_TOKEN"]
@app.post("/b24/events")
def b24_event():
form = request.form
if form.get("auth[application_token]") != APP_TOKEN:
abort(403)
event = form.get("event")
entity_id = form.get("data[FIELDS][ID]")
if not event or not entity_id:
abort(400)
enqueue({"event": event, "id": int(entity_id), "ts": form.get("ts")})
return "", 200
Three rules are baked in here:
-
Check
application_token. The handler URL is public; without the check anyone can push fake events. - Do not call the API inside the handler. Acknowledge fast and queue the work. A slow or failing handler loses events, and Bitrix24 keeps no replay log for you.
- Store only the ID. By the time the worker runs, the deal may have changed again, and you want the latest state anyway.
Events are a hint, not a guarantee
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:
-
Offline events. For applications, Bitrix24 can store events on its side; you pull them with
event.offline.getand confirm processing. Push becomes pull and survives your downtime. -
A periodic reconciliation pass. Every N minutes, request entities with
DATE_MODIFYafter your last checkpoint and run them through the same worker. Cheap, and it catches whatever the event stream dropped.
The echo loop
If your integration writes back to the same entity it listens to, you will create a loop: you update deal 1287, Bitrix24 fires ONCRMDEALUPDATE, your worker fetches it, sees a "change", pushes it to the external system, which pushes a status back, which updates the deal again.
Break it explicitly. Options that work:
- Compare a hash of the fields you care about with the last hash you synced. If nothing relevant changed, stop.
- Check
MODIFY_BY_ID(orupdatedByincrm.item.*) and ignore changes made by your technical integration user. - Write a sync marker into a custom field, but only if the business is fine with an extra field on the card.
The first option is the most robust, because it also filters out updates that touched fields you do not sync.
batch: up to 50 calls in one request
batch takes a set of named commands and executes them on the server in one HTTP round trip. The limit is 50 commands per batch.
import requests
WEBHOOK = "https://yourcompany.bitrix24.kz/rest/1/x7k2abc9def0ghij/"
def call(method, params=None):
r = requests.post(WEBHOOK + method + ".json", json=params or {}, timeout=30)
data = r.json() # read the body even on 503: the error code says which limit you hit
if "error" in data:
raise RuntimeError(f"{data['error']}: {data.get('error_description')}")
return data
cmd = {
f"d{deal_id}": f"crm.deal.get?id={deal_id}"
for deal_id in [101, 102, 103]
}
resp = call("batch", {"halt": 0, "cmd": cmd})
results = resp["result"]["result"] # successful commands
errors = resp["result"]["result_error"] # failed commands, keyed the same way
Things to know before relying on it:
Check result_error every time. With halt: 0 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 result, those 7 are lost silently.
halt: 1 is not a transaction. 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.
Commands are query strings. Each value in cmd is a URL-encoded string, not JSON. Hand-built strings break on &, = or Cyrillic. Use an encoder for PHP-style nested arrays (fields[TITLE]=..., filter[>ID]=...); plain urlencode on a nested dict gets it wrong.
Results can feed later commands. You can reference an earlier result inside the same batch:
cmd = {
"contact": "crm.contact.add?fields[NAME]=Aigerim&fields[PHONE][0][VALUE]=%2B77001234567&fields[PHONE][0][VALUE_TYPE]=WORK",
"deal": "crm.deal.add?fields[TITLE]=Order%20123&fields[CONTACT_ID]=$result[contact]",
}
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.
A batch is one request for the rate limiter, but not free. 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 list calls with complex filters in one batch can exhaust the time budget faster than fifty simple get calls spread over a minute.
How the rate limits actually work
There are two independent limits, and integrations usually discover them in this order.
1. Request frequency: a leaky bucket
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 QUERY_LIMIT_EXCEEDED. 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.
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:
import time, random, threading
class LeakyBucket:
def __init__(self, rate=2.0, capacity=50):
self.rate, self.capacity = rate, capacity
self.level, self.last = 0.0, time.monotonic()
self.lock = threading.Lock()
def acquire(self):
while True:
with self.lock:
now = time.monotonic()
self.level = max(0.0, self.level - (now - self.last) * self.rate)
self.last = now
if self.level + 1 <= self.capacity:
self.level += 1
return
wait = (self.level + 1 - self.capacity) / self.rate
time.sleep(wait)
bucket = LeakyBucket(rate=1.8, capacity=40) # stay below the real limit
def call_limited(method, params=None, attempts=6):
for attempt in range(attempts):
bucket.acquire()
try:
return call(method, params)
except RuntimeError as e:
if "QUERY_LIMIT_EXCEEDED" not in str(e) and "OPERATION_TIME_LIMIT" not in str(e):
raise
time.sleep(min(60, 2 ** attempt) + random.random())
raise RuntimeError(f"{method}: rate limited after {attempts} attempts")
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.
2. Execution time per method
Newer versions of the platform also limit how much server time a single method may consume over a sliding window. Responses carry a time block, with fields such as operating and operating_reset_at telling you how much time the method has used. When a method exceeds its budget, it is blocked for a while with OPERATION_TIME_LIMIT, while other methods keep working.
This bites bulk exports. crm.deal.list filtering on a custom field with a large select is expensive, and a loop of them eats the budget. Log the time block during test runs to see which calls are heavy before production does.
Pagination that does not get slower on every page
List methods return 50 records per page with next and total. The obvious loop passes start=0, 50, 100.... On large tables this degrades, because the server still counts and offsets through everything before your page, and it also computes total on every call.
The faster pattern: sort by ID, filter by >ID from the last record you saw, and pass start=-1 to turn off the count.
def iter_deals(select):
last_id = 0
while True:
resp = call_limited("crm.deal.list", {
"order": {"ID": "ASC"},
"filter": {">ID": last_id},
"select": select,
"start": -1,
})
rows = resp["result"]
if not rows:
return
yield from rows
last_id = int(rows[-1]["ID"])
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.
Always pass select. Without it you get the default field set, which excludes custom UF_CRM_* fields, and you will wonder why they are empty. With ["*", "UF_*"] you get everything, which is expensive. List the fields you need.
Field-level pitfalls
These do not throw errors. They corrupt data quietly.
Multi-fields append by default. PHONE, EMAIL, WEB and IM are arrays of {ID, VALUE, VALUE_TYPE}. If you call crm.contact.update with PHONE: [{"VALUE": "+77001234567"}], 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 ID; to delete one, pass its ID with an empty VALUE.
Two naming conventions. crm.deal.* uses UPPER_SNAKE (STAGE_ID, ASSIGNED_BY_ID). The newer universal crm.item.* methods with entityTypeId (2 for deals, 3 for contacts, 4 for companies, plus IDs for smart processes) use camelCase (stageId, assignedById) and wrap results in item or items. Mixing them in one codebase leads to fields that are "set" but never saved. Pick one family per entity and stick to it.
Stage IDs depend on the pipeline. In the default pipeline a stage is NEW or WON; in additional pipelines it becomes C3:NEW, C3:WON, where 3 is the category ID. Hardcoding WON works until sales creates a second pipeline.
Dates come with a timezone offset. Values look like 2026-10-29T11:00:00+05:00. 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.
List fields store IDs, not labels. A custom enum field returns "45", not "Wholesale". Load the field definitions with crm.deal.userfield.list once and map both ways.
What to log
Log method, entity ID, direction, duration, the time 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.
Checklist
- Dedicated technical user, minimal webhook scopes
- Event handler: token check, enqueue, return 200
- Reconciliation pass by
DATE_MODIFYto catch lost events - Echo-loop protection by field hash or author check
-
batchwithresult_errorchecked on every call - Shared client-side limiter below the published rate, backoff on 503
-
>IDpagination withstart=-1and explicitselect - Multi-fields updated by
ID, stage IDs resolved per pipeline - Logs with timing data and an alert on queue age
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 Bitrix24 REST API integration with warehouse, logistics and banking systems.
Top comments (1)
The weekend deals that never reached the warehouse system is the failure mode that defines this kind of integration β not an error anyone saw, just an absence discovered a week later by comparing two systems by hand. That is why the bulk-sync limit and the webhook design have to be thought about together: webhooks tell you something changed, but any missed event needs a periodic reconciliation pull behind it, or the sync is only as reliable as its least reliable delivery. The two-unrelated-things-called-webhook naming does not help; half the debugging stories in this space start with someone configuring the wrong one confidently.