Documentation
¶
Overview ¶
Package b24gosdk is a client for the Bitrix24 REST API.
There are two ways to authorize REST calls, and the SDK covers both.
An inbound webhook carries its secret in the URL and needs no further setup:
client := b24gosdk.NewClient(webhookURL)
res, err := client.Core().Call(ctx, "crm.deal.add", map[string]any{
"fields": map[string]any{"TITLE": "New Deal"},
})
An application authorizes over OAuth 2.0 and calls REST with an access token that lives about an hour. Tokens reach an application in two ways: with the POST data of an application page, parsed by ParseAppRequest, and with the install event, parsed by ParseOnAppInstallRequest. Both carry a refresh token, which is what keeps an application working afterwards:
req, err := b24gosdk.ParseAppRequest(r) client, err := b24gosdk.NewClientFromAppRequest(req)
Pass WithTokenRefresher to have the SDK renew an expired token on its own; oauth.Refresher implements renewal including the rotation of refresh tokens.
Every REST method is called the same way, by name, through Core. The SDK ships no per-method wrappers: a method Bitrix24 released today is callable today, and nothing has to be regenerated when the API grows.
res, err := client.Core().Call(ctx, "crm.deal.list", params)
Call returns the pagination metadata of list methods as well; CallJSON returns just the result, and CallMultipart uploads files. Params is a shorter name for the map[string]any those parameters travel in.
Walking a list ¶
Pages follows the server's own cursor; Scan pages by id instead, so a deep page of a large export costs the same as the first one. Both are safer than hand-rolling the loop: the ABSENCE of next ends a list (next:0 is a real offset), a method that ignores the cursor is caught rather than looped forever, and an error surfaces from Err after the loop instead of vanishing.
p, err := client.Core().Scan("crm.deal.list", nil,
b24gosdk.WithDescending(), // newest first
b24gosdk.WithCallOptions(b24gosdk.WithTimeout(30*time.Second))) // bound ONE page
for p.Next(ctx) {
for _, row := range p.Rows() { … }
}
return p.Err()
Pager.Take bounds a walk by a row count. It is not sugar: a break inside the inner loop leaves the outer condition — a call to the portal — to be evaluated again, so "the first 45" written by hand fetches a page it never reads.
Reading a result ¶
Unwrap strips the single-key object many methods wrap their payload in; UnwrapFold does it when the portal renamed the field; Keys shows what actually came back. IsEmpty answers whether anything is there, across the five ways Bitrix24 spells emptiness, and Result.Kind answers what is there — one field arrives as an object or as an array depending on the DATA, so the shape is worth asking about before choosing a decode target. ID decodes an identifier whether it arrives as a number or as a quoted number.
Batch ¶
Up to 50 calls travel in one request, for one token of the FREQUENCY limit instead of fifty, through Batch and CallBatch; Ref feeds one command's result into the parameters of the next, and commands run in the order they were added. CallBatchChunked splits many INDEPENDENT commands at the server's limit — a $result reference cannot cross that boundary.
b := b24gosdk.NewBatch()
b.AddAs("c", "crm.contact.add", map[string]any{"fields": fields})
res, err := client.Core().CallBatch(ctx, b)
What a batch does not save is the work: the commands run sequentially on the portal, their execution time is still charged to the resource-intensity limit, and one request is not a quick one — 50 crm.deal.add take half a minute inside it. A client timeout shorter than that cuts the connection after part of the entities exist, and their ids are lost with the response. See Batch.
A batch failure is PARTIAL: the server answers HTTP 200 with per-command errors, so BatchError carries the result alongside them and it must not be discarded. Per-command next and total live in BatchResult.Next and .Total, not inside each command's result. BatchResult.IDs reads the identifiers out of a batch of adds, one entry per command so the gaps left by the ones that failed do not shift the rest.
REST 3.0 ¶
Pass a base URL with the /rest/api/ segment and calls go to REST 3.0. Nothing else is needed and there is no version option: the URL states the version already, since without /api/ the portal runs the v1 method of that name.
client := b24gosdk.NewClient("https://portal.bitrix24.ru/rest/api/1/TOKEN/")
The success envelope is the v1 one, so Call, Result, Unwrap and the rest are unchanged; error codes arrive nested and are parsed into the same *APIError, with Validation carrying the fields a request was rejected over.
Pages, Scan, CallBatch and CallBatchChunked do NOT work on v3 and refuse with ErrV3WalkUnsupported and ErrV3BatchUnsupported rather than half-work — v3 paginates on its own pagination parameter and has a different batch protocol. Both sentinels name what to call instead.
Errors ¶
Errors reported by the API are returned as *APIError. Match a code with errors.Is and the ErrorCode constants, never with == on a string. Retry is decided by "did the request execute?", not by "was it transient?", so an ambiguous failure is replayed only for a call marked WithIdempotent.
Testing, and writing integrations with an AI agent ¶
Package b24test provides a fake portal and wire fixtures, so an integration is tested without a network or a real portal. llms.txt in the repository root is an entry point for an AI coding agent writing an integration.
Index ¶
- Constants
- Variables
- func Code(c ErrorCode) error
- func IsEmpty(raw json.RawMessage) bool
- func Keys(raw json.RawMessage) ([]string, bool)
- func MultifieldAdd(value, valueType string) map[string]any
- func MultifieldDelete(id ID) map[string]any
- func MultifieldSet(id ID, value string) map[string]any
- func Ref(id CmdID, path ...string) (string, error)
- func Unwrap(raw json.RawMessage, path ...string) (json.RawMessage, bool)
- func UnwrapFold(raw json.RawMessage, path ...string) (json.RawMessage, bool)
- type APIError
- type AppAuth
- type AppRequest
- type Batch
- type BatchError
- type BatchResult
- type CallOption
- type CallResult
- type Client
- type Cmd
- type CmdID
- type Core
- func (c *Core) AccessToken() string
- func (c *Core) BaseURL() string
- func (c *Core) Call(ctx context.Context, method string, params any, opts ...CallOption) (*CallResult, error)
- func (c *Core) CallBatch(ctx context.Context, b *Batch) (*BatchResult, error)
- func (c *Core) CallBatchChunked(ctx context.Context, b *Batch) (*BatchResult, error)
- func (c *Core) CallJSON(ctx context.Context, method string, params any, opts ...CallOption) (json.RawMessage, error)
- func (c *Core) CallMultipart(ctx context.Context, method string, params map[string]string, ...) (json.RawMessage, error)
- func (c *Core) Pages(method string, params any, opts ...PageOption) (*Pager, error)
- func (c *Core) Scan(method string, params any, opts ...PageOption) (*Pager, error)
- func (c *Core) SetAccessToken(token string)
- type ErrorCode
- type ID
- type Kind
- type OnAppInstallData
- type OnAppInstallEvent
- type OnAppUninstallData
- type OnAppUninstallEvent
- type Option
- type PageOption
- type Pager
- type Params
- type Result
- type RetryConfig
- type TokenRefresher
- type ValidationError
Examples ¶
Constants ¶
const DefaultPageSize = 50
DefaultPageSize is how many rows a list method returns per page. It is fixed by the server, not by the SDK: asking for a different size does nothing.
const MaxBatchCommands = 50
MaxBatchCommands is the number of commands the server executes in one batch.
The limit is not left to the server to enforce: it answers an over-long batch with a per-command ERROR_BATCH_LENGTH_EXCEEDED for each surplus command, so the batch comes back looking like a partial success instead of a rejected call.
Variables ¶
var ( ErrQueryLimitExceeded = Code(CodeQueryLimitExceeded) ErrOperationTimeLimit = Code(CodeOperationTimeLimit) ErrExpiredToken = Code(CodeExpiredToken) ErrInvalidToken = Code(CodeInvalidToken) ErrInvalidGrant = Code(CodeInvalidGrant) ErrInsufficientScope = Code(CodeInsufficientScope) ErrMethodNotFound = Code(CodeMethodNotFound) ErrAccessDenied = Code(CodeAccessDenied) ErrPaymentRequired = Code(CodePaymentRequired) // REST 3.0 conditions with no v1 counterpart to fold into. ErrV3Validation = Code(CodeV3Validation) ErrV3EntityNotFound = Code(CodeV3EntityNotFound) ErrV3AccessDenied = Code(CodeV3AccessDenied) )
Frequently matched codes as ready sentinels.
var ErrBadRef = errors.New("b24gosdk: bad $result reference")
ErrBadRef is returned by Ref for an id or path segment the server cannot read back. Every Ref error wraps it.
var ErrBatchLengthExceeded = errors.New("b24gosdk: batch is longer than the server accepts")
ErrBatchLengthExceeded is returned when a batch holds more than MaxBatchCommands commands. Split it with Chunks, or use CallBatchChunked.
var ErrCursorStalled = errors.New("b24gosdk: the cursor did not move")
ErrCursorStalled is returned when a walk asks for the next page and the server answers with the same page again.
It matters because the failure is otherwise SILENT: the method answers a valid success forever, and the loop spends the customer's rate limit until something else breaks. The two known causes are a method that consumes a differently named cursor (see WithCursorParam) and a Scan over a method that ignores the generated id filter.
var ErrNoRows = errors.New("b24gosdk: no row array in result")
ErrNoRows is returned when a page carries no row array where one was expected.
var ErrV3BatchUnsupported = errors.New("b24gosdk: Batch works with REST v1 only; REST 3.0 batch takes method/query commands and answers with a positional array — call it through Core.Call")
ErrV3BatchUnsupported is returned by CallBatch and CallBatchChunked when the client addresses REST 3.0.
REST 3.0 has a batch method, but not this batch: it takes each command as {"method": …, "query": {…}} at the top level of the body, answers with a plain ARRAY in submission order — the command ids are discarded — and aborts the whole request on the first failing command instead of reporting per-command errors in result_error. So Batch, Ref, Halt and BatchResult, which are the v1 protocol, have nothing to map onto, and the "partial failure" that BatchError exists for does not occur.
The refusal replaces the portal's own answer to a v1 batch body, which is BITRIX_REST_V3_EXCEPTION_INVALIDSELECTEXCEPTION, "Не удается распознать выражение select" — a message about `select`, for a request that has none.
Until the SDK speaks the v3 format, send it through Core.Call:
res, err := client.Core().Call(ctx, "batch", b24gosdk.Params{
"cnt": b24gosdk.Params{"method": "humanresources.employee.count", "query": b24gosdk.Params{}},
})
// res.Result is [{"total":19}] — an array, positional
var ErrV3WalkUnsupported = errors.New("b24gosdk: Pages and Scan work with REST v1 only; REST 3.0 paginates with the pagination parameter and returns no next cursor")
ErrV3WalkUnsupported is returned by Pages and Scan when the client addresses REST 3.0. Page through v3 with Core.Call and its pagination parameter.
Why a refusal rather than a best effort ¶
Because the best effort loses data quietly. Both walks are built on the v1 cursor protocol — send `start`, read back `next`, stop once `next` is gone — and v3 implements none of it: it paginates on pagination{page,limit,offset}, ignores `start`, and answers with neither `next` nor `total`. Measured on a live portal: Pages over tasks.task.list on a v3 URL read the first page, found no `next`, and reported a FINISHED walk, Err() == nil, after 2 rows out of the 423 the portal held. A partial export that looks complete is the one failure a walk must never have. Scan happens to fail loudly instead — it sends a v1 filter that v3 rejects — but a walk whose safety depends on which method it was pointed at is not a guarantee.
Functions ¶
func Code ¶
Code returns a sentinel error that errors.Is matches against any error carrying that code:
if errors.Is(err, b24gosdk.ErrMethodNotFound) { … }
if errors.Is(err, b24gosdk.Code("CREATE_DYNAMIC_TYPE_RESTRICTED")) { … }
Why not compare the string ¶
apiErr.Code == "ERROR_METHOD_NOT_FUND" compiles, runs, and quietly takes the wrong branch forever. Going through Code means the comparison is case-insensitive and the well-known codes have a named constant the compiler checks. A code the SDK has never heard of still works — the argument is a plain string type.
func IsEmpty ¶
func IsEmpty(raw json.RawMessage) bool
IsEmpty reports whether a JSON value carries nothing usable.
Bitrix24 spells "this field is empty" in at least five ways, and which one you get depends on the method and on the field's type: an unset user field comes back as null, as "", or as false, and an empty collection comes back as [] or {}. Checking each shape by hand takes five comparisons at every call site, and missing one produces a decode error on data that is simply absent.
IsEmpty is true for: no value at all, null, "", false, [] and {}. It is false for 0 — a numeric zero is a value, not an absence.
func Keys ¶
func Keys(raw json.RawMessage) ([]string, bool)
Keys lists the field names of a JSON object, in no particular order.
It exists for the position Unwrap's exact matching can leave you in: the field is not spelled the way the request spelled it, and you need to see what the portal actually returned. ok is false when the value is not a JSON object.
func MultifieldAdd ¶
MultifieldAdd builds a crm_multifield row that ADDS a new value.
valueType is the subtype Bitrix24 shows next to the value — "WORK", "MOBILE", "HOME" for phones, "WORK", "HOME" for emails. An empty valueType is omitted, which lets the portal apply its own default.
client.Core().Call(ctx, "crm.contact.update", map[string]any{
"id": contactID,
"fields": map[string]any{
"PHONE": []map[string]any{
b24gosdk.MultifieldAdd("+7 900 000-00-00", "MOBILE"),
},
},
})
Because a row without an ID always adds, calling this for a value that already exists creates a duplicate. Use MultifieldSet with the existing row's ID to change a value in place.
Example ¶
Phones and emails: which keys a row carries decides what the server does, and rows you do not mention are left alone.
package main
import (
"context"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
var existingRowID b24.ID = 55
_, err := client.Core().Call(context.Background(), "crm.contact.update", map[string]any{
"id": 42,
"fields": map[string]any{
"PHONE": []map[string]any{
b24.MultifieldAdd("+7 900 000-00-00", "MOBILE"), // no ID -> adds
b24.MultifieldSet(existingRowID, "+7 900 111-11-11"),
b24.MultifieldDelete(existingRowID),
},
},
})
if err != nil {
log.Fatal(err)
}
}
Output:
func MultifieldDelete ¶
MultifieldDelete builds a crm_multifield row that REMOVES an existing value.
Deleting has to be explicit: rows absent from the list are preserved, so a shorter list does not delete anything.
The portal accepts two spellings — {ID, "DELETE": "Y"} and {ID, "VALUE": ""} — and both were confirmed against a live portal. This constructor emits the explicit DELETE form, because an empty VALUE is indistinguishable from a caller who meant to blank the field and reads as a bug at the call site.
func MultifieldSet ¶
MultifieldSet builds a crm_multifield row that CHANGES an existing value.
id is the row's ID as the portal returned it — read it back from the entity (crm.contact.get answers PHONE as a list of rows carrying ID), never guess it. An id that does not belong to the entity is ignored silently.
func Ref ¶
Ref builds a placeholder the server substitutes with an earlier command's result:
b24gosdk.Ref("get_user", "ID") // "$result[get_user][ID]"
b24gosdk.Ref("new_contact") // "$result[new_contact]" — no path
Pass NO path segments when the referenced command's result IS the value you want — crm.contact.add answers with a bare id, so Ref("new_contact") is right and Ref("new_contact", "ID") is not.
The placeholder is an ORDINARY parameter value: do not pre-escape it. The server runs parse_str BEFORE substituting, so an encoded placeholder is decoded back before the substitution regex sees it.
What the server does that Ref cannot prevent ¶
- A missing path segment does NOT error. The walk stops at the last resolved ancestor and substitutes THAT, so a typo injects a whole object where a scalar was meant.
- The placeholder is terminated by WHITESPACE and the replacement eats the whole match, so a SUFFIX is destroyed: "X" + Ref(...) + "-Y" loses the "-Y". A PREFIX survives, because the match begins at the '$' — which is the form Bitrix24's own documentation uses for file fields, where the disk object id wants an "n" in front ("n" + Ref(...) -> n2107).
- Substitution also runs on parameter KEYS, where it resolves to nothing. Placeholders belong in values.
Ref refuses an id or segment containing whitespace, '$', '[' or ']', and returns the error rather than a placeholder the server would misread: a malformed placeholder does not fail server-side, it silently substitutes the wrong value. Every error wraps ErrBadRef.
func Unwrap ¶
func Unwrap(raw json.RawMessage, path ...string) (json.RawMessage, bool)
Unwrap returns the value at path inside a JSON object result.
Why this exists ¶
Many methods wrap their payload in a single-key object: tasks.* answers {"task": {...}}, disk.* answers {"file": {...}}, catalog.* answers {"products": [...]}, crm.item.add answers {"item": {...}}. Without Unwrap every such call needs a throwaway struct whose only job is to hold that one field — a cost that showed up in five of the twelve tutorials written against this SDK.
res, err := client.Core().Call(ctx, "tasks.task.get", map[string]any{"taskId": 42})
raw, ok := b24gosdk.Unwrap(res.Result, "task")
if !ok {
return fmt.Errorf("no task in result")
}
var t MyTask
err = json.Unmarshal(raw, &t)
Several keys walk deeper: Unwrap(res.Result, "task", "creator", "id"). Zero keys return the input unchanged.
ok is false when a segment is missing or is reached on a value that is not a JSON object. Keys match EXACTLY: Bitrix24 renames fields between request and response (select takes UF_TASK_WEBDAV_FILES, the answer carries ufTaskWebdavFiles), and a fuzzy default would turn such a rename into a silent nil instead of a visible miss. Use UnwrapFold when the spelling differs, or Keys to see what actually came back.
Example ¶
Reading a result: unwrap the single-key envelope many methods use, and cope with the five ways Bitrix24 spells "empty".
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
res, err := client.Core().Call(context.Background(), "tasks.task.get", map[string]any{"taskId": 42})
if err != nil {
log.Fatal(err)
}
raw, ok := b24.Unwrap(res.Result, "task")
if !ok {
// Exact match missed — see what the portal actually returned.
keys, _ := b24.Keys(res.Result)
log.Fatalf("no task in result; keys: %v", keys)
}
var task struct {
ID b24.ID `json:"id"`
Title string `json:"title"`
}
if err := json.Unmarshal(raw, &task); err != nil {
log.Fatal(err)
}
// select takes UF_TASK_WEBDAV_FILES, the answer carries ufTaskWebdavFiles.
files, _ := b24.UnwrapFold(raw, "UF_TASK_WEBDAV_FILES")
if b24.IsEmpty(files) {
fmt.Println("no files attached")
}
}
Output:
func UnwrapFold ¶
func UnwrapFold(raw json.RawMessage, path ...string) (json.RawMessage, bool)
UnwrapFold is Unwrap for the case where the portal renamed the field: it matches ignoring ASCII case AND underscores, so UF_TASK_WEBDAV_FILES finds ufTaskWebdavFiles.
Matching loosely is right for that case and wrong as a default, because a loose match can quietly pick a neighbouring field — so it is opt-in and named so the looseness is visible at the call site. An exact match always wins.
If two different keys normalise to the same string, UnwrapFold reports NOT FOUND rather than choosing one: an ambiguous match is exactly where a silent wrong value would come from. Use Keys and Unwrap to disambiguate.
Types ¶
type APIError ¶
type APIError struct {
Code string
Description string
HTTPStatus int
RawBody string
// Validation carries the per-field details of a REST 3.0 request
// validation error. Empty for every other error and for all of REST v1,
// which has no equivalent.
Validation []ValidationError
}
APIError represents an error returned by the Bitrix24 REST API.
Example ¶
Reacting to an API error by code.
package main
import (
"context"
"errors"
"fmt"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
_, err := client.Core().Call(context.Background(), "crm.deal.list", nil)
var apiErr *b24.APIError
if errors.As(err, &apiErr) {
fmt.Println(apiErr.Code, apiErr.Description, apiErr.HTTPStatus)
}
}
Output:
Example (Validation) ¶
A REST 3.0 validation error. Its code and message are the same generic pair for every rejected request, so the field names in Validation are the only part that says what was actually wrong.
package main
import (
"context"
"errors"
"fmt"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient("https://portal.bitrix24.ru/rest/api/1/TOKEN/")
_, err := client.Core().Call(context.Background(), "tasks.task.get", nil, b24.WithIdempotent())
if errors.Is(err, b24.ErrV3Validation) {
var apiErr *b24.APIError
if errors.As(err, &apiErr) {
for _, v := range apiErr.Validation {
fmt.Printf("field %s: %s\n", v.Field, v.Message)
}
}
}
}
Output:
func (*APIError) Is ¶
Is lets errors.Is match an *APIError against a Code sentinel.
Matching is on the CODE ALONE, never on the HTTP status: OVERLOAD_LIMIT and QUERY_LIMIT_EXCEEDED both arrive as 503, so a status-based match would treat a manual block as a rate limit and retry something that must not be retried.
A REST 3.0 code additionally matches the v1 sentinel for the same condition; see v3Aliases for which, and for why that list is short.
type AppAuth ¶
type AppAuth struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn string `json:"expires_in"`
Expires string `json:"expires"`
Scope string `json:"scope"`
Domain string `json:"domain"`
ServerEndpoint string `json:"server_endpoint"`
ClientEndpoint string `json:"client_endpoint"`
MemberID string `json:"member_id"`
UserID string `json:"user_id"`
ApplicationToken string `json:"application_token"`
Status string `json:"status"`
}
AppAuth contains auth data provided in app events.
All values are strings: Bitrix24 delivers events as application/x-www-form-urlencoded, where every value is textual. RefreshToken is present in ONAPPINSTALL and is normally absent in other events.
func (*AppAuth) VerifyApplicationToken ¶
VerifyApplicationToken reports whether the application_token of an incoming event matches the one saved during installation. Comparison is constant-time.
Empty values never match: an event carrying no token must not pass verification against an unsaved token.
type AppRequest ¶
type AppRequest struct {
Domain string // DOMAIN: portal domain, host or host:port
Protocol string // PROTOCOL: "0" for http, "1" for https
Lang string // LANG
AppSID string // APP_SID
AuthID string // AUTH_ID: access token
AuthExpires string // AUTH_EXPIRES: access token lifetime in seconds
RefreshID string // REFRESH_ID: refresh token
MemberID string // member_id
Status string // status
// Fields below are sent by the portal in addition to the documented set.
ServerEndpoint string // SERVER_ENDPOINT: REST endpoint of the auth server
ApplicationToken string // APPLICATION_TOKEN: token identifying events of this install
ApplicationScope string // APPLICATION_SCOPE: granted scopes, comma separated
Placement string // PLACEMENT: where the app was opened
PlacementOptions string // PLACEMENT_OPTIONS: raw JSON with placement details
}
AppRequest contains data Bitrix24 sends to an application page: both to the regular app page and to the installation page shown during setup.
All values are strings, as delivered in the request. AuthID is the access token, RefreshID is the refresh token — store both to keep working with REST after the access token expires.
func ParseAppRequest ¶
func ParseAppRequest(r *http.Request) (*AppRequest, error)
ParseAppRequest parses application data from an incoming HTTP request.
The portal splits the data: DOMAIN, PROTOCOL, LANG and APP_SID arrive in the query string, while the tokens and the remaining fields arrive in the body. Credentials are therefore accepted from the body only — a token supplied in the query string is ignored, because URLs end up in server logs.
The body is limited to 1 MiB.
Example ¶
A page opened inside Bitrix24 receives its tokens in the POST body.
package main
import (
"net/http"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
http.HandleFunc("/b24/app", func(w http.ResponseWriter, r *http.Request) {
req, err := b24.ParseAppRequest(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
client, err := b24.NewClientFromAppRequest(req)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if _, err := client.Core().Call(r.Context(), "user.current", nil); err != nil {
http.Error(w, "upstream", http.StatusBadGateway)
return
}
})
}
Output:
func ParseAppRequestForm ¶
func ParseAppRequestForm(values url.Values) (*AppRequest, error)
ParseAppRequestForm parses application POST data from form values.
DOMAIN and AUTH_ID are required: without them no REST client can be built. DOMAIN is additionally checked to be a plain host, so that a forged request cannot turn it into an arbitrary URL.
func (*AppRequest) ClientEndpoint ¶
func (r *AppRequest) ClientEndpoint() string
ClientEndpoint returns the REST endpoint of the portal the request came from.
The scheme is always https: REST requires it, and a plain http call is rejected by Bitrix24 with INVALID_REQUEST. The PROTOCOL field is therefore parsed and kept for reference, but does not affect the endpoint.
type Batch ¶
type Batch struct {
// Halt stops the batch at the first command that fails.
//
// A CHAINED batch — one where a later command references an earlier result
// through Ref — SHOULD set it. When a producer fails, its $result
// placeholder is not an error: the server substitutes the unresolved text as
// a LITERAL value, and the consumer runs with a corrupted parameter instead
// of not running at all.
Halt bool
// contains filtered or unexported fields
}
Batch is a set of REST calls executed by the server in one request.
One batch costs ONE token of the FREQUENCY limit instead of one per command, which is the whole reason it exists: fifty creates through Call spend fifty tokens and take fifty round trips.
What it does not save ¶
The work. The portal still runs all fifty commands, one after another, and still charges their execution time against the RESOURCE-INTENSITY limit (the `operating` seconds in a response's time block). That counter is kept per method, and a batch is charged on its own — so watching crm.deal.add's counter after a batch of fifty adds shows almost nothing, and the cost is nonetheless there. Batching many writes moves the pressure from one limit to the other; it does not remove it.
How long it takes ¶
As long as the sum. The commands run sequentially inside ONE HTTP request: 50 crm.deal.add measured at 28-32 seconds on a live portal, roughly 0.6s each, with the first command finishing half a minute before the last.
That is longer than most default timeouts, and the failure it produces is the expensive kind. An http.Client{Timeout: 30 * time.Second} cuts the connection AFTER the portal has already created part of the deals, which is an ambiguous failure — the SDK does not replay it, because replaying would create them twice — and the identifiers of everything that WAS created go with it.
So bound a batch of writes by what it will actually take: the ctx passed to CallBatch, and an http.Client timeout above the expected total (the SDK's default client has none, which is why this bites only those who set one).
Commands run in submission order, so a later command can reference an earlier one's result through Ref.
func (*Batch) Add ¶
Add appends a command and returns the generated id to reference it by.
params may be nil for a method that takes none.
func (*Batch) AddAs ¶
AddAs appends a command under an id you choose, which is what a readable $result chain wants: Ref("user", "ID") beats Ref("cmd1", "ID").
func (*Batch) Chunks ¶
Chunks splits the batch into pieces the server accepts.
It is for INDEPENDENT commands only. A $result reference cannot cross a chunk boundary — each chunk is a separate request with its own result namespace — so splitting a chained batch produces placeholders that resolve to nothing.
type BatchError ¶
type BatchError struct {
Failed map[CmdID]*APIError
Result *BatchResult
}
BatchError reports that at least one command in the batch failed.
The batch itself succeeded at the transport level — the server answers HTTP 200 and puts per-command failures in result_error — so the result is returned ALONGSIDE this error and holds everything that did succeed. Do not discard it: retrying the whole batch would re-run the commands that already committed.
func (*BatchError) Error ¶
func (e *BatchError) Error() string
func (*BatchError) Unwrap ¶
func (e *BatchError) Unwrap() []error
Unwrap exposes the per-command errors to errors.Is and errors.As, so a caller can ask whether any command hit a particular API code.
type BatchResult ¶
type BatchResult struct {
// Order lists the command ids in submission order.
Order []CmdID
// Results holds the payload of each command that ran.
Results map[CmdID]json.RawMessage
// Errors holds the failure of each command that failed.
Errors map[CmdID]*APIError
// Next and Total carry the pagination metadata of list commands. The server
// lifts them OUT of each command's result into their own sections.
Next map[CmdID]int
Total map[CmdID]int
// contains filtered or unexported fields
}
BatchResult holds one result per command.
func (*BatchResult) Executed ¶
func (r *BatchResult) Executed(id CmdID) bool
Executed reports whether the command ran, whatever the outcome.
func (*BatchResult) Get ¶
func (r *BatchResult) Get(id CmdID) (json.RawMessage, error)
Get returns one command's result, or the error that command failed with.
A command that never ran — because halt stopped the batch before it — yields an error saying so, which is a different thing from a command that ran and failed. Executed tells them apart without an error value.
func (*BatchResult) IDs ¶ added in v0.2.0
func (r *BatchResult) IDs() ([]ID, error)
IDs decodes every command's result as an identifier, in submission order.
Why this exists ¶
"Create n things and get n ids back" is the batch a real integration writes, and reading the ids out of it is the same loop every time: walk Order, call Get, unmarshal into an ID, cope with the ones that failed. Written out at each call site it is a dozen lines whose only interesting part is the coping, which is exactly the part that gets skipped.
res, err := client.Core().CallBatchChunked(ctx, b) ids, idErr := res.IDs() // ids[i] belongs to res.Order[i]
What a partial success returns ¶
A slice as long as Order, ALWAYS, positionally aligned with it — so ids[i] is the id of the i-th command added, and the alignment with whatever the caller built the batch from survives. A command that produced no identifier leaves a ZERO there (ID.IsZero) rather than shortening the slice: dropping the gaps would silently shift every id after the first failure onto the wrong entity.
The error names every gap and wraps the underlying failures, so errors.Is and errors.As reach the *APIError of a command that failed. It is returned alongside the ids, never instead of them: the commands that succeeded have already committed on the portal, and a caller that drops the slice has no way to learn what exists.
It decodes the result AS the identifier ¶
That is what the classic add methods answer — crm.deal.add gives a bare id. A method that wraps it (crm.item.add answers {"item":{"id":…}}) or answers something else entirely (crm.deal.update answers true) is reported as a gap with its raw result quoted, rather than turned into a plausible 0. For those, read Get and Unwrap yourself.
Example ¶
Create n things and get n ids back — the batch a real integration writes.
The ids line up with the commands positionally, and a command that produced none leaves a zero rather than shortening the slice: dropping the gaps would slide every later id onto the wrong entity, which is not an error anywhere.
package main
import (
"context"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
titles := []string{"Deal A", "Deal B", "Deal C"}
b := b24.NewBatch()
for _, title := range titles {
if _, err := b.Add("crm.deal.add", b24.Params{
"fields": b24.Params{"TITLE": title},
}); err != nil {
log.Fatal(err)
}
}
res, err := client.Core().CallBatchChunked(context.Background(), b)
if err != nil && res == nil {
log.Fatal(err) // the call itself failed; nothing committed
}
ids, idErr := res.IDs()
if idErr != nil {
// Some commands produced no id. The rest did, and they already exist on
// the portal — do not throw them away with the error.
fmt.Println("incomplete:", idErr)
}
for i, id := range ids {
if id.IsZero() {
continue
}
fmt.Println(titles[i], "->", id)
}
}
Output:
type CallOption ¶
type CallOption func(*callConfig)
CallOption configures a single call.
func WithIdempotent ¶
func WithIdempotent() CallOption
WithIdempotent declares that repeating this call cannot change the outcome, so the SDK may retry it after an AMBIGUOUS failure — a dial error, a timeout, an unreadable body, a 5xx with no error code — where it otherwise gives up.
Why this is opt-in ¶
After a network failure the SDK cannot tell whether the portal ran the call. Replaying crm.deal.add there would create a second deal, so by default an ambiguous failure is returned rather than retried, and only the provably-not-executed case (QUERY_LIMIT_EXCEEDED, which is the rate limiter refusing the call before it ran) is repeated. A universal caller cannot know which methods are idempotent — the method is a string — so it does not guess.
Pass it for reads (*.get, *.list, *.fields) and for writes that are safe to repeat, such as an update that sets fields to fixed values. Do NOT pass it for *.add, and not for an update whose new value is derived from the old one.
res, err := client.Core().Call(ctx, "crm.deal.get",
map[string]any{"id": 42}, b24gosdk.WithIdempotent())
Why a walk is idempotent and the same method through Call is not ¶
Pages and Scan mark every page idempotent themselves — without it one dropped connection at page 40 abandons a 200-page scan the caller cannot resume — while Call(ctx, "crm.deal.list", …) does not, even though it is the very same method. The asymmetry is deliberate, and it is not about the name.
A walk knows what it is doing by CONSTRUCTION. A Pager only ever re-issues the method it was built with, moving a cursor; there is no arrangement of options under which it writes anything. Call knows nothing: the method is a string it forwards, and "crm.deal.list" is not a fact about the call, it is text.
Reading intent out of that text is the one thing this SDK will not do. A rule like "*.list is a read" would be a guess about every method family Bitrix24 has shipped and every one it ships next, applied silently, on the side that creates duplicates when it is wrong. The universal caller is universal precisely because it does not interpret the name — so the caller, who knows which method they typed, says it.
func WithTimeout ¶ added in v0.2.0
func WithTimeout(d time.Duration) CallOption
WithTimeout bounds ONE call: everything the SDK does to answer it, retries and their backoff included, and a token renewal if one turns out to be needed.
Why a per-call timeout, when a context already exists ¶
Because inside a walk there is no per-call context to set. Pages and Scan take one ctx for the whole loop and issue a request per page, so bounding that ctx bounds the ENTIRE export — a 200-page scan then has to guess its own total duration up front — while leaving it unbounded lets one hung page hang the export forever. There is no way to say "this export may take as long as it takes, but no single page may stall for more than 30s" without an option that travels down to the individual call. That is what this is for, and it reaches a walk through WithCallOptions.
pager, err := client.Core().Scan("crm.deal.list", nil,
b24gosdk.WithCallOptions(b24gosdk.WithTimeout(30*time.Second)))
A client-wide bound already exists and is a different thing: WithHTTPClient(&http.Client{Timeout: d}) limits each HTTP request separately, so a call retried three times can still take 3d. This limits the call.
A timeout that fires ends the call with context.DeadlineExceeded, which — like any ambiguous failure — does NOT say whether the portal ran it. Do not treat it as "nothing happened" for a write.
Zero or negative means no bound, which is the default.
type CallResult ¶
type CallResult struct {
Result json.RawMessage
Next *int
Total *int
Time json.RawMessage
}
CallResult is a REST API response together with its pagination metadata.
Next and Total are set by list methods: Total is the number of records matching the query, Next is the offset to pass to the following call. Both are nil when the response does not carry them.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is the main SDK entry point.
Every REST method is reached through Core: the SDK ships no per-method wrappers, so a method Bitrix24 released today is callable today, without waiting for the SDK to catch up.
res, err := client.Core().Call(ctx, "crm.deal.add", map[string]any{
"fields": map[string]any{"TITLE": "New Deal"},
})
func NewClient ¶
NewClient creates a client for inbound webhook URL.
The webhook secret is part of the URL, so no token handling is involved.
Example (RestV3) ¶
Calling REST 3.0. The base URL alone selects the version: /rest/api/ instead of /rest/. Filters are arrays there and paging goes through the pagination parameter rather than start/next — which is why Pages and Scan refuse a v3 client instead of walking one page and calling the list finished.
package main
import (
"context"
"fmt"
"log"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
// v1 would be https://portal.bitrix24.ru/rest/1/TOKEN/
client := b24.NewClient("https://portal.bitrix24.ru/rest/api/1/TOKEN/")
for page := 1; ; page++ {
res, err := client.Core().Call(context.Background(), "tasks.task.list", b24.Params{
"select": []string{"id", "title"},
"filter": [][]any{{"id", ">", 500}},
"pagination": b24.Params{"limit": 50, "page": page},
}, b24.WithIdempotent())
if err != nil {
log.Fatal(err)
}
items, ok := b24.Unwrap(res.Result, "items")
if !ok || b24.IsEmpty(items) {
break // v3 sends no next: an empty page is the end of the list
}
fmt.Println(string(items))
}
}
Output:
func NewClientFromAppRequest ¶
func NewClientFromAppRequest(req *AppRequest, opts ...Option) (*Client, error)
NewClientFromAppRequest creates a client authorized by the access token of an application request.
Note that the access token lives about an hour. For long running work store AuthID and RefreshID and renew the token when it expires.
func NewOAuthClient ¶
NewOAuthClient creates a client for OAuth access token and client endpoint.
The endpoint is the client_endpoint of the portal, for example https://portal.bitrix24.com/rest/. Since an access token expires in about an hour, pass WithTokenRefresher for long running work.
func (*Client) Core ¶
Core returns the REST caller every method goes through.
Example ¶
The universal call: any REST method by name, through an inbound webhook. The webhook URL's path is the secret, so it comes from the environment.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
res, err := client.Core().Call(context.Background(), "crm.deal.add", map[string]any{
"fields": map[string]any{"TITLE": "New deal"},
})
if err != nil {
log.Fatal(err)
}
// An identifier arrives as a number in some families and a quoted number in
// others; b24.ID decodes both.
var dealID b24.ID
if err := json.Unmarshal(res.Result, &dealID); err != nil {
log.Fatal(err)
}
fmt.Println("created deal", dealID)
}
Output:
func (*Client) SetAccessToken ¶
SetAccessToken updates OAuth access token used by the client.
It is safe to call while requests are in flight. With WithTokenRefresher enabled the SDK keeps the token up to date on its own.
type CmdID ¶
type CmdID string
CmdID identifies one command inside a batch. It is the key the results come back under and the name a $result placeholder refers to.
type Core ¶
type Core struct {
// contains filtered or unexported fields
}
Core provides low-level REST calls.
func (*Core) AccessToken ¶
AccessToken returns the OAuth access token currently used in requests.
func (*Core) Call ¶
func (c *Core) Call(ctx context.Context, method string, params any, opts ...CallOption) (*CallResult, error)
Call calls a REST method with JSON parameters and returns the full response, including the pagination metadata of list methods.
An ambiguous network failure is NOT retried unless the call is marked with WithIdempotent; see that option for why.
Example (File) ¶
A file rides as base64 inside the JSON params — no multipart needed.
package main
import (
"context"
"encoding/base64"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
content := []byte("report body")
_, err := client.Core().Call(context.Background(), "disk.folder.uploadfile", map[string]any{
"id": 123,
"data": map[string]any{"NAME": "report.txt"},
"fileContent": []string{"report.txt", base64.StdEncoding.EncodeToString(content)},
})
if err != nil {
log.Fatal(err)
}
}
Output:
func (*Core) CallBatch ¶
CallBatch runs the batch in ONE request.
It does not split: a batch longer than MaxBatchCommands is refused with ErrBatchLengthExceeded rather than sent, because the server would answer with what looks like a partial success. Use CallBatchChunked for many independent commands.
err is non-nil when the call itself failed, AND when any command failed — in the latter case as a *BatchError, with the result still returned so the commands that succeeded are not lost.
One request, but not a quick one: the commands run sequentially on the portal, so a batch of writes holds the connection for the sum of their durations — half a minute for 50 crm.deal.add. Give ctx a deadline that fits, and see Batch for what a timeout that fires costs.
REST v1 only: on a v3 client it returns ErrV3BatchUnsupported.
Example ¶
A chained batch: create a contact, then comment on it in the same request.
package main
import (
"context"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
b := b24.NewBatch()
// A chain MUST halt: a failed producer's $result is substituted as a literal
// string rather than erroring, so the consumer would run with a corrupted
// parameter.
b.Halt = true
if err := b.AddAs("c", "crm.contact.add", map[string]any{
"fields": map[string]any{"NAME": "Ann"},
}); err != nil {
log.Fatal(err)
}
// No path segments: crm.contact.add answers with the bare id.
ref, err := b24.Ref("c")
if err != nil {
log.Fatal(err)
}
if err := b.AddAs("note", "crm.timeline.comment.add", map[string]any{
"fields": map[string]any{"ENTITY_ID": ref, "ENTITY_TYPE": "contact"},
}); err != nil {
log.Fatal(err)
}
res, err := client.Core().CallBatch(context.Background(), b)
if err != nil {
log.Fatal(err)
}
raw, err := res.Get("c")
if err != nil {
log.Fatal(err)
}
fmt.Println("new contact:", string(raw))
}
Output:
func (*Core) CallBatchChunked ¶
CallBatchChunked splits the batch at MaxBatchCommands, runs the pieces in order and merges the results.
For INDEPENDENT commands only: a $result reference cannot cross a chunk boundary. For a chained batch use CallBatch, which keeps everything in one request.
It returns what has been collected so far even when a chunk fails outright — earlier chunks have already committed on the portal, and a caller that drops the result has no way to know what exists.
Example ¶
Many independent commands: this is the one that chunks. A batch error is PARTIAL, so the result must not be thrown away with it.
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
b := b24.NewBatch()
for _, name := range []string{"Ann", "Bob", "Cid"} {
if _, err := b.Add("crm.contact.add", map[string]any{
"fields": map[string]any{"NAME": name},
}); err != nil {
log.Fatal(err)
}
}
res, err := client.Core().CallBatchChunked(context.Background(), b)
var be *b24.BatchError
if errors.As(err, &be) {
// be.Failed is what failed; res holds everything that succeeded.
// Rebuild a batch from be.Failed rather than replaying the whole thing.
for id := range be.Failed {
fmt.Println("failed:", id, "executed:", res.Executed(id))
}
} else if err != nil {
log.Fatal(err)
}
}
Output:
func (*Core) CallJSON ¶
func (c *Core) CallJSON(ctx context.Context, method string, params any, opts ...CallOption) (json.RawMessage, error)
CallJSON calls a REST method with JSON parameters and returns only the result.
Use Call when the pagination metadata of a list method is needed.
func (*Core) CallMultipart ¶
func (c *Core) CallMultipart(ctx context.Context, method string, params map[string]string, files map[string]io.Reader) (json.RawMessage, error)
CallMultipart calls a REST method with multipart form data (uploads).
func (*Core) Pages ¶
Pages walks a list method with the server's own cursor: it sends `start` and echoes back the `next` the server returned, until the server stops returning one.
This is the right walk for a few pages. For tens of thousands of rows use Scan: offset paging makes the server count past every skipped row, so the last pages of a large list get slower and slower.
REST v1 only: on a v3 client it returns ErrV3WalkUnsupported.
Example ¶
Walking a list. Err after the loop is not optional: Next reports false both at the end of the list and on failure.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
p, err := client.Core().Pages("crm.deal.list", map[string]any{
"select": []any{"ID", "TITLE"},
})
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
for p.Next(ctx) {
for _, row := range p.Rows() {
var deal struct {
ID b24.ID `json:"ID"`
Title string `json:"TITLE"`
}
if err := json.Unmarshal(row, &deal); err != nil {
log.Fatal(err)
}
fmt.Println(deal.ID, deal.Title)
}
}
if err := p.Err(); err != nil {
log.Fatal(err)
}
}
Output:
func (*Core) Scan ¶
Scan walks a large list by ID instead of by offset.
It sends start=-1 (which turns OFF the server's row count), orders by id and filters on the last id seen, so every page costs the same regardless of how deep it is. This is the method Bitrix24 documents for exporting big lists. The walk runs oldest first unless WithDescending reverses it.
It requires a method that sorts and filters by an id field. Where the id is not spelled "ID" both ways, pass WithIDField.
REST v1 only: on a v3 client it returns ErrV3WalkUnsupported.
Example ¶
A big export pages by id instead of by offset, so a deep page costs the same as the first.
package main
import (
"context"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
p, err := client.Core().Scan("crm.deal.list", nil)
if err != nil {
log.Fatal(err)
}
for p.Next(context.Background()) {
fmt.Println("rows so far:", p.Count())
}
if err := p.Err(); err != nil {
log.Fatal(err)
}
}
Output:
func (*Core) SetAccessToken ¶
SetAccessToken updates OAuth access token used in requests.
type ErrorCode ¶
type ErrorCode string
ErrorCode is the machine-readable code Bitrix24 puts in an error body.
It is a string type rather than an enum on purpose: the portal ships new codes without warning, and a closed set would make an unknown one unexpressible. The constants below are spelling aids for the ones met most often, not a complete list — b24gosdk.Code("SOME_NEW_CODE") is equally valid.
const ( // Rate and resource limits. CodeQueryLimitExceeded ErrorCode = "QUERY_LIMIT_EXCEEDED" CodeOperationTimeLimit ErrorCode = "OPERATION_TIME_LIMIT" CodeOverloadLimit ErrorCode = "OVERLOAD_LIMIT" // Authorization. CodeExpiredToken ErrorCode = "expired_token" CodeInvalidToken ErrorCode = "invalid_token" CodeInvalidGrant ErrorCode = "invalid_grant" CodeNoAuthFound ErrorCode = "NO_AUTH_FOUND" CodeInsufficientScope ErrorCode = "insufficient_scope" // Method and rights. CodeMethodNotFound ErrorCode = "ERROR_METHOD_NOT_FOUND" CodeAccessDenied ErrorCode = "ACCESS_DENIED" CodePaymentRequired ErrorCode = "PAYMENT_REQUIRED" // Batch. CodeBatchLengthExceeded ErrorCode = "ERROR_BATCH_LENGTH_EXCEEDED" CodeBatchMethodNotAllow ErrorCode = "ERROR_BATCH_METHOD_NOT_ALLOWED" )
Codes seen most often. The list is deliberately short: it covers what a caller routinely branches on, not everything the portal can answer.
const ( CodeV3MethodNotFound ErrorCode = "BITRIX_REST_V3_EXCEPTION_METHODNOTFOUNDEXCEPTION" CodeV3Validation ErrorCode = "BITRIX_REST_V3_EXCEPTION_VALIDATION_REQUESTVALIDATIONEXCEPTION" CodeV3EntityNotFound ErrorCode = "BITRIX_REST_V3_EXCEPTION_ENTITYNOTFOUNDEXCEPTION" CodeV3AccessDenied ErrorCode = "BITRIX_REST_V3_EXCEPTION_ACCESSDENIEDEXCEPTION" CodeV3UnknownDTOProperty ErrorCode = "BITRIX_REST_V3_EXCEPTION_UNKNOWNDTOPROPERTYEXCEPTION" CodeV3InvalidSelect ErrorCode = "BITRIX_REST_V3_EXCEPTION_INVALIDSELECTEXCEPTION" CodeV3InvalidFilter ErrorCode = "BITRIX_REST_V3_EXCEPTION_INVALIDFILTEREXCEPTION" CodeV3InvalidJSON ErrorCode = "BITRIX_REST_V3_EXCEPTION_INVALIDJSONEXCEPTION" )
REST 3.0 codes, all met on a live portal.
They are their own constants rather than new spellings of the ones above because most of them describe a condition v1 has no code for at all: v1 rejects a bad parameter with whatever the module felt like saying, v3 always with a validation error naming the field.
The BITRIX_REST_V3_EXCEPTION_ prefix is NOT universal, so do not derive a code from it: crm.deal.timeline.activity.email.list answers a bad id with CRM_EMAIL_INVALID_REQUEST, in the v3 envelope, with no prefix. Anything not listed here is still matchable — Code("SOME_NEW_CODE") takes any string.
func CodeOf ¶
CodeOf reports the Bitrix24 error code an error carries, if any.
The code is the one that ARRIVED, normalized in case only. On REST 3.0 that is the v3 spelling: a missing method gives BITRIX_REST_V3_EXCEPTION_METHODNOTFOUNDEXCEPTION here, while errors.Is(err, ErrMethodNotFound) is true for the same error. The two answer different questions — what the portal said, and what condition it was — so prefer errors.Is for branching and CodeOf for logging.
ok is false for an error that is not an *APIError, and for an *APIError whose body carried no code — which happens when a proxy answers instead of the portal.
func (ErrorCode) Normalize ¶
Normalize folds a code to a canonical form for comparison.
Bitrix24 is not consistent about case: REST errors come upper-cased (QUERY_LIMIT_EXCEEDED) while the OAuth ones come lower-cased (expired_token), and the same code has been seen both ways in the documentation. Comparing normalized forms means a caller never has to guess which spelling arrived.
type ID ¶
type ID int64
ID is an entity identifier that decodes from a JSON number OR a JSON string.
Why this type exists ¶
Bitrix24 is not consistent about the wire type of an identifier, and the inconsistency appears INSIDE a single workflow, not only between distant method families: disk.* answers with "ID": 6687 (a number) while tasks.* answers with "id": "3711" (a string). Both are identifiers, both are read in the same handler, and encoding/json refuses to put a string into an int field or a number into a string field. Without this type every caller writes the same dozen-line UnmarshalJSON — which is what happened twice while writing tutorials against this SDK.
Declare the field as ID and the difference stops mattering:
var task struct {
ID b24gosdk.ID `json:"id"`
Title string `json:"title"`
}
err := json.Unmarshal(res.Result, &task)
It also decodes a result that IS the identifier, which is what the add methods answer:
var dealID b24gosdk.ID err := json.Unmarshal(res.Result, &dealID)
An empty string and null decode to 0: an unset identifier is a normal answer from Bitrix24, not a malformed one. ID marshals back as a JSON number, so a decoded value can be sent straight back as a parameter.
func (ID) IsZero ¶
IsZero reports whether the identifier is unset. Bitrix24 spells "unset" as 0, "" or null depending on the method; all three decode to a zero ID.
func (ID) MarshalJSON ¶
MarshalJSON emits the identifier as a JSON number.
func (*ID) UnmarshalJSON ¶
UnmarshalJSON accepts a JSON number, a quoted number, an empty string or null. Anything else is an error: silently zeroing a value we do not understand would hide a changed API behind a plausible-looking 0.
type Kind ¶ added in v0.2.0
type Kind uint8
Kind names the JSON shape of a value.
The JSON shapes, plus KindInvalid for a value that is not JSON at all.
type OnAppInstallData ¶
type OnAppInstallData struct {
Version string `json:"VERSION"`
Active string `json:"ACTIVE"`
Installed string `json:"INSTALLED"`
LanguageID string `json:"LANGUAGE_ID"`
}
OnAppInstallData contains payload for ONAPPINSTALL.
type OnAppInstallEvent ¶
type OnAppInstallEvent struct {
Event string `json:"event"`
Data OnAppInstallData `json:"data"`
TS string `json:"ts"`
Auth AppAuth `json:"auth"`
// EventHandlerID identifies the handler registration the portal called.
EventHandlerID string `json:"event_handler_id"`
}
OnAppInstallEvent is sent after successful app installation.
func ParseOnAppInstall ¶
func ParseOnAppInstall(payload []byte) (*OnAppInstallEvent, error)
ParseOnAppInstall parses an ONAPPINSTALL event payload in JSON format.
Bitrix24 itself sends events as form data; use ParseOnAppInstallRequest for incoming HTTP requests and this function for payloads already stored as JSON.
func ParseOnAppInstallForm ¶
func ParseOnAppInstallForm(values url.Values) (*OnAppInstallEvent, error)
ParseOnAppInstallForm parses an ONAPPINSTALL event from form values with PHP-style keys, for example auth[access_token].
func ParseOnAppInstallRequest ¶
func ParseOnAppInstallRequest(r *http.Request) (*OnAppInstallEvent, error)
ParseOnAppInstallRequest parses an ONAPPINSTALL event from an incoming HTTP request, choosing form or JSON parsing by Content-Type.
The request body is limited to 1 MiB. Parsing does not authenticate the request: verify AppAuth.ApplicationToken against the saved value with AppAuth.VerifyApplicationToken.
Example ¶
The install event is the only time the tokens arrive — persist them, and verify the event before acting on it.
package main
import (
"net/http"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
http.HandleFunc("/b24/install", func(w http.ResponseWriter, r *http.Request) {
ev, err := b24.ParseOnAppInstallRequest(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
storeTokens(ev.Auth.AccessToken, ev.Auth.RefreshToken)
storeApplicationToken(ev.Auth.MemberID, ev.Auth.ApplicationToken)
})
// Every later event carries the same application_token; compare it against
// what the install stored, in constant time.
http.HandleFunc("/b24/events", func(w http.ResponseWriter, r *http.Request) {
ev, err := b24.ParseOnAppUninstallRequest(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !ev.Auth.VerifyApplicationToken(savedApplicationToken(ev.Auth.MemberID)) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
})
}
func storeTokens(access, refresh string) {}
func storeApplicationToken(memberID, token string) {}
func savedApplicationToken(memberID string) string { return "" }
Output:
type OnAppUninstallData ¶
OnAppUninstallData contains payload for ONAPPUNINSTALL.
type OnAppUninstallEvent ¶
type OnAppUninstallEvent struct {
Event string `json:"event"`
Data OnAppUninstallData `json:"data"`
TS string `json:"ts"`
Auth AppAuth `json:"auth"`
}
OnAppUninstallEvent is sent when app is removed.
func ParseOnAppUninstall ¶
func ParseOnAppUninstall(payload []byte) (*OnAppUninstallEvent, error)
ParseOnAppUninstall parses an ONAPPUNINSTALL event payload in JSON format.
See ParseOnAppInstall on choosing between JSON and form parsing.
func ParseOnAppUninstallForm ¶
func ParseOnAppUninstallForm(values url.Values) (*OnAppUninstallEvent, error)
ParseOnAppUninstallForm parses an ONAPPUNINSTALL event from form values with PHP-style keys, for example auth[application_token].
func ParseOnAppUninstallRequest ¶
func ParseOnAppUninstallRequest(r *http.Request) (*OnAppUninstallEvent, error)
ParseOnAppUninstallRequest parses an ONAPPUNINSTALL event from an incoming HTTP request. See ParseOnAppInstallRequest for details.
type Option ¶
type Option func(*coreOptions)
Option configures SDK behavior.
func WithAccessToken ¶
WithAccessToken injects OAuth access token into REST calls.
func WithHTTPClient ¶
WithHTTPClient sets a custom HTTP client.
func WithRetry ¶
func WithRetry(cfg RetryConfig) Option
WithRetry sets retry policy for rate limit errors.
func WithTokenRefresher ¶
func WithTokenRefresher(refresh TokenRefresher) Option
WithTokenRefresher enables automatic renewal of an expired access token.
When a call fails with expired_token, the SDK calls refresh once, stores the returned token and repeats the call a single time. Renewal is not scheduled on a timer: the auth server must only be contacted when a token actually turns out to be expired.
Concurrent calls that hit an expired token share one renewal, and a renewal that fails is not repeated for the same token, so that a broken authorization cannot flood the auth server.
Use oauth.Refresher for a ready-made implementation that keeps track of the rotating refresh token.
Example ¶
An OAuth application, with the SDK renewing the access token on its own.
package main
import (
"context"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
"github.com/bitrix24/b24gosdk/oauth"
)
func main() {
oauthClient := oauth.NewClient(os.Getenv("B24_CLIENT_ID"), os.Getenv("B24_CLIENT_SECRET"))
resp, err := oauthClient.ExchangeCode(context.Background(), "code-from-redirect")
if err != nil {
log.Fatal(err)
}
// The auth server rotates the refresh token on every renewal, so the new
// pair has to be persisted or the next restart starts from a dead token.
refresher := oauth.NewRefresher(oauthClient, resp.RefreshToken, func(t oauth.TokenResponse) {
storeTokens(t.AccessToken, t.RefreshToken)
})
client := b24.NewOAuthClient(resp.ClientEndpoint, resp.AccessToken,
b24.WithTokenRefresher(refresher.Refresh))
if _, err := client.Core().Call(context.Background(), "user.current", nil); err != nil {
log.Fatal(err)
}
}
func storeTokens(access, refresh string) {}
Output:
type PageOption ¶
type PageOption func(*Pager)
PageOption configures a walk.
func WithCallOptions ¶ added in v0.2.0
func WithCallOptions(opts ...CallOption) PageOption
WithCallOptions applies CallOptions to every request the walk makes.
A walk issues one call per page and gives the caller no way to reach those calls, so anything expressed as a CallOption is otherwise unreachable inside it. The one that matters today is WithTimeout: a walk takes a single ctx for the whole loop, so without this the only choice is a deadline on the entire export or no bound at all on a page that stalls.
pager, err := client.Core().Scan("crm.deal.list", nil,
b24gosdk.WithCallOptions(b24gosdk.WithTimeout(30*time.Second)))
A walk stays idempotent whatever is passed here: it applies WithIdempotent first, and no option withdraws it. Paging is a read, and a page abandoned after a dropped connection would end a 200-page scan the caller cannot resume.
func WithCursorParam ¶
func WithCursorParam(name string) PageOption
WithCursorParam renames the offset parameter for a method that does not read `start`.
im.department.colleagues returns `next` but consumes OFFSET: writing start into it is a no-op, so the server answers page 1 and next:50 again, forever, on the customer's quota. Without this option the walk hits ErrCursorStalled rather than looping.
func WithDescending ¶ added in v0.2.0
func WithDescending() PageOption
WithDescending walks the list from the end: newest id first.
Why an option, and not just order in the params ¶
In Pages it is indeed only sugar: the walk follows the server's own cursor, so direction is nothing but the order parameter, and this option writes order {ID: "DESC"} for you (or the top-level SORT/ORDER pair, for the methods that take that instead).
In Scan it is not sugar, because a descending scan has TWO halves and writing one without the other is silently wrong. Scan pages by id: it orders by id and filters on the last id it saw. Ascending that pair is order ASC + filter >id. Reversing only the order gives order DESC + filter >id — which asks for rows ABOVE the newest one already seen, so the walk returns an empty page and stops at the end of page one, reporting a complete list that is missing everything. This option flips both: order DESC and filter <id.
The direction does not change what a walk costs or when it ends; it changes which end it starts from, which is what a "most recent first" export wants.
Example ¶
Newest first, with a bound on ONE page instead of on the whole export.
A walk takes a single ctx for the entire loop, so without WithCallOptions the only choices are a deadline on the whole scan or no bound at all on a page that stalls.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"time"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
p, err := client.Core().Scan("crm.deal.list", b24.Params{
"select": []any{"ID", "TITLE"},
},
b24.WithDescending(),
b24.WithCallOptions(b24.WithTimeout(30*time.Second)),
)
if err != nil {
log.Fatal(err)
}
for p.Next(context.Background()) {
for _, row := range p.Rows() {
var d struct {
ID b24.ID `json:"ID"`
}
if err := json.Unmarshal(row, &d); err != nil {
log.Fatal(err)
}
fmt.Println(d.ID)
}
}
if err := p.Err(); err != nil { // ALWAYS: Next is false at the end AND on error
log.Fatal(err)
}
}
Output:
func WithIDField ¶
func WithIDField(request, response string) PageOption
WithIDField sets the id field, separately for the request and the response.
Two names, because some methods use two: tasks.task.list RETURNS "id" but SORTS AND FILTERS BY "ID". Passing one name for both silently produces a walk that never advances.
func WithPageSize ¶
func WithPageSize(n int) PageOption
WithPageSize tells Scan how many rows a page holds, when a method departs from DefaultPageSize. It does not ask the server for a different size — nothing can — it only tells Scan how to recognise the last page.
func WithRowPath ¶
func WithRowPath(path ...string) PageOption
WithRowPath pins where the rows live inside `result`.
Needed when a method wraps its rows in a key the SDK does not know about. Without it the walk descends through single-key objects until it finds an array, which covers the common shapes but is deliberately not a guess when the object has several keys.
type Pager ¶
type Pager struct {
// contains filtered or unexported fields
}
Pager walks a list method page by page.
It is a struct with Next/Rows/Err rather than an iterator function, so an error has somewhere to surface AFTER the loop instead of ending it silently:
p, err := client.Core().Pages("crm.deal.list", params)
if err != nil {
return err
}
for p.Next(ctx) {
for _, row := range p.Rows() {
...
}
}
return p.Err() // ALWAYS check this: a walk that stops early stops quietly
Take is the same walk bounded by a row count, for when the answer is "the first n" rather than "all of them".
A Pager is NOT safe for concurrent use.
func (*Pager) Count ¶
Count reports how many rows the walk has HANDED OVER: the rows returned by Next and Take, and not the rows still waiting inside a page Take stopped in.
What it cannot count ¶
It cannot count what your loop did with them. Next hands over a whole page, so a break inside the inner range leaves Count reporting the page — 50, when 45 were used — because nothing about that break reaches the Pager. If the number that matters is "how many rows do I have", take them with Take, which stops where you asked and counts what it gave you.
func (*Pager) Err ¶
Err returns the error that stopped the walk, if any.
ALWAYS check it after the loop. Next reports false both at the end of the list and on failure, so a walk that broke halfway looks exactly like a walk that finished.
func (*Pager) Next ¶
Next fetches the following page. It reports false at the end of the list and on error; check Err after the loop to tell those apart.
It requests nothing while rows a Take left behind are still waiting: those rows are the next rows, and they are handed over first.
func (*Pager) Page ¶
func (p *Pager) Page() *CallResult
Page returns the whole envelope of the page last FETCHED, for Total and Time.
After a Take that stopped mid-page this is still that page: the rows waiting behind the ones handed over came from it.
func (*Pager) Rows ¶
func (p *Pager) Rows() []json.RawMessage
Rows returns the rows handed over by the last Next — or by the last Take, which may have collected them from more than one page.
func (*Pager) Take ¶ added in v0.2.0
Take walks until it holds n rows, and stops there.
Why this exists rather than a break inside the loop ¶
The obvious way to write "the 45 newest deals" wastes a request, silently:
for p.Next(ctx) { // <- the 46th row is on page 1,
for _, row := range p.Rows() { // but the loop asks for page 2
if len(taken) == want { break } // before it re-tests this
taken = append(taken, row)
}
}
The inner break leaves the OUTER condition to be evaluated again, and that condition is a call to the portal. It compiles, it looks right, it returns the right rows, and it spends a rate-limit token on a page nobody reads. The form that does not is the bound in the loop header — for len(taken) < want && p.Next(ctx) — which one has to know. Take is that, packaged:
rows, err := p.Take(ctx, 45)
Take returns fewer than n rows only at the end of the list or on error, and the error is returned ALONGSIDE the rows it did collect: a walk that failed on page three still read pages one and two, and throwing them away only buys the caller a second trip over the same rows.
Rows fetched but not returned are kept, so a Take that stops in the middle of a page costs neither those rows nor another request: the next Take or Next hands them over without going to the portal. Take and Next therefore mix freely — Take the first n, then walk the rest.
n <= 0 returns nothing and asks the portal for nothing.
A page that adds nothing ends the take ¶
If a page comes back with no rows while the server still reports more, Take stops with ErrCursorStalled instead of asking again. Next hands that page back and lets the caller decide, because the caller owns that loop; inside Take there is no loop to break out of, so an endless supply of empty pages would spend the customer's rate limit with nothing able to stop it. Use Next for a list that really does answer with empty pages.
Example ¶
The first n rows, without paying for a page nobody reads.
Breaking out of the inner range over Rows does not stop the walk: the outer condition is evaluated again after the break, and that condition is a call to the portal. Take stops where it was asked to.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
p, err := client.Core().Scan("crm.deal.list", b24.Params{
"select": []any{"ID", "TITLE"},
}, b24.WithDescending()) // newest first
if err != nil {
log.Fatal(err)
}
rows, err := p.Take(context.Background(), 45)
if err != nil { // fewer than 45 rows come back with it, not instead of it
log.Fatal(err)
}
for _, row := range rows {
var deal struct {
ID b24.ID `json:"ID"`
Title string `json:"TITLE"`
}
if err := json.Unmarshal(row, &deal); err != nil {
log.Fatal(err)
}
fmt.Println(deal.ID, deal.Title)
}
fmt.Println(p.Count(), "rows taken") // 45, not the 50 that were fetched
}
Output:
type Params ¶ added in v0.2.0
Params is the parameter map of a REST call: field names to values.
Why this exists ¶
Bitrix24 parameters nest — a filter inside a list call, a fields object inside an add, an address object inside a requisite — and every level of that nesting is another map[string]any spelled out in full:
params := map[string]any{
"fields": map[string]any{
"TITLE": "New deal",
"UF_CRM_ADDRESS": map[string]any{"ADDRESS_1": "…", "CITY": "…"},
},
}
Params says the same thing in a third of the width, which is what makes a nested literal readable at a glance:
params := b24gosdk.Params{
"fields": b24gosdk.Params{
"TITLE": "New deal",
"UF_CRM_ADDRESS": b24gosdk.Params{"ADDRESS_1": "…", "CITY": "…"},
},
}
It is an ALIAS, and that is the point ¶
Params is `= map[string]any`, not `map[string]any`. A defined type would be a different type: a function taking Params would reject a map[string]any the caller already had, every existing call site would need a conversion, and the two spellings could not be mixed inside one nested literal. As an alias they are the same type, so this is purely a shorter name — nothing in the SDK requires it, and code that never mentions Params keeps working unchanged.
type Result ¶ added in v0.2.0
type Result []byte
Result is a raw JSON value from a response, with the shape question attached.
json.RawMessage converts into it, so it is reached by conversion at the point the question comes up rather than by changing what a call returns:
switch b24gosdk.Result(raw).Kind() {
case b24gosdk.KindArray:
…
}
func (Result) Kind ¶ added in v0.2.0
Kind reports the JSON shape of the value.
Why this exists ¶
One Bitrix24 field answers with more than one shape, and which one you get depends on the DATA rather than on the method. A single-value product property comes back as an object and a multiple one as an array of the same objects; a list result is an array until the method wraps it in an object; and a single-value list property that has only one list value is silently reclassified as a Yes/No field, whose value arrives as the bare string "N". Decoding without asking first turns each of those into "cannot unmarshal object into Go value of type []T" — an error about Go, at the wrong layer, for a portal fact.
Kind is the question IsEmpty does not answer. IsEmpty says whether anything is there, across the five ways Bitrix24 spells emptiness; Kind says what is there, so the right decode can be chosen:
switch b24gosdk.Result(raw).Kind() {
case b24gosdk.KindArray:
err = json.Unmarshal(raw, &values)
case b24gosdk.KindObject:
var one value
err = json.Unmarshal(raw, &one)
values = []value{one}
case b24gosdk.KindNull:
values = nil
default:
err = fmt.Errorf("%s came back as a %v", field, b24gosdk.Result(raw).Kind())
}
It reads the leading token, and does not validate ¶
Kind answers from the first token, so a truncated array still reports KindArray. That is deliberate: the decode that follows is the validator and reports malformed input with a position, which a shape check cannot. What Kind guarantees is the dispatch — that json.Unmarshal is handed a target that matches what actually arrived. The three literals are matched WHOLE, so a value beginning with n, t or f but spelling something else is KindInvalid rather than a null or a bool.
Example ¶
Asking what shape arrived before decoding it. ONE field answers with more than one shape, and which one depends on the data: a single-value product property is an object, a multiple one an array of the same objects.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
res, err := client.Core().Call(context.Background(), "catalog.product.get",
b24.Params{"id": 105}, b24.WithIdempotent())
if err != nil {
log.Fatal(err)
}
raw, ok := b24.Unwrap(res.Result, "product", "property411")
if !ok {
log.Fatal("no property411 in result")
}
type value struct {
Value string `json:"value"`
}
var values []value
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:
// A single-value list property holding only ONE list value is
// reclassified as a Yes/No field, and then arrives as the string "N".
err = fmt.Errorf("property411 came back as a %v", b24.Result(raw).Kind())
}
if err != nil {
log.Fatal(err)
}
fmt.Println(len(values))
}
Output:
type RetryConfig ¶
RetryConfig controls retry behavior for rate limit errors.
type TokenRefresher ¶
TokenRefresher obtains a new access token. It is called by the SDK when the API reports that the current token has expired.
type ValidationError ¶ added in v0.2.0
ValidationError names one field a REST 3.0 request was rejected over.
REST 3.0 answers a bad request with a single generic code and message — BITRIX_REST_V3_EXCEPTION_VALIDATION_REQUESTVALIDATIONEXCEPTION, "Ошибка при валидации объекта запроса" — and puts the only part a caller can act on, the field, in a separate array. Without it the error says that something in the request was wrong but not what.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package b24test builds Bitrix24 wire fixtures and fake portals, so code that uses this SDK can be tested without a network and without a real portal.
|
Package b24test builds Bitrix24 wire fixtures and fake portals, so code that uses this SDK can be tested without a network and without a real portal. |
|
internal
|
|
|
phpq
Package phpq encodes and decodes PHP bracket notation — k[a][0][b]=v — in both directions.
|
Package phpq encodes and decodes PHP bracket notation — k[a][0][b]=v — in both directions. |
|
Package oauth implements the Bitrix24 OAuth 2.0 authorization protocol: building the authorization URL, exchanging the authorization code for tokens and renewing them.
|
Package oauth implements the Bitrix24 OAuth 2.0 authorization protocol: building the authorization URL, exchanging the authorization code for tokens and renewing them. |