b24jssdk core
Three entry points share the same REST surface, exposed via $b24.actions.v{2,3}.*. Pick by where the code runs:
| Entry point | Where | Auth source |
|---|---|---|
B24Hook |
Node.js / scripts / serverless | inbound webhook URL (/rest/<userId>/<secret>) |
B24Frame |
Browser, inside a Bitrix24 placement iframe | postMessage handshake with the parent window |
B24OAuth |
Server side of an OAuth-installed Bitrix24 app | accessToken + refreshToken from Bitrix24 install events |
Once initialized, write the rest of your code against the abstract type
TypeB24so the same logic runs on any of the three.
B24Hook (backend / scripts)
import { B24Hook, ConsoleV2Handler, LogLevel, Logger } from '@bitrix24/b24jssdk'
// Node, so the handler is created explicitly with `useStyles: false`; the
// browser factory below emits CSS styling a terminal prints as literal noise.
const logger = Logger.create('Srv')
logger.pushHandler(new ConsoleV2Handler(LogLevel.INFO, { useStyles: false }))
const $b24 = B24Hook.fromWebhookUrl(
// Format: https://<portal>.bitrix24.<tld>/rest/<userId>/<secret>
process.env.B24_HOOK!
)
const me = await $b24.actions.v2.call.make<{ NAME: string; ID: number }>({
method: 'profile',
requestId: 'profile-1'
})
logger.info(`Hello, ${me.getData()!.result.NAME}`)
Alternative constructor (manual parts):
import { B24Hook } from '@bitrix24/b24jssdk'
const $b24 = new B24Hook({
b24Url: 'https://your_domain.bitrix24.com',
userId: 1,
secret: 'k32t88gf3azpmwv3'
})
Notes:
- Keep
B24Hookserver-side only. The webhook URL contains a long-lived secret. - Supported Node:
>=22(Node 20 was dropped in 3.0.0 — it is EOL).
B24Frame (in-iframe app)
import { initializeB24Frame, LoggerFactory, type B24Frame } from '@bitrix24/b24jssdk'
const logger = LoggerFactory.createForBrowser('App', import.meta.env?.DEV === true)
let $b24: B24Frame
async function boot() {
$b24 = await initializeB24Frame()
// The frame handles auth transparently; refresh on 401 is automatic.
}
function teardown() {
$b24?.destroy()
}
initializeB24Frame() deduplicates concurrent calls — safe to await it from multiple places.
If the app is in install mode, finish the installation. Until installFinish()
is called the portal treats the app as half-installed and delivers no events to
it — bot handlers, event.bind handlers and other outgoing calls never arrive, with
nothing failing loudly to say so:
if ($b24.isInstallMode) {
// provision whatever the app needs, then finish — the portal reloads the page
// in response, so this is the last statement of the flow.
await $b24.installFinish()
}
See the b24jssdk-frame-ui skill for the full rule. Note this is a lifecycle
step, not a security one — an OAuth install/uninstall endpoint still verifies
application_token (see the security checklist below).
B24OAuth (server side of an OAuth app)
import { B24OAuth } from '@bitrix24/b24jssdk'
// `b24OAuthParams` come from the install/refresh events of your Bitrix24 app
// (applicationToken, accessToken, refreshToken, expires, expiresIn, domain, …)
// `oAuthSecret` is your registered app's clientId/clientSecret pair.
const $b24 = new B24OAuth(b24OAuthParams, { clientId, clientSecret })
// Persist refreshed credentials so the next process gets the latest tokens
$b24.setCallbackRefreshAuth(async ({ authData, b24OAuthParams }) => {
await db.appCredentials.upsert(b24OAuthParams)
})
await $b24.initIsAdmin() // populates auth.isAdmin
B24OAuth automatically refreshes the access token on 401. Always register setCallbackRefreshAuth on the server so refreshed tokens are persisted.
Logging
In the browser, one factory call is enough:
import { LoggerFactory } from '@bitrix24/b24jssdk'
const logger = LoggerFactory.createForBrowser('AppName', /* isDev */ true)
Under Node, build it explicitly so the output is not styled for a browser console:
import { ConsoleV2Handler, LogLevel, Logger } from '@bitrix24/b24jssdk'
const logger = Logger.create('AppName')
logger.pushHandler(new ConsoleV2Handler(LogLevel.INFO, { useStyles: false }))
Either way the logging calls are the same. The second argument is a context
object, not a second message — every level takes (message: string, context?: Record<string, any>), so interpolate values into the message or pass them as
named fields:
logger.info('starting up')
logger.warning('something looks off', { retries: 2 })
try {
await risky()
} catch (err) {
// Never `logger.error('failure', err)` — an Error's message and stack are not
// own enumerable properties, so it serialises to `{}` and the reason is lost.
logger.error('failure', {
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined
})
}
Note warning, not warn. isDev toggles verbose output. To get SDK-internal
traces:
$b24.setLogger(logger)
Error handling
actions.v{2,3}.call.make returns an AjaxResult. REST-level failures throw AjaxError. SDK-level failures (wrong API version for a method, etc.) throw SdkError.
import { AjaxError, SdkError } from '@bitrix24/b24jssdk'
async function loadDeal() {
try {
const res = await $b24.actions.v2.call.make<{ item: Deal }>({
method: 'crm.item.get',
params: { entityTypeId: 2, id: 999_999 }
})
if (!res.isSuccess) {
// Soft errors only (see softErrorCodes below). Most failures throw.
logger.warning('non-success result', { errors: res.getErrorMessages() })
return
}
return res.getData()!.result.item
} catch (e) {
if (e instanceof AjaxError) {
logger.error('REST error', { code: e.code, status: e.status, message: e.message, requestInfo: e.requestInfo })
// restApi:v3 only: `e.validation` names the field that failed, which
// `message` does not. Both `field` and `message` are optional.
for (const detail of e.validation ?? []) {
logger.error('invalid field', { field: detail.field, message: detail.message })
}
} else if (e instanceof SdkError) {
logger.error('SDK error', { code: e.code, message: e.message })
} else {
throw e
}
}
}
requestInfois safe to log becauseAjaxErrorredacts it, not because the call site is careful. Its constructor runs the request params throughredactSensitiveParams, replacingauth,token,secret,access_token,refresh_token,client_secret,application_token,password,sessid,keyandsignaturewith***REDACTED***. So do not rebuild that context by hand from the original params — a hand-assembled{ method, params }inherits none of that and will put a live credential into the log.
Common AjaxError codes worth handling:
ERROR_NOT_FOUND— id does not exist (404)INVALID_CREDENTIALS/EXPIRED_TOKEN(401) — the SDK auto-refreshes the token and retries once on every entry point; forB24Hookthe refresh is a no-op, so a wrong webhook still failsQUERY_LIMIT_EXCEEDED— rate limit. The SDK already throttles, but you may needbatchByChunkinstead of a tight loop.INTERNAL_SERVER_ERROR(50x) — transient. The SDK retries automatically up tomaxRetries.
Common SdkError codes:
JSSDK_CORE_METHOD_NOT_SUPPORT_IN_API_V3— thrown only byAjaxResult.getNext()/fetchNext()against a v3 client — they arerestApi:v2-only (not deprecated), and v3 has nonextoffset to follow.actions.v3.*.makeno longer throws it: the SDK dropped its v3 method allowlist, so an unknown v3 method comes back as aMETHODNOTFOUNDEXCEPTIONsoft error on the result instead.
Tuning retry / throw behaviour
The restriction manager decides how a failed call reaches you: hard means the promise rejects and you catch an AjaxError; soft means it resolves and response.isSuccess === false. That is delivery, not severity — neither affects retries (any 4xx except 408/429 stops retrying either way), and anything not classified soft is thrown, unlisted codes included.
Defaults pin the well-known Bitrix24 codes; extend per-app via RestrictionParams.
import { B24Hook, ParamsFactory, ApiVersion } from '@bitrix24/b24jssdk'
const $b24 = B24Hook.fromWebhookUrl(process.env.B24_HOOK!)
await $b24.setRestrictionManagerParams({
...ParamsFactory.getDefault(),
// Codes that must throw immediately (no retry). Use for business-specific
// error codes that the SDK doesn't know about — otherwise the SDK treats
// unknown codes as transient and retries with backoff.
hardErrorCodes: [
'DOCUMENT_GENERATOR_ALREADY_IN_QUEUE',
'MY_APP_BAD_PAYLOAD'
],
// Codes that should be returned in AjaxResult as soft errors instead of
// thrown — useful when you want control-flow on a specific REST error.
softErrorCodes: [
'CUSTOM_VALIDATION_ERROR'
],
// For NON-IDEMPOTENT methods (any *.add, file uploads) — set to false so the
// SDK does NOT retry on NETWORK_ERROR / REQUEST_TIMEOUT. A client-side
// timeout doesn't mean the server didn't process the call; retrying creates
// duplicates.
retryOnNetworkError: false,
maxRetries: 3,
retryDelay: 1_000
})
The category rule needs no configuration. On restApi:v3 an error that arrived in the v3 error envelope carrying a 4xx other than 401/408/429 is soft, whatever its code. Through the 2.x line this was opt-in behind a classifyV3ErrorsByCategory parameter; since 3.0.0 it is simply the behaviour and that parameter no longer exists — passing it is a compile error, and the fix is to delete the line.
Why the category rule exists. The built-in soft list holds nine v3 codes; one on-premise build was measured to ship at least 39, and the set grows with every portal module. So classification by list is per-module-shipping-date, not per-error-kind: INVALIDSELECTEXCEPTION is soft while INVALIDPAGINATIONEXCEPTION — same caller mistake, same request, same HTTP 400 — throws. Pinned codes (built-in and yours) still outrank the rule; 5xx, 401, 408, 429 and all of restApi:v2 are untouched; 403 is soft, matching the already-pinned …ACCESSDENIEDEXCEPTION — except …INSUFFICIENTSCOPEEXCEPTION, pinned hard because it is a missing OAuth grant and its v2 twin insufficient_scope has always thrown.
Never match on the BITRIX_REST_V3_EXCEPTION_ prefix — modules ship unprefixed codes such as NOTE_SEARCH_QUERY_TOO_SHORT. And never match on message: it is localised.
…ACCESSDENIEDEXCEPTION is not a permission signal. On v3 it is the catch-all for every authentication failure: a wrong credential, a missing one, and a valid one sent over plain HTTP all answer it with 401, byte-identically — the portal folds any checkAuth() failure into this one exception and drops the reason. The same code arrives with 403 for a different thing entirely: a method or controller disabled on the portal. So the code alone says only "not authorised". The SDK delivers it soft at both statuses — it sits in the built-in soft list, which isSoftError consults before it looks at any status, and a 401 here does not trigger a token refresh either (that path additionally requires expired_token / invalid_token). So pair the code with status to decide what to do, not to predict how it arrives, and if the credential is one you trust, check the URL scheme before you check scopes. (restApi:v2 distinguishes these: INVALID_CREDENTIALS vs INVALID_REQUEST / Https required.)
hardErrorCodes and softErrorCodes are additive — the built-in lists (auth/fatal codes) are always hard, and you can't remove them, only extend (per packages/jssdk/src/types/limiters.ts:120-146).
setRestrictionManagerParams replaces the parameters you name and keeps the rest — setRestrictionManagerParams({ maxRetries: 5 }) after the block above leaves hardErrorCodes and the others intact; a key set to undefined counts as not mentioned. The merge is shallow: rateLimit / operatingLimit / adaptiveConfig are replaced whole, their types have no optional fields, and a partial block from JavaScript is refused with JSSDK_LIMITER_INVALID_CONFIG_BLOCK rather than reaching a limiter half-built. To clear a list pass hardErrorCodes: [] — spreading ...ParamsFactory.getDefault() does not clear it, because the factory carries no such key. getRestrictionManagerParams() returns a copy, nested blocks included. (The method used to replace the whole configuration, so a partial update reset everything it did not mention — #479.)
Scope:
setRestrictionManagerParamsupdates the policy on the$b24instance, not on the call you're about to make. Every concurrent or subsequent call on the same$b24sees the new params until you set them again. In code paths that mix idempotent reads with non-idempotent writes on the same$b24, use a dedicated$b24instance for the non-idempotent flow instead of flipping the policy in-flight. OnrestApi:v3there is a better answer than either: passidempotencyKeyon the write itself and let the portal deduplicate it — see Idempotency-Key. OnrestApi:v2, where the portal ignores the header, a per-method idempotency token plus manual reconciliation is still the only option.
For heavy long-running calls, also raise the axios timeout on the underlying HTTP client:
import { ApiVersion } from '@bitrix24/b24jssdk'
const clientAxios = $b24.getHttpClient(ApiVersion.v2).ajaxClient
clientAxios.defaults.timeout = 120_000
Enterprise limits
LicenseManager (from useB24Helper) automatically swaps in the enterprise restriction params if the portal is enterprise. To do it manually:
import { ParamsFactory } from '@bitrix24/b24jssdk'
await $b24.setRestrictionManagerParams(ParamsFactory.getEnterprise())
Security checklist for event-receiver recipes
When the code RECEIVES events from Bitrix24 (outbound webhook handlers, OAuth install / uninstall endpoints), apply this checklist — both anti-spoof and anti-retry-storm. Concrete worked examples live in recipes 07-webhook-handler.ts and 12-oauth-install.ts.
- Respond
200first, verify after. Bitrix24 retries any non-2xx response for up to 24 h. Sendres.status(200).send('ok')immediately on payload receipt, then do the verification + business logic asynchronously. Failures in those async steps log + drop, not retry-cascade. - Verify
application_tokenfor outbound webhooks. Comparepayload.auth?.application_tokenagainst the value from your Bitrix24 dev console (typically supplied via env var). On mismatch — log and ignore. Without this, any caller that knows the URL can replay arbitrary events. - Verify
application_tokenagainst persisted credentials on uninstall. OnONAPPUNINSTALL, look up the stored creds for the incomingmember_id, compareapplication_token, and only delete on match. Without this, anyone who reaches/uninstallcan erase credentials for any portal whosemember_idthey guess. - Persist refreshed OAuth tokens. Always call
setCallbackRefreshAuthon everyB24OAuthinstance to write fresh tokens back to your store. The next cold start expects them. - Keep
B24Hookserver-side. It bundles a long-lived secret, and nothing in the SDK stops that secret reaching a browser bundle — keeping it out is the application's job. The client-side warning is a smoke alarm, not a guard: it fires only in a browser (_checkClientSideWarningreturns early whenisServerSide()), so on Node there is nothing to silence, andoffClientSideWarning()there is a no-op. Do not call it in application code — per AGENTS.md, suppressing warnings is for testing only. Calling it in a server template is worse than pointless: it silences nothing where it sits, and travels with the code if that code is ever copied into a browser, taking the one signal about the leaked secret with it. - HTML-escape user input before posting to chat / IM. When sending CRM text through
parse_mode: 'HTML'(Telegram) orim.message.addHTML, escape</>/&in the payload.
Picking method names
| Need | Method | API version |
|---|---|---|
| CRM entities (deals, contacts, companies, leads, smart processes) | crm.item.{list,get,add,update,delete} with entityTypeId from EnumCrmEntityTypeId |
v2 |
| Tasks read/write/list | tasks.task.{add,get,update,delete,list} |
v3 |
| Tasks extras | tasks.task.checklistitem.*, … |
v2 |
| Disk | disk.storage.getlist, disk.folder.{getchildren,addsubfolder}, disk.file.get |
v2 |
| Profile / users | profile, user.get, user.current |
v2 |
| IM | im.notify, im.message.add |
v2 |
| Event log | main.eventlog.{list,get,tail} |
v3 |
mail.mailbox.*, mail.message.*, mail.recipient.* |
v3 | |
| Org structure (HR) | humanresources.node.*, humanresources.employee.* |
v3 |
| Time tracking | timeman.record.* (read-only) |
v3 |
The version column is a recommendation, not a gate: the SDK no longer keeps a v3 allowlist, so
actions.v3.*will send any method to the v3 endpoint (the server validates it) andactions.v2.*no longer warns about v3-eligible methods. Pick the version that gives you the representation you want.
When you don't know which entry point you're in
Write functions against TypeB24:
import type { TypeB24 } from '@bitrix24/b24jssdk'
import { EnumCrmEntityTypeId } from '@bitrix24/b24jssdk'
interface Deal { id: number; title: string }
export async function loadDeal($b24: TypeB24, id: number): Promise<Deal> {
const res = await $b24.actions.v2.call.make<{ item: Deal }>({
method: 'crm.item.get',
params: { entityTypeId: EnumCrmEntityTypeId.deal, id }
})
if (!res.isSuccess) throw new Error(res.getErrorMessages().join('; '))
return res.getData()!.result.item
}
Same loadDeal works unchanged with B24Hook, B24Frame, and B24OAuth.