How to Create a Custom Lead Edit Form
Scope:
crm,user_briefWho can execute the scenario: a user with permissions to read, add, and edit leads, read associated companies and contacts, and access CRM settings. Scope
user_briefis required to call user.get
If you are developing integrations for Bitrix24 using AI tools (Codex, Claude Code, Cursor), connect to the MCP server so that the assistant can utilize the official REST documentation.
In this example, we will create a web form for adding and editing a lead. The form does not contain a predefined list of fields: the generator retrieves the field descriptions using the crm.item.fields method and selects the appropriate HTML control for each field type. Therefore, custom fields created in Bitrix24 appear in the form without any code changes.
If you open the page without the ID parameter, the form will be empty, and the handler will create a lead. If you pass an identifier, for example ?ID=123, the generator will retrieve the lead using the crm.item.get method, populate the form, and the handler will update this lead.
The example consists of two files:
- The generator retrieves field descriptions, lead data, and dictionary values, then outputs an HTML form.
- The handler receives the form data and calls the add or update method.
How the Scenario Works
- The generator retrieves field descriptions using the crm.item.fields method.
- If the URL contains
ID, the generator retrieves the lead values using the crm.item.get method. - The generator replaces internal codes with readable names using additional methods:
- crm.status.list — fields
STATUS_IDandNAMEfor stages, sources, and other CRM lookup lists. - crm.currency.list — fields
CURRENCYandFULL_NAMEfor currencies. - crm.item.get — the
titlefield of the associated company. - crm.item.list — fields
id,name, andlastNameof associated contacts. - user.get — fields
ID,NAME, andLAST_NAMEof users.
- crm.status.list — fields
- The handler retrieves the field descriptions again using the crm.item.fields method and converts the form values to Bitrix24 REST API types.
- The handler adds a new lead using the crm.item.add method or updates an existing one using the crm.item.update method.
1. Prepare the Environment
Create an incoming webhook with crm and user_brief permissions. The webhook user requires permissions to read, add, and edit leads, read companies and contacts, and access CRM settings.
Keep the webhook secret
The webhook executes requests with the permissions of the user who created it. Do not add the webhook URL to public repositories, client-side JavaScript, or error messages.
Choose one language, create a separate folder for the example, and open a terminal in it. Save the code from steps 2 and 3 into two corresponding files before executing the launch command.
|
Language |
Form Generator |
Handler |
|
JavaScript |
|
|
|
PHP |
|
|
|
Python |
|
|
Install dependencies.
npm init -y
npm install @bitrix24/b24jssdk express
composer require bitrix24/b24phpsdk:"^3.0"
pip install b24pysdk flask
Before installing dependencies, check the PHP version using the php -v command and the list of enabled extensions using the php -m command. B24PhpSDK version 3 requires PHP 8.4 or 8.5. B24PhpSDK and its dependencies require the bcmath, curl, intl, and json extensions. After installation, run composer check-platform-reqs.
Specify the webhook URL:
- In JavaScript, set the
B24_HOOKenvironment variable - In PHP, replace the full URL in
initFromWebhook - In Python, replace
your-domain.bitrix24.comandUSER_ID/TOKEN
Launch the example.
Bash:
export B24_HOOK='https://your-domain.bitrix24.com/rest/USER_ID/TOKEN/'
node form.mjs
PowerShell:
$env:B24_HOOK='https://your-domain.bitrix24.com/rest/USER_ID/TOKEN/'
node form.mjs
Bash and PowerShell:
php -S localhost:8000
Bash and PowerShell:
python app.py
The form pages will be available at the following addresses:
|
Language |
New lead |
Existing lead with identifier |
|
JavaScript |
|
|
|
PHP |
|
|
|
Python |
|
|
2. Create a Lead Form
The generator passes two parameters to crm.item.fields:
entityTypeId: 1— the "lead" object type. Values for other objects are provided in the CRM Object Type table.useOriginalUfNames: Y— return the original names of custom fieldsUF_*.
In the Python SDK, the useOriginalUfNames parameter is named use_original_uf_names and accepts a boolean value True.
The abbreviated response of crm.item.fields for a lead looks like this:
{
"result": {
"fields": {
"title": {
"type": "string",
"isRequired": false,
"isReadOnly": false,
"isMultiple": false,
"title": "Lead Name"
},
"stageId": {
"type": "crm_status",
"isRequired": false,
"isReadOnly": false,
"isMultiple": false,
"statusType": "STATUS"
},
"sourceId": {
"type": "crm_status",
"isRequired": false,
"isReadOnly": false,
"isMultiple": false,
"statusType": "SOURCE"
},
"companyId": {
"type": "crm_company",
"isRequired": false,
"isReadOnly": false,
"isMultiple": false
},
"contactIds": {
"type": "crm_contact",
"isRequired": false,
"isReadOnly": false,
"isMultiple": true
},
"opportunity": {
"type": "double",
"isRequired": false,
"isReadOnly": false,
"isMultiple": false
},
"currencyId": {
"type": "crm_currency",
"isRequired": false,
"isReadOnly": false,
"isMultiple": false
},
"isManualOpportunity": {
"type": "boolean",
"isRequired": false,
"isReadOnly": false,
"isMultiple": false
},
"fm": {
"type": "crm_multifield",
"isRequired": false,
"isReadOnly": false,
"isMultiple": true
}
}
}
}
The next step requires the keys type, isRequired, isReadOnly, isMultiple, title, or formLabel. For fields of type crm_status, a statusType is also required: for stageId it is STATUS, for sourceId — SOURCE. The generator passes this value to the ENTITY_ID filter of the crm.status.list method.
If ID=123 is passed, crm.item.get will return data in result.item. The generator retrieves values from this object using the same camelCase keys that arrived in the field description:
{
"result": {
"item": {
"id": 123,
"title": "Website inquiry",
"stageId": "NEW",
"sourceId": "WEB",
"companyId": 27,
"contactIds": [31, 44],
"opportunity": 15000,
"currencyId": "EUR",
"isManualOpportunity": "Y",
"fm": [
{
"id": 451,
"typeId": "PHONE",
"valueType": "WORK",
"value": "+49 495 111-22-33"
}
]
}
}
}
The companyId field contains a single company identifier, while contactIds is an array of contact identifiers. The contactIds field itself is structured the same way as for a company. The difference for a lead is an additional single link companyId: a lead can simultaneously have one company and several contacts. The example does not output the deprecated single field contactId and the service field contacts to avoid sending multiple representations of the same link.
The fm multi-field is applicable to leads. The following types are available in the form: PHONE, EMAIL, WEB, IM.
The lead amount is stored in opportunity of type double, and the currency is in currencyId of type crm_currency. The amount calculation mode is stored separately in the logical field isManualOpportunity, so the form displays a separate checkbox for it.
The generator maps field types to form controls.
|
Field type |
Form control |
|
|
|
|
|
numeric field and current company name |
|
|
one or more numeric fields and current contact names |
|
|
numeric field and user name |
|
|
rows |
|
|
|
|
|
checkbox |
|
|
numeric field |
|
|
amount and list of currencies |
|
|
unsupported type message |
|
Other types |
text field |
How to Use Examples in Documentation
Full Form Generator Code
Save the code to the generator file: JavaScript — form.mjs, PHP — index.php, Python — app.py.
import express from 'express'
import { B24Hook } from '@bitrix24/b24jssdk'
import { saveForm } from './save-form.mjs'
const ENTITY_TYPE_ID = 1
const SKIPPED_FIELDS = new Set(['contactId', 'contacts'])
const $b24 = B24Hook.fromWebhookUrl(process.env.B24_HOOK)
const app = express()
app.use(express.urlencoded({ extended: true }))
async function call(method, params, requestId) {
const response = await $b24.actions.v2.call.make({ method, params, requestId })
if (!response.isSuccess) throw new Error(response.getErrorMessages().join('; '))
return response.getData().result
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[char])
}
function input(params) {
const values = params.MULTIPLE
? [...(Array.isArray(params.VALUE) ? params.VALUE : [params.VALUE ?? '']), '']
: [params.VALUE ?? '']
return values.map((value, index) => {
let html = '<input class="form-control"'
html += ` name="${escapeHtml(params.NAME)}${params.MULTIPLE ? '[]' : ''}"`
html += ` type="${escapeHtml(params.TYPE || 'text')}"`
if (params.STEP) html += ` step="${escapeHtml(params.STEP)}"`
if (params.REQUIRED && index === 0) html += ' required'
if (params.DISABLE) html += ' disabled'
if (params.CHECKED) html += ' checked'
return html + ` value="${escapeHtml(value)}">`
}).join('')
}
function select(params, options) {
let html = `<select class="form-control" name="${escapeHtml(params.NAME)}${params.MULTIPLE ? '[]' : ''}"`
if (params.REQUIRED) html += ' required'
if (params.DISABLE) html += ' disabled'
if (params.MULTIPLE) html += ' multiple'
html += '>'
if (!params.REQUIRED && !params.MULTIPLE) html += '<option value="">-- Not selected --</option>'
const selectedValues = (Array.isArray(params.VALUE) ? params.VALUE : [params.VALUE ?? '']).map(String)
for (const [key, title] of Object.entries(options)) {
const selected = selectedValues.includes(String(key)) ? ' selected' : ''
html += `<option value="${escapeHtml(key)}"${selected}>${escapeHtml(title)}</option>`
}
return html + '</select>'
}
function multifields(values) {
const rows = [...(Array.isArray(values) ? values : [])]
rows.push(...Array.from({ length: 3 }, () => ({ id: '', typeId: 'PHONE', valueType: 'WORK', value: '' })))
return rows.map((row, index) => {
const typeOptions = ['PHONE', 'EMAIL', 'WEB', 'IM']
.map((type) => `<option value="${type}"${row.typeId === type ? ' selected' : ''}>${type}</option>`)
.join('')
return `<div class="border rounded p-2 mb-2">
<input type="hidden" name="fm[${index}][id]" value="${escapeHtml(row.id)}">
<select class="form-control mb-1" name="fm[${index}][typeId]">${typeOptions}</select>
<input class="form-control mb-1" name="fm[${index}][valueType]" value="${escapeHtml(row.valueType || 'WORK')}" placeholder="WORK">
<input class="form-control mb-1" name="fm[${index}][value]" value="${escapeHtml(row.value)}" placeholder="Value">
${row.id ? `<label><input type="checkbox" name="fm[${index}][delete]" value="Y"> Delete</label>` : ''}
</div>`
}).join('')
}
app.get('/', async (req, res) => {
const id = parseInt(String(req.query.ID ?? '0'), 10) || 0
try {
const fieldResult = await call('crm.item.fields', {
entityTypeId: ENTITY_TYPE_ID,
useOriginalUfNames: 'Y',
}, 'lead-fields')
const fields = fieldResult.fields
const currencies = await call('crm.currency.list', {}, 'currencies')
const item = id > 0
? (await call('crm.item.get', {
entityTypeId: ENTITY_TYPE_ID,
id,
useOriginalUfNames: 'Y',
}, 'lead-get')).item
: {}
let standard = ''
let custom = ''
for (const [key, field] of Object.entries(fields)) {
if (SKIPPED_FIELDS.has(key)) continue
let value = item[key] ?? ''
let control = ''
const params = {
NAME: `form[${key}]`, VALUE: value,
REQUIRED: field.isRequired, DISABLE: field.isReadOnly, MULTIPLE: field.isMultiple,
}
if (field.type === 'crm_status') {
const rows = await call('crm.status.list', {
filter: { ENTITY_ID: field.statusType },
}, `status-${key}`)
control = select(params, Object.fromEntries(rows.map((row) => [row.STATUS_ID, row.NAME])))
} else if (field.type === 'crm_currency') {
control = select(params, Object.fromEntries(currencies.map((row) => [row.CURRENCY, row.FULL_NAME])))
} else if (field.type === 'enumeration') {
const options = Object.fromEntries((field.items || []).map((row) => [row.ID ?? row.id, row.VALUE ?? row.value]))
control = select(params, options)
} else if (field.type === 'crm_multifield') {
control = multifields(value)
} else if (field.type === 'crm_company') {
control = input({ ...params, TYPE: 'number' })
if (value) {
const company = (await call('crm.item.get', {
entityTypeId: 4, id: Number(value),
}, `company-${key}`)).item
control += ` (${escapeHtml(company.title)})`
}
} else if (field.type === 'crm_contact') {
control = input({ ...params, TYPE: 'number' })
const ids = (Array.isArray(value) ? value : [value]).map(Number).filter((itemId) => itemId > 0)
if (ids.length) {
const contacts = (await call('crm.item.list', {
entityTypeId: 3,
filter: { '@id': ids },
select: ['id', 'name', 'lastName'],
}, `contacts-${key}`)).items
const names = contacts.map((contact) => [contact.name, contact.lastName].filter(Boolean).join(' '))
control += ` (${escapeHtml(names.join(', '))})`
}
} else if (field.type === 'user') {
control = input({ ...params, TYPE: 'number' })
if (value) {
const users = await call('user.get', { filter: { ID: value } }, `user-${key}`)
const names = users.map((user) => [user.NAME, user.LAST_NAME].filter(Boolean).join(' '))
control += ` (${escapeHtml(names.join(', '))})`
}
} else if (['file', 'resourcebooking'].includes(field.type)) {
control = `Type ${escapeHtml(field.type)} not supported in this example`
} else if (field.type === 'date') {
control = input({ ...params, VALUE: value ? String(value).slice(0, 10) : '', TYPE: 'date' })
} else if (field.type === 'datetime') {
control = input({ ...params, VALUE: value ? String(value).slice(0, 19) : '', TYPE: 'datetime-local' })
} else if (['boolean', 'char'].includes(field.type)) {
control = input({ ...params, REQUIRED: false, VALUE: 'Y', CHECKED: value === 'Y', TYPE: 'checkbox' })
} else if (['integer', 'double'].includes(field.type)) {
control = input({ ...params, TYPE: 'number', STEP: field.type === 'double' ? 'any' : '' })
} else if (field.type === 'money') {
const [amount, currency] = String(value).split('|')
control = input({ ...params, VALUE: amount, TYPE: 'number', STEP: 'any' })
control += select({ ...params, NAME: `form[${key}_CURRENCY]`, VALUE: currency },
Object.fromEntries(currencies.map((row) => [row.CURRENCY, row.FULL_NAME])))
} else {
control = input({ ...params, TYPE: 'text' })
}
const label = escapeHtml(field.formLabel || field.title || key)
const block = `<div class="col-4 mt-3">${label}: </div><div class="col-6 mt-3">${control}</div>`
if (key.startsWith('UF_')) custom += block
else standard += block
}
res.send(`
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" crossorigin="anonymous">
<div class="container"><form id="auto_form" method="post">
${item.id ? `<input type="hidden" name="form[id]" value="${escapeHtml(item.id)}">` : ''}
<h2>System fields</h2><div class="row">${standard}</div>
<h2>Custom fields</h2><div class="row">${custom}</div>
<div class="row"><div class="col-sm-10 mt-5"><input type="submit" class="btn btn-primary" value="Save"></div></div>
</form></div>
<script>
document.getElementById('auto_form').addEventListener('submit', async (event) => {
event.preventDefault()
const body = new URLSearchParams(new FormData(event.currentTarget))
const response = await fetch('/form', { method: 'POST', body })
const json = await response.json()
alert(json.message || json.error)
})
<\/script>
`)
} catch (error) {
res.status(500).send(escapeHtml(error.message))
}
})
app.post('/form', saveForm)
app.listen(3000)
<?php
// composer require bitrix24/b24phpsdk:"^3.0"
require_once 'vendor/autoload.php';
use Bitrix24\SDK\Services\ServiceBuilderFactory;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Psr\Log\NullLogger;
$sb = (new ServiceBuilderFactory(new EventDispatcher(), new NullLogger()))
->initFromWebhook('https://your-domain.bitrix24.com/rest/USER_ID/TOKEN/');
$crm = $sb->getCRMScope();
const ENTITY_TYPE_ID = 1;
$ID = (int)($_REQUEST['ID'] ?? 0);
function callCore($sb, string $method, array $params): array
{
return $sb->core->call($method, $params)->getResponseData()->getResult();
}
function esc($value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function inputField(array $params): string
{
$values = !empty($params['MULTIPLE'])
? array_merge((array)($params['VALUE'] ?? []), [''])
: [$params['VALUE'] ?? ''];
$html = '';
foreach ($values as $index => $value)
{
$html .= '<input class="form-control" name="' . esc($params['NAME']) . (!empty($params['MULTIPLE']) ? '[]' : '') . '"';
$html .= ' type="' . esc($params['TYPE'] ?? 'text') . '"';
$html .= !empty($params['STEP']) ? ' step="' . esc($params['STEP']) . '"' : '';
$html .= !empty($params['REQUIRED']) && $index === 0 ? ' required' : '';
$html .= !empty($params['DISABLE']) ? ' disabled' : '';
$html .= !empty($params['CHECKED']) ? ' checked' : '';
$html .= ' value="' . esc($value) . '">';
}
return $html;
}
function selectField(array $params, array $options): string
{
$html = '<select class="form-control" name="' . esc($params['NAME']) . (!empty($params['MULTIPLE']) ? '[]' : '') . '"';
$html .= !empty($params['REQUIRED']) ? ' required' : '';
$html .= !empty($params['DISABLE']) ? ' disabled' : '';
$html .= !empty($params['MULTIPLE']) ? ' multiple' : '';
$html .= '>';
if (empty($params['REQUIRED']) && empty($params['MULTIPLE']))
{
$html .= '<option value="">-- Not selected --</option>';
}
$selectedValues = array_map('strval', (array)($params['VALUE'] ?? []));
foreach ($options as $key => $title)
{
$selected = in_array((string)$key, $selectedValues, true) ? ' selected' : '';
$html .= '<option value="' . esc($key) . '"' . $selected . '>' . esc($title) . '</option>';
}
return $html . '</select>';
}
function multifields($values): string
{
$rows = is_array($values) ? $values : [];
for ($i = 0; $i < 3; $i++)
{
$rows[] = ['id' => '', 'typeId' => 'PHONE', 'valueType' => 'WORK', 'value' => ''];
}
$html = '';
foreach ($rows as $index => $row)
{
$options = '';
foreach (['PHONE', 'EMAIL', 'WEB', 'IM'] as $type)
{
$selected = ($row['typeId'] ?? '') === $type ? ' selected' : '';
$options .= '<option value="' . $type . '"' . $selected . '>' . $type . '</option>';
}
$id = $row['id'] ?? '';
$html .= '<div class="border rounded p-2 mb-2">';
$html .= '<input type="hidden" name="fm[' . $index . '][id]" value="' . esc($id) . '">';
$html .= '<select class="form-control mb-1" name="fm[' . $index . '][typeId]">' . $options . '</select>';
$html .= '<input class="form-control mb-1" name="fm[' . $index . '][valueType]" value="' . esc($row['valueType'] ?? 'WORK') . '" placeholder="WORK">';
$html .= '<input class="form-control mb-1" name="fm[' . $index . '][value]" value="' . esc($row['value'] ?? '') . '" placeholder="Value">';
if ($id)
{
$html .= '<label><input type="checkbox" name="fm[' . $index . '][delete]" value="Y"> Delete</label>';
}
$html .= '</div>';
}
return $html;
}
$fieldResult = callCore($sb, 'crm.item.fields', [
'entityTypeId' => ENTITY_TYPE_ID,
'useOriginalUfNames' => 'Y',
]);
$fields = $fieldResult['fields'];
$currencies = [];
foreach ($crm->currency()->list([])->getCurrencies() as $currency)
{
$currencies[$currency->CURRENCY->getCode()] = $currency->FULL_NAME;
}
$item = [];
if ($ID > 0)
{
$item = callCore($sb, 'crm.item.get', [
'entityTypeId' => ENTITY_TYPE_ID,
'id' => $ID,
'useOriginalUfNames' => 'Y',
])['item'];
}
$standard = '';
$custom = '';
foreach ($fields as $key => $field)
{
if (in_array($key, ['contactId', 'contacts'], true)) continue;
$value = $item[$key] ?? '';
$params = [
'NAME' => 'form[' . $key . ']', 'VALUE' => $value,
'REQUIRED' => $field['isRequired'], 'DISABLE' => $field['isReadOnly'], 'MULTIPLE' => $field['isMultiple'],
];
$control = '';
if ($field['type'] === 'crm_status')
{
$options = [];
foreach ($crm->status()->list([], ['ENTITY_ID' => $field['statusType']], [])->getStatuses() as $status)
{
$options[$status->STATUS_ID] = $status->NAME;
}
$control = selectField($params, $options);
}
elseif ($field['type'] === 'crm_currency')
{
$control = selectField($params, $currencies);
}
elseif ($field['type'] === 'enumeration')
{
$options = [];
foreach ($field['items'] ?? [] as $row)
{
$options[$row['ID'] ?? $row['id']] = $row['VALUE'] ?? $row['value'];
}
$control = selectField($params, $options);
}
elseif ($field['type'] === 'crm_multifield')
{
$control = multifields($value);
}
elseif ($field['type'] === 'crm_company')
{
$control = inputField(['TYPE' => 'number'] + $params);
if ($value)
{
$company = $crm->item()->get(4, (int)$value)->item();
$control .= ' (' . esc($company->title) . ')';
}
}
elseif ($field['type'] === 'crm_contact')
{
$control = inputField(['TYPE' => 'number'] + $params);
$ids = array_values(array_filter(array_map('intval', (array)$value)));
if ($ids)
{
$names = [];
foreach ($crm->item()->list(3, [], ['@id' => $ids], ['id', 'name', 'lastName'], 0)->getItems() as $contact)
{
$names[] = trim($contact->name . ' ' . $contact->lastName);
}
$control .= ' (' . esc(implode(', ', $names)) . ')';
}
}
elseif ($field['type'] === 'user')
{
$control = inputField(['TYPE' => 'number'] + $params);
if ($value)
{
$names = [];
foreach ($sb->getUserScope()->user()->get([], ['ID' => $value], true)->getUsers() as $user)
{
$names[] = trim($user->NAME . ' ' . $user->LAST_NAME);
}
$control .= ' (' . esc(implode(', ', $names)) . ')';
}
}
elseif (in_array($field['type'], ['file', 'resourcebooking'], true))
{
$control = 'Type ' . esc($field['type']) . ' not supported in this example';
}
elseif ($field['type'] === 'date')
{
$formatted = $value ? substr((string)$value, 0, 10) : '';
$control = inputField(['TYPE' => 'date', 'VALUE' => $formatted] + $params);
}
elseif ($field['type'] === 'datetime')
{
$formatted = $value
? (new DateTimeImmutable((string)$value))->format('Y-m-d\TH:i:s')
: '';
$control = inputField(['TYPE' => 'datetime-local', 'VALUE' => $formatted] + $params);
}
elseif (in_array($field['type'], ['boolean', 'char'], true))
{
$control = inputField(['TYPE' => 'checkbox', 'VALUE' => 'Y', 'CHECKED' => $value === 'Y', 'REQUIRED' => false] + $params);
}
elseif (in_array($field['type'], ['integer', 'double'], true))
{
$control = inputField(['TYPE' => 'number', 'STEP' => $field['type'] === 'double' ? 'any' : ''] + $params);
}
elseif ($field['type'] === 'money')
{
[$amount, $currency] = array_pad(explode('|', (string)$value, 2), 2, '');
$control = inputField(['TYPE' => 'number', 'STEP' => 'any', 'VALUE' => $amount] + $params);
$control .= selectField(['NAME' => 'form[' . $key . '_CURRENCY]', 'VALUE' => $currency] + $params, $currencies);
}
else
{
$control = inputField($params + ['TYPE' => 'text']);
}
$label = esc($field['formLabel'] ?? $field['title'] ?? $key);
$block = '<div class="col-4 mt-3">' . $label . ': </div><div class="col-6 mt-3">' . $control . '</div>';
if (str_starts_with($key, 'UF_')) $custom .= $block;
else $standard .= $block;
}
?>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" crossorigin="anonymous">
<div class="container">
<form id="auto_form" method="post">
<?php if (!empty($item['id'])): ?>
<input type="hidden" name="form[id]" value="<?= esc($item['id']) ?>">
<?php endif; ?>
<h2>System fields</h2><div class="row"><?= $standard ?></div>
<h2>Custom fields</h2><div class="row"><?= $custom ?></div>
<div class="row"><div class="col-sm-10 mt-5"><input type="submit" class="btn btn-primary" value="Save"></div></div>
</form>
</div>
<script>
document.getElementById('auto_form').addEventListener('submit', async (event) => {
event.preventDefault();
const body = new URLSearchParams(new FormData(event.currentTarget));
const response = await fetch('auto_form.php', { method: 'POST', body });
const json = await response.json();
alert(json.message || json.error);
});
</script>
# pip install b24pysdk flask
from html import escape
from flask import Flask, request
from b24pysdk import BitrixWebhook, Client
from save_form import save_form
app = Flask(__name__)
ENTITY_TYPE_ID = 1
SKIPPED_FIELDS = {"contactId", "contacts"}
client = Client(BitrixWebhook(
domain="your-domain.bitrix24.com",
webhook_token="USER_ID/TOKEN",
))
def value_or_empty(value):
return "" if value is None else value
def input_field(params):
value = params.get("VALUE")
values = (
[*(value if isinstance(value, list) else [value_or_empty(value)]), ""]
if params.get("MULTIPLE") else [value_or_empty(value)]
)
html = ""
for index, value in enumerate(values):
html += f'<input class="form-control" name="{escape(params["NAME"])}{"[]" if params.get("MULTIPLE") else ""}"'
html += f' type="{escape(params.get("TYPE", "text"))}"'
if params.get("STEP"):
html += f' step="{escape(params["STEP"])}"'
if params.get("REQUIRED") and index == 0:
html += " required"
if params.get("DISABLE"):
html += " disabled"
if params.get("CHECKED"):
html += " checked"
html += f' value="{escape(str(value))}">'
return html
def select_field(params, options):
html = f'<select class="form-control" name="{escape(params["NAME"])}{"[]" if params.get("MULTIPLE") else ""}"'
if params.get("REQUIRED"):
html += " required"
if params.get("DISABLE"):
html += " disabled"
if params.get("MULTIPLE"):
html += " multiple"
html += ">"
if not params.get("REQUIRED") and not params.get("MULTIPLE"):
html += '<option value="">-- Not selected --</option>'
value = params.get("VALUE")
selected_values = value if isinstance(value, list) else [value_or_empty(value)]
selected_values = [str(value) for value in selected_values]
for key, title in options.items():
selected = " selected" if str(key) in selected_values else ""
html += f'<option value="{escape(str(key))}"{selected}>{escape(str(title))}</option>'
return html + "</select>"
def multifields(values):
rows = list(values) if isinstance(values, list) else []
rows.extend({"id": "", "typeId": "PHONE", "valueType": "WORK", "value": ""} for _ in range(3))
html = ""
for index, row in enumerate(rows):
options = "".join(
f'<option value="{field_type}"{" selected" if row.get("typeId") == field_type else ""}>{field_type}</option>'
for field_type in ("PHONE", "EMAIL", "WEB", "IM")
)
item_id = row.get("id") or ""
delete = (
f'<label><input type="checkbox" name="fm[{index}][delete]" value="Y"> Delete</label>'
if item_id else ""
)
html += f"""<div class="border rounded p-2 mb-2">
<input type="hidden" name="fm[{index}][id]" value="{escape(str(item_id))}">
<select class="form-control mb-1" name="fm[{index}][typeId]">{options}</select>
<input class="form-control mb-1" name="fm[{index}][valueType]" value="{escape(row.get('valueType') or 'WORK')}" placeholder="WORK">
<input class="form-control mb-1" name="fm[{index}][value]" value="{escape(row.get('value') or '')}" placeholder="Value">
{delete}
</div>"""
return html
PAGE = """
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" crossorigin="anonymous">
<div class="container"><form id="auto_form" method="post">
%(hidden_id)s
<h2>System fields</h2><div class="row">%(standard)s</div>
<h2>Custom fields</h2><div class="row">%(custom)s</div>
<div class="row"><div class="col-sm-10 mt-5"><input type="submit" class="btn btn-primary" value="Save"></div></div>
</form></div>
<script>
document.getElementById('auto_form').addEventListener('submit', async (event) => {
event.preventDefault();
const body = new URLSearchParams(new FormData(event.currentTarget));
const response = await fetch('/form', { method: 'POST', body });
const json = await response.json();
alert(json.message || json.error);
});
</script>
"""
@app.route("/")
def form_page():
raw_id = request.args.get("ID", "0")
try:
item_id = int(raw_id)
except ValueError:
return "The ID parameter must be an integer", 400
fields = client.crm.item.fields(
entity_type_id=ENTITY_TYPE_ID,
use_original_uf_names=True,
).response.result["fields"]
currencies = client.crm.currency.list().response.result
item = {}
if item_id > 0:
item = client.crm.item.get(
entity_type_id=ENTITY_TYPE_ID,
bitrix_id=item_id,
use_original_uf_names=True,
).response.result["item"]
standard = ""
custom = ""
for key, field in fields.items():
if key in SKIPPED_FIELDS:
continue
value = value_or_empty(item.get(key))
params = {
"NAME": f"form[{key}]", "VALUE": value,
"REQUIRED": field.get("isRequired"), "DISABLE": field.get("isReadOnly"), "MULTIPLE": field.get("isMultiple"),
}
field_type = field.get("type")
control = ""
if field_type == "crm_status":
rows = client.crm.status.list(filter={"ENTITY_ID": field["statusType"]}).response.result
control = select_field(params, {row["STATUS_ID"]: row["NAME"] for row in rows})
elif field_type == "crm_currency":
control = select_field(params, {row["CURRENCY"]: row["FULL_NAME"] for row in currencies})
elif field_type == "enumeration":
options = {
row.get("ID", row.get("id")): row.get("VALUE", row.get("value"))
for row in field.get("items", [])
}
control = select_field(params, options)
elif field_type == "crm_multifield":
control = multifields(value)
elif field_type == "crm_company":
control = input_field({**params, "TYPE": "number"})
if value:
company = client.crm.item.get(entity_type_id=4, bitrix_id=int(value)).response.result["item"]
control += f" ({escape(company['title'])})"
elif field_type == "crm_contact":
control = input_field({**params, "TYPE": "number"})
ids = [int(contact_id) for contact_id in (value if isinstance(value, list) else [value]) if contact_id]
if ids:
contacts = client.crm.item.list(
entity_type_id=3,
filter={"@id": ids},
select=["id", "name", "lastName"],
).response.result["items"]
names = [" ".join(filter(None, [row.get("name"), row.get("lastName")])) for row in contacts]
control += f" ({escape(', '.join(names))})"
elif field_type == "user":
control = input_field({**params, "TYPE": "number"})
if value:
users = client.user.get(filter={"ID": value}).response.result
names = [" ".join(filter(None, [row.get("NAME"), row.get("LAST_NAME")])) for row in users]
control += f" ({escape(', '.join(names))})"
elif field_type in ("file", "resourcebooking"):
control = f"Type {escape(field_type)} is not supported in this example"
elif field_type == "date":
control = input_field({**params, "VALUE": str(value)[:10] if value else "", "TYPE": "date"})
elif field_type == "datetime":
control = input_field({**params, "VALUE": str(value)[:19] if value else "", "TYPE": "datetime-local"})
elif field_type in ("boolean", "char"):
control = input_field({**params, "REQUIRED": False, "VALUE": "Y", "CHECKED": value == "Y", "TYPE": "checkbox"})
elif field_type in ("integer", "double"):
control = input_field({**params, "TYPE": "number", "STEP": "any" if field_type == "double" else ""})
elif field_type == "money":
amount, _, currency = str(value).partition("|")
control = input_field({**params, "VALUE": amount, "TYPE": "number", "STEP": "any"})
control += select_field(
{**params, "NAME": f"form[{key}_CURRENCY]", "VALUE": currency},
{row["CURRENCY"]: row["FULL_NAME"] for row in currencies},
)
else:
control = input_field({**params, "TYPE": "text"})
label = escape(str(field.get("formLabel") or field.get("title") or key))
block = f'<div class="col-4 mt-3">{label}: </div><div class="col-6 mt-3">{control}</div>'
if key.startswith("UF_"):
custom += block
else:
standard += block
hidden_id = f'<input type="hidden" name="form[id]" value="{escape(str(item["id"]))}">' if item.get("id") else ""
return PAGE % {"hidden_id": hidden_id, "standard": standard, "custom": custom}
app.add_url_rule("/form", view_func=save_form, methods=["POST"])
if __name__ == "__main__":
app.run(port=5000)
3. Save the Form Data
The handler calls crm.item.fields again. It passes only known fields, skips read-only fields, and does not send unsupported file and resourcebooking types.
The browser does not send unchecked checkboxes. Therefore, the handler passes Y for a checked boolean field or char and N for an unchecked one. For multiple fields, the handler collects an array; for the money field, it joins the amount and currency via |; for companyId, it passes a single numeric identifier; and for contactIds, it passes an array of numeric identifiers.
If a field available for editing is not present in the submitted form, the handler passes an empty value: an empty string for a single field, and an empty array for a multiple field, including a custom field of the "list" type. Therefore, do not remove a field from the HTML form without making corresponding changes to the handler.
Each fm row contains id, typeId, valueType, value:
- an existing non-empty row retains the numeric
idand updates the record - a new non-empty row receives a key such as
n0orn1and adds the record - an existing cleared row retains
id, but passes an emptyvalueand deletes the record - a new empty row is not passed
For example, the fm object before updating may look like this:
{
"451": { "typeId": "PHONE", "valueType": "WORK", "value": "+49 495 111-22-33" },
"452": { "typeId": "EMAIL", "valueType": "WORK", "value": "" },
"n0": { "typeId": "EMAIL", "valueType": "WORK", "value": "lead@example.com" }
}
The 451 record will be updated, 452 will be deleted, and n0 will be added.
If the form contains id, the handler calls crm.item.update. Without id, it calls crm.item.add. In both cases, the identifier of the saved lead is located in result.item.id:
{
"result": {
"item": {
"id": 123,
"title": "Website inquiry",
"stageId": "NEW",
"sourceId": "WEB",
"companyId": 27,
"contactIds": [31, 44],
"opportunity": 15000,
"currencyId": "EUR",
"isManualOpportunity": "Y"
}
}
}
The next action requires result.item.id: add it to the ID parameter of the form address to open the created lead for editing.
Full Handler Code
Save the handler to a file: JavaScript — save-form.mjs, PHP — auto_form.php, Python — save_form.py.
import { B24Hook } from '@bitrix24/b24jssdk'
const ENTITY_TYPE_ID = 1
const SKIPPED_FIELDS = new Set(['contactId', 'contacts'])
const $b24 = B24Hook.fromWebhookUrl(process.env.B24_HOOK)
async function call(method, params, requestId) {
const response = await $b24.actions.v2.call.make({ method, params, requestId })
if (!response.isSuccess) throw new Error(response.getErrorMessages().join('; '))
return response.getData().result
}
function asArray(value) {
if (Array.isArray(value)) return value
return value === undefined || value === null ? [] : [value]
}
function buildMultifields(rows) {
const result = {}
let newIndex = 0
for (const row of Object.values(rows ?? {})) {
const id = parseInt(String(row.id ?? '0'), 10) || 0
const value = String(row.value ?? '')
const shouldDelete = row.delete === 'Y' || value === ''
if (!id && shouldDelete) continue
const key = id ? String(id) : `n${newIndex++}`
result[key] = {
typeId: String(row.typeId || 'PHONE'),
valueType: String(row.valueType || 'WORK'),
value: shouldDelete ? '' : value,
}
}
return result
}
export async function saveForm(req, res) {
try {
const submitted = req.body.form ?? {}
const fieldResult = await call('crm.item.fields', {
entityTypeId: ENTITY_TYPE_ID,
useOriginalUfNames: 'Y',
}, 'lead-fields-save')
const fields = {}
for (const [key, prop] of Object.entries(fieldResult.fields)) {
if (SKIPPED_FIELDS.has(key) || prop.isReadOnly || ['file', 'resourcebooking'].includes(prop.type)) continue
if (['boolean', 'char'].includes(prop.type)) {
fields[key] = key in submitted ? 'Y' : 'N'
continue
}
if (prop.type === 'crm_multifield') {
fields[key] = buildMultifields(req.body.fm)
continue
}
if (!(key in submitted)) {
fields[key] = prop.isMultiple ? [] : ''
continue
}
let value = submitted[key]
if (prop.type === 'money') {
value = `${value ?? ''}|${submitted[`${key}_CURRENCY`] ?? ''}`
} else if (prop.type === 'crm_company') {
value = Number(value) || 0
} else if (prop.type === 'crm_contact') {
value = asArray(value).map(Number).filter((itemId) => itemId > 0)
} else if (prop.isMultiple) {
value = asArray(value).filter((item) => item !== '')
}
fields[key] = value
}
const id = parseInt(String(submitted.id ?? '0'), 10) || 0
const method = id > 0 ? 'crm.item.update' : 'crm.item.add'
const params = { entityTypeId: ENTITY_TYPE_ID, fields, useOriginalUfNames: 'Y' }
if (id > 0) params.id = id
const result = await call(method, params, `lead-${id > 0 ? 'update' : 'add'}`)
res.json({ message: `Lead saved, ID: ${result.item.id}` })
} catch (error) {
res.status(400).json({ error: error.message })
}
}
<?php
// composer require bitrix24/b24phpsdk:"^3.0"
require_once 'vendor/autoload.php';
use Bitrix24\SDK\Services\ServiceBuilderFactory;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Psr\Log\NullLogger;
$sb = (new ServiceBuilderFactory(new EventDispatcher(), new NullLogger()))
->initFromWebhook('https://your-domain.bitrix24.com/rest/USER_ID/TOKEN/');
const ENTITY_TYPE_ID = 1;
header('Content-Type: application/json; charset=utf-8');
function callCore($sb, string $method, array $params): array
{
return $sb->core->call($method, $params)->getResponseData()->getResult();
}
function buildMultifields(array $rows): array
{
$result = [];
$newIndex = 0;
foreach ($rows as $row)
{
$id = (int)($row['id'] ?? 0);
$value = (string)($row['value'] ?? '');
$shouldDelete = ($row['delete'] ?? '') === 'Y' || $value === '';
if ($id <= 0 && $shouldDelete) continue;
$key = $id > 0 ? (string)$id : 'n' . $newIndex++;
$result[$key] = [
'typeId' => (string)($row['typeId'] ?? 'PHONE'),
'valueType' => (string)($row['valueType'] ?? 'WORK'),
'value' => $shouldDelete ? '' : $value,
];
}
return $result;
}
try
{
$submitted = is_array($_POST['form'] ?? null) ? $_POST['form'] : [];
$fieldResult = callCore($sb, 'crm.item.fields', [
'entityTypeId' => ENTITY_TYPE_ID,
'useOriginalUfNames' => 'Y',
]);
$fields = [];
foreach ($fieldResult['fields'] as $key => $prop)
{
if (in_array($key, ['contactId', 'contacts'], true)
|| !empty($prop['isReadOnly'])
|| in_array($prop['type'], ['file', 'resourcebooking'], true))
{
continue;
}
if (in_array($prop['type'], ['boolean', 'char'], true))
{
$fields[$key] = array_key_exists($key, $submitted) ? 'Y' : 'N';
continue;
}
if ($prop['type'] === 'crm_multifield')
{
$fields[$key] = buildMultifields(is_array($_POST['fm'] ?? null) ? $_POST['fm'] : []);
continue;
}
if (!array_key_exists($key, $submitted))
{
$fields[$key] = !empty($prop['isMultiple']) ? [] : '';
continue;
}
$value = $submitted[$key];
if ($prop['type'] === 'money')
{
$value = (string)$value . '|' . (string)($submitted[$key . '_CURRENCY'] ?? '');
}
elseif ($prop['type'] === 'crm_company')
{
$value = (int)$value;
}
elseif ($prop['type'] === 'crm_contact')
{
$value = array_values(array_filter(array_map('intval', (array)$value)));
}
elseif (!empty($prop['isMultiple']))
{
$value = array_values(array_filter((array)$value, static fn($item) => $item !== ''));
}
$fields[$key] = $value;
}
$id = (int)($submitted['id'] ?? 0);
$method = $id > 0 ? 'crm.item.update' : 'crm.item.add';
$params = [
'entityTypeId' => ENTITY_TYPE_ID,
'fields' => $fields,
'useOriginalUfNames' => 'Y',
];
if ($id > 0) $params['id'] = $id;
$result = callCore($sb, $method, $params);
echo json_encode(['message' => 'Lead saved, ID: ' . $result['item']['id']], JSON_UNESCAPED_UNICODE);
}
catch (Throwable $error)
{
http_response_code(400);
echo json_encode(['error' => $error->getMessage()], JSON_UNESCAPED_UNICODE);
}
# pip install b24pysdk flask
import re
from flask import jsonify, request
from b24pysdk import BitrixWebhook, Client
ENTITY_TYPE_ID = 1
SKIPPED_FIELDS = {"contactId", "contacts"}
client = Client(BitrixWebhook(
domain="your-domain.bitrix24.com",
webhook_token="USER_ID/TOKEN",
))
def values_for(name):
return request.form.getlist(name) or request.form.getlist(f"{name}[]")
def build_multifields():
rows = {}
pattern = re.compile(r"^fm\[(\d+)]\[(id|typeId|valueType|value|delete)]$")
for full_key in request.form:
match = pattern.match(full_key)
if match:
rows.setdefault(match.group(1), {})[match.group(2)] = request.form[full_key]
result = {}
new_index = 0
for row in rows.values():
item_id = int(row.get("id") or 0)
value = row.get("value", "")
should_delete = row.get("delete") == "Y" or value == ""
if not item_id and should_delete:
continue
key = str(item_id) if item_id else f"n{new_index}"
if not item_id:
new_index += 1
result[key] = {
"typeId": row.get("typeId") or "PHONE",
"valueType": row.get("valueType") or "WORK",
"value": "" if should_delete else value,
}
return result
def save_form():
try:
submitted = {}
for full_key in request.form:
match = re.match(r"^form\[([^]]+)](\[\])?$", full_key)
if match:
submitted[match.group(1)] = values_for(full_key) if match.group(2) else request.form[full_key]
field_result = client.crm.item.fields(
entity_type_id=ENTITY_TYPE_ID,
use_original_uf_names=True,
).response.result
fields = {}
for key, prop in field_result["fields"].items():
if key in SKIPPED_FIELDS or prop.get("isReadOnly") or prop.get("type") in ("file", "resourcebooking"):
continue
field_type = prop.get("type")
if field_type in ("boolean", "char"):
fields[key] = "Y" if key in submitted else "N"
continue
if field_type == "crm_multifield":
fields[key] = build_multifields()
continue
if key not in submitted:
fields[key] = [] if prop.get("isMultiple") else ""
continue
value = submitted[key]
if field_type == "money":
value = f"{value}|{submitted.get(f'{key}_CURRENCY', '')}"
elif field_type == "crm_company":
value = int(value or 0)
elif field_type == "crm_contact":
value = [int(item_id) for item_id in (value if isinstance(value, list) else [value]) if item_id]
elif prop.get("isMultiple"):
value = [item for item in (value if isinstance(value, list) else [value]) if item != ""]
fields[key] = value
item_id = int(submitted.get("id") or 0)
if item_id > 0:
result = client.crm.item.update(
entity_type_id=ENTITY_TYPE_ID,
bitrix_id=item_id,
fields=fields,
use_original_uf_names=True,
).response.result
else:
result = client.crm.item.add(
entity_type_id=ENTITY_TYPE_ID,
fields=fields,
use_original_uf_names=True,
).response.result
return jsonify(message=f"Lead saved, ID: {result['item']['id']}")
except Exception as error:
return jsonify(error=str(error)), 400
Verify the Result
- Open the form page without the
IDparameter - Fill in the lead name, stage, source, amount, and currency. Check the Calculate amount manually checkbox. If necessary, specify a company identifier, several contact identifiers, a phone number, and an email address
- Click Save. Copy the identifier of the created lead from the message, for example
123 - Open the form with this identifier: JavaScript —
http://localhost:3000/?ID=123, PHP —http://localhost:8000/index.php?ID=123, Python —http://localhost:5000/?ID=123 - Verify that the form contains the saved values, including the company, all contacts, and the
fmentries - Change the name, amount, or source, then click Save again
- Open the lead card in Bitrix24 and check the modified fields
- Call crm.item.get with
entityTypeId = 1and the obtainedid. Make sure thatresult.itemcontains the savedtitle,stageId,sourceId,companyId,contactIds,opportunity,currencyId,isManualOpportunity, andfm
Errors and Diagnostics
|
Symptom |
What to check and fix |
|
The first request to Bitrix24 ends with an authorization error |
Check the full webhook address, user identifier, and secret token. Ensure that in Python |
|
|
Grant the webhook user permissions to read, add, and edit leads, read related companies and contacts, and access CRM settings. After fixing, reload the form page |
|
user.get returns an access error |
Add scope |
|
crm.item.get returns |
Check that a lead with this identifier exists and is accessible to the webhook user. After fixing, open the form with the correct |
|
|
Check the value of the field named in |
|
Multiple field error |
Pass |
|
After removing all values from a multiple "list" type custom field, the previous values remained |
The handler sends an empty array, the crm.item.update request succeeds, but crm.item.get returns the previous values. The clearing method in the example is not implemented |
|
The lead is not saved after submitting a required field |
Check |
|
The empty lead name was not saved |
The |
|
PHP does not start after installing dependencies |
Perform |
|
Port is already in use |
Stop the process on the port or start the server on a different port and change the verification address |
Key Considerations
-
The example outputs all available fields, so a large form may be inconvenient for a real application
-
File uploading requires a separate implementation
To upload a file via crm.item.add or crm.item.update, pass an array containing the filename and Base64 content to the field, for example
["document.pdf", "Base64"]. For a multiple field, pass an array of such pairs. crm.item.get returns the already uploaded file as an object with keysidandurl: use for reading the fileurl -
Types unknown to the generator are output as text fields; add format handling for them before using them in a production application
-
A multiple custom field of type
moneyrequires a separate implementation for multiple "amount — currency" pairs -
If all values are cleared from a multiple custom field of type "list", the handler will send an empty array. The crm.item.update request will succeed, but crm.item.get will return the previous values
-
crm.item.list returns paginated data. The example loads only the first page. If a lead has more than 50 associated contacts, some contact labels will be missing. For a production application, split
contactIdsinto groups of up to 50 identifiers and merge the results. The same limit applies to multiple fields of typeuser. The example also loads only the first page from crm.status.list and crm.currency.list — no more than 50 records. If a lookup list contains more values, some options will be missing from the form -
The example does not validate the format of phone numbers, email addresses, dates, and other values in the browser beyond the capabilities of standard HTML fields
Do not publish the example without your own authentication
The form page allows performing actions on behalf of the webhook user. Before publishing it on the internet, add your own authentication, user permission checks, CSRF protection, and secure error logging.