b24gosdk

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 6, 2026 License: MIT Imports: 17 Imported by: 0

README

Bitrix24 Go SDK (b24gosdk)

SDK для работы с Bitrix24 REST API на Go. Поддерживает вебхуки, OAuth и сценарии разработки приложений.

Установка

SDK доступен как пакет:

import b24 "github.com/bitrix24/b24gosdk"

Быстрый старт (вебхук)

client := b24.NewClient(webhookURL)
res, err := client.Core().Call(ctx, "crm.deal.add", map[string]any{
	"fields": map[string]any{"TITLE": "New Deal"},
})

Любой метод REST вызывается по имени — обёрток на каждый метод нет намеренно, поэтому метод, выпущенный Битрикс24 сегодня, доступен сегодня же.

OAuth (приложения)

Если приложение открывается внутри Битрикс24, токены приходят вместе с POST-данными страницы — смотрите раздел Приложение в интерфейсе Битрикс24, это самый короткий путь.

Полный протокол нужен, когда пользователь работает во внешнем сервисе и тот получает доступ к порталу. Сначала пользователя отправляют на страницу авторизации портала, оттуда он возвращается с кодом (code), который живёт 30 секунд и обменивается на пару токенов:

import (
	"github.com/bitrix24/b24gosdk"
	"github.com/bitrix24/b24gosdk/oauth"
)

oauthClient := oauth.NewClient(clientID, clientSecret)

// 1. Отправить пользователя на страницу авторизации его портала.
url := oauthClient.AuthorizeURL("portal.bitrix24.com", state, redirectURI)

// 2. Обменять полученный code на токены.
resp, err := oauthClient.ExchangeCode(ctx, code)
client := b24gosdk.NewOAuthClient(resp.ClientEndpoint, resp.AccessToken)

Сохраните resp.RefreshToken — без него доступ придётся получать заново.

Автоматическое продление токенов

Access-токен живёт около часа. Чтобы SDK продлевал его сам, передайте WithTokenRefresher. Готовая реализация — oauth.NewRefresher: она хранит актуальный refresh-токен (сервер авторизации выдаёт новую пару при каждом продлении) и отдаёт новую пару в колбэк, где её нужно сохранить.

refresher := oauth.NewRefresher(oauthClient, savedRefreshToken, func(t oauth.TokenResponse) {
	// Сохранить новую пару: после перезапуска работа продолжится с неё.
	storeTokens(t.AccessToken, t.RefreshToken)
})

client := b24gosdk.NewOAuthClient(clientEndpoint, savedAccessToken,
	b24gosdk.WithTokenRefresher(refresher.Refresh))

Дальше вызовы делаются как обычно: если портал ответит expired_token, SDK один раз продлит токен и повторит запрос. Продление по таймеру не выполняется — сервер авторизации дёргается только когда токен действительно истёк. Если несколько запросов истекли одновременно, продление будет одно на всех.

Продлить вручную тоже можно:

resp, err := oauthClient.RefreshToken(ctx, refreshToken)
client.SetAccessToken(resp.AccessToken)

Структура SDK

  • Client — точка входа.
  • Core() — универсальный вызов любого метода REST: Call, CallJSON, CallMultipart.

Универсальный вызов

Имя метода — строка, поэтому доступен весь REST, а не подмножество, которое успели обернуть:

res, err := client.Core().CallJSON(ctx, "crm.deal.list", params)

Для загрузки файлов:

res, err := client.Core().CallMultipart(ctx, "bizproc.workflow.template.add", params, files)
Списки и постраничный вывод

CallJSON возвращает только result. Если нужны служебные поля ответа — Call: в Total приходит общее число записей, в Next — смещение следующей страницы (nil, когда данные закончились).

res, err := client.Core().Call(ctx, "crm.deal.list", map[string]any{"start": 0})
if err != nil {
	return err
}
// res.Result — данные, res.Total — сколько всего, res.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
	}
}
Разбор ответа

Ответ — сырой JSON, поэтому его разбирает вызывающий. Четыре вещи в API повторялись в примерах достаточно часто, чтобы попасть в SDK.

Идентификаторы приходят то числом, то строкой — иногда в рамках одного сценария: disk.* отдаёт "ID": 6687, tasks.*"id": "3711". Поле типа b24.ID разбирает оба варианта, а обратно сериализуется числом:

