b24gosdk

package module
v0.2.0 Latest Latest
Warning

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

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

README

Русский

Bitrix24 Go SDK (b24gosdk)

CI Go Reference

A Go SDK for the Bitrix24 REST API. It covers webhooks, OAuth and the scenarios of application development.

Requires Go 1.21+. No external dependencies.

Installation

go mod init example.com/your/module   # inside a module already? skip this
go get github.com/bitrix24/b24gosdk
import b24 "github.com/bitrix24/b24gosdk"

go get only works inside a module: run outside one, it answers go.mod file not found in current directory or any parent directory and installs nothing.

The API reference is on pkg.go.dev.

Writing an integration with an AI agent? Hand it llms.txt — an entry point written for exactly that reader: what this SDK does not hold (REST method names, which must not be invented), the seven things an agent gets wrong with this SDK specifically, and the traps that cost data rather than an error.

Quick start (webhook)

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

Every REST method is called by name — the absence of per-method wrappers is deliberate, and it is why a method Bitrix24 released today is callable today.

OAuth (applications)

If the application opens inside Bitrix24, tokens arrive with the POST data of the page — see An application inside the Bitrix24 interface, which is the shortest path.

The full protocol is for when the user works in an external service and that service gains access to a portal. The user is first sent to the portal's authorization page and comes back from it with a code, which lives 30 seconds and is exchanged for a pair of tokens:

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

oauthClient := oauth.NewClient(clientID, clientSecret)

// 1. Send the user to their portal's authorization page.
url := oauthClient.AuthorizeURL("portal.bitrix24.com", state, redirectURI)

// 2. Exchange the code for tokens.
resp, err := oauthClient.ExchangeCode(ctx, code)
client := b24gosdk.NewOAuthClient(resp.ClientEndpoint, resp.AccessToken)

Save resp.RefreshToken: without it, access has to be granted from scratch again.

Renewing tokens automatically

An access token lives about an hour. To have the SDK renew it on its own, pass WithTokenRefresher. oauth.NewRefresher is a ready implementation: it keeps the current refresh token (the authorization server issues a new pair on every renewal) and hands the new pair to a callback, where it has to be persisted.

refresher := oauth.NewRefresher(oauthClient, savedRefreshToken, func(t oauth.TokenResponse) {
	// Store the new pair: after a restart, work resumes from it.
	storeTokens(t.AccessToken, t.RefreshToken)
})

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

Calls are then made as usual: if the portal answers expired_token, the SDK renews the token once and replays the request. There is no renewal on a timer — the authorization server is touched only when a token has actually expired. If several requests expire at the same moment, they share one renewal.

Renewing by hand is possible too:

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

How the SDK is laid out

  • Client — the entry point.
  • Core() — the universal call for any REST method: Call, CallJSON, CallMultipart.

The universal call

A method name is a string, so the whole of REST is reachable, not the subset somebody got round to wrapping:

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

For file uploads:

res, err := client.Core().CallMultipart(ctx, "bizproc.workflow.template.add", params, files)

Parameters are a map[string]any, and b24.Params is a shorter name for it. Bitrix24 parameters are nested, and every level of nesting is another whole map; in a three-level literal the difference shows:

params := b24.Params{
	"fields": b24.Params{
		"TITLE":          "Deal",
		"UF_CRM_ADDRESS": b24.Params{"ADDRESS_1": "…", "CITY": "…"},
	},
}

It is an alias, not a separate type: b24.Params and map[string]any are the same thing, the two spellings mix inside one literal, and code that has never heard of Params keeps working unchanged.

Lists and pagination

CallJSON returns only result. When the response's pagination metadata is needed, use Call: Total carries the total number of records, Next the offset of the following page (nil once the data has run out).

res, err := client.Core().Call(ctx, "crm.deal.list", map[string]any{"start": 0})
if err != nil {
	return err
}
// res.Result is the data, res.Total how many there are, res.Next the next start.
for res.Next != nil {
	res, err = client.Core().Call(ctx, "crm.deal.list", map[string]any{"start": *res.Next})
	if err != nil {
		return err
	}
}
Reading a result

