A Go SDK for the Bitrix24 REST API. It covers webhooks, OAuth and the scenarios of application development.
Requires Go 1.21+. No external dependencies.
go mod init example.com/your/module # inside a module already? skip this
go get github.com/bitrix24/b24gosdkimport b24 "github.com/bitrix24/b24gosdk"go get only works inside a module: run outside one, it answers go.mod file not found in current directory or any parent directory and installs nothing.
The API reference is on pkg.go.dev.
Writing an integration with an AI agent? Hand it llms.txt —
an entry point written for exactly that reader: what this SDK does not hold
(REST method names, which must not be invented), the seven things an agent gets
wrong with this SDK specifically, and the traps that cost data rather than an
error.
client := b24.NewClient(webhookURL)
res, err := client.Core().Call(ctx, "crm.deal.add", map[string]any{
"fields": map[string]any{"TITLE": "New Deal"},
})Every REST method is called by name — the absence of per-method wrappers is deliberate, and it is why a method Bitrix24 released today is callable today.
If the application opens inside Bitrix24, tokens arrive with the POST data of the page — see An application inside the Bitrix24 interface, which is the shortest path.
The full protocol is for when the user works in an external service and that
service gains access to a portal. The user is first sent to the portal's
authorization page and comes back from it with a code, which lives 30 seconds
and is exchanged for a pair of tokens:
import (
"github.com/bitrix24/b24gosdk"
"github.com/bitrix24/b24gosdk/oauth"
)
oauthClient := oauth.NewClient(clientID, clientSecret)
// 1. Send the user to their portal's authorization page.
url := oauthClient.AuthorizeURL("portal.bitrix24.com", state, redirectURI)
// 2. Exchange the code for tokens.
resp, err := oauthClient.ExchangeCode(ctx, code)
client := b24gosdk.NewOAuthClient(resp.ClientEndpoint, resp.AccessToken)Save resp.RefreshToken: without it, access has to be granted from scratch
again.
An access token lives about an hour. To have the SDK renew it on its own, pass
WithTokenRefresher. oauth.NewRefresher is a ready implementation: it keeps
the current refresh token (the authorization server issues a new pair on every
renewal) and hands the new pair to a callback, where it has to be persisted.
refresher := oauth.NewRefresher(oauthClient, savedRefreshToken, func(t oauth.TokenResponse) {
// Store the new pair: after a restart, work resumes from it.
storeTokens(t.AccessToken, t.RefreshToken)
})
client := b24gosdk.NewOAuthClient(clientEndpoint, savedAccessToken,
b24gosdk.WithTokenRefresher(refresher.Refresh))Calls are then made as usual: if the portal answers expired_token, the SDK
renews the token once and replays the request. There is no renewal on a timer —
the authorization server is touched only when a token has actually expired. If
several requests expire at the same moment, they share one renewal.
Renewing by hand is possible too:
resp, err := oauthClient.RefreshToken(ctx, refreshToken)
client.SetAccessToken(resp.AccessToken)Client— the entry point.Core()— the universal call for any REST method:Call,CallJSON,CallMultipart.
A method name is a string, so the whole of REST is reachable, not the subset somebody got round to wrapping:
res, err := client.Core().CallJSON(ctx, "crm.deal.list", params)For file uploads:
res, err := client.Core().CallMultipart(ctx, "bizproc.workflow.template.add", params, files)Parameters are a map[string]any, and b24.Params is a shorter name for it.
Bitrix24 parameters are nested, and every level of nesting is another whole map;
in a three-level literal the difference shows:
params := b24.Params{
"fields": b24.Params{
"TITLE": "Deal",
"UF_CRM_ADDRESS": b24.Params{"ADDRESS_1": "…", "CITY": "…"},
},
}It is an alias, not a separate type: b24.Params and map[string]any are
the same thing, the two spellings mix inside one literal, and code that has
never heard of Params keeps working unchanged.
CallJSON returns only result. When the response's pagination metadata is
needed, use Call: Total carries the total number of records, Next the
offset of the following page (nil once the data has run out).
res, err := client.Core().Call(ctx, "crm.deal.list", map[string]any{"start": 0})
if err != nil {
return err
}
// res.Result is the data, res.Total how many there are, res.Next the next start.
for res.Next != nil {
res, err = client.Core().Call(ctx, "crm.deal.list", map[string]any{"start": *res.Next})
if err != nil {
return err
}
}A response is raw JSON, so the caller decodes it. Four things about the API came up in the examples often enough to end up in the SDK.
Identifiers arrive as a number in one place and as a string in another —
sometimes within one workflow: disk.* answers "ID": 6687, tasks.* answers
"id": "3711". A field of type b24.ID decodes both, and marshals back as a
number:
var task struct {
ID b24.ID `json:"id"`
Title string `json:"title"`
}
err := json.Unmarshal(res.Result, &task)Many methods wrap the payload in a single-key object — {"task": {…}},
{"products": […]}. Unwrap strips the wrapper without a struct declared for
the sake of one field:
raw, ok := b24.Unwrap(res.Result, "task")Keys are matched exactly. If the portal renamed the field
(UF_TASK_WEBDAV_FILES goes out in select, ufTaskWebdavFiles comes back),
UnwrapFold helps — it ignores case and underscores — or Keys, to see what
actually came back.
An empty field arrives as null, "", false, [] or {}, depending on
the method and the field type. b24.IsEmpty(raw) covers all five; a numeric 0
does not count as empty.
One and the same field answers with more than one shape, and which one it is depends on the data, not on the method. A product property with a single value arrives as an object, one with several as an array of those same objects. A fixed decode target is wrong exactly half the time, so the shape is asked about first:
switch b24.Result(raw).Kind() {
case b24.KindArray:
err = json.Unmarshal(raw, &values)
case b24.KindObject:
var one value
err = json.Unmarshal(raw, &one)
values = []value{one}
case b24.KindNull:
values = nil
default:
err = fmt.Errorf("unexpected shape: %v", b24.Result(raw).Kind())
}IsEmpty answers "is there anything here", Kind answers "what exactly": one
that came back null holds no value, while one that came back "" holds the
scalar the field was reclassified into. The shapes: KindNull, KindBool,
KindNumber, KindString, KindArray, KindObject, KindInvalid.
The shape is read off the leading token and is not validated — the validator
is the Unmarshal that follows, and it reports broken JSON with a position.
CRM stores them as a list of rows, and the set of keys in a row decides what happens. Rows you did not mention are left as they were, so deleting has to be explicit:
"PHONE": []map[string]any{
b24.MultifieldAdd("+7 900 000-00-00", "MOBILE"), // no ID: adds
b24.MultifieldSet(rowID, "+7 900 111-11-11"), // by ID: changes
b24.MultifieldDelete(rowID), // by ID: removes
}A row without an ID always adds: an existing phone re-sent without its
ID creates a duplicate instead of updating the record.
Pages walks the pages itself — it sends start and follows next for as long
as the server hands them out:
p, err := client.Core().Pages("crm.deal.list", map[string]any{
"select": []any{"ID", "TITLE"},
})
if err != nil {
return err
}
for p.Next(ctx) {
for _, row := range p.Rows() {
var d Deal
if err := json.Unmarshal(row, &d); err != nil {
return err
}
}
}
return p.Err() // ALWAYS CHECKNext returns false both at the end of a list and on an error, so a walk that
broke off looks like one that finished — Err() after the loop tells them
apart.
Need n rows? Take. The obvious way to write "take 45 deals" quietly spends
an extra request:
for p.Next(ctx) { // row 46 is already on the first page,
for _, row := range p.Rows() { // but the outer condition is a call to
if len(taken) == want { break } // the portal, and after the break it is
taken = append(taken, row) // evaluated once more
}
}break leaves the inner loop, the outer condition is evaluated again — and that
is a request for a page nobody will read. It compiles, it looks right, it hands
back the right rows. The correct form puts the cut-off in the loop header
(for len(taken) < want && p.Next(ctx)), but you have to know it. Take is
that same form, packaged:
rows, err := p.Take(ctx, 45)Take returns fewer than n rows only at the end of a list or on an error, and
it returns the error alongside the rows it already read. Rows fetched but
not handed over are not lost: the next Take or Next gives them out with no
request to the portal — so Take and Next mix freely.
Count() counts rows handed over, not pages walked: after Take(ctx, 45)
it is 45, not 50. What it cannot count is what you did with them afterwards:
Next hands over a whole page, and a break inside your own loop never reaches
the Pager. When the number of rows is what matters, take them with Take.
For big exports, Scan. Paging by offset makes the server count off every
skipped row, so the last pages of a long list get slower and slower. Scan
pages by identifier: start=-1 (which turns the counting off), ordering by id
and a filter on the last one seen — every page costs the same:
p, err := client.Core().Scan("crm.deal.list", nil)Families with a non-standard response shape (crm.item.* — rows under items
and a lowercase id; tasks.task.* — answers id but sorts on ID;
catalog.product.*; user/department — top-level SORT/ORDER) are known
to the SDK and work with no configuration. For the rest there are
WithRowPath, WithIDField, WithCursorParam.
If a method ignores the cursor, the walk does not loop: it stops with
ErrCursorStalled instead of requesting one and the same page forever at the
expense of the portal's limits.
From the end, WithDescending. Do not reverse a Scan by hand: a
descending walk has two halves. Scan pages by id, ordering on it and
filtering on the last one seen; ascending, that is order ASC plus
filter >id. Flip only the ordering and you get order DESC plus filter >id
— a request for rows above the newest one already seen, of which there are
none. The walk gets an empty second page, stops, and reports a complete export
that holds only the first page. The option flips both halves.
To bound one page, WithCallOptions(WithTimeout(...)). A walk takes one
ctx for the whole loop, so otherwise the choice is: a deadline on the entire
export — and then a 200-page scan has to guess its own duration up front — or no
bound at all on a page that hangs.
p, err := client.Core().Scan("crm.deal.list", nil,
b24.WithDescending(), // newest first
b24.WithCallOptions(b24.WithTimeout(30*time.Second)), // no page longer than 30 s
)WithTimeout bounds everything the SDK does for one call: the attempt, the
pauses between retries and a token renewal. It combines with the caller's own
deadline, and the earlier one wins. A walk stays idempotent whatever the options
— WithIdempotent is applied first and cannot be taken away.
A batch spends one token of the frequency limit instead of one per command, so fifty creates are one request, not fifty.
The work. The portal still runs all fifty commands, one after another, and
still charges their time to the resource-intensity limit (the operating
seconds in the response's time block). That counter is kept per method,
and a batch is charged on its own — which is why crm.deal.add's counter looks
almost untouched after a batch of fifty creates, while the cost is there all the
same. A batch moves the pressure from one limit to the other; it does not remove
it.
The time. A batch takes as long as all of its commands together — inside
one HTTP request: 50 crm.deal.add on a live portal is 28–32 seconds,
about 0.6 s per command, and the first command finishes half a minute before the
last.
That is longer than most default timeouts, and the failure it produces is the most expensive kind there is:
client := b24.NewClient(webhookURL,
b24.WithHTTPClient(&http.Client{Timeout: 30 * time.Second})) // not enoughThe connection is cut after the portal has created part of the deals. That
is an ambiguous failure — the SDK does not replay it, because a replay would
create them a second time — and the identifiers of everything created are lost
with the response. Bound a batch of writes by what it really takes: by the
deadline of the ctx given to CallBatch, and by an HTTP client timeout above
the expected total. The SDK's client has no timeout by default, so this only
catches code that set one.
b := b24.NewBatch()
idUser, _ := b.Add("user.current", nil)
b.AddAs("deals", "crm.deal.list", map[string]any{"filter": map[string]any{">ID": 5}})
res, err := client.Core().CallBatch(ctx, b)
if err != nil {
// Some commands may have run — see below.
}
raw, err := res.Get(idUser)Commands run in the order they were added, whatever they are called — that is what the chain below rests on.
Get hands back a command's raw result. Per-command next/total do not
travel inside it: the server lifts them into sections of their own, and the SDK
puts them in res.Next[id] / res.Total[id]. What means "there is another
page" is the presence of the key, not its value: next:0 is a legitimate
first offset, and comparing against zero would quietly truncate the output.
if next, more := res.Next[idDeals]; more {
// this command has more pages, starting at offset next
}Ref builds the $result[...] substitution the server expands between
commands:
b := b24.NewBatch()
b.Halt = true // mandatory for a chain, see below
b.AddAs("c", "crm.contact.add", map[string]any{"fields": map[string]any{"NAME": "Anna"}})
ref, _ := b24.Ref("c") // no path: crm.contact.add answers a bare id
b.AddAs("note", "crm.timeline.comment.add", map[string]any{
"fields": map[string]any{"ENTITY_ID": ref, "ENTITY_TYPE": "contact"},
})Halt is mandatory for a chain. If the producing command fails, its
$result does not become an error — the server substitutes the unresolved text
as an ordinary value, and the next command runs with a corrupted parameter.
Halt stops the chain instead.
CallBatch does not split a batch: longer than 50 and it returns
ErrBatchLengthExceeded, because the server would answer such a batch with
something that looks like a partial success. For independent commands there is
CallBatchChunked — it cuts at 50 and stitches the results back together:
b := b24.NewBatch()
for _, c := range contacts {
b.Add("crm.contact.add", map[string]any{"fields": c})
}
res, err := client.Core().CallBatchChunked(ctx, b)A chain cannot be split this way: $result does not survive a chunk
boundary — those are separate requests with separate result namespaces.
"Create N entities and get N identifiers" is the most common batch, and reading
its result is the same work every time: walk Order, call Get, unmarshal into
b24.ID, do something about the commands that failed. The last part is the
interesting one, and it is exactly the part usually skipped.
res, err := client.Core().CallBatchChunked(ctx, b)
ids, idErr := res.IDs() // ids[i] belongs to the i-th command addedIDs always returns a slice as long as Order and aligned with it position
by position, so the arrangement the batch was built from is preserved. A command
that produced no identifier leaves a zero in its place (ID.IsZero) rather
than dropping out of the slice: drop the gaps and every later identifier slides
onto someone else's entity — and that is not an error anywhere, the identifiers
being real, they just belong to the wrong thing.
The error names every gap and wraps the originals, so errors.Is and
errors.As reach the *APIError of the command that failed. It is returned
alongside the identifiers, not instead of them.
What is decoded is a result that is the identifier — the way the classic
*.add methods answer. A method that wraps it (crm.item.add answers
{"item":{"id":…}}) or answers something else entirely (crm.deal.update
answers true) becomes a gap quoting its own response, rather than a plausible
0; for those, use Get and Unwrap.
The server answers HTTP 200 and puts the commands' failures in result_error,
so err != nil does not mean nothing ran. The result comes back with the
error and must not be discarded: re-running the whole batch would re-execute the
commands that already committed.
res, err := client.Core().CallBatch(ctx, b)
var be *b24.BatchError
if errors.As(err, &be) {
// be.Failed is what failed; res is everything that succeeded.
// Rebuild a batch from be.Failed and retry only that.
}res.Executed(id) tells "the command ran and failed" from "it never ran,
because Halt stopped the batch earlier".
The b24test package brings up a fake portal and assembles fixtures in the form
the bytes arrive in from a real one:
import "github.com/bitrix24/b24gosdk/b24test"
func TestMyIntegration(t *testing.T) {
p := b24test.NewPortal(t)
p.On("crm.deal.get", b24test.Result(map[string]any{"ID": "42", "TITLE": "Deal"}))
title, err := findDealTitle(ctx, p.Client(), 42) // your integration's code
// ...
if p.CallsTo("crm.deal.get")[0].Params["id"] != float64(42) {
t.Error("the wrong id went out")
}
}Why fixtures rather than a mock of the client: what breaks is usually the wire, not the logic. An identifier arrives quoted, a list is hidden under a key, a rate-limit error arrives with HTTP 503 and a body, one command inside a batch failed within an HTTP 200. A mock reproduces your assumptions, a fixture reproduces what the portal actually sends; and the request goes through the same parsing, retries and error classification as in production.
Available: Result, ListResult, WrappedListResult, BatchResult,
ErrorBody, InstallForm, UninstallForm, AppPageForm. Portal.OnError
fills in the status the portal uses for that code by itself (StatusFor) —
handling of QUERY_LIMIT_EXCEEDED cannot be tested at HTTP 200.
The values in the fixtures are placeholders, taken from the documentation's examples: fixtures get committed, and a real token in one would mean leaked access.
Bitrix24 sends events as a POST request in application/x-www-form-urlencoded
format. Parsing goes straight from an *http.Request:
func handler(w http.ResponseWriter, r *http.Request) {
evt, err := b24gosdk.ParseOnAppInstallRequest(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Persist for the work ahead: access_token, refresh_token,
// client_endpoint, member_id and application_token.
}For uninstall, ParseOnAppUninstallRequest. If the payload is already stored as
JSON, there are ParseOnAppInstall / ParseOnAppUninstall.
Inbound events have to be verified against the application_token saved at
install time:
if !evt.Auth.VerifyApplicationToken(savedToken) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}When an application page opens (and on the install page too), Bitrix24 passes the authorization data in a POST request:
req, err := b24gosdk.ParseAppRequest(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
client, err := b24gosdk.NewClientFromAppRequest(req)req.AuthID is the access token, req.RefreshID the refresh token; to keep
working with REST for longer than an hour, save both.
installFinish is called from the application's install page through the
frontend (BX24 JS). It is a REST method like any other, so through the SDK it is
called by name:
_, err := client.Core().CallJSON(ctx, "installFinish", nil)Use it only if the scenario genuinely requires a server-side call. In the standard scenarios the method is called from the frontend.
To call REST 3.0
methods, it is enough to pass the new version's URL — with a /rest/api/
segment in place of /rest/. There is no option for it:
// v1: https://portal.bitrix24.com/rest/1/TOKEN/
// v3: https://portal.bitrix24.com/rest/api/1/TOKEN/
client := b24.NewClient("https://portal.bitrix24.com/rest/api/1/TOKEN/")
res, err := client.Core().Call(ctx, "tasks.task.list", b24.Params{
"select": []string{"id", "title"},
"filter": [][]any{{"id", ">", 500}},
"pagination": b24.Params{"limit": 20, "page": 1},
})The version is derived from the URL rather than set separately, because the URL
sets it anyway: without /api/, the portal runs the old version's method or
answers "method not found". A second source of truth could be made to disagree
with the first — say "version 3" and forget /api/ in the URL — and then every
call would go to v1 and be decoded by the rules of v3.
For an application, the v3 URL is https://portal.bitrix24.com/rest/api/; the
token still travels in the request body.
Verified against a live portal (Bitrix24 cloud, August 2026):
Call,CallJSON— yes. v3's success envelope is the same as v1's, soCallResult,Result,Kind,Unwrap,UnwrapFold,IsEmptyandIDwork unchanged.- Error codes — yes:
errors.Is,CodeOfand the sentinel errors are filled in from v3's nested format (see below). - Retries and
WithIdempotent— yes, the same logic. Infrastructure errors (QUERY_LIMIT_EXCEEDEDamong them) arrive on a v3 URL in v1's flat format, and the SDK parses both. WithTimeout,WithHTTPClient,WithRetry— yes, that is transport; it does not concern the version.
-
PagesandScan—ErrV3WalkUnsupported. v3 has no cursor:startis ignored,nextandtotalare absent from the response, and a page is selected by thepaginationparameter (page,limit,offset). The walk does not degrade — it refuses to start, deliberately: on a live portalPagesovertasks.task.listread the first page, saw nonextand reported a completed walk withErr() == nil— 2 rows out of 423. A partial export that looks like a complete one is worse than an error. Page withCallinstead:for page := 1; ; page++ { res, err := client.Core().Call(ctx, "tasks.task.list", b24.Params{ "select": []string{"id"}, "pagination": b24.Params{"limit": 50, "page": page}, }, b24.WithIdempotent()) if err != nil { return err } items, _ := b24.Unwrap(res.Result, "items") // empty — the pages have run out }
-
Batch,CallBatch,CallBatchChunked,Ref,Halt—ErrV3BatchUnsupported. v3 does have abatchmethod, but it is a different protocol: commands go into the root of the body as{"method": …, "query": {…}}, the reply is an array in the order sent (the command keys are discarded), and the first failing command aborts the whole request instead of producingresult_error. Until the SDK speaks that format, call it directly:res, err := client.Core().Call(ctx, "batch", b24.Params{ "cnt": b24.Params{"method": "humanresources.employee.count", "query": b24.Params{}}, "tsk": b24.Params{"method": "tasks.task.list", "query": b24.Params{"select": []string{"id"}}}, }) // res.Result = [{"total":19},{"items":[{"id":25}]}] — positional
-
CallMultipart— untested. v3 declares a JSON body only. -
OAuth authorization on v3 — untested: the run went over a webhook. The token travels in the body, as it does on v1, so it ought to work, but there is no measurement.
The portal hands it out itself, through the documentation method, in OpenAPI
format. But it must not be fetched through the SDK: that method answers with
the document itself, with no {"result": …} envelope, so Call returns
Result == nil and no error at all — the request succeeded and there is no
data.
Fetch it with a plain HTTP request:
resp, err := http.Get(webhookURL + "documentation") // a v3 URL, GET, no parametersOn the portal that was checked the document holds 177 methods, 25 of which are also available over GET.
Errors the portal reported are returned as *APIError — with a code, a
description and an HTTP status:
var apiErr *b24gosdk.APIError
if errors.As(err, &apiErr) && apiErr.Code == "expired_token" {
// ...
}Authorization-server errors arrive as *oauth.Error.
A typo in a string literal compiles, runs, and quietly takes the wrong branch:
if errors.Is(err, b24.ErrMethodNotFound) { … }
if errors.Is(err, b24.ErrAccessDenied) { … }
if errors.Is(err, b24.Code("CREATE_DYNAMIC_TYPE_RESTRICTED")) { … } // any codeMatching is case-insensitive (the portal sends QUERY_LIMIT_EXCEEDED
upper-cased and expired_token lower-cased) and goes on the code alone:
OVERLOAD_LIMIT and QUERY_LIMIT_EXCEEDED both arrive with HTTP 503, so a
status-based check would confuse a manual block with a rate limit. To read the
code back, b24.CodeOf(err).
The ready sentinel errors: ErrQueryLimitExceeded, ErrOperationTimeLimit,
ErrExpiredToken, ErrInvalidToken, ErrInvalidGrant, ErrInsufficientScope,
ErrMethodNotFound, ErrAccessDenied, ErrPaymentRequired. The Code*
constants name the same codes, and b24.Code(...) covers everything else — the
portal ships new codes without warning, so the set is deliberately open.
v3's response has a different shape — the code and the text sit in a nested
object ({"error":{"code":…,"message":…}}) rather than flat — but none of that
shows from the outside: *APIError is filled in from both shapes, and
errors.Is and CodeOf work as before. Parsing goes by the shape of the
body, not by the version of the URL, because a v3 URL answers with both:
gateway errors (QUERY_LIMIT_EXCEEDED among them, which is what retries rest
on) arrive in v1's flat format on v3 as well.
The versions' codes are different, and one of them the SDK folds onto the old one — the one that means the same thing in both versions:
// on a v3 URL this is true; the code on the wire is
// BITRIX_REST_V3_EXCEPTION_METHODNOTFOUNDEXCEPTION
errors.Is(err, b24.ErrMethodNotFound)The rest are not folded, and that is not an omission.
BITRIX_REST_V3_EXCEPTION_ACCESSDENIEDEXCEPTION looks like ACCESS_DENIED, but
the measurement showed that v3 answers with it on a wrong webhook token too,
where v1 answers INVALID_CREDENTIALS: one v3 code covers two v1 codes. Folding
them means making the "the rights are wrong, the credentials are fine" branch
fire on dead credentials. So such cases get sentinels of their own —
ErrV3Validation, ErrV3EntityNotFound, ErrV3AccessDenied — and the CodeV3*
constants; any code not on the list is still reached through b24.Code(...).
The BITRIX_REST_V3_EXCEPTION_ prefix is not universal — do not derive a
code from it. Measured: crm.deal.timeline.activity.email.list on a bad id
answers CRM_EMAIL_INVALID_REQUEST, in a v3 envelope and with no prefix at all.
CodeOf returns the code as it arrived, untranslated: it goes into a log,
and a foreign code there would send the reader hunting for a string the portal
never sent. For branching, errors.Is; for the log, CodeOf.
v3's validation errors carry something v1 has no equivalent for: the list of fields the request was rejected over. The code and the text of every such error are equally generic, so without that list there is no telling what exactly is wrong:
var apiErr *b24.APIError
if errors.As(err, &apiErr) {
for _, v := range apiErr.Validation {
log.Printf("field %s: %s", v.Field, v.Message)
// field id: the required field `id` is missing
}
}QUERY_LIMIT_EXCEEDED(HTTP 503) is the rate limiter refusing the call before the portal ran it. A replay cannot duplicate anything, so the SDK always retries such a request, whatever the method.- A network failure, a timeout, an unreadable body, a 5xx with no error code
are ambiguous: the request may have reached the portal and executed. By
default they are not retried, because replaying
crm.deal.addwould create a second deal. - If a call is safe to replay, say so explicitly:
res, err := client.Core().Call(ctx, "crm.deal.get",
map[string]any{"id": 42}, b24.WithIdempotent())Put WithIdempotent on reads (*.get, *.list, *.fields) and on writes that
set fixed values. Do not put it on *.add, nor on an update whose new value
is derived from the old one.
Pages/Scan retry a page themselves, and Call(ctx, "crm.deal.list", …)
does not. The method is the same one, and the asymmetry here is deliberate.
A walk knows what it is doing by construction: a Pager can only re-ask the
method it was built with, moving a cursor, and no set of options will make it
write. Call has only the string it was handed — and reading intent out of that
string is what the SDK will not do. The rule "*.list is a read" would be a
guess about every method family Bitrix24 has already shipped and every one it
will ship, applied silently, and on the side where being wrong means duplicates.
So on your reading calls, put WithIdempotent there yourself.
A batch is never retried: it may hold commands that already committed.
Nothing has to be added. A method is called by name, so a new Bitrix24 method is available at once, without an SDK update:
res, err := client.Core().Call(ctx, "crm.newmethod.add", params)The exact method names and their parameters are in the official REST documentation.
The development rules are in CONTRIBUTING.md.
MIT, see LICENSE.