var task struct {
	ID    b24.ID `json:"id"`
	Title string `json:"title"`
}
err := json.Unmarshal(res.Result, &task)

Многие методы заворачивают ответ в объект с одним ключом{"task": {…}}, {"products": […]}. Unwrap снимает обёртку, не заводя структуру ради одного поля:

raw, ok := b24.Unwrap(res.Result, "task")

Ключи сравниваются точно. Если портал переименовал поле (в select уходит UF_TASK_WEBDAV_FILES, в ответе — ufTaskWebdavFiles), поможет UnwrapFold (игнорирует регистр и подчёркивания) или Keys — посмотреть, что реально пришло.

Пустое поле приходит как null, "", false, [] или {} — в зависимости от метода и типа поля. b24.IsEmpty(raw) покрывает все пять; числовой 0 пустым не считается.

Телефоны и почта (crm_multifield)

CRM хранит их списком строк, и набор ключей в строке решает, что произойдёт. Строки, которые вы не упомянули, остаются как были, поэтому удаление должно быть явным:

"PHONE": []map[string]any{
	b24.MultifieldAdd("+7 900 000-00-00", "MOBILE"), // без ID — добавляет
	b24.MultifieldSet(rowID, "+7 900 111-11-11"),    // по ID — меняет
	b24.MultifieldDelete(rowID),                     // по ID — удаляет
}

Строка без ID всегда добавляет: повторно отправленный существующий телефон без его ID создаст дубль, а не обновит запись.

Обход списка

Pages ведёт постраничный обход сам — отправляет start и возвращает next, пока сервер их отдаёт:

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()   // ПРОВЕРЯТЬ ВСЕГДА

Next возвращает false и в конце списка, и при ошибке, поэтому оборвавшийся обход выглядит как завершившийся — Err() после цикла отличает одно от другого.

Для больших выгрузок — Scan. Постраничный обход заставляет сервер отсчитывать все пропущенные строки, поэтому последние страницы длинного списка идут всё медленнее. Scan идёт по идентификатору: start=-1 (отключает подсчёт), сортировка по id и фильтр по последнему увиденному — каждая страница стоит одинаково:

p, err := client.Core().Scan("crm.deal.list", nil)

Семейства с нестандартной формой ответа (crm.item.* — строки в items и строчный id, tasks.task.* — возвращает id, но сортирует по ID, catalog.product.*, user/department — верхнеуровневые SORT/ORDER) известны SDK и работают без настройки. Для остальных есть WithRowPath, WithIDField, WithCursorParam.

Если метод игнорирует курсор, обход не зацикливается: он останавливается с ErrCursorStalled вместо того, чтобы бесконечно запрашивать одну и ту же страницу за счёт лимитов портала.

Batch — до 50 вызовов одним запросом

Батч тратит один токен лимита вместо одного на команду, поэтому пятьдесят созданий — это один запрос, а не пятьдесят.

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 {
	// Часть команд могла выполниться — см. ниже.
}
raw, err := res.Get(idUser)
Цепочки: результат одной команды в параметрах другой

Ref строит подстановку $result[...], которую сервер разворачивает между командами:

b := b24.NewBatch()
b.Halt = true                       // для цепочки — обязательно, см. ниже
b.AddAs("c", "crm.contact.add", map[string]any{"fields": map[string]any{"NAME": "Анна"}})

ref, _ := b24.Ref("c")              // без сегментов: результат crm.contact.add — сам ID
b.AddAs("note", "crm.timeline.comment.add", map[string]any{
	"fields": map[string]any{"ENTITY_ID": ref, "ENTITY_TYPE": "contact"},
})

Halt для цепочки обязателен. Если команда-источник упала, её $result не становится ошибкой — сервер подставляет неразрешённый текст как обычное значение, и следующая команда выполняется с испорченным параметром. Halt останавливает цепочку вместо этого.

Много независимых команд

CallBatch не делит батч: длиннее 50 — вернёт ErrBatchLengthExceeded, потому что сервер ответил бы на такой батч тем, что выглядит как частичный успех. Для независимых команд есть CallBatchChunked — он режет по 50 и склеивает результаты:

