Documentation
¶
Overview ¶
Package b24gosdk is a client for the Bitrix24 REST API.
There are two ways to authorize REST calls, and the SDK covers both.
An inbound webhook carries its secret in the URL and needs no further setup:
client := b24gosdk.NewClient(webhookURL)
res, err := client.Core().Call(ctx, "crm.deal.add", map[string]any{
"fields": map[string]any{"TITLE": "New Deal"},
})
An application authorizes over OAuth 2.0 and calls REST with an access token that lives about an hour. Tokens reach an application in two ways: with the POST data of an application page, parsed by ParseAppRequest, and with the install event, parsed by ParseOnAppInstallRequest. Both carry a refresh token, which is what keeps an application working afterwards:
req, err := b24gosdk.ParseAppRequest(r) client, err := b24gosdk.NewClientFromAppRequest(req)
Pass WithTokenRefresher to have the SDK renew an expired token on its own; oauth.Refresher implements renewal including the rotation of refresh tokens.
Every REST method is called the same way, by name, through Core. The SDK ships no per-method wrappers: a method Bitrix24 released today is callable today, and nothing has to be regenerated when the API grows.
res, err := client.Core().Call(ctx, "crm.deal.list", params)
Call returns the pagination metadata of list methods as well; CallJSON returns just the result, and CallMultipart uploads files.
Up to 50 calls travel in one request, for one rate-limit token instead of fifty, through Batch and CallBatch; Ref feeds one command's result into the parameters of the next. CallBatchChunked splits many INDEPENDENT commands at the server's limit — a $result reference cannot cross that boundary.
b := b24gosdk.NewBatch()
b.AddAs("c", "crm.contact.add", map[string]any{"fields": fields})
res, err := client.Core().CallBatch(ctx, b)
Errors reported by the API are returned as *APIError, so a specific code can be matched with errors.As. Rate limit errors are retried by the SDK itself.
Index ¶
- Constants
- Variables
- func Code(c ErrorCode) error
- func IsEmpty(raw json.RawMessage) bool
- func Keys(raw json.RawMessage) ([]string, bool)
- func MultifieldAdd(value, valueType string) map[string]any
- func MultifieldDelete(id ID) map[string]any
- func MultifieldSet(id ID, value string) map[string]any
- func Ref(id CmdID, path ...string) (string, error)
- func Unwrap(raw json.RawMessage, path ...string) (json.RawMessage, bool)
- func UnwrapFold(raw json.RawMessage, path ...string) (json.RawMessage, bool)
- type APIError
- type AppAuth
- type AppRequest
- type Batch
- type BatchError
- type BatchResult
- type CallOption
- type CallResult
- type Client
- type Cmd
- type CmdID
- type Core
- func (c *Core) AccessToken() string
- func (c *Core) BaseURL() string
- func (c *Core) Call(ctx context.Context, method string, params any, opts ...CallOption) (*CallResult, error)
- func (c *Core) CallBatch(ctx context.Context, b *Batch) (*BatchResult, error)
- func (c *Core) CallBatchChunked(ctx context.Context, b *Batch) (*BatchResult, error)
- func (c *Core) CallJSON(ctx context.Context, method string, params any, opts ...CallOption) (json.RawMessage, error)
- func (c *Core) CallMultipart(ctx context.Context, method string, params map[string]string, ...) (json.RawMessage, error)
- func (c *Core) Pages(method string, params any, opts ...PageOption) (*Pager, error)
- func (c *Core) Scan(method string, params any, opts ...PageOption) (*Pager, error)
- func (c *Core) SetAccessToken(token string)
- type ErrorCode
- type ID
- type OnAppInstallData
- type OnAppInstallEvent
- type OnAppUninstallData
- type OnAppUninstallEvent
- type Option
- type PageOption
- type Pager
- type RetryConfig
- type TokenRefresher
Examples ¶
Constants ¶
const DefaultPageSize = 50
DefaultPageSize is how many rows a list method returns per page. It is fixed by the server, not by the SDK: asking for a different size does nothing.
const MaxBatchCommands = 50
MaxBatchCommands is the number of commands the server executes in one batch.
The limit is not left to the server to enforce: it answers an over-long batch with a per-command ERROR_BATCH_LENGTH_EXCEEDED for each surplus command, so the batch comes back looking like a partial success instead of a rejected call.
Variables ¶
var ( ErrQueryLimitExceeded = Code(CodeQueryLimitExceeded) ErrOperationTimeLimit = Code(CodeOperationTimeLimit) ErrExpiredToken = Code(CodeExpiredToken) ErrInvalidToken = Code(CodeInvalidToken) ErrInvalidGrant = Code(CodeInvalidGrant) ErrInsufficientScope = Code(CodeInsufficientScope) ErrMethodNotFound = Code(CodeMethodNotFound) ErrAccessDenied = Code(CodeAccessDenied) ErrPaymentRequired = Code(CodePaymentRequired) )
Frequently matched codes as ready sentinels.
var ErrBadRef = errors.New("b24gosdk: bad $result reference")
ErrBadRef is returned by Ref for an id or path segment the server cannot read back. Every Ref error wraps it.
var ErrBatchLengthExceeded = errors.New("b24gosdk: batch is longer than the server accepts")
ErrBatchLengthExceeded is returned when a batch holds more than MaxBatchCommands commands. Split it with Chunks, or use CallBatchChunked.
var ErrCursorStalled = errors.New("b24gosdk: the cursor did not move")
ErrCursorStalled is returned when a walk asks for the next page and the server answers with the same page again.
It matters because the failure is otherwise SILENT: the method answers a valid success forever, and the loop spends the customer's rate limit until something else breaks. The two known causes are a method that consumes a differently named cursor (see WithCursorParam) and a Scan over a method that ignores the generated id filter.
var ErrNoRows = errors.New("b24gosdk: no row array in result")
ErrNoRows is returned when a page carries no row array where one was expected.
Functions ¶
func Code ¶
Code returns a sentinel error that errors.Is matches against any error carrying that code:
if errors.Is(err, b24gosdk.ErrMethodNotFound) { … }
if errors.Is(err, b24gosdk.Code("CREATE_DYNAMIC_TYPE_RESTRICTED")) { … }
Why not compare the string ¶
apiErr.Code == "ERROR_METHOD_NOT_FUND" compiles, runs, and quietly takes the wrong branch forever. Going through Code means the comparison is case-insensitive and the well-known codes have a named constant the compiler checks. A code the SDK has never heard of still works — the argument is a plain string type.
func IsEmpty ¶
func IsEmpty(raw json.RawMessage) bool
IsEmpty reports whether a JSON value carries nothing usable.
Bitrix24 spells "this field is empty" in at least five ways, and which one you get depends on the method and on the field's type: an unset user field comes back as null, as "", or as false, and an empty collection comes back as [] or {}. Checking each shape by hand takes five comparisons at every call site, and missing one produces a decode error on data that is simply absent.
IsEmpty is true for: no value at all, null, "", false, [] and {}. It is false for 0 — a numeric zero is a value, not an absence.
func Keys ¶
func Keys(raw json.RawMessage) ([]string, bool)
Keys lists the field names of a JSON object, in no particular order.
It exists for the position Unwrap's exact matching can leave you in: the field is not spelled the way the request spelled it, and you need to see what the portal actually returned. ok is false when the value is not a JSON object.
func MultifieldAdd ¶
MultifieldAdd builds a crm_multifield row that ADDS a new value.
valueType is the subtype Bitrix24 shows next to the value — "WORK", "MOBILE", "HOME" for phones, "WORK", "HOME" for emails. An empty valueType is omitted, which lets the portal apply its own default.
client.Core().Call(ctx, "crm.contact.update", map[string]any{
"id": contactID,
"fields": map[string]any{
"PHONE": []map[string]any{
b24gosdk.MultifieldAdd("+7 900 000-00-00", "MOBILE"),
},
},
})
Because a row without an ID always adds, calling this for a value that already exists creates a duplicate. Use MultifieldSet with the existing row's ID to change a value in place.
Example ¶
Phones and emails: which keys a row carries decides what the server does, and rows you do not mention are left alone.
package main
import (
"context"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
var existingRowID b24.ID = 55
_, err := client.Core().Call(context.Background(), "crm.contact.update", map[string]any{
"id": 42,
"fields": map[string]any{
"PHONE": []map[string]any{
b24.MultifieldAdd("+7 900 000-00-00", "MOBILE"), // no ID -> adds
b24.MultifieldSet(existingRowID, "+7 900 111-11-11"),
b24.MultifieldDelete(existingRowID),
},
},
})
if err != nil {
log.Fatal(err)
}
}
Output:
func MultifieldDelete ¶
MultifieldDelete builds a crm_multifield row that REMOVES an existing value.
Deleting has to be explicit: rows absent from the list are preserved, so a shorter list does not delete anything.
The portal accepts two spellings — {ID, "DELETE": "Y"} and {ID, "VALUE": ""} — and both were confirmed against a live portal. This constructor emits the explicit DELETE form, because an empty VALUE is indistinguishable from a caller who meant to blank the field and reads as a bug at the call site.
func MultifieldSet ¶
MultifieldSet builds a crm_multifield row that CHANGES an existing value.
id is the row's ID as the portal returned it — read it back from the entity (crm.contact.get answers PHONE as a list of rows carrying ID), never guess it. An id that does not belong to the entity is ignored silently.
func Ref ¶
Ref builds a placeholder the server substitutes with an earlier command's result:
b24gosdk.Ref("get_user", "ID") // "$result[get_user][ID]"
b24gosdk.Ref("new_contact") // "$result[new_contact]" — no path
Pass NO path segments when the referenced command's result IS the value you want — crm.contact.add answers with a bare id, so Ref("new_contact") is right and Ref("new_contact", "ID") is not.
The placeholder is an ORDINARY parameter value: do not pre-escape it. The server runs parse_str BEFORE substituting, so an encoded placeholder is decoded back before the substitution regex sees it.
What the server does that Ref cannot prevent ¶
- A missing path segment does NOT error. The walk stops at the last resolved ancestor and substitutes THAT, so a typo injects a whole object where a scalar was meant.
- The placeholder is terminated by WHITESPACE and the replacement eats the whole match, so a SUFFIX is destroyed: "X" + Ref(...) + "-Y" loses the "-Y". A PREFIX survives, because the match begins at the '$' — which is the form Bitrix24's own documentation uses for file fields, where the disk object id wants an "n" in front ("n" + Ref(...) -> n2107).
- Substitution also runs on parameter KEYS, where it resolves to nothing. Placeholders belong in values.
Ref refuses an id or segment containing whitespace, '$', '[' or ']', and returns the error rather than a placeholder the server would misread: a malformed placeholder does not fail server-side, it silently substitutes the wrong value. Every error wraps ErrBadRef.
func Unwrap ¶
func Unwrap(raw json.RawMessage, path ...string) (json.RawMessage, bool)
Unwrap returns the value at path inside a JSON object result.
Why this exists ¶
Many methods wrap their payload in a single-key object: tasks.* answers {"task": {...}}, disk.* answers {"file": {...}}, catalog.* answers {"products": [...]}, crm.item.add answers {"item": {...}}. Without Unwrap every such call needs a throwaway struct whose only job is to hold that one field — a cost that showed up in five of the twelve tutorials written against this SDK.
res, err := client.Core().Call(ctx, "tasks.task.get", map[string]any{"taskId": 42})
raw, ok := b24gosdk.Unwrap(res.Result, "task")
if !ok {
return fmt.Errorf("no task in result")
}
var t MyTask
err = json.Unmarshal(raw, &t)
Several keys walk deeper: Unwrap(res.Result, "task", "creator", "id"). Zero keys return the input unchanged.
ok is false when a segment is missing or is reached on a value that is not a JSON object. Keys match EXACTLY: Bitrix24 renames fields between request and response (select takes UF_TASK_WEBDAV_FILES, the answer carries ufTaskWebdavFiles), and a fuzzy default would turn such a rename into a silent nil instead of a visible miss. Use UnwrapFold when the spelling differs, or Keys to see what actually came back.
Example ¶
Reading a result: unwrap the single-key envelope many methods use, and cope with the five ways Bitrix24 spells "empty".
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
res, err := client.Core().Call(context.Background(), "tasks.task.get", map[string]any{"taskId": 42})
if err != nil {
log.Fatal(err)
}
raw, ok := b24.Unwrap(res.Result, "task")
if !ok {
// Exact match missed — see what the portal actually returned.
keys, _ := b24.Keys(res.Result)
log.Fatalf("no task in result; keys: %v", keys)
}
var task struct {
ID b24.ID `json:"id"`
Title string `json:"title"`
}
if err := json.Unmarshal(raw, &task); err != nil {
log.Fatal(err)
}
// select takes UF_TASK_WEBDAV_FILES, the answer carries ufTaskWebdavFiles.
files, _ := b24.UnwrapFold(raw, "UF_TASK_WEBDAV_FILES")
if b24.IsEmpty(files) {
fmt.Println("no files attached")
}
}
Output:
func UnwrapFold ¶
func UnwrapFold(raw json.RawMessage, path ...string) (json.RawMessage, bool)
UnwrapFold is Unwrap for the case where the portal renamed the field: it matches ignoring ASCII case AND underscores, so UF_TASK_WEBDAV_FILES finds ufTaskWebdavFiles.
Matching loosely is right for that case and wrong as a default, because a loose match can quietly pick a neighbouring field — so it is opt-in and named so the looseness is visible at the call site. An exact match always wins.
If two different keys normalise to the same string, UnwrapFold reports NOT FOUND rather than choosing one: an ambiguous match is exactly where a silent wrong value would come from. Use Keys and Unwrap to disambiguate.
Types ¶
type APIError ¶
APIError represents an error returned by the Bitrix24 REST API.
Example ¶
Reacting to an API error by code.
package main
import (
"context"
"errors"
"fmt"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
_, err := client.Core().Call(context.Background(), "crm.deal.list", nil)
var apiErr *b24.APIError
if errors.As(err, &apiErr) {
fmt.Println(apiErr.Code, apiErr.Description, apiErr.HTTPStatus)
}
}
Output:
func (*APIError) Is ¶
Is lets errors.Is match an *APIError against a Code sentinel.
Matching is on the CODE ALONE, never on the HTTP status: OVERLOAD_LIMIT and QUERY_LIMIT_EXCEEDED both arrive as 503, so a status-based match would treat a manual block as a rate limit and retry something that must not be retried.
type AppAuth ¶
type AppAuth struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn string `json:"expires_in"`
Expires string `json:"expires"`
Scope string `json:"scope"`
Domain string `json:"domain"`
ServerEndpoint string `json:"server_endpoint"`
ClientEndpoint string `json:"client_endpoint"`
MemberID string `json:"member_id"`
UserID string `json:"user_id"`
ApplicationToken string `json:"application_token"`
Status string `json:"status"`
}
AppAuth contains auth data provided in app events.
All values are strings: Bitrix24 delivers events as application/x-www-form-urlencoded, where every value is textual. RefreshToken is present in ONAPPINSTALL and is normally absent in other events.
func (*AppAuth) VerifyApplicationToken ¶
VerifyApplicationToken reports whether the application_token of an incoming event matches the one saved during installation. Comparison is constant-time.
Empty values never match: an event carrying no token must not pass verification against an unsaved token.
type AppRequest ¶
type AppRequest struct {
Domain string // DOMAIN: portal domain, host or host:port
Protocol string // PROTOCOL: "0" for http, "1" for https
Lang string // LANG
AppSID string // APP_SID
AuthID string // AUTH_ID: access token
AuthExpires string // AUTH_EXPIRES: access token lifetime in seconds
RefreshID string // REFRESH_ID: refresh token
MemberID string // member_id
Status string // status
// Fields below are sent by the portal in addition to the documented set.
ServerEndpoint string // SERVER_ENDPOINT: REST endpoint of the auth server
ApplicationToken string // APPLICATION_TOKEN: token identifying events of this install
ApplicationScope string // APPLICATION_SCOPE: granted scopes, comma separated
Placement string // PLACEMENT: where the app was opened
PlacementOptions string // PLACEMENT_OPTIONS: raw JSON with placement details
}
AppRequest contains data Bitrix24 sends to an application page: both to the regular app page and to the installation page shown during setup.
All values are strings, as delivered in the request. AuthID is the access token, RefreshID is the refresh token — store both to keep working with REST after the access token expires.
func ParseAppRequest ¶
func ParseAppRequest(r *http.Request) (*AppRequest, error)
ParseAppRequest parses application data from an incoming HTTP request.
The portal splits the data: DOMAIN, PROTOCOL, LANG and APP_SID arrive in the query string, while the tokens and the remaining fields arrive in the body. Credentials are therefore accepted from the body only — a token supplied in the query string is ignored, because URLs end up in server logs.
The body is limited to 1 MiB.
Example ¶
A page opened inside Bitrix24 receives its tokens in the POST body.
package main
import (
"net/http"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
http.HandleFunc("/b24/app", func(w http.ResponseWriter, r *http.Request) {
req, err := b24.ParseAppRequest(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
client, err := b24.NewClientFromAppRequest(req)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if _, err := client.Core().Call(r.Context(), "user.current", nil); err != nil {
http.Error(w, "upstream", http.StatusBadGateway)
return
}
})
}
Output:
func ParseAppRequestForm ¶
func ParseAppRequestForm(values url.Values) (*AppRequest, error)
ParseAppRequestForm parses application POST data from form values.
DOMAIN and AUTH_ID are required: without them no REST client can be built. DOMAIN is additionally checked to be a plain host, so that a forged request cannot turn it into an arbitrary URL.
func (*AppRequest) ClientEndpoint ¶
func (r *AppRequest) ClientEndpoint() string
ClientEndpoint returns the REST endpoint of the portal the request came from.
The scheme is always https: REST requires it, and a plain http call is rejected by Bitrix24 with INVALID_REQUEST. The PROTOCOL field is therefore parsed and kept for reference, but does not affect the endpoint.
type Batch ¶
type Batch struct {
// Halt stops the batch at the first command that fails.
//
// A CHAINED batch — one where a later command references an earlier result
// through Ref — SHOULD set it. When a producer fails, its $result
// placeholder is not an error: the server substitutes the unresolved text as
// a LITERAL value, and the consumer runs with a corrupted parameter instead
// of not running at all.
Halt bool
// contains filtered or unexported fields
}
Batch is a set of REST calls executed by the server in one request.
One batch costs ONE rate-limit token instead of one per command, which is the whole reason it exists: fifty creates through Call spend fifty tokens and take fifty round trips.
Commands run in submission order, so a later command can reference an earlier one's result through Ref.
func (*Batch) Add ¶
Add appends a command and returns the generated id to reference it by.
params may be nil for a method that takes none.
func (*Batch) AddAs ¶
AddAs appends a command under an id you choose, which is what a readable $result chain wants: Ref("user", "ID") beats Ref("cmd1", "ID").
func (*Batch) Chunks ¶
Chunks splits the batch into pieces the server accepts.
It is for INDEPENDENT commands only. A $result reference cannot cross a chunk boundary — each chunk is a separate request with its own result namespace — so splitting a chained batch produces placeholders that resolve to nothing.
type BatchError ¶
type BatchError struct {
Failed map[CmdID]*APIError
Result *BatchResult
}
BatchError reports that at least one command in the batch failed.
The batch itself succeeded at the transport level — the server answers HTTP 200 and puts per-command failures in result_error — so the result is returned ALONGSIDE this error and holds everything that did succeed. Do not discard it: retrying the whole batch would re-run the commands that already committed.
func (*BatchError) Error ¶
func (e *BatchError) Error() string
func (*BatchError) Unwrap ¶
func (e *BatchError) Unwrap() []error
Unwrap exposes the per-command errors to errors.Is and errors.As, so a caller can ask whether any command hit a particular API code.
type BatchResult ¶
type BatchResult struct {
// Order lists the command ids in submission order.
Order []CmdID
// Results holds the payload of each command that ran.
Results map[CmdID]json.RawMessage
// Errors holds the failure of each command that failed.
Errors map[CmdID]*APIError
// Next and Total carry the pagination metadata of list commands. The server
// lifts them OUT of each command's result into their own sections.
Next map[CmdID]int
Total map[CmdID]int
// contains filtered or unexported fields
}
BatchResult holds one result per command.
func (*BatchResult) Executed ¶
func (r *BatchResult) Executed(id CmdID) bool
Executed reports whether the command ran, whatever the outcome.
func (*BatchResult) Get ¶
func (r *BatchResult) Get(id CmdID) (json.RawMessage, error)
Get returns one command's result, or the error that command failed with.
A command that never ran — because halt stopped the batch before it — yields an error saying so, which is a different thing from a command that ran and failed. Executed tells them apart without an error value.
type CallOption ¶
type CallOption func(*callConfig)
CallOption configures a single call.
func WithIdempotent ¶
func WithIdempotent() CallOption
WithIdempotent declares that repeating this call cannot change the outcome, so the SDK may retry it after an AMBIGUOUS failure — a dial error, a timeout, an unreadable body, a 5xx with no error code — where it otherwise gives up.
Why this is opt-in ¶
After a network failure the SDK cannot tell whether the portal ran the call. Replaying crm.deal.add there would create a second deal, so by default an ambiguous failure is returned rather than retried, and only the provably-not-executed case (QUERY_LIMIT_EXCEEDED, which is the rate limiter refusing the call before it ran) is repeated. A universal caller cannot know which methods are idempotent — the method is a string — so it does not guess.
Pass it for reads (*.get, *.list, *.fields) and for writes that are safe to repeat, such as an update that sets fields to fixed values. Do NOT pass it for *.add, and not for an update whose new value is derived from the old one.
res, err := client.Core().Call(ctx, "crm.deal.get",
map[string]any{"id": 42}, b24gosdk.WithIdempotent())
Pages and Scan already walk idempotently: a list walk is a read, and without it a single dropped connection at page 40 would abandon a 200-page scan.
type CallResult ¶
type CallResult struct {
Result json.RawMessage
Next *int
Total *int
Time json.RawMessage
}
CallResult is a REST API response together with its pagination metadata.
Next and Total are set by list methods: Total is the number of records matching the query, Next is the offset to pass to the following call. Both are nil when the response does not carry them.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is the main SDK entry point.
Every REST method is reached through Core: the SDK ships no per-method wrappers, so a method Bitrix24 released today is callable today, without waiting for the SDK to catch up.
res, err := client.Core().Call(ctx, "crm.deal.add", map[string]any{
"fields": map[string]any{"TITLE": "New Deal"},
})
func NewClient ¶
NewClient creates a client for inbound webhook URL.
The webhook secret is part of the URL, so no token handling is involved.
func NewClientFromAppRequest ¶
func NewClientFromAppRequest(req *AppRequest, opts ...Option) (*Client, error)
NewClientFromAppRequest creates a client authorized by the access token of an application request.
Note that the access token lives about an hour. For long running work store AuthID and RefreshID and renew the token when it expires.
func NewOAuthClient ¶
NewOAuthClient creates a client for OAuth access token and client endpoint.
The endpoint is the client_endpoint of the portal, for example https://portal.bitrix24.com/rest/. Since an access token expires in about an hour, pass WithTokenRefresher for long running work.
func (*Client) Core ¶
Core returns the REST caller every method goes through.
Example ¶
The universal call: any REST method by name, through an inbound webhook. The webhook URL's path is the secret, so it comes from the environment.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
res, err := client.Core().Call(context.Background(), "crm.deal.add", map[string]any{
"fields": map[string]any{"TITLE": "New deal"},
})
if err != nil {
log.Fatal(err)
}
// An identifier arrives as a number in some families and a quoted number in
// others; b24.ID decodes both.
var dealID b24.ID
if err := json.Unmarshal(res.Result, &dealID); err != nil {
log.Fatal(err)
}
fmt.Println("created deal", dealID)
}
Output:
func (*Client) SetAccessToken ¶
SetAccessToken updates OAuth access token used by the client.
It is safe to call while requests are in flight. With WithTokenRefresher enabled the SDK keeps the token up to date on its own.
type CmdID ¶
type CmdID string
CmdID identifies one command inside a batch. It is the key the results come back under and the name a $result placeholder refers to.
type Core ¶
type Core struct {
// contains filtered or unexported fields
}
Core provides low-level REST calls.
func (*Core) AccessToken ¶
AccessToken returns the OAuth access token currently used in requests.
func (*Core) Call ¶
func (c *Core) Call(ctx context.Context, method string, params any, opts ...CallOption) (*CallResult, error)
Call calls a REST method with JSON parameters and returns the full response, including the pagination metadata of list methods.
An ambiguous network failure is NOT retried unless the call is marked with WithIdempotent; see that option for why.
Example (File) ¶
A file rides as base64 inside the JSON params — no multipart needed.
package main
import (
"context"
"encoding/base64"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
content := []byte("report body")
_, err := client.Core().Call(context.Background(), "disk.folder.uploadfile", map[string]any{
"id": 123,
"data": map[string]any{"NAME": "report.txt"},
"fileContent": []string{"report.txt", base64.StdEncoding.EncodeToString(content)},
})
if err != nil {
log.Fatal(err)
}
}
Output:
func (*Core) CallBatch ¶
CallBatch runs the batch in ONE request.
It does not split: a batch longer than MaxBatchCommands is refused with ErrBatchLengthExceeded rather than sent, because the server would answer with what looks like a partial success. Use CallBatchChunked for many independent commands.
err is non-nil when the call itself failed, AND when any command failed — in the latter case as a *BatchError, with the result still returned so the commands that succeeded are not lost.
Example ¶
A chained batch: create a contact, then comment on it in the same request.
package main
import (
"context"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
b := b24.NewBatch()
// A chain MUST halt: a failed producer's $result is substituted as a literal
// string rather than erroring, so the consumer would run with a corrupted
// parameter.
b.Halt = true
if err := b.AddAs("c", "crm.contact.add", map[string]any{
"fields": map[string]any{"NAME": "Ann"},
}); err != nil {
log.Fatal(err)
}
// No path segments: crm.contact.add answers with the bare id.
ref, err := b24.Ref("c")
if err != nil {
log.Fatal(err)
}
if err := b.AddAs("note", "crm.timeline.comment.add", map[string]any{
"fields": map[string]any{"ENTITY_ID": ref, "ENTITY_TYPE": "contact"},
}); err != nil {
log.Fatal(err)
}
res, err := client.Core().CallBatch(context.Background(), b)
if err != nil {
log.Fatal(err)
}
raw, err := res.Get("c")
if err != nil {
log.Fatal(err)
}
fmt.Println("new contact:", string(raw))
}
Output:
func (*Core) CallBatchChunked ¶
CallBatchChunked splits the batch at MaxBatchCommands, runs the pieces in order and merges the results.
For INDEPENDENT commands only: a $result reference cannot cross a chunk boundary. For a chained batch use CallBatch, which keeps everything in one request.
It returns what has been collected so far even when a chunk fails outright — earlier chunks have already committed on the portal, and a caller that drops the result has no way to know what exists.
Example ¶
Many independent commands: this is the one that chunks. A batch error is PARTIAL, so the result must not be thrown away with it.
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
b := b24.NewBatch()
for _, name := range []string{"Ann", "Bob", "Cid"} {
if _, err := b.Add("crm.contact.add", map[string]any{
"fields": map[string]any{"NAME": name},
}); err != nil {
log.Fatal(err)
}
}
res, err := client.Core().CallBatchChunked(context.Background(), b)
var be *b24.BatchError
if errors.As(err, &be) {
// be.Failed is what failed; res holds everything that succeeded.
// Rebuild a batch from be.Failed rather than replaying the whole thing.
for id := range be.Failed {
fmt.Println("failed:", id, "executed:", res.Executed(id))
}
} else if err != nil {
log.Fatal(err)
}
}
Output:
func (*Core) CallJSON ¶
func (c *Core) CallJSON(ctx context.Context, method string, params any, opts ...CallOption) (json.RawMessage, error)
CallJSON calls a REST method with JSON parameters and returns only the result.
Use Call when the pagination metadata of a list method is needed.
func (*Core) CallMultipart ¶
func (c *Core) CallMultipart(ctx context.Context, method string, params map[string]string, files map[string]io.Reader) (json.RawMessage, error)
CallMultipart calls a REST method with multipart form data (uploads).
func (*Core) Pages ¶
Pages walks a list method with the server's own cursor: it sends `start` and echoes back the `next` the server returned, until the server stops returning one.
This is the right walk for a few pages. For tens of thousands of rows use Scan: offset paging makes the server count past every skipped row, so the last pages of a large list get slower and slower.
Example ¶
Walking a list. Err after the loop is not optional: Next reports false both at the end of the list and on failure.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
p, err := client.Core().Pages("crm.deal.list", map[string]any{
"select": []any{"ID", "TITLE"},
})
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
for p.Next(ctx) {
for _, row := range p.Rows() {
var deal struct {
ID b24.ID `json:"ID"`
Title string `json:"TITLE"`
}
if err := json.Unmarshal(row, &deal); err != nil {
log.Fatal(err)
}
fmt.Println(deal.ID, deal.Title)
}
}
if err := p.Err(); err != nil {
log.Fatal(err)
}
}
Output:
func (*Core) Scan ¶
Scan walks a large list by ID instead of by offset.
It sends start=-1 (which turns OFF the server's row count), orders by id ascending and filters on the last id seen, so every page costs the same regardless of how deep it is. This is the method Bitrix24 documents for exporting big lists.
It requires a method that sorts and filters by an id field. Where the id is not spelled "ID" both ways, pass WithIDField.
Example ¶
A big export pages by id instead of by offset, so a deep page costs the same as the first.
package main
import (
"context"
"fmt"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))
p, err := client.Core().Scan("crm.deal.list", nil)
if err != nil {
log.Fatal(err)
}
for p.Next(context.Background()) {
fmt.Println("rows so far:", p.Count())
}
if err := p.Err(); err != nil {
log.Fatal(err)
}
}
Output:
func (*Core) SetAccessToken ¶
SetAccessToken updates OAuth access token used in requests.
type ErrorCode ¶
type ErrorCode string
ErrorCode is the machine-readable code Bitrix24 puts in an error body.
It is a string type rather than an enum on purpose: the portal ships new codes without warning, and a closed set would make an unknown one unexpressible. The constants below are spelling aids for the ones met most often, not a complete list — b24gosdk.Code("SOME_NEW_CODE") is equally valid.
const ( // Rate and resource limits. CodeQueryLimitExceeded ErrorCode = "QUERY_LIMIT_EXCEEDED" CodeOperationTimeLimit ErrorCode = "OPERATION_TIME_LIMIT" CodeOverloadLimit ErrorCode = "OVERLOAD_LIMIT" // Authorization. CodeExpiredToken ErrorCode = "expired_token" CodeInvalidToken ErrorCode = "invalid_token" CodeInvalidGrant ErrorCode = "invalid_grant" CodeNoAuthFound ErrorCode = "NO_AUTH_FOUND" CodeInsufficientScope ErrorCode = "insufficient_scope" // Method and rights. CodeMethodNotFound ErrorCode = "ERROR_METHOD_NOT_FOUND" CodeAccessDenied ErrorCode = "ACCESS_DENIED" CodePaymentRequired ErrorCode = "PAYMENT_REQUIRED" // Batch. CodeBatchLengthExceeded ErrorCode = "ERROR_BATCH_LENGTH_EXCEEDED" CodeBatchMethodNotAllow ErrorCode = "ERROR_BATCH_METHOD_NOT_ALLOWED" )
Codes seen most often. The list is deliberately short: it covers what a caller routinely branches on, not everything the portal can answer.
func CodeOf ¶
CodeOf reports the Bitrix24 error code an error carries, if any.
ok is false for an error that is not an *APIError, and for an *APIError whose body carried no code — which happens when a proxy answers instead of the portal. Compare with the normalized constants, or use errors.Is with Code.
func (ErrorCode) Normalize ¶
Normalize folds a code to a canonical form for comparison.
Bitrix24 is not consistent about case: REST errors come upper-cased (QUERY_LIMIT_EXCEEDED) while the OAuth ones come lower-cased (expired_token), and the same code has been seen both ways in the documentation. Comparing normalized forms means a caller never has to guess which spelling arrived.
type ID ¶
type ID int64
ID is an entity identifier that decodes from a JSON number OR a JSON string.
Why this type exists ¶
Bitrix24 is not consistent about the wire type of an identifier, and the inconsistency appears INSIDE a single workflow, not only between distant method families: disk.* answers with "ID": 6687 (a number) while tasks.* answers with "id": "3711" (a string). Both are identifiers, both are read in the same handler, and encoding/json refuses to put a string into an int field or a number into a string field. Without this type every caller writes the same dozen-line UnmarshalJSON — which is what happened twice while writing tutorials against this SDK.
Declare the field as ID and the difference stops mattering:
var task struct {
ID b24gosdk.ID `json:"id"`
Title string `json:"title"`
}
err := json.Unmarshal(res.Result, &task)
It also decodes a result that IS the identifier, which is what the add methods answer:
var dealID b24gosdk.ID err := json.Unmarshal(res.Result, &dealID)
An empty string and null decode to 0: an unset identifier is a normal answer from Bitrix24, not a malformed one. ID marshals back as a JSON number, so a decoded value can be sent straight back as a parameter.
func (ID) IsZero ¶
IsZero reports whether the identifier is unset. Bitrix24 spells "unset" as 0, "" or null depending on the method; all three decode to a zero ID.
func (ID) MarshalJSON ¶
MarshalJSON emits the identifier as a JSON number.
func (*ID) UnmarshalJSON ¶
UnmarshalJSON accepts a JSON number, a quoted number, an empty string or null. Anything else is an error: silently zeroing a value we do not understand would hide a changed API behind a plausible-looking 0.
type OnAppInstallData ¶
type OnAppInstallData struct {
Version string `json:"VERSION"`
Active string `json:"ACTIVE"`
Installed string `json:"INSTALLED"`
LanguageID string `json:"LANGUAGE_ID"`
}
OnAppInstallData contains payload for ONAPPINSTALL.
type OnAppInstallEvent ¶
type OnAppInstallEvent struct {
Event string `json:"event"`
Data OnAppInstallData `json:"data"`
TS string `json:"ts"`
Auth AppAuth `json:"auth"`
// EventHandlerID identifies the handler registration the portal called.
EventHandlerID string `json:"event_handler_id"`
}
OnAppInstallEvent is sent after successful app installation.
func ParseOnAppInstall ¶
func ParseOnAppInstall(payload []byte) (*OnAppInstallEvent, error)
ParseOnAppInstall parses an ONAPPINSTALL event payload in JSON format.
Bitrix24 itself sends events as form data; use ParseOnAppInstallRequest for incoming HTTP requests and this function for payloads already stored as JSON.
func ParseOnAppInstallForm ¶
func ParseOnAppInstallForm(values url.Values) (*OnAppInstallEvent, error)
ParseOnAppInstallForm parses an ONAPPINSTALL event from form values with PHP-style keys, for example auth[access_token].
func ParseOnAppInstallRequest ¶
func ParseOnAppInstallRequest(r *http.Request) (*OnAppInstallEvent, error)
ParseOnAppInstallRequest parses an ONAPPINSTALL event from an incoming HTTP request, choosing form or JSON parsing by Content-Type.
The request body is limited to 1 MiB. Parsing does not authenticate the request: verify AppAuth.ApplicationToken against the saved value with AppAuth.VerifyApplicationToken.
Example ¶
The install event is the only time the tokens arrive — persist them, and verify the event before acting on it.
package main
import (
"net/http"
b24 "github.com/bitrix24/b24gosdk"
)
func main() {
http.HandleFunc("/b24/install", func(w http.ResponseWriter, r *http.Request) {
ev, err := b24.ParseOnAppInstallRequest(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
storeTokens(ev.Auth.AccessToken, ev.Auth.RefreshToken)
storeApplicationToken(ev.Auth.MemberID, ev.Auth.ApplicationToken)
})
// Every later event carries the same application_token; compare it against
// what the install stored, in constant time.
http.HandleFunc("/b24/events", func(w http.ResponseWriter, r *http.Request) {
ev, err := b24.ParseOnAppUninstallRequest(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !ev.Auth.VerifyApplicationToken(savedApplicationToken(ev.Auth.MemberID)) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
})
}
func storeTokens(access, refresh string) {}
func storeApplicationToken(memberID, token string) {}
func savedApplicationToken(memberID string) string { return "" }
Output:
type OnAppUninstallData ¶
OnAppUninstallData contains payload for ONAPPUNINSTALL.
type OnAppUninstallEvent ¶
type OnAppUninstallEvent struct {
Event string `json:"event"`
Data OnAppUninstallData `json:"data"`
TS string `json:"ts"`
Auth AppAuth `json:"auth"`
}
OnAppUninstallEvent is sent when app is removed.
func ParseOnAppUninstall ¶
func ParseOnAppUninstall(payload []byte) (*OnAppUninstallEvent, error)
ParseOnAppUninstall parses an ONAPPUNINSTALL event payload in JSON format.
See ParseOnAppInstall on choosing between JSON and form parsing.
func ParseOnAppUninstallForm ¶
func ParseOnAppUninstallForm(values url.Values) (*OnAppUninstallEvent, error)
ParseOnAppUninstallForm parses an ONAPPUNINSTALL event from form values with PHP-style keys, for example auth[application_token].
func ParseOnAppUninstallRequest ¶
func ParseOnAppUninstallRequest(r *http.Request) (*OnAppUninstallEvent, error)
ParseOnAppUninstallRequest parses an ONAPPUNINSTALL event from an incoming HTTP request. See ParseOnAppInstallRequest for details.
type Option ¶
type Option func(*coreOptions)
Option configures SDK behavior.
func WithAccessToken ¶
WithAccessToken injects OAuth access token into REST calls.
func WithHTTPClient ¶
WithHTTPClient sets a custom HTTP client.
func WithRetry ¶
func WithRetry(cfg RetryConfig) Option
WithRetry sets retry policy for rate limit errors.
func WithTokenRefresher ¶
func WithTokenRefresher(refresh TokenRefresher) Option
WithTokenRefresher enables automatic renewal of an expired access token.
When a call fails with expired_token, the SDK calls refresh once, stores the returned token and repeats the call a single time. Renewal is not scheduled on a timer: the auth server must only be contacted when a token actually turns out to be expired.
Concurrent calls that hit an expired token share one renewal, and a renewal that fails is not repeated for the same token, so that a broken authorization cannot flood the auth server.
Use oauth.Refresher for a ready-made implementation that keeps track of the rotating refresh token.
Example ¶
An OAuth application, with the SDK renewing the access token on its own.
package main
import (
"context"
"log"
"os"
b24 "github.com/bitrix24/b24gosdk"
"github.com/bitrix24/b24gosdk/oauth"
)
func main() {
oauthClient := oauth.NewClient(os.Getenv("B24_CLIENT_ID"), os.Getenv("B24_CLIENT_SECRET"))
resp, err := oauthClient.ExchangeCode(context.Background(), "code-from-redirect")
if err != nil {
log.Fatal(err)
}
// The auth server rotates the refresh token on every renewal, so the new
// pair has to be persisted or the next restart starts from a dead token.
refresher := oauth.NewRefresher(oauthClient, resp.RefreshToken, func(t oauth.TokenResponse) {
storeTokens(t.AccessToken, t.RefreshToken)
})
client := b24.NewOAuthClient(resp.ClientEndpoint, resp.AccessToken,
b24.WithTokenRefresher(refresher.Refresh))
if _, err := client.Core().Call(context.Background(), "user.current", nil); err != nil {
log.Fatal(err)
}
}
func storeTokens(access, refresh string) {}
Output:
type PageOption ¶
type PageOption func(*Pager)
PageOption configures a walk.
func WithCursorParam ¶
func WithCursorParam(name string) PageOption
WithCursorParam renames the offset parameter for a method that does not read `start`.
im.department.colleagues returns `next` but consumes OFFSET: writing start into it is a no-op, so the server answers page 1 and next:50 again, forever, on the customer's quota. Without this option the walk hits ErrCursorStalled rather than looping.
func WithIDField ¶
func WithIDField(request, response string) PageOption
WithIDField sets the id field, separately for the request and the response.
Two names, because some methods use two: tasks.task.list RETURNS "id" but SORTS AND FILTERS BY "ID". Passing one name for both silently produces a walk that never advances.
func WithPageSize ¶
func WithPageSize(n int) PageOption
WithPageSize tells Scan how many rows a page holds, when a method departs from DefaultPageSize. It does not ask the server for a different size — nothing can — it only tells Scan how to recognise the last page.
func WithRowPath ¶
func WithRowPath(path ...string) PageOption
WithRowPath pins where the rows live inside `result`.
Needed when a method wraps its rows in a key the SDK does not know about. Without it the walk descends through single-key objects until it finds an array, which covers the common shapes but is deliberately not a guess when the object has several keys.
type Pager ¶
type Pager struct {
// contains filtered or unexported fields
}
Pager walks a list method page by page.
It is a struct with Next/Rows/Err rather than an iterator function, so an error has somewhere to surface AFTER the loop instead of ending it silently:
p, err := client.Core().Pages("crm.deal.list", params)
if err != nil {
return err
}
for p.Next(ctx) {
for _, row := range p.Rows() {
...
}
}
return p.Err() // ALWAYS check this: a walk that stops early stops quietly
A Pager is NOT safe for concurrent use.
func (*Pager) Err ¶
Err returns the error that stopped the walk, if any.
ALWAYS check it after the loop. Next reports false both at the end of the list and on failure, so a walk that broke halfway looks exactly like a walk that finished.
func (*Pager) Next ¶
Next fetches the following page. It reports false at the end of the list and on error; check Err after the loop to tell those apart.
func (*Pager) Page ¶
func (p *Pager) Page() *CallResult
Page returns the whole envelope of the page just read, for Total and Time.
func (*Pager) Rows ¶
func (p *Pager) Rows() []json.RawMessage
Rows returns the rows of the page just read.
type RetryConfig ¶
RetryConfig controls retry behavior for rate limit errors.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package b24test builds Bitrix24 wire fixtures and fake portals, so code that uses this SDK can be tested without a network and without a real portal.
|
Package b24test builds Bitrix24 wire fixtures and fake portals, so code that uses this SDK can be tested without a network and without a real portal. |
|
internal
|
|
|
phpq
Package phpq encodes and decodes PHP bracket notation — k[a][0][b]=v — in both directions.
|
Package phpq encodes and decodes PHP bracket notation — k[a][0][b]=v — in both directions. |
|
Package oauth implements the Bitrix24 OAuth 2.0 authorization protocol: building the authorization URL, exchanging the authorization code for tokens and renewing them.
|
Package oauth implements the Bitrix24 OAuth 2.0 authorization protocol: building the authorization URL, exchanging the authorization code for tokens and renewing them. |