Developing a Callback Request Form for 1C-Bitrix
A callback form is one of the highest-converting elements on a website when implemented correctly. The standard bitrix:main.feedback component technically works, but it falls short in real-world projects: there is no client-side validation, no spam protection, no CRM and telephony integration, and no operator working-hours management. Custom development addresses all of these requirements.
Component Architecture
The form consists of four layers:
Client-side JS
→ Phone field validation (mask + regex)
→ AJAX request to /local/api/callback.php
→ Server-side validation + anti-spam
→ Lead creation in Bitrix CRM
→ (Optional) Call initiation via Asterisk/Mango/Zadarma
→ Email/SMS notification to the manager
Server Handler
// /local/api/callback.php
require_once($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php');
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit(json_encode(['success' => false, 'error' => 'Method not allowed']));
}
$data = json_decode(file_get_contents('php://input'), true);
// CSRF check
$csrfToken = $data['sessid'] ?? '';
if (!\bitrix_sessid_check($csrfToken)) {
http_response_code(403);
exit(json_encode(['success' => false, 'error' => 'Invalid session']));
}
$phone = preg_replace('/\D/', '', $data['phone'] ?? '');
// Phone validation
if (!preg_match('/^[78]\d{10}$/', $phone)) {
exit(json_encode(['success' => false, 'error' => 'Invalid phone number format']));
}
$phone = '+7' . substr($phone, -10);
// Anti-spam: no more than 2 submissions from the same IP per hour
$limiter = new \Local\Callback\RateLimiter();
if (!$limiter->allow($_SERVER['REMOTE_ADDR'])) {
exit(json_encode(['success' => false, 'error' => 'Too many requests. Please try again later.']));
}
// Create lead in CRM
$leadCreator = new \Local\Callback\LeadCreator();
$leadId = $leadCreator->create([
'phone' => $phone,
'name' => htmlspecialchars(mb_substr($data['name'] ?? '', 0, 100)),
'comment' => htmlspecialchars(mb_substr($data['comment'] ?? '', 0, 500)),
'source' => $data['source'] ?? 'callback_form',
'page' => $_SERVER['HTTP_REFERER'] ?? '',
'utm' => $data['utm'] ?? [],
]);
// Initiate callback if within working hours
$scheduler = new \Local\Callback\WorkSchedule();
if ($scheduler->isWorkingNow()) {
(new \Local\Callback\AutoDialer())->initiate($phone, $leadId);
$message = 'We will call you back within 2 minutes';
} else {
$message = 'We will call you back at the next available working time: ' . $scheduler->getNextWorkStart();
}
exit(json_encode(['success' => true, 'message' => $message, 'lead_id' => $leadId]));
Creating a Lead in CRM
namespace Local\Callback;
class LeadCreator
{
public function create(array $data): int
{
$fields = [
'TITLE' => 'Callback: ' . $data['phone'],
'NAME' => $data['name'] ?: 'Client',
'PHONE' => [['VALUE' => $data['phone'], 'VALUE_TYPE' => 'WORK']],
'SOURCE_ID' => 'CALLBACK',
'STATUS_ID' => 'NEW',
'ASSIGNED_BY_ID' => $this->getAvailableManager(),
'COMMENTS' => $this->buildComment($data),
'UF_UTM_SOURCE' => $data['utm']['utm_source'] ?? '',
'UF_UTM_CAMPAIGN'=> $data['utm']['utm_campaign'] ?? '',
'UF_CALLBACK_PAGE' => mb_substr($data['page'] ?? '', 0, 255),
];
$lead = new \CCrmLead(false);
$leadId = $lead->Add($fields, true);
if ($leadId) {
// Add a task for the manager: call back
$this->addCallTask($leadId, $data['phone'], $fields['ASSIGNED_BY_ID']);
}
return (int)$leadId;
}
private function addCallTask(int $leadId, string $phone, int $assigneeId): void
{
\CCrmActivity::Add([
'TYPE_ID' => \CCrmActivityType::Call,
'SUBJECT' => 'Call back: ' . $phone,
'OWNER_TYPE_ID' => \CCrmOwnerType::Lead,
'OWNER_ID' => $leadId,
'RESPONSIBLE_ID' => $assigneeId,
'DEADLINE' => (new \Bitrix\Main\Type\DateTime())->add('+1H'),
'COMPLETED' => 'N',
]);
}
private function getAvailableManager(): int
{
// Round-robin: select the manager with the fewest open leads
$managers = [5, 7, 12, 15]; // employee IDs
$counts = [];
foreach ($managers as $id) {
$res = \CCrmLead::GetList(
[], ['ASSIGNED_BY_ID' => $id, 'STATUS_ID' => 'NEW'],
['COUNT' => true]
);
$counts[$id] = (int)$res;
}
asort($counts);
return array_key_first($counts);
}
}
Working Hours Management
namespace Local\Callback;
class WorkSchedule
{
private array $schedule = [
1 => ['09:00', '19:00'], // Mon
2 => ['09:00', '19:00'], // Tue
3 => ['09:00', '19:00'], // Wed
4 => ['09:00', '19:00'], // Thu
5 => ['09:00', '19:00'], // Fri
6 => ['10:00', '16:00'], // Sat
0 => null, // Sun — day off
];
public function isWorkingNow(): bool
{
$tz = new \DateTimeZone('Europe/Moscow');
$now = new \DateTime('now', $tz);
$dow = (int)$now->format('w'); // 0=Sun
$hours = $this->schedule[$dow] ?? null;
if (!$hours) return false;
$start = \DateTime::createFromFormat('H:i', $hours[0], $tz);
$end = \DateTime::createFromFormat('H:i', $hours[1], $tz);
return $now >= $start && $now < $end;
}
public function getNextWorkStart(): string
{
$tz = new \DateTimeZone('Europe/Moscow');
$now = new \DateTime('now', $tz);
for ($i = 1; $i <= 7; $i++) {
$next = clone $now;
$next->modify("+{$i} day");
$dow = (int)$next->format('w');
$hours = $this->schedule[$dow] ?? null;
if ($hours) {
$next->setTime(...explode(':', $hours[0]));
return $next->format('d.m at H:i');
}
}
return 'Monday';
}
}
Rate Limiter via Bitrix Cache
namespace Local\Callback;
class RateLimiter
{
private const MAX_ATTEMPTS = 2;
private const WINDOW_SECONDS = 3600;
public function allow(string $identifier): bool
{
$key = 'callback_rl_' . md5($identifier);
$cache = \Bitrix\Main\Application::getInstance()->getManagedCache();
$count = (int)$cache->get($key);
if ($count >= self::MAX_ATTEMPTS) {
return false;
}
$cache->set($key, $count + 1, self::WINDOW_SECONDS);
return true;
}
}
Client Side: Form with Mask and AJAX
(function () {
const form = document.getElementById('callback-form');
if (!form) return;
const phoneInput = form.querySelector('[name="phone"]');
// Phone input mask
phoneInput.addEventListener('input', function () {
let val = this.value.replace(/\D/g, '');
if (val.startsWith('8') || val.startsWith('7')) val = val.slice(1);
val = val.slice(0, 10);
let formatted = '+7 ';
if (val.length > 0) formatted += '(' + val.slice(0, 3);
if (val.length >= 3) formatted += ') ' + val.slice(3, 6);
if (val.length >= 6) formatted += '-' + val.slice(6, 8);
if (val.length >= 8) formatted += '-' + val.slice(8, 10);
this.value = formatted;
});
form.addEventListener('submit', async function (e) {
e.preventDefault();
const submitBtn = form.querySelector('[type="submit"]');
submitBtn.disabled = true;
const phone = phoneInput.value.replace(/\D/g, '');
if (phone.length < 11) {
showError('Please enter a valid phone number');
submitBtn.disabled = false;
return;
}
const payload = {
phone : phone,
name : form.querySelector('[name="name"]')?.value || '',
sessid : BX.bitrix_sessid(),
utm : getUtmParams(),
};
try {
const res = await fetch('/local/api/callback.php', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
body : JSON.stringify(payload),
});
const data = await res.json();
if (data.success) {
showSuccess(data.message);
form.reset();
} else {
showError(data.error || 'An error occurred');
}
} catch {
showError('Connection error. Please try again.');
}
submitBtn.disabled = false;
});
function getUtmParams() {
const params = new URLSearchParams(window.location.search);
return {
utm_source : params.get('utm_source') || getCookie('utm_source') || '',
utm_campaign : params.get('utm_campaign') || getCookie('utm_campaign') || '',
};
}
})();
Scope of Work
- Custom component
local:callback.formwith templates (popup, inline form, floating button) - Server handler with CSRF, rate limiting, and validation
- Lead creation in CRM, manager task assignment, responsible party rotation
- Working-hours schedule with timezone support
- JS: phone mask, AJAX submission, UTM tracking
- Email/SMS notification on new submission
- (Optional) Auto-dial via telephony API
Timeline: basic form with CRM integration — 1–2 weeks. Full feature set with auto-dial, schedule, and analytics — 3–4 weeks.