b := b24.NewBatch()
for _, c := range contacts {
	b.Add("crm.contact.add", map[string]any{"fields": c})
}
res, err := client.Core().CallBatchChunked(ctx, b)

Цепочку так делить нельзя: $result не переживает границу чанка — это отдельные запросы с отдельными пространствами результатов.

Ошибка батча — частичная

Сервер отвечает HTTP 200 и кладёт неудачи команд в result_error, поэтому err != nil не значит, что не выполнилось ничего. Результат возвращается вместе с ошибкой, и его нельзя выбрасывать: повторный прогон всего батча переисполнил бы уже закоммиченные команды.

res, err := client.Core().CallBatch(ctx, b)
var be *b24.BatchError
if errors.As(err, &be) {
	// be.Failed — что упало; res — всё, что прошло.
	// Пересобрать батч из be.Failed и повторить только его.
}

res.Executed(id) отличает «команда выполнилась и упала» от «не выполнялась, потому что Halt остановил батч раньше».

Тестирование интеграции без портала

Пакет b24test поднимает фейковый портал и собирает фикстуры в том виде, в каком байты приходят с настоящего:

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": "Сделка"}))

	title, err := findDealTitle(ctx, p.Client(), 42)   // код вашей интеграции
	// ...
	if p.CallsTo("crm.deal.get")[0].Params["id"] != float64(42) {
		t.Error("ушёл не тот id")
	}
}

Почему фикстуры, а не мок клиента: ломается обычно провод, а не логика. Идентификатор приходит строкой, список спрятан под ключом, ошибка лимита приезжает с HTTP 503 и телом, в батче одна команда упала внутри HTTP 200. Мок воспроизводит ваши предположения, фикстура — то, что реально шлёт портал; запрос при этом идёт через тот же разбор, ретраи и классификацию ошибок, что и в бою.

Есть: Result, ListResult, WrappedListResult, BatchResult, ErrorBody, InstallForm, UninstallForm, AppPageForm. Portal.OnError сам подставляет статус, который портал использует для этого кода (StatusFor), — на 200 проверить обработку QUERY_LIMIT_EXCEEDED невозможно.

Значения в фикстурах — подставные, из примеров документации: фикстуры попадают в репозиторий, и настоящий токен в них означал бы утёкший доступ.

События установки/удаления приложения

Битрикс24 присылает события POST-запросом в формате application/x-www-form-urlencoded. Разбор — прямо из *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
	}
	// Сохранить для дальнейшей работы: access_token, refresh_token,
	// client_endpoint, member_id и application_token.
}

Для удаления — ParseOnAppUninstallRequest. Если payload уже сохранён как JSON, есть ParseOnAppInstall / ParseOnAppUninstall.

Входящие события нужно проверять по application_token, сохранённому при установке:

if !evt.Auth.VerifyApplicationToken(savedToken) {
	http.Error(w, "forbidden", http.StatusForbidden)
	return
}

Приложение в интерфейсе Битрикс24

При открытии страницы приложения (и на странице установки) Битрикс24 передаёт авторизационные данные POST-запросом:

req, err := b24gosdk.ParseAppRequest(r)
if err != nil {
	http.Error(w, "bad request", http.StatusBadRequest)
	return
}
client, err := b24gosdk.NewClientFromAppRequest(req)

req.AuthID — access-токен, req.RefreshID — refresh-токен; чтобы работать с REST дольше часа, сохраните оба.

installFinish

installFinish вызывается со страницы установки приложения через фронтенд (BX24 JS). В SDK доступен метод:

_, err := client.App().InstallFinish(ctx, nil)

Используйте его только если сценарий действительно требует серверного вызова. В стандартных сценариях метод вызывается во фронтенде.

Ошибки

Ошибки, о которых сообщил портал, возвращаются как *APIError — с кодом, описанием и HTTP-статусом:

var apiErr *b24gosdk.APIError
if errors.As(err, &apiErr) && apiErr.Code == "expired_token" {
	// ...
}

Ошибки сервера авторизации приходят как *oauth.Error.

Коды ошибок сравнивайте через errors.Is, а не строкой

Опечатка в строковом литерале компилируется, запускается и молча уводит выполнение не в ту ветку:

if errors.Is(err, b24.ErrMethodNotFound) { … }
if errors.Is(err, b24.ErrAccessDenied) { … }
if errors.Is(err, b24.Code("CREATE_DYNAMIC_TYPE_RESTRICTED")) { … } // любой код

Сравнение регистронезависимо (портал шлёт QUERY_LIMIT_EXCEEDED в верхнем регистре, а expired_token — в нижнем) и только по коду: OVERLOAD_LIMIT и QUERY_LIMIT_EXCEEDED оба приезжают с HTTP 503, поэтому проверка по статусу спутала бы ручную блокировку с превышением лимита. Прочитать код обратно — b24.CodeOf(err).

Готовые sentinel-ошибки: ErrQueryLimitExceeded, ErrOperationTimeLimit, ErrExpiredToken, ErrInvalidToken, ErrInvalidGrant, ErrInsufficientScope, ErrMethodNotFound, ErrAccessDenied, ErrPaymentRequired. Константы Code* называют те же коды, а b24.Code(...) покрывает всё остальное — портал выпускает новые коды без предупреждения, поэтому набор намеренно открытый.

Повторы: важно не «временная ли ошибка», а «выполнился ли запрос»
  • QUERY_LIMIT_EXCEEDED (HTTP 503) — это лимитер отказал в вызове до того, как портал его выполнил. Повтор ничего не задвоит, поэтому SDK повторяет такой запрос всегда, каким бы ни был метод.
  • Сетевой сбой, таймаут, нечитаемое тело, 5xx без кода ошибкинеоднозначны: запрос мог дойти до портала и выполниться. По умолчанию они не повторяются, потому что повтор crm.deal.add создал бы вторую сделку.
  • Если вызов безопасно повторять — скажите об этом явно:
res, err := client.Core().Call(ctx, "crm.deal.get",
	map[string]any{"id": 42}, b24.WithIdempotent())

Ставьте WithIdempotent на чтения (*.get, *.list, *.fields) и на записи, проставляющие фиксированные значения. Не ставьте на *.add и на обновление, где новое значение вычисляется из старого.

SDK не угадывает: имя метода — строка, и по ней нельзя понять, идемпотентен ли вызов. Pages/Scan повторяют страницу сами — обход списка это чтение. Батч не повторяется никогда: в нём могут быть уже закоммиченные команды.

Новые методы REST

Ничего добавлять не нужно. Метод вызывается по имени, поэтому новый метод Битрикс24 доступен сразу, без обновления SDK:

res, err := client.Core().Call(ctx, "crm.newmethod.add", params)

Точные имена методов и их параметры — в официальной документации REST.

Правила разработки — в CONTRIBUTING.md.

Лицензия

MIT, смотрите LICENSE.

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.

Up to 50 calls travel in one request, for one rate-limit token instead of fifty, through Batch and CallBatch; Ref feeds one command's result into the parameters of the next. 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)

Errors reported by the API are returned as *APIError, so a specific code can be matched with errors.As. Rate limit errors are retried by the SDK itself.

Index

Examples

Constants

View Source
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.

View Source
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

View Source
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)
)

Frequently matched codes as ready sentinels.

View Source
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.

View Source
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.

View Source
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.

View Source
var ErrNoRows = errors.New("b24gosdk: no row array in result")

ErrNoRows is returned when a page carries no row array where one was expected.

Functions

func Code

func Code(c ErrorCode) error

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

func MultifieldAdd(value, valueType string) map[string]any

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)
	}
}

func MultifieldDelete

func MultifieldDelete(id ID) map[string]any

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

func MultifieldSet(id ID, value string) map[string]any

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

func Ref(id CmdID, path ...string) (string, error)

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")
	}
}

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
}

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)
	}
}

func (*APIError) Error

func (e *APIError) Error() string

func (*APIError) Is

func (e *APIError) Is(target error) bool

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.

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

func (a *AppAuth) VerifyApplicationToken(saved string) bool

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
		}
	})
}

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 rate-limit token instead of one per command, which is the whole reason it exists: fifty creates through Call spend fifty tokens and take fifty round trips.

Commands run in submission order, so a later command can reference an earlier one's result through Ref.

func NewBatch

func NewBatch() *Batch

NewBatch returns an empty batch.