A response is raw JSON, so the caller decodes it. Four things about the API came up in the examples often enough to end up in the SDK.

Identifiers arrive as a number in one place and as a string in another — sometimes within one workflow: disk.* answers "ID": 6687, tasks.* answers "id": "3711". A field of type b24.ID decodes both, and marshals back as a number:

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

Many methods wrap the payload in a single-key object{"task": {…}}, {"products": […]}. Unwrap strips the wrapper without a struct declared for the sake of one field:

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

Keys are matched exactly. If the portal renamed the field (UF_TASK_WEBDAV_FILES goes out in select, ufTaskWebdavFiles comes back), UnwrapFold helps — it ignores case and underscores — or Keys, to see what actually came back.

An empty field arrives as null, "", false, [] or {}, depending on the method and the field type. b24.IsEmpty(raw) covers all five; a numeric 0 does not count as empty.

One and the same field answers with more than one shape, and which one it is depends on the data, not on the method. A product property with a single value arrives as an object, one with several as an array of those same objects. A fixed decode target is wrong exactly half the time, so the shape is asked about first:

switch b24.Result(raw).Kind() {
case b24.KindArray:
	err = json.Unmarshal(raw, &values)
case b24.KindObject:
	var one value
	err = json.Unmarshal(raw, &one)
	values = []value{one}
case b24.KindNull:
	values = nil
default:
	err = fmt.Errorf("unexpected shape: %v", b24.Result(raw).Kind())
}

IsEmpty answers "is there anything here", Kind answers "what exactly": one that came back null holds no value, while one that came back "" holds the scalar the field was reclassified into. The shapes: KindNull, KindBool, KindNumber, KindString, KindArray, KindObject, KindInvalid.

The shape is read off the leading token and is not validated — the validator is the Unmarshal that follows, and it reports broken JSON with a position.

Phones and emails (crm_multifield)

CRM stores them as a list of rows, and the set of keys in a row decides what happens. Rows you did not mention are left as they were, so deleting has to be explicit:

"PHONE": []map[string]any{
	b24.MultifieldAdd("+7 900 000-00-00", "MOBILE"), // no ID: adds
	b24.MultifieldSet(rowID, "+7 900 111-11-11"),    // by ID: changes
	b24.MultifieldDelete(rowID),                     // by ID: removes
}

A row without an ID always adds: an existing phone re-sent without its ID creates a duplicate instead of updating the record.

Walking a list

Pages walks the pages itself — it sends start and follows next for as long as the server hands them out:

p, err := client.Core().Pages("crm.deal.list", map[string]any{
	"select": []any{"ID", "TITLE"},
})
if err != nil {
	return err
}
for p.Next(ctx) {
	for _, row := range p.Rows() {
		var d Deal
		if err := json.Unmarshal(row, &d); err != nil {
			return err
		}
	}
}
return p.Err()   // ALWAYS CHECK

Next returns false both at the end of a list and on an error, so a walk that broke off looks like one that finished — Err() after the loop tells them apart.

Need n rows? Take. The obvious way to write "take 45 deals" quietly spends an extra request:

for p.Next(ctx) {                       // row 46 is already on the first page,
	for _, row := range p.Rows() {      // but the outer condition is a call to
		if len(taken) == want { break } // the portal, and after the break it is
		taken = append(taken, row)      // evaluated once more
	}
}

break leaves the inner loop, the outer condition is evaluated again — and that is a request for a page nobody will read. It compiles, it looks right, it hands back the right rows. The correct form puts the cut-off in the loop header (for len(taken) < want && p.Next(ctx)), but you have to know it. Take is that same form, packaged:

rows, err := p.Take(ctx, 45)

Take returns fewer than n rows only at the end of a list or on an error, and it returns the error alongside the rows it already read. Rows fetched but not handed over are not lost: the next Take or Next gives them out with no request to the portal — so Take and Next mix freely.

Count() counts rows handed over, not pages walked: after Take(ctx, 45) it is 45, not 50. What it cannot count is what you did with them afterwards: Next hands over a whole page, and a break inside your own loop never reaches the Pager. When the number of rows is what matters, take them with Take.

For big exports, Scan. Paging by offset makes the server count off every skipped row, so the last pages of a long list get slower and slower. Scan pages by identifier: start=-1 (which turns the counting off), ordering by id and a filter on the last one seen — every page costs the same:

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

Families with a non-standard response shape (crm.item.* — rows under items and a lowercase id; tasks.task.* — answers id but sorts on ID; catalog.product.*; user/department — top-level SORT/ORDER) are known to the SDK and work with no configuration. For the rest there are WithRowPath, WithIDField, WithCursorParam.

If a method ignores the cursor, the walk does not loop: it stops with ErrCursorStalled instead of requesting one and the same page forever at the expense of the portal's limits.

From the end, WithDescending. Do not reverse a Scan by hand: a descending walk has two halves. Scan pages by id, ordering on it and filtering on the last one seen; ascending, that is order ASC plus filter >id. Flip only the ordering and you get order DESC plus filter >id — a request for rows above the newest one already seen, of which there are none. The walk gets an empty second page, stops, and reports a complete export that holds only the first page. The option flips both halves.

To bound one page, WithCallOptions(WithTimeout(...)). A walk takes one ctx for the whole loop, so otherwise the choice is: a deadline on the entire export — and then a 200-page scan has to guess its own duration up front — or no bound at all on a page that hangs.

p, err := client.Core().Scan("crm.deal.list", nil,
	b24.WithDescending(),                                 // newest first
	b24.WithCallOptions(b24.WithTimeout(30*time.Second)), // no page longer than 30 s
)

WithTimeout bounds everything the SDK does for one call: the attempt, the pauses between retries and a token renewal. It combines with the caller's own deadline, and the earlier one wins. A walk stays idempotent whatever the options — WithIdempotent is applied first and cannot be taken away.

Batch: up to 50 calls in one request

A batch spends one token of the frequency limit instead of one per command, so fifty creates are one request, not fifty.

What a batch does not save

The work. The portal still runs all fifty commands, one after another, and still charges their time to the resource-intensity limit (the operating seconds in the response's time block). That counter is kept per method, and a batch is charged on its own — which is why crm.deal.add's counter looks almost untouched after a batch of fifty creates, while the cost is there all the same. A batch moves the pressure from one limit to the other; it does not remove it.

The time. A batch takes as long as all of its commands together — inside one HTTP request: 50 crm.deal.add on a live portal is 28–32 seconds, about 0.6 s per command, and the first command finishes half a minute before the last.

That is longer than most default timeouts, and the failure it produces is the most expensive kind there is:

client := b24.NewClient(webhookURL,
	b24.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}))  // not enough

The connection is cut after the portal has created part of the deals. That is an ambiguous failure — the SDK does not replay it, because a replay would create them a second time — and the identifiers of everything created are lost with the response. Bound a batch of writes by what it really takes: by the deadline of the ctx given to CallBatch, and by an HTTP client timeout above the expected total. The SDK's client has no timeout by default, so this only catches code that set one.

b := b24.NewBatch()
idUser, _ := b.Add("user.current", nil)
b.AddAs("deals", "crm.deal.list", map[string]any{"filter": map[string]any{">ID": 5}})

res, err := client.Core().CallBatch(ctx, b)
if err != nil {
	// Some commands may have run — see below.
}
raw, err := res.Get(idUser)

Commands run in the order they were added, whatever they are called — that is what the chain below rests on.

Get hands back a command's raw result. Per-command next/total do not travel inside it: the server lifts them into sections of their own, and the SDK puts them in res.Next[id] / res.Total[id]. What means "there is another page" is the presence of the key, not its value: next:0 is a legitimate first offset, and comparing against zero would quietly truncate the output.

if next, more := res.Next[idDeals]; more {
	// this command has more pages, starting at offset next
}
Chains: one command's result in another's parameters

Ref builds the $result[...] substitution the server expands between commands:

b := b24.NewBatch()
b.Halt = true                       // mandatory for a chain, see below
b.AddAs("c", "crm.contact.add", map[string]any{"fields": map[string]any{"NAME": "Anna"}})

ref, _ := b24.Ref("c")              // no path: crm.contact.add answers a bare id
b.AddAs("note", "crm.timeline.comment.add", map[string]any{
	"fields": map[string]any{"ENTITY_ID": ref, "ENTITY_TYPE": "contact"},
})

Halt is mandatory for a chain. If the producing command fails, its $result does not become an error — the server substitutes the unresolved text as an ordinary value, and the next command runs with a corrupted parameter. Halt stops the chain instead.

Many independent commands

CallBatch does not split a batch: longer than 50 and it returns ErrBatchLengthExceeded, because the server would answer such a batch with something that looks like a partial success. For independent commands there is CallBatchChunked — it cuts at 50 and stitches the results back together:

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

A chain cannot be split this way: $result does not survive a chunk boundary — those are separate requests with separate result namespaces.

The identifiers of what was created: IDs

"Create N entities and get N identifiers" is the most common batch, and reading its result is the same work every time: walk Order, call Get, unmarshal into b24.ID, do something about the commands that failed. The last part is the interesting one, and it is exactly the part usually skipped.

res, err := client.Core().CallBatchChunked(ctx, b)
ids, idErr := res.IDs()   // ids[i] belongs to the i-th command added

IDs always returns a slice as long as Order and aligned with it position by position, so the arrangement the batch was built from is preserved. A command that produced no identifier leaves a zero in its place (ID.IsZero) rather than dropping out of the slice: drop the gaps and every later identifier slides onto someone else's entity — and that is not an error anywhere, the identifiers being real, they just belong to the wrong thing.

The error names every gap and wraps the originals, so errors.Is and errors.As reach the *APIError of the command that failed. It is returned alongside the identifiers, not instead of them.

What is decoded is a result that is the identifier — the way the classic *.add methods answer. A method that wraps it (crm.item.add answers {"item":{"id":…}}) or answers something else entirely (crm.deal.update answers true) becomes a gap quoting its own response, rather than a plausible 0; for those, use Get and Unwrap.

A batch failure is partial

The server answers HTTP 200 and puts the commands' failures in result_error, so err != nil does not mean nothing ran. The result comes back with the error and must not be discarded: re-running the whole batch would re-execute the commands that already committed.

res, err := client.Core().CallBatch(ctx, b)
var be *b24.BatchError
if errors.As(err, &be) {
	// be.Failed is what failed; res is everything that succeeded.
	// Rebuild a batch from be.Failed and retry only that.
}

res.Executed(id) tells "the command ran and failed" from "it never ran, because Halt stopped the batch earlier".

Testing an integration without a portal

The b24test package brings up a fake portal and assembles fixtures in the form the bytes arrive in from a real one:

import "github.com/bitrix24/b24gosdk/b24test"

func TestMyIntegration(t *testing.T) {
	p := b24test.NewPortal(t)
	p.On("crm.deal.get", b24test.Result(map[string]any{"ID": "42", "TITLE": "Deal"}))

	title, err := findDealTitle(ctx, p.Client(), 42)   // your integration's code
	// ...
	if p.CallsTo("crm.deal.get")[0].Params["id"] != float64(42) {
		t.Error("the wrong id went out")
	}
}

Why fixtures rather than a mock of the client: what breaks is usually the wire, not the logic. An identifier arrives quoted, a list is hidden under a key, a rate-limit error arrives with HTTP 503 and a body, one command inside a batch failed within an HTTP 200. A mock reproduces your assumptions, a fixture reproduces what the portal actually sends; and the request goes through the same parsing, retries and error classification as in production.

Available: Result, ListResult, WrappedListResult, BatchResult, ErrorBody, InstallForm, UninstallForm, AppPageForm. Portal.OnError fills in the status the portal uses for that code by itself (StatusFor) — handling of QUERY_LIMIT_EXCEEDED cannot be tested at HTTP 200.

The values in the fixtures are placeholders, taken from the documentation's examples: fixtures get committed, and a real token in one would mean leaked access.

Application install and uninstall events

Bitrix24 sends events as a POST request in application/x-www-form-urlencoded format. Parsing goes straight from an *http.Request:

func handler(w http.ResponseWriter, r *http.Request) {
	evt, err := b24gosdk.ParseOnAppInstallRequest(r)
	if err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	// Persist for the work ahead: access_token, refresh_token,
	// client_endpoint, member_id and application_token.
}

For uninstall, ParseOnAppUninstallRequest. If the payload is already stored as JSON, there are ParseOnAppInstall / ParseOnAppUninstall.

Inbound events have to be verified against the application_token saved at install time:

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

An application inside the Bitrix24 interface

When an application page opens (and on the install page too), Bitrix24 passes the authorization data in a POST request:

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

req.AuthID is the access token, req.RefreshID the refresh token; to keep working with REST for longer than an hour, save both.

installFinish

installFinish is called from the application's install page through the frontend (BX24 JS). It is a REST method like any other, so through the SDK it is called by name:

_, err := client.Core().CallJSON(ctx, "installFinish", nil)

Use it only if the scenario genuinely requires a server-side call. In the standard scenarios the method is called from the frontend.

REST API 3.0

To call REST 3.0 methods, it is enough to pass the new version's URL — with a /rest/api/ segment in place of /rest/. There is no option for it:

// v1: https://portal.bitrix24.com/rest/1/TOKEN/
// v3: https://portal.bitrix24.com/rest/api/1/TOKEN/
client := b24.NewClient("https://portal.bitrix24.com/rest/api/1/TOKEN/")

res, err := client.Core().Call(ctx, "tasks.task.list", b24.Params{
	"select":     []string{"id", "title"},
	"filter":     [][]any{{"id", ">", 500}},
	"pagination": b24.Params{"limit": 20, "page": 1},
})

The version is derived from the URL rather than set separately, because the URL sets it anyway: without /api/, the portal runs the old version's method or answers "method not found". A second source of truth could be made to disagree with the first — say "version 3" and forget /api/ in the URL — and then every call would go to v1 and be decoded by the rules of v3.

For an application, the v3 URL is https://portal.bitrix24.com/rest/api/; the token still travels in the request body.

What works on v3

Verified against a live portal (Bitrix24 cloud, August 2026):

  • Call, CallJSON — yes. v3's success envelope is the same as v1's, so CallResult, Result, Kind, Unwrap, UnwrapFold, IsEmpty and ID work unchanged.
  • Error codes — yes: errors.Is, CodeOf and the sentinel errors are filled in from v3's nested format (see below).
  • Retries and WithIdempotent — yes, the same logic. Infrastructure errors (QUERY_LIMIT_EXCEEDED among them) arrive on a v3 URL in v1's flat format, and the SDK parses both.
  • WithTimeout, WithHTTPClient, WithRetry — yes, that is transport; it does not concern the version.
What does not work on v3
  • Pages and ScanErrV3WalkUnsupported. v3 has no cursor: start is ignored, next and total are absent from the response, and a page is selected by the pagination parameter (page, limit, offset). The walk does not degrade — it refuses to start, deliberately: on a live portal Pages over tasks.task.list read the first page, saw no next and reported a completed walk with Err() == nil — 2 rows out of 423. A partial export that looks like a complete one is worse than an error. Page with Call instead:

    for page := 1; ; page++ {
    	res, err := client.Core().Call(ctx, "tasks.task.list", b24.Params{
    		"select":     []string{"id"},
    		"pagination": b24.Params{"limit": 50, "page": page},
    	}, b24.WithIdempotent())
    	if err != nil {
    		return err
    	}
    	items, _ := b24.Unwrap(res.Result, "items")
    	// empty — the pages have run out
    }
    
  • Batch, CallBatch, CallBatchChunked, Ref, HaltErrV3BatchUnsupported. v3 does have a batch method, but it is a different protocol: commands go into the root of the body as {"method": …, "query": {…}}, the reply is an array in the order sent (the command keys are discarded), and the first failing command aborts the whole request instead of producing result_error. Until the SDK speaks that format, call it directly:

    res, err := client.Core().Call(ctx, "batch", b24.Params{
    	"cnt": b24.Params{"method": "humanresources.employee.count", "query": b24.Params{}},
    	"tsk": b24.Params{"method": "tasks.task.list", "query": b24.Params{"select": []string{"id"}}},
    })
    // res.Result = [{"total":19},{"items":[{"id":25}]}] — positional
    
  • CallMultipart — untested. v3 declares a JSON body only.

  • OAuth authorization on v3 — untested: the run went over a webhook. The token travels in the body, as it does on v1, so it ought to work, but there is no measurement.

The list of v3 methods — not through the SDK

The portal hands it out itself, through the documentation method, in OpenAPI format. But it must not be fetched through the SDK: that method answers with the document itself, with no {"result": …} envelope, so Call returns Result == nil and no error at all — the request succeeded and there is no data.

Fetch it with a plain HTTP request:

resp, err := http.Get(webhookURL + "documentation") // a v3 URL, GET, no parameters

On the portal that was checked the document holds 177 methods, 25 of which are also available over GET.

Errors

Errors the portal reported are returned as *APIError — with a code, a description and an HTTP status:

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

Authorization-server errors arrive as *oauth.Error.

Match error codes with errors.Is, not with a string

A typo in a string literal compiles, runs, and quietly takes the wrong branch:

if errors.Is(err, b24.ErrMethodNotFound) { … }
if errors.Is(err, b24.ErrAccessDenied) { … }
if errors.Is(err, b24.Code("CREATE_DYNAMIC_TYPE_RESTRICTED")) { … } // any code

Matching is case-insensitive (the portal sends QUERY_LIMIT_EXCEEDED upper-cased and expired_token lower-cased) and goes on the code alone: OVERLOAD_LIMIT and QUERY_LIMIT_EXCEEDED both arrive with HTTP 503, so a status-based check would confuse a manual block with a rate limit. To read the code back, b24.CodeOf(err).

The ready sentinel errors: ErrQueryLimitExceeded, ErrOperationTimeLimit, ErrExpiredToken, ErrInvalidToken, ErrInvalidGrant, ErrInsufficientScope, ErrMethodNotFound, ErrAccessDenied, ErrPaymentRequired. The Code* constants name the same codes, and b24.Code(...) covers everything else — the portal ships new codes without warning, so the set is deliberately open.

REST 3.0 errors

v3's response has a different shape — the code and the text sit in a nested object ({"error":{"code":…,"message":…}}) rather than flat — but none of that shows from the outside: *APIError is filled in from both shapes, and errors.Is and CodeOf work as before. Parsing goes by the shape of the body, not by the version of the URL, because a v3 URL answers with both: gateway errors (QUERY_LIMIT_EXCEEDED among them, which is what retries rest on) arrive in v1's flat format on v3 as well.

The versions' codes are different, and one of them the SDK folds onto the old one — the one that means the same thing in both versions:

// on a v3 URL this is true; the code on the wire is
// BITRIX_REST_V3_EXCEPTION_METHODNOTFOUNDEXCEPTION
errors.Is(err, b24.ErrMethodNotFound)

The rest are not folded, and that is not an omission. BITRIX_REST_V3_EXCEPTION_ACCESSDENIEDEXCEPTION looks like ACCESS_DENIED, but the measurement showed that v3 answers with it on a wrong webhook token too, where v1 answers INVALID_CREDENTIALS: one v3 code covers two v1 codes. Folding them means making the "the rights are wrong, the credentials are fine" branch fire on dead credentials. So such cases get sentinels of their own — ErrV3Validation, ErrV3EntityNotFound, ErrV3AccessDenied — and the CodeV3* constants; any code not on the list is still reached through b24.Code(...).

The BITRIX_REST_V3_EXCEPTION_ prefix is not universal — do not derive a code from it. Measured: crm.deal.timeline.activity.email.list on a bad id answers CRM_EMAIL_INVALID_REQUEST, in a v3 envelope and with no prefix at all.

CodeOf returns the code as it arrived, untranslated: it goes into a log, and a foreign code there would send the reader hunting for a string the portal never sent. For branching, errors.Is; for the log, CodeOf.

v3's validation errors carry something v1 has no equivalent for: the list of fields the request was rejected over. The code and the text of every such error are equally generic, so without that list there is no telling what exactly is wrong:

var apiErr *b24.APIError
if errors.As(err, &apiErr) {
	for _, v := range apiErr.Validation {
		log.Printf("field %s: %s", v.Field, v.Message)
		// field id: the required field `id` is missing
	}
}
Retries: the question is not "was it transient", it is "did it execute"
  • QUERY_LIMIT_EXCEEDED (HTTP 503) is the rate limiter refusing the call before the portal ran it. A replay cannot duplicate anything, so the SDK always retries such a request, whatever the method.
  • A network failure, a timeout, an unreadable body, a 5xx with no error code are ambiguous: the request may have reached the portal and executed. By default they are not retried, because replaying crm.deal.add would create a second deal.
  • If a call is safe to replay, say so explicitly:
res, err := client.Core().Call(ctx, "crm.deal.get",
	map[string]any{"id": 42}, b24.WithIdempotent())

Put WithIdempotent on reads (*.get, *.list, *.fields) and on writes that set fixed values. Do not put it on *.add, nor on an update whose new value is derived from the old one.

Pages/Scan retry a page themselves, and Call(ctx, "crm.deal.list", …) does not. The method is the same one, and the asymmetry here is deliberate.

A walk knows what it is doing by construction: a Pager can only re-ask the method it was built with, moving a cursor, and no set of options will make it write. Call has only the string it was handed — and reading intent out of that string is what the SDK will not do. The rule "*.list is a read" would be a guess about every method family Bitrix24 has already shipped and every one it will ship, applied silently, and on the side where being wrong means duplicates. So on your reading calls, put WithIdempotent there yourself.

A batch is never retried: it may hold commands that already committed.

New REST methods

Nothing has to be added. A method is called by name, so a new Bitrix24 method is available at once, without an SDK update:

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

The exact method names and their parameters are in the official REST documentation.

The development rules are in CONTRIBUTING.md.

License

MIT, see 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. 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

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)

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

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.

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

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

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

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.

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

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

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

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

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.

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

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.

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

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.

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

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

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.

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

func CodeOf(err error) (ErrorCode, bool)

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

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 Kind added in v0.2.0

type Kind uint8

Kind names the JSON shape of a value.

const (
	KindInvalid Kind = iota
	KindNull
	KindBool
	KindNumber
	KindString
	KindArray
	KindObject
)

The JSON shapes, plus KindInvalid for a value that is not JSON at all.

func (Kind) String added in v0.2.0

func (k Kind) String() string

String renders a Kind for an error message.

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

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

func (p *Pager) Count() int

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

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.

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

func (p *Pager) Take(ctx context.Context, n int) ([]json.RawMessage, error)

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
}

type Params added in v0.2.0

type Params = map[string]any

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

func (r Result) Kind() Kind

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

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.

type ValidationError added in v0.2.0

type ValidationError struct {
	Field   string
	Message string
}

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.

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