Overview
This page is for servers that expose a public HTTP endpoint Bitrix24 POSTs to:
- Outbound event receivers — an app that registers events with
event.bind(ONCRMDEALADD,ONCRMDEALUPDATE, …) and processes them, as in the webhook handler recipe. - OAuth apps — a marketplace app that handles
ONAPPINSTALL/ONAPPUPDATE/ONAPPUNINSTALLcallbacks and stores per-portal credentials, as in the OAuth install recipe.
Both share the same threat model: the URL is reachable by anyone, and Bitrix24 authenticates each delivery with an application_token in the payload. Two patterns keep these endpoints correct and safe. They are independent of the SDK transport surface — they live in your HTTP handler — so they are documented here rather than on an action page.
Pattern 1 — Reply 200 first, verify after
Bitrix24 doesn't call your handler in real time — it queues events on a separate server and watches how fast your handler answers. A handler that runs verification or processing before replying answers slowly, and Bitrix24 starts delivering to it with lower priority (longer pauses between calls). And there is no automatic retry: if your handler errors or times out, Bitrix24 records the failure and never re-sends that event — "no second chance" (see the events overview). So a slow-or-throwing handler both degrades your delivery rate and silently drops events.
The fix is ordering: send 2xx first, then verify and process. The acknowledgement goes out fast (keeping you off the slow-handler penalty), and verification/processing no longer block it. If losing an event is unacceptable, don't rely on live delivery — use offline events as the durable channel.
import { type Request, type Response } from 'express'
// Stand-ins for your real verification / processing logic.
function verifyOrigin(req: Request): boolean { return Boolean(req.body?.auth) }
async function handleEvent(payload: unknown): Promise<void> { /* … */ }
// WRONG — verify + process run BEFORE the 200 reply.
async function onWebhookWrong(req: Request, res: Response) {
if (!verifyOrigin(req)) return // may be slow, may throw
await handleEvent(req.body) // if this throws, the next line never runs…
res.sendStatus(200) // …so the ack never goes out — the event is
// lost (no retry) and the slow path gets you
// deprioritized
}
import { type Request, type Response } from 'express'
function verifyOrigin(req: Request): boolean { return Boolean(req.body?.auth) }
async function handleEvent(payload: unknown): Promise<void> { /* … */ }
// CORRECT — acknowledge first, verify + process after.
async function onWebhook(req: Request, res: Response) {
const payload = req.body
res.sendStatus(200) // 1. reply immediately — fast ack avoids the
// slow-handler penalty
if (!verifyOrigin(req)) return // 2. drop spoofed / stale events (already acked)
await handleEvent(payload) // 3. safe: Bitrix24 already has its 200
}
The webhook handler recipe replies with a small JSON acknowledgement — res.status(200).json({ status: 'ok' }) — which is equivalent to res.sendStatus(200); either works as long as it goes out first.
Acknowledging first has a consequence worth planning for: delivery is
at-least-once. Bitrix24 retries a non-2xx for up to 24 hours, and a handler
that answers after the portal has given up waiting will see the same event
again. If your handler writes something, make the write idempotent rather
than assuming one delivery — on restApi:v3 pass an
idempotencyKey
derived from the event, so a redelivery replays the stored response instead of
creating a second record. Recipe 7 does exactly this.
Pattern 2 — Verify application_token in constant time
Bitrix24 includes an application_token in the auth block of every event. It is the shared secret that proves the request really came from your portal — without checking it, anyone who learns the URL can replay arbitrary events.
Compare it with a constant-time function. A plain === (or !=) returns as soon as it hits the first differing byte, so an attacker can recover the token one character at a time by measuring response latency. Node's crypto.timingSafeEqual compares in constant time, but it throws when the two buffers differ in length — so pre-check the length first:
The helper is spelled out here because you should read it before trusting it. The
recipes themselves import one shared copy from
lib/crypto.ts
rather than each carrying their own — a constant-time compare that drifts in one
of several forks is a vulnerability that reads like a refactor.
import { type Request, type Response } from 'express'
import { timingSafeEqual } from 'node:crypto'
// Constant-time compare. `timingSafeEqual` throws on a length mismatch, so the
// length pre-check is what turns that throw into a plain `false`. It does not
// hide the length — the early return is length-dependent — but a token's length
// is not the secret. Do not "fix" that by padding: you would then be comparing
// the padding rather than the token.
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a, 'utf8')
const bb = Buffer.from(b, 'utf8')
if (ab.length !== bb.length) return false
return timingSafeEqual(ab, bb)
}
const expectedApplicationToken = process.env.B24_APPLICATION_TOKEN ?? ''
// Called AFTER res.sendStatus(200) (Pattern 1). Returns false → drop the event.
function verifyEvent(req: Request, _res: Response): boolean {
const incomingToken = req.body?.auth?.application_token ?? ''
return safeEqual(incomingToken, expectedApplicationToken)
}
Store the expected token out of band (an env var such as B24_APPLICATION_TOKEN, sourced from the Bitrix24 dev console → Local Application → application_token) and fail closed when it is unset.
OAuth uninstall: verify before deleting
The same rule is critical on the uninstall callback. ONAPPUNINSTALL deletes a portal's stored credentials — so if you delete without verifying, anyone who can reach /uninstall can wipe a portal's tokens just by guessing its member_id. Always compare the incoming application_token against the one you recorded at install time, in constant time, before removing anything:
import { type Request, type Response } from 'express'
// `safeEqual` is the constant-time helper from Pattern 2 above.
declare function safeEqual(a: string, b: string): boolean
declare function getCredentials(memberId: string): Promise<{ applicationToken: string } | null>
declare function deleteCredentials(memberId: string): Promise<void>
async function handleUninstall(req: Request, res: Response) {
res.sendStatus(200) // reply first — Pattern 1 applies to uninstall too
const memberId = req.body?.auth?.member_id ?? ''
const receivedToken = req.body?.auth?.application_token ?? ''
if (!memberId || !receivedToken) return
const stored = await getCredentials(memberId)
if (!stored) return // idempotent — nothing to delete
// ALWAYS verify application_token before deleting. Without this check, anyone
// who reaches /uninstall could wipe a portal's tokens by guessing member_id.
if (!safeEqual(stored.applicationToken, receivedToken)) return
await deleteCredentials(memberId)
}
Pattern 3 — Validate the portal URLs an install event carries
Install is the one callback that cannot verify its caller, and no amount of
care changes that: application_token is issued by the install event, so a
first-time handler has nothing to compare against. Bitrix24 sends no pre-shared
secret with ONAPPINSTALL. That is a platform constraint.
So the question is not how to reject a forged install — you cannot — but what a forged install is worth. That depends entirely on what you do with three fields it carries.
server_endpoint is the one to think about twice, and it is also the one most
easily got wrong in the other direction: it is not the portal. On the cloud
it is a shared token server — oauth.bitrix.info — for every portal, so a check
that requires it to match domain rejects every legitimate cloud install.
What to check before persisting:
https:only, and no credentials embedded in the URL.client_endpointon the same host asdomain. This needs no list, so it holds for a self-hosted portal whose host nobody could have listed.domainandserver_endpointeach against their own allow-list — and make both configurable. A boxed Bitrix24 lives at whatever domain its owner chose and is its own token server, so hard-coded*.bitrix24.*suffixes reject every legitimate on-premise install. Naming one known host is stronger than a suffix default, not weaker.- Log the reason, answer nothing. Telling the caller which check it tripped turns the endpoint into an oracle for finding one that passes.
With those, the worst a forged install achieves is a corrupted record — that portal's calls fail, which is noisy and recoverable. Without them it is a credential leak.
None of this makes the endpoint safe to expose carelessly. It is still an unauthenticated write to your datastore, so put it behind a network control: an allow-list of Bitrix24's egress ranges, or a secret path segment.
Recipe 12 implements this in
skills/b24jssdk-recipes/lib/portal-url.ts.
Checklist
Every event-receiver endpoint (event.bind handlers):
- Serve the handler over HTTPS at a publicly reachable URL — Bitrix24 calls it from its own servers, and the
application_tokentravels in the request body, so plain HTTP would expose it in transit. - Reply
2xxbefore verifying or processing (Pattern 1). - Require an
application_tokenenv var and fail closed if it is unset. - Compare the incoming
application_tokenwithsafeEqual(Pattern 2), never===. - Drop — do not error — on a mismatch (return the
2xxyou already sent; don't reveal which check failed). - Never log the
application_token(or the rawauthblock). Log a decision, not the secret.
OAuth apps (install / uninstall callbacks), additionally:
- Persist
application_tokenalongside the tokens at install time. - On uninstall, verify
application_tokenbefore deleting credentials. - Treat a missing record as idempotent success (no error — it is already gone).
- Store tokens with restrictive permissions (file mode
0o600or a datastore with per-tenant isolation). - Validate the portal URLs before persisting them (Pattern 3), and put
/installbehind a network control.
See also
- Webhook handler recipe — full outbound-event server these patterns come from.
- OAuth install recipe — install/uninstall handshake with per-portal storage.
- Outbound event registration recipe —
event.bind/event.unbindfrom the SDK. - Logging & Credential Redaction — what the SDK strips before request data reaches a logger.
Discovering v3 methods
Use rest.documentation.openapi to fetch the portal's own machine-readable list of every available REST API v3 method — the source of truth the SDK relies on instead of a hardcoded allowlist. Especially useful for AI agents and codegen.
Discovering entity fields
Ask the portal which fields an entity has, what type each one is, and whether it can be filtered, sorted or written — instead of guessing them or downloading the whole OpenAPI document. Also states the v3 camelCase field-name rule.