func (*Batch) Add

func (b *Batch) Add(method string, params any) (CmdID, error)

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

func (b *Batch) AddAs(id CmdID, method string, params any) error

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

func (b *Batch) Chunks() []*Batch

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.

func (*Batch) Cmds

func (b *Batch) Cmds() []Cmd

Cmds returns the commands in submission order.

func (*Batch) Len

func (b *Batch) Len() int

Len reports how many commands the batch holds.

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.

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())

Pages and Scan already walk idempotently: a list walk is a read, and without it a single dropped connection at page 40 would abandon a 200-page scan.

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

func NewClient(webhookURL string, opts ...Option) *Client

NewClient creates a client for inbound webhook URL.

The webhook secret is part of the URL, so no token handling is involved.

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

func NewOAuthClient(clientEndpoint, accessToken string, opts ...Option) *Client

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

func (c *Client) Core() *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)
}

func (*Client) SetAccessToken

func (c *Client) SetAccessToken(token string)

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 Cmd

type Cmd struct {
	ID     CmdID
	Method string
	Params any
}

Cmd is one command of a batch.

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

func (c *Core) AccessToken() string

AccessToken returns the OAuth access token currently used in requests.

func (*Core) BaseURL

func (c *Core) BaseURL() string

BaseURL returns REST base URL.

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)
	}
}

func (*Core) CallBatch

func (c *Core) CallBatch(ctx context.Context, b *Batch) (*BatchResult, error)

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.

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))
}

func (*Core) CallBatchChunked

func (c *Core) CallBatchChunked(ctx context.Context, b *Batch) (*BatchResult, error)

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)
	}
}

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

func (c *Core) Pages(method string, params any, opts ...PageOption) (*Pager, error)

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.

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)
	}
}

func (*Core) Scan

func (c *Core) Scan(method string, params any, opts ...PageOption) (*Pager, error)

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 ascending 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.

It requires a method that sorts and filters by an id field. Where the id is not spelled "ID" both ways, pass WithIDField.

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)
	}
}

func (*Core) SetAccessToken

func (c *Core) SetAccessToken(token string)

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.

func CodeOf

func CodeOf(err error) (ErrorCode, bool)

CodeOf reports the Bitrix24 error code an error carries, if any.

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. Compare with the normalized constants, or use errors.Is with Code.

func (ErrorCode) Normalize

func (c ErrorCode) Normalize() ErrorCode

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) Int64

func (id ID) Int64() int64

Int64 returns the identifier as an int64.

func (ID) IsZero

func (id ID) IsZero() bool

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

func (id ID) MarshalJSON() ([]byte, error)

MarshalJSON emits the identifier as a JSON number.

func (ID) String

func (id ID) String() string

String returns the identifier in base 10, for a parameter that wants a string.

func (*ID) UnmarshalJSON

func (id *ID) UnmarshalJSON(b []byte) error

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 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 "" }

type OnAppUninstallData

type OnAppUninstallData struct {
	LanguageID string `json:"LANGUAGE_ID"`
	Clean      int    `json:"CLEAN"`
}

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

func WithAccessToken(token string) Option

WithAccessToken injects OAuth access token into REST calls.

func WithHTTPClient

func WithHTTPClient(client *http.Client) Option

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) {}

type PageOption

type PageOption func(*Pager)

PageOption configures a walk.

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 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

A Pager is NOT safe for concurrent use.

func (*Pager) Count

func (p *Pager) Count() int

Count reports how many rows have been walked so far.

func (*Pager) Err

func (p *Pager) Err() error

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

func (p *Pager) Next(ctx context.Context) bool

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.

func (*Pager) Page

func (p *Pager) Page() *CallResult

Page returns the whole envelope of the page just read, for Total and Time.

func (*Pager) Rows

func (p *Pager) Rows() []json.RawMessage

Rows returns the rows of the page just read.

type RetryConfig

type RetryConfig struct {
	MaxAttempts int
	BaseDelay   time.Duration
	MaxDelay    time.Duration
}

RetryConfig controls retry behavior for rate limit errors.

type TokenRefresher

type TokenRefresher func(ctx context.Context) (accessToken string, err error)

TokenRefresher obtains a new access token. It is called by the SDK when the API reports that the current token has expired.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL