How to Create a Custom Contact Edit Form
Scope:
crm,user_briefWho can execute the scenario: a user with permissions to read, add, and edit contacts, read associated companies and leads, and access CRM settings. The
user_briefscope is 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 contact. The generator retrieves field descriptions using crm.item.fields and selects an HTML form control based on the type of each writable field. As a result, custom fields appear in the form without code changes.
If you open the page without the ID parameter, the handler creates a contact. If you pass an identifier, such as ?ID=123, the generator loads the contact using crm.item.get, populates the form, and the handler updates that contact.
The example consists of two files:
- the generator retrieves field descriptions, contact data, pipelines, and lookup values, then renders the HTML form
- the handler validates the form data against the current field descriptions and saves the contact
A contact does not have a separate card title field. The name and lastName fields serve this purpose. The API does not return a separate full name field, so verify these two fields after saving.
How the Scenario Works
- crm.item.fields returns the contact field descriptions in
result.fields - If the URL contains
ID, crm.item.get returns the contact values inresult.item, including thefmmultifield - crm.category.list returns contact pipelines in
result.categories - The generator replaces internal codes with readable names using additional methods
- crm.status.list — the
STATUS_IDandNAMEfields for the salutation, contact type, source, and other CRM lookups - crm.currency.list — the
CURRENCYandFULL_NAMEfields for currencies in custom fields of themoneytype - crm.item.list — the
idandtitlefields of associated companies - crm.item.get — the
titlefield of the associated lead - user.get — the users'
ID,NAME, andLAST_NAMEfields
- crm.status.list — the
- The handler retrieves the field descriptions again using crm.item.fields and converts the form values to the API types
- For a new contact, crm.item.add returns the created contact in
result.item. For an existing contact, crm.item.update returns the updated contact under the same key
1. Prepare the Environment
Create an Incoming Webhook with the crm and user_brief permissions.
Keep the Webhook Secret
The webhook sends requests with the permissions of the user who created it. Do not add the webhook URL to a public repository, client-side JavaScript, or error messages.
Choose one language, create a separate folder for the example, and open a terminal in it. First save the code from steps 2 and 3, then run the startup command.
|
Language |
Form Generator |
Handler |
|
JavaScript |
|
|
|
PHP |
|
|
|
Python |
|
|
Install the dependencies.
npm init -y
npm install @bitrix24/b24jssdk express
composer require bitrix24/b24phpsdk:"^3.0"
pip install b24pysdk flask
Before installing the dependencies, check the Node.js, PHP, and Python versions using node --version, php -v, and python --version, and list the PHP extensions using php -m. @bitrix24/b24jssdk supports Node.js 18, 20, 22, and later. B24PhpSDK version 3 requires PHP 8.4 or 8.5 and the bcmath, curl, intl, and json extensions. b24pysdk requires Python 3.9 or later. After installing the PHP dependencies, 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
In PHP and Python, the URL is specified twice: in the generator file and in the handler file. If you replace it in only one file, the form will open, but saving will not work.
Run the example. It is designed to run locally: the form page performs actions on behalf of the webhook user, so follow the requirements in the Key Considerations section before publishing it.
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 -d max_execution_time=0 -S localhost:8000
Bash and PowerShell:
python app.py
Two of these commands may look unusual. The generator builds the form using several consecutive requests, and the built-in PHP server can terminate the page after its 30-second limit. The max_execution_time parameter removes this limit for local runs. For the same reason, the Python example increases the client timeout: by default, b24pysdk waits about three seconds for a response, which can cause the request to fail on a busy Bitrix24 account.
The form pages will be available at these URLs:
|
Language |
New Contact |
Contact with ID |
|
JavaScript |
|
|
|
PHP |
|
|
|
Python |
|
|
2. Create the Form
The generator passes two parameters to crm.item.fields:
entityTypeId: 3— the contact object type identifier from the CRM Object Type tableuseOriginalUfNames: Y— return the original names of custom fieldsUF_*
In the Python SDK, the useOriginalUfNames parameter is named use_original_uf_names and accepts the Boolean value True.
The verified crm.item.fields response is shortened to the fields relevant to a contact:
{
"result": {
"fields": {
"name": {
"type": "string",
"isRequired": false,
"isReadOnly": false,
"isMultiple": false
},
"categoryId": {
"type": "crm_category",
"isRequired": false,
"isReadOnly": false,
"isMultiple": false
},
"companyIds": {
"type": "crm_company",
"isRequired": false,
"isReadOnly": false,
"isMultiple": true
},
"fm": {
"type": "crm_multifield",
"isRequired": false,
"isReadOnly": false,
"isMultiple": true
}
}
}
}
Other response properties are omitted.
The verified crm.item.get response for the contact being edited is shortened to the fields used by the form:
{
"result": {
"item": {
"id": 123,
"name": "Klaus",
"lastName": "Weber",
"categoryId": 0,
"companyIds": [27, 31],
"leadId": 17,
"fm": [
{
"id": 451,
"typeId": "PHONE",
"valueType": "WORK",
"value": "+49 30 12345678"
}
]
}
}
}
The fm field returns the numeric id of an existing value, while companyIds returns an array of company identifiers.
The contact description contains standard fields, links, lookups, and automatically calculated values.
|
Field |
Type or Attribute |
How the Form Uses It |
|
|
|
Displays a list of salutations |
|
|
Strings |
Displays the first and last name fields |
|
|
The |
Is not returned in either |
|
|
|
Displays an unsupported type message and does not send the field |
|
|
|
Displays a list of contact types |
|
|
|
Displays a list of sources |
|
|
BBCode-formatted text |
Displays a multiline field |
|
|
Required Boolean field |
Always displays a checkbox and sends |
|
|
User; the value cannot be cleared |
Displays a numeric field and the responsible person's name |
|
|
Multiple links to companies |
Displays identifiers in link order and company names |
|
|
Link to a lead |
Displays the identifier and attempts to retrieve the lead name |
|
|
Contact pipeline |
Displays a list from crm.category.list |
|
|
Multifield for phone numbers, email addresses, websites, and messengers |
Displays rows containing |
The birthdaySort field is also hidden by the Hidden attribute and is absent from result.fields in the crm.item.fields response. The hasPhone, hasEmail, hasImol, id, createdBy, updatedBy, createdTime, updatedTime, and lastCommunicationTime fields are read-only. The generator skips them based on isReadOnly, and the handler does not send them. The webformId field is returned in the description with isImmutable = true: it can be set only when adding a contact, while an update returns the previous value without an error. The generator and handler skip these fields as well.
Contacts have pipelines but no stages or Sales Funnels. The pipeline list includes the default pipeline with id = 0 and custom pipelines. For a new contact, the example selects categoryId = 0.
The NotDisplayed attribute describes how categoryId appears in the standard card; it does not prevent the field from being used through the universal REST API. The categoryId field is returned by crm.item.fields and accepted by crm.item.add and crm.item.update, so the form displays it.
Comments in comments are stored as BBCode. Plain text and BBCode are retained as entered, while Bitrix24 removes HTML tags and keeps only their content: <strong>Important contact</strong> is saved as Important contact. To apply emphasis, pass [b]Important contact[/b].
You can create contact custom fields using crm.contact.userfield.add and retrieve them using crm.contact.userfield.list. For the money type, the generator also retrieves currencies using crm.currency.list.
How the Form Handles Multifields
The fm field combines phone numbers, email addresses, websites, and messengers. The example supports the PHONE, EMAIL, WEB, and IM values for typeId. The value format depends on the type: a phone number, email address, URL, or messenger identifier.
The generator displays each existing value and stores its numeric id in a hidden field. After the existing values, it adds three empty rows for new data.
The generator maps field types to form controls.
|
Field Type |
Form Control |
|
|
|
|
|
One or more numeric fields and the current company names |
|
|
A numeric field and the current lead name, if available |
|
|
A numeric field and the user name |
|
|
Rows containing |
|
|
|
|
|
Checkbox |
|
|
Numeric field |
|
|
Amount and currency list |
|
|
Unsupported type message |
|
Other Types |
Text field |
If the webhook user cannot read the associated lead, the form still opens. The generator keeps the leadId field and adds a diagnostic message instead of the lead name.
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 = 3
const SKIPPED_FIELDS = new Set([
'companyId', 'companies', 'hasPhone', 'hasEmail', 'hasImol',
'birthdaySort', 'fullName', 'id', 'createdBy', 'updatedBy',
'createdTime', 'updatedTime', 'lastCommunicationTime',
])
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 current = params.MULTIPLE
? (Array.isArray(params.VALUE) ? params.VALUE : [params.VALUE ?? ''])
: [params.VALUE ?? '']
const values = params.MULTIPLE ? [...current, ''] : current
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.CHECKED) html += ' checked'
return html + ` value="${escapeHtml(value)}">`
}).join('')
}
function textarea(params) {
return `<textarea class="form-control" name="${escapeHtml(params.NAME)}"${params.REQUIRED ? ' required' : ''}>${escapeHtml(params.VALUE)}</textarea>`
}
function select(params, options) {
let html = `<select class="form-control" name="${escapeHtml(params.NAME)}${params.MULTIPLE ? '[]' : ''}"`
if (params.REQUIRED) html += ' required'
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('')
const itemId = row.id ?? ''
return `<div class="border rounded p-2 mb-2">
<input type="hidden" name="fm[${index}][id]" value="${escapeHtml(itemId)}">
<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">
${itemId ? `<label><input type="checkbox" name="fm[${index}][delete]" value="Y"> Delete</label>` : ''}
</div>`
}).join('')
}
app.get('/', async (req, res) => {
const rawId = String(req.query.ID ?? '').trim()
const id = /^\d+$/.test(rawId) ? Number(rawId) : 0
try {
const fields = (await call('crm.item.fields', {
entityTypeId: ENTITY_TYPE_ID,
useOriginalUfNames: 'Y',
}, 'contact-fields')).fields
const currencies = await call('crm.currency.list', {}, 'currencies')
const categories = (await call('crm.category.list', {
entityTypeId: ENTITY_TYPE_ID,
}, 'contact-categories')).categories
const item = id > 0
? (await call('crm.item.get', {
entityTypeId: ENTITY_TYPE_ID,
id,
useOriginalUfNames: 'Y',
}, 'contact-get')).item
: {}
const categoryOptions = Object.fromEntries(
categories.map((category) => [category.id, category.name]),
)
const diagnostics = []
let standard = ''
let custom = ''
for (const [key, field] of Object.entries(fields)) {
if (field.isReadOnly || field.isImmutable || SKIPPED_FIELDS.has(key)) continue
const value = key === 'categoryId' ? (item[key] ?? 0) : (item[key] ?? '')
const params = {
NAME: `form[${key}]`, VALUE: value,
REQUIRED: field.isRequired, MULTIPLE: field.isMultiple,
}
let control = ''
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_category') {
control = select(params, categoryOptions)
} 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' })
const ids = (Array.isArray(value) ? value : [value])
.map(Number).filter((companyId) => companyId > 0)
if (ids.length) {
const companies = (await call('crm.item.list', {
entityTypeId: 4,
filter: { '@id': ids },
select: ['id', 'title'],
}, `companies-${key}`)).items
const titleById = Object.fromEntries(
companies.map((company) => [String(company.id), company.title]),
)
const titles = ids.map((companyId) => titleById[String(companyId)] || `ID ${companyId}`)
control += ` (${escapeHtml(titles.join(', '))})`
}
} else if (field.type === 'crm_lead') {
control = input({ ...params, TYPE: 'number' })
if (Number(value) > 0) {
try {
const lead = (await call('crm.item.get', {
entityTypeId: 1,
id: Number(value),
}, `lead-${key}`)).item
control += ` (${escapeHtml(lead.title)})`
} catch (error) {
diagnostics.push(`Lead name for ID ${value} is unavailable: ${error.message}`)
}
}
} else if (field.type === 'user') {
control = input({ ...params, TYPE: 'number' })
const ids = (Array.isArray(value) ? value : [value])
.map(Number).filter((userId) => userId > 0)
if (ids.length) {
const users = await call('user.get', {
filter: { ID: ids },
}, `users-${key}`)
const names = users.map((user) => [user.NAME, user.LAST_NAME]
.filter((part) => part !== null && part !== undefined && part !== '')
.join(' '))
control += ` (${escapeHtml(names.join(', '))})`
}
} else if (key === 'comments') {
control = textarea(params)
} else if (['file', 'resourcebooking'].includes(field.type)) {
control = `The ${escapeHtml(field.type)} type is 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
}
const diagnosticHtml = diagnostics.length
? `<div class="alert alert-warning">${diagnostics.map(escapeHtml).join('<br>')}</div>`
: ''
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)}">` : ''}
${diagnosticHtml}
<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))
try {
const response = await fetch('/form', { method: 'POST', body })
const text = await response.text()
try {
const json = JSON.parse(text)
alert(json.message || json.error || ('Response code ' + response.status))
} catch (parseError) {
alert('The server response is not JSON, code ' + response.status + ': ' + text.slice(0, 200))
}
} catch (networkError) {
alert('Could not submit the form: ' + networkError.message)
}
})
<\/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/');
const ENTITY_TYPE_ID = 3;
const SKIPPED_FIELDS = [
'companyId', 'companies', 'hasPhone', 'hasEmail', 'hasImol',
'birthdaySort', 'fullName', 'id', 'createdBy', 'updatedBy',
'createdTime', 'updatedTime', 'lastCommunicationTime',
];
$rawId = trim((string)($_GET['ID'] ?? ''));
$ID = preg_match('/^\d+$/D', $rawId) === 1 ? (int)$rawId : 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
{
$current = !empty($params['MULTIPLE'])
? (is_array($params['VALUE']) ? $params['VALUE'] : [$params['VALUE'] ?? ''])
: [$params['VALUE'] ?? ''];
$values = !empty($params['MULTIPLE']) ? array_merge($current, ['']) : $current;
$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['CHECKED']) ? ' checked' : '';
$html .= ' value="' . esc($value) . '">';
}
return $html;
}
function textareaField(array $params): string
{
return '<textarea class="form-control" name="' . esc($params['NAME']) . '"'
. (!empty($params['REQUIRED']) ? ' required' : '') . '>'
. esc($params['VALUE'] ?? '') . '</textarea>';
}
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['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>';
}
$itemId = $row['id'] ?? '';
$html .= '<div class="border rounded p-2 mb-2">';
$html .= '<input type="hidden" name="fm[' . $index . '][id]" value="' . esc($itemId) . '">';
$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 ($itemId !== '' && $itemId !== null)
{
$html .= '<label><input type="checkbox" name="fm[' . $index
. '][delete]" value="Y"> Delete</label>';
}
$html .= '</div>';
}
return $html;
}
$fields = callCore($sb, 'crm.item.fields', [
'entityTypeId' => ENTITY_TYPE_ID,
'useOriginalUfNames' => 'Y',
])['fields'];
$currencies = callCore($sb, 'crm.currency.list', []);
$categories = callCore($sb, 'crm.category.list', [
'entityTypeId' => ENTITY_TYPE_ID,
])['categories'];
$item = $ID > 0 ? callCore($sb, 'crm.item.get', [
'entityTypeId' => ENTITY_TYPE_ID,
'id' => $ID,
'useOriginalUfNames' => 'Y',
])['item'] : [];
$currencyOptions = [];
foreach ($currencies as $currency)
{
$currencyOptions[$currency['CURRENCY']] = $currency['FULL_NAME'];
}
$categoryOptions = [];
foreach ($categories as $category)
{
$categoryOptions[$category['id']] = $category['name'];
}
$standard = '';
$custom = '';
$diagnostics = [];
foreach ($fields as $key => $field)
{
if (!empty($field['isReadOnly']) || !empty($field['isImmutable'])
|| in_array($key, SKIPPED_FIELDS, true)) continue;
$value = $key === 'categoryId' ? ($item[$key] ?? 0) : ($item[$key] ?? '');
$params = [
'NAME' => 'form[' . $key . ']', 'VALUE' => $value,
'REQUIRED' => $field['isRequired'], 'MULTIPLE' => $field['isMultiple'],
];
$control = '';
if ($field['type'] === 'crm_status')
{
$options = [];
foreach (callCore($sb, 'crm.status.list', [
'filter' => ['ENTITY_ID' => $field['statusType']],
]) as $row)
{
$options[$row['STATUS_ID']] = $row['NAME'];
}
$control = selectField($params, $options);
}
elseif ($field['type'] === 'crm_category')
{
$control = selectField($params, $categoryOptions);
}
elseif ($field['type'] === 'crm_currency')
{
$control = selectField($params, $currencyOptions);
}
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);
$ids = array_values(array_filter(array_map('intval', (array)$value)));
if ($ids)
{
$companyResult = callCore($sb, 'crm.item.list', [
'entityTypeId' => 4,
'filter' => ['@id' => $ids],
'select' => ['id', 'title'],
]);
$titleById = [];
foreach ($companyResult['items'] as $company)
{
$titleById[(string)$company['id']] = $company['title'];
}
$titles = [];
foreach ($ids as $companyId)
{
$titles[] = $titleById[(string)$companyId] ?? 'ID ' . $companyId;
}
$control .= ' (' . esc(implode(', ', $titles)) . ')';
}
}
elseif ($field['type'] === 'crm_lead')
{
$control = inputField(['TYPE' => 'number'] + $params);
if ((int)$value > 0)
{
try
{
$lead = callCore($sb, 'crm.item.get', [
'entityTypeId' => 1,
'id' => (int)$value,
])['item'];
$control .= ' (' . esc($lead['title']) . ')';
}
catch (Throwable $error)
{
$diagnostics[] = 'Lead name for ID ' . $value
. ' is unavailable: ' . $error->getMessage();
}
}
}
elseif ($field['type'] === 'user')
{
$control = inputField(['TYPE' => 'number'] + $params);
$ids = array_values(array_filter(array_map('intval', (array)$value)));
if ($ids)
{
$users = callCore($sb, 'user.get', ['filter' => ['ID' => $ids]]);
$names = [];
foreach ($users as $user)
{
$names[] = trim(($user['NAME'] ?? '') . ' ' . ($user['LAST_NAME'] ?? ''));
}
$control .= ' (' . esc(implode(', ', $names)) . ')';
}
}
elseif ($key === 'comments')
{
$control = textareaField($params);
}
elseif (in_array($field['type'], ['file', 'resourcebooking'], true))
{
$control = 'The ' . esc($field['type']) . ' type is 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 !== ''
? substr(str_replace(' ', 'T', (string)$value), 0, 19)
: '';
$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, $currencyOptions);
}
else
{
$control = inputField(['TYPE' => 'text'] + $params);
}
$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;
}
$diagnosticHtml = '';
if ($diagnostics)
{
$diagnosticHtml = '<div class="alert alert-warning">'
. implode('<br>', array_map('esc', $diagnostics)) . '</div>';
}
?>
<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 (array_key_exists('id', $item)): ?>
<input type="hidden" name="form[id]" value="<?= esc($item['id']) ?>">
<?php endif; ?>
<?= $diagnosticHtml ?>
<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));
try {
const response = await fetch('auto_form.php', { method: 'POST', body });
const text = await response.text();
try {
const json = JSON.parse(text);
alert(json.message || json.error || ('Response code ' + response.status));
} catch (parseError) {
alert('The server response is not JSON, code ' + response.status + ': ' + text.slice(0, 200));
}
} catch (networkError) {
alert('Could not submit the form: ' + networkError.message);
}
});
</script>
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 = 3
SKIPPED_FIELDS = {
"companyId", "companies", "hasPhone", "hasEmail", "hasImol",
"birthdaySort", "fullName", "id", "createdBy", "updatedBy",
"createdTime", "updatedTime", "lastCommunicationTime",
}
client = Client(BitrixWebhook(
domain="your-domain.bitrix24.com",
webhook_token="USER_ID/TOKEN",
), timeout=60)
def value_or_empty(value):
return "" if value is None else value
def input_field(params):
value = params.get("VALUE")
current = value if isinstance(value, list) else [value_or_empty(value)]
values = [*current, ""] if params.get("MULTIPLE") else current
html = ""
for index, item 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("CHECKED"):
html += " checked"
html += f' value="{escape(str(value_or_empty(item)))}">'
return html
def textarea_field(params):
required = " required" if params.get("REQUIRED") else ""
return (
f'<textarea class="form-control" name="{escape(params["NAME"])}"{required}>'
f'{escape(str(value_or_empty(params.get("VALUE"))))}</textarea>'
)
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("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(item) for item 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 = value_or_empty(row.get("id"))
delete = (
f'<label><input type="checkbox" name="fm[{index}][delete]" value="Y"> Delete</label>'
if item_id != "" else ""
)
value_type = value_or_empty(row.get("valueType"))
if value_type == "":
value_type = "WORK"
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(str(value_type))}" placeholder="WORK">
<input class="form-control mb-1" name="fm[{index}][value]" value="{escape(str(value_or_empty(row.get('value'))))}" 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
%(diagnostics)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));
try {
const response = await fetch('/form', { method: 'POST', body });
const text = await response.text();
try {
const json = JSON.parse(text);
alert(json.message || json.error || ('Response code ' + response.status));
} catch (parseError) {
alert('The server response is not JSON, code ' + response.status + ': ' + text.slice(0, 200));
}
} catch (networkError) {
alert('Could not submit the form: ' + networkError.message);
}
});
</script>
"""
@app.route("/")
def form_page():
raw_id = request.args.get("ID", "0")
try:
item_id = int(raw_id)
except (TypeError, ValueError):
item_id = 0
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
categories = client.crm.category.list(
entity_type_id=ENTITY_TYPE_ID,
).response.result["categories"]
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"]
currency_options = {
row["CURRENCY"]: row["FULL_NAME"] for row in currencies
}
category_options = {row["id"]: row["name"] for row in categories}
diagnostics = []
standard = ""
custom = ""
for key, field in fields.items():
if field.get("isReadOnly") or field.get("isImmutable") or key in SKIPPED_FIELDS:
continue
value = item.get(key, 0) if key == "categoryId" else value_or_empty(item.get(key))
params = {
"NAME": f"form[{key}]", "VALUE": value,
"REQUIRED": field.get("isRequired"), "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_category":
control = select_field(params, category_options)
elif field_type == "crm_currency":
control = select_field(params, currency_options)
elif field_type == "enumeration":
control = select_field(params, {
row.get("ID", row.get("id")): row.get("VALUE", row.get("value"))
for row in field.get("items", [])
})
elif field_type == "crm_multifield":
control = multifields(value)
elif field_type == "crm_company":
control = input_field({**params, "TYPE": "number"})
ids = [
int(company_id) for company_id in (value if isinstance(value, list) else [value])
if str(company_id).isdigit() and int(company_id) > 0
]
if ids:
companies = client.crm.item.list(
entity_type_id=4,
filter={"@id": ids},
select=["id", "title"],
).response.result["items"]
title_by_id = {str(company["id"]): company["title"] for company in companies}
titles = [title_by_id.get(str(company_id), f"ID {company_id}") for company_id in ids]
control += f" ({escape(', '.join(titles))})"
elif field_type == "crm_lead":
control = input_field({**params, "TYPE": "number"})
if str(value).isdigit() and int(value) > 0:
try:
lead = client.crm.item.get(
entity_type_id=1,
bitrix_id=int(value),
).response.result["item"]
control += f" ({escape(str(lead['title']))})"
except Exception as error:
diagnostics.append(f"Lead name for ID {value} is unavailable: {error}")
elif field_type == "user":
control = input_field({**params, "TYPE": "number"})
ids = [
int(user_id) for user_id in (value if isinstance(value, list) else [value])
if str(user_id).isdigit() and int(user_id) > 0
]
if ids:
users = client.user.get(filter={"ID": ids}).response.result
names = [
" ".join(
str(part) for part in (user.get("NAME"), user.get("LAST_NAME"))
if part not in (None, "")
)
for user in users
]
control += f" ({escape(', '.join(names))})"
elif key == "comments":
control = textarea_field(params)
elif field_type in ("file", "resourcebooking"):
control = f"The {escape(str(field_type))} 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,
}, currency_options)
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 "id" in item else ""
)
diagnostic_html = (
'<div class="alert alert-warning">'
+ "<br>".join(escape(str(message)) for message in diagnostics)
+ "</div>"
if diagnostics else ""
)
return PAGE % {
"hidden_id": hidden_id, "diagnostics": diagnostic_html,
"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 browser submits the form to the handler in application/x-www-form-urlencoded format. The handler calls crm.item.fields again so that it does not trust types from the client request.
The handler skips fields with isReadOnly = true and isImmutable = true, internal automatically calculated fields, and the file and resourcebooking types. Therefore, the photo field retains its previous value.
For the remaining fields, the handler performs these conversions:
- passes
booleanandchar, including the requiredopenedfield, asYorN - converts
crm_company,crm_lead, anduserto numeric identifiers while accounting for multiple values - combines the amount and currency for
moneyinto theamount|currencystring - passes a missing single-value field as an empty string and a multiple field as an empty array
If assignedById is absent from the submitted form, the handler passes an empty string. When updating an existing contact, Bitrix24 restores the previous responsible person without an error.
How the Handler Builds fm
Each fm row contains a hidden id and visible typeId, valueType, and value fields.
- for an existing nonempty value, its numeric
idbecomes the key, so the server updates the same entry - for a new nonempty value, the handler creates the key
n0,n1, and so on, so the server adds an entry - if an existing value is cleared or the Delete checkbox is selected, the handler retains its
idbut passes an emptyvalue, so the server deletes the entry - the handler does not send empty rows without an
id
All fm changes are sent in a single request. For example, the object can look like this:
{
"451": { "typeId": "PHONE", "valueType": "WORK", "value": "+49 30 12345678" },
"452": { "typeId": "EMAIL", "valueType": "WORK", "value": "" },
"n0": { "typeId": "EMAIL", "valueType": "WORK", "value": "contact@example.com" }
}
Entry 451 is updated, 452 is deleted, and n0 is added.
If the form contains id, the handler calls crm.item.update. Without id, it calls crm.item.add. The saved contact identifier is located in result.item.id:
{
"result": {
"item": {
"id": 123
}
}
}
The next action requires result.item.id: add it to the ID parameter in the form URL to open the created contact 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 = 3
const SKIPPED_FIELDS = new Set([
'companyId', 'companies', 'hasPhone', 'hasEmail', 'hasImol',
'birthdaySort', 'fullName', 'id', 'createdBy', 'updatedBy',
'createdTime', 'updatedTime', 'lastCommunicationTime',
])
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
}
function relationValue(value, multiple) {
const ids = asArray(value).map(Number).filter((itemId) => itemId > 0)
return multiple ? ids : (ids[0] ?? 0)
}
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',
}, 'contact-fields-save')
const fields = {}
for (const [key, prop] of Object.entries(fieldResult.fields)) {
if (SKIPPED_FIELDS.has(key) || prop.isReadOnly || prop.isImmutable
|| ['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') {
const amount = String(value ?? '').trim()
const currency = String(submitted[`${key}_CURRENCY`] ?? '').trim()
value = amount === '' ? '' : (currency === '' ? amount : `${amount}|${currency}`)
} else if (['crm_company', 'crm_lead', 'user'].includes(prop.type)) {
value = relationValue(value, prop.isMultiple)
} else if (prop.isMultiple) {
value = asArray(value).filter((item) => item !== '')
}
fields[key] = value
}
const rawId = String(submitted.id ?? '').trim()
const id = /^\d+$/.test(rawId) ? Number(rawId) : 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, `contact-${id > 0 ? 'update' : 'add'}`)
res.json({ message: `Contact 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 = 3;
const SKIPPED_FIELDS = [
'companyId', 'companies', 'hasPhone', 'hasEmail', 'hasImol',
'birthdaySort', 'fullName', 'id', 'createdBy', 'updatedBy',
'createdTime', 'updatedTime', 'lastCommunicationTime',
];
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;
}
function relationValue($value, bool $multiple)
{
$ids = array_values(array_filter(array_map('intval', (array)$value)));
return $multiple ? $ids : ($ids[0] ?? 0);
}
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, SKIPPED_FIELDS, true)
|| !empty($prop['isReadOnly'])
|| !empty($prop['isImmutable'])
|| 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')
{
$amount = trim((string)$value);
$currency = trim((string)($submitted[$key . '_CURRENCY'] ?? ''));
$value = $amount === '' ? '' : ($currency === '' ? $amount : $amount . '|' . $currency);
}
elseif (in_array($prop['type'], ['crm_company', 'crm_lead', 'user'], true))
{
$value = relationValue($value, !empty($prop['isMultiple']));
}
elseif (!empty($prop['isMultiple']))
{
$value = array_values(array_filter(
(array)$value,
static fn($item) => $item !== ''
));
}
$fields[$key] = $value;
}
$rawId = trim((string)($submitted['id'] ?? ''));
$id = preg_match('/^\d+$/D', $rawId) === 1 ? (int)$rawId : 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' => 'Contact saved, ID: ' . $result['item']['id']],
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
}
catch (Throwable $error)
{
http_response_code(400);
echo json_encode(['error' => $error->getMessage()], JSON_UNESCAPED_UNICODE);
}
import re
from flask import jsonify, request
from b24pysdk import BitrixWebhook, Client
ENTITY_TYPE_ID = 3
SKIPPED_FIELDS = {
"companyId", "companies", "hasPhone", "hasEmail", "hasImol",
"birthdaySort", "fullName", "id", "createdBy", "updatedBy",
"createdTime", "updatedTime", "lastCommunicationTime",
}
FORM_KEY = re.compile(r"^form\[([^]]+)](\[\])?$")
FM_KEY = re.compile(r"^fm\[(\d+)]\[(id|typeId|valueType|value|delete)]$")
client = Client(BitrixWebhook(
domain="your-domain.bitrix24.com",
webhook_token="USER_ID/TOKEN",
), timeout=60)
def parse_form():
result = {}
for full_key in request.form:
match = FORM_KEY.match(full_key)
if not match:
continue
values = request.form.getlist(full_key)
result[match.group(1)] = values if match.group(2) else values[-1]
return result
def parse_multifield_rows():
rows = {}
for full_key in request.form:
match = FM_KEY.match(full_key)
if match:
rows.setdefault(int(match.group(1)), {})[match.group(2)] = request.form[full_key]
return rows
def build_multifields(rows):
result = {}
new_index = 0
for row in rows.values():
try:
item_id = int(row.get("id") or 0)
except (TypeError, ValueError):
item_id = 0
value = str(row.get("value") or "")
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": str(row.get("typeId") or "PHONE"),
"valueType": str(row.get("valueType") or "WORK"),
"value": "" if should_delete else value,
}
return result
def relation_value(value, multiple):
values = value if isinstance(value, list) else [value]
ids = [int(item_id) for item_id in values if str(item_id).isdigit() and int(item_id) > 0]
return ids if multiple else (ids[0] if ids else 0)
def save_form():
try:
submitted = parse_form()
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("isImmutable")
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(parse_multifield_rows())
continue
if key not in submitted:
fields[key] = [] if prop.get("isMultiple") else ""
continue
value = submitted[key]
if field_type == "money":
amount = str(value or "").strip()
currency = str(submitted.get(f"{key}_CURRENCY", "") or "").strip()
value = "" if amount == "" else (amount if currency == "" else f"{amount}|{currency}")
elif field_type in ("crm_company", "crm_lead", "user"):
value = relation_value(value, bool(prop.get("isMultiple")))
elif prop.get("isMultiple"):
values = value if isinstance(value, list) else [value]
value = [item for item in values if item != ""]
fields[key] = value
try:
item_id = int(submitted.get("id") or 0)
except (TypeError, ValueError):
item_id = 0
if item_id > 0:
response = client.crm.item.update(
entity_type_id=ENTITY_TYPE_ID,
bitrix_id=item_id,
fields=fields,
use_original_uf_names=True,
).response
else:
response = client.crm.item.add(
entity_type_id=ENTITY_TYPE_ID,
fields=fields,
use_original_uf_names=True,
).response
return jsonify(message=f"Contact saved, ID: {response.result['item']['id']}")
except Exception as error:
return jsonify(error=str(error)), 400
Verify the Result
- Open the page without
ID - Enter a first or last name, then select a salutation, contact type, source, and pipeline
- Enter a comment in BBCode, such as
[b]Contact for a follow-up call[/b] - In the first empty
fmrow, keepPHONEandWORK, then enter a phone number. In the second row, selectEMAIL, keepWORK, and enter an email address - Add two company identifiers in the required order and, if necessary, a lead identifier. Retrieve suitable identifiers using crm.item.list with
entityTypeId = 4for companies andentityTypeId = 1for leads, or copy them from the card URL in Bitrix24 - Click Save and copy the contact identifier from the message
- Open the form with this identifier and verify the first name, last name, pipeline, companies, lead, comment, and all
fmrows - Change the phone number, select Delete for the old email address, add a new email address, and save the form again
- In the browser developer tools, remove the element with
name="form[assignedById]", save the form, and verify that the previous responsible person remains assigned without an error - Call crm.item.get with
entityTypeId = 3, the retrievedid, anduseOriginalUfNames = Y. Compareresult.itemwith the form and verifyname,lastName,companyIds, andfm
Also verify the name rule: clear both name and lastName for an existing contact. crm.item.update should return the CRM_FIELD_ERROR_REQUIRED error. When creating a contact with empty name and lastName, Bitrix24 assigns a default name, but the example recommends filling in at least one field.
Errors and Diagnostics
|
Symptom |
What to Check and Fix |
|
The first request to Bitrix24 fails with an authorization error |
Check the full webhook URL, user identifier, and secret token. In Python, make sure |
|
|
Grant the webhook user permissions to read, add, and edit contacts, read associated companies and leads, and access CRM settings. Reload the form page after correcting the permissions |
|
user.get returns an access error |
Add the |
|
The form opens empty or crm.item.get returns an error |
The example converts a nonnumeric, zero, or negative |
|
|
You cannot clear both the first and last name of an existing contact. Fill in at least one field and submit the form again |
|
|
Check the field named in |
|
Previous values remain after clearing all values from a multiple custom list field |
The handler sends an empty array, and crm.item.update succeeds, but crm.item.get returns the previous values. The example does not implement a way to clear this field |
|
A message appears instead of the associated lead name |
The webhook user does not have permission to read this lead. Leave |
|
PHP does not start after installing the dependencies |
Run |
Key Considerations
- The
companyIdfield is deprecated, butcompanyIdsis available in the universal methods and is not deprecated. When saving, identifier order is preserved throughSORT, the first company becomes the primary company, existing link roles are retained, and new links receive the default role - The form manages only the
companyIdslist and does not directly editROLE_ID, the primary company flag, orSORT. Retrieve advanced link properties using crm.contact.company.items.get, replace them using crm.contact.company.items.set, add them using crm.contact.company.add, and delete them using crm.contact.company.delete - The system
photofield has its own rules. The universal crm.item.update method does not accept a photo: both the["photo.jpg", "Base64"]array and the{"fileData": ["photo.jpg", "Base64"]}object return an error. Set a photo using crm.contact.update by passing the{"fileData": ["photo.jpg", "Base64"]}object in thePHOTOfield. crm.item.get then returns the photo as a number, such as36933, which is the file identifier - A custom file field works differently: upload the file as an array containing the file name and Base64 content using crm.item.update with
useOriginalUfNames = Y. ThefileDatavariant succeeds without an error, but the field remains empty. In the response, this field is returned as an object with theid,url, andurlMachinekeys. Both URLs point to an internal file download and may contain authorization data. Do not record them in logs, reports, or error messages, and do not share them with third parties - A custom field of the
moneytype accepts either an amount alone or an amount with a currency. If you send1234.56, Bitrix24 adds the default currency and saves1234.56|EUR. If the amount is missing, the field is cleared:"",|EUR, andEURare saved as empty values. Therefore, the handler sends an empty string when the field is blank. The currency code is not validated, so1234.56|XXXis saved as entered - A multiple custom field of the
moneytype requires separate handling for multiple amount-currency pairs - To retain the value of a field that is absent from the form, add it to the skipped fields list
- crm.item.list returns no more than 50 items per page. If a contact has more than 50 associated companies or values in a multiple
userfield, add pagination - crm.category.list, crm.status.list, and crm.currency.list return no more than 50 records per call. The example retrieves only the first page, so request subsequent pages using
startin a production application - Add your own authentication, CSRF protection, and secure error logging in a production application