Привет, Хабр! Меня зовут Иван, я разработчик из команды KISLOROD. Рассказываю, как мы настроили автоимпорт резюме с hh.ru в Битрикс24: от нюансов API до фильтрации и борьбы с дублями — без лишней магии, но с кучей тонкостей.
О проекте
Клиент — крупное промышленное предприятие. В день — до 40 откликов с hh.ru, каждый обрабатывался вручную: открыть, скачать, скопировать, создать сделку, прикрепить файл, оставить комментарий. HR-отдел тратил на это по 3–4 часа в день, ошибки, дубли, потери кандидатов — все по классике.
Цель проекта простая: автоматизировать подбор без ручного участия. А если точнее — раз в сутки получать все новые резюме с hh.ru, удовлетворяющие условиям: профессия «сварщик» или «оператор ЧПУ» и регион проживания «Чувашия». Резюме с такими параметрами должны автоматически передаваться в Bitrix24, где создается контакт и сделка, к которой он прикрепляется. Дубли исключаются, ни один кандидат не должен появляться дважды.

Регистрация приложения и работа с API hh.ru
Для доступа к API hh.ru нужно зарегистрировать приложение на dev.hh.ru. После регистрации техподдержка проверяет заявку вручную, это может занять до двух недель — мы заложили время с запасом.
API платное: для доступа к контактам необходимо приобрести платный пакет определенного региона. Например, в нашей задаче мы работали с Чувашией. В пакете — N запросов. Бесплатные запросы уходят на получение списков резюме, платные — на раскрытие контактов.
Контакт списывается один раз: если резюме уже открыто, повторный просмотр бесплатный. Поэтому важно уметь определять, открыт ли контакт ранее. Мы ориентируемся на наличие ключа actions.get_with_contact.url: если его нет, используем actions.url — контакт уже доступен.

Авторизация: стандартная, но с ограничениями
hh.ru использует OAuth 2.0. Чтобы получить доступ к API, сначала нужно зарегистрировать приложение и пройти модерацию. После этого мы получаем временный authorization_code, который используется всего один раз: для обмена на пару токенов accessToken и refreshToken.
Обмен происходит через POST-запрос: POST https://hh.ru/oauth/token
В ответе сервер возвращает срок жизни accessToken в параметре expires_in — он приходит в секундах. Это ключевой параметр: мы не можем обновлять токен заранее, только строго по истечении срока действия. Если попробовать обновить accessToken раньше, получим ошибку. Поэтому в скрипте реализована проверка на expires_in, чтобы точно понимать, когда пора обновлять токен, и не терять доступ к API.
Если по какой-то причине токен не был обновлен, скрипт останавливается и не делает запросы, чтобы не получить 401 Unauthorized и не тратить лимит впустую.
Такой подход добавляет немного логики, но делает интеграцию стабильной и предсказуемой: токены обновляются только при необходимости и в нужный момент.
Реализованные методы по получению токенов доступа
Скрытый текст
/**
* Требуется выполнить для первого получения пары accessToken и refreshToken.
* code - можно получить, авторизуясь по ссылке https://hh.ru/oauth/authorize?response_type=code&client_id={client_id}
*/
public function getFirstTokens($code)
{
$parameters = [
'grant_type' => 'authorization_code',
'client_id' => self::CLIENT_ID,
'client_secret' => self::CLIENT_SECRET,
'code' => $code
];
$uri = $this->getUri(self::SERVICE_URL, self::RESOURCE_AUTHENTICATE);
$currentTime = time();
$response = $this->doRequest($uri, self::METHOD_POST, $parameters, false);
$result = json_decode($response);
if ($result->access_token) {
$this->setAccessToken($result->access_token);
Option::set('main', 'access_token', $result->access_token);
}
if ($result->refresh_token) {
$this->setRefreshToken($result->refresh_token);
Option::set('main', 'refresh_token', $result->refresh_token);
}
if ($result->expires_in) {
$tokenEndTime = $currentTime + $result->expires_in;
$this->setTokenEndTime($tokenEndTime);
Option::set('main', 'token_end_time', $currentTime + $result->expires_in);
}
}
public function refreshTokens()
{
$refreshToken = $this->getRefreshToken();
if (!$refreshToken) {
return false;
}
$tokenEndTime = $this->getTokenEndTime();
$currentTime = time();
if ($tokenEndTime > $currentTime) {
return false;
}
$parameters = [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken
];
$uri = $this->getUri(self::SERVICE_URL, self::RESOURCE_AUTHENTICATE);
$response = $this->doRequest($uri, self::METHOD_POST, $parameters, false);
$result = json_decode($response);
if ($result->error) {
return false;
}
if ($result->access_token) {
$this->setAccessToken($result->access_token);
Option::set('main', 'access_token', $result->access_token);
}
if ($result->refresh_token) {
$this->setRefreshToken($result->refresh_token);
Option::set('main', 'refresh_token', $result->refresh_token);
}
if ($result->expires_in) {
$tokenEndTime = $currentTime + $result->expires_in;
$this->setTokenEndTime($tokenEndTime);
Option::set('main', 'token_end_time', $currentTime + $result->expires_in);
}
}
Работа с фильтром area: как мы поймали ложноположительные срезы
По умолчанию метод hh.ru/resumes возвращает не только резюме из указанного региона, но и соискателей, которые готовы к переезду. Это поведение неочевидно и может сильно исказить выборку: в нашем случае в запрос на Чувашию попадали кандидаты из Москвы, Питера и даже Узбекистана — только потому, что они отметили в профиле «готов к переезду».

Чтобы фильтр работал корректно и возвращал именно локальных соискателей, мы сделали следующее:
Сначала получаем список всех регионов с помощью запроса GET /areas и находим нужные id (в нашем случае — Чувашия и прилегающие регионы);
При формировании запроса передаём фильтр как area=1652&area=3680, повторяя параметр area — именно так требует hh API. Если попытаться передать массив (area[]=...), фильтр просто игнорируется;
Добавляем логирование: если фильтр составлен некорректно или выборка вернулась пустой — сразу видим, что именно не сработало, и можем быстро поправить.
Этот блок оказался неожиданно оказался «своенравным»: на него ушло больше времени, чем на реализацию авторизации и работу с токенами. Без правильной передачи area фильтр работает нестабильно, и это не сразу очевидно. Мы поймали это на этапе тестов, когда в выборке внезапно оказались резюме не из региона — и только тогда поняли, как hh API интерпретирует фильтры.
Теперь фильтр отрабатывает точно: в CRM попадают только нужные резюме, а не все подряд с пометкой «готов к переезду».
Класс по работе с API
Скрытый текст
<?php
namespace O2k\HeadHunter\Integration;
use \Bitrix\Main\Config\Option;
class Api
{
const CLIENT_ID = '...';
const CLIENT_SECRET = '...';
const SERVICE_URL = 'https://hh.ru/';
const API_URL = 'https://api.hh.ru/';
const APPLICATION_NAME = 'Интеграция с CRM Битрикс24';
const APPLICATION_EMAIL = '...';
const RESOURCE_AUTHENTICATE = 'oauth/token';
const RESOURCE_RESUMES = 'resumes';
const RESOURCE_AREAS = 'areas';
const METHOD_GET = 'GET';
const METHOD_POST = 'POST';
private $accessToken;
private $refreshToken;
/**
* Требуется выполнить для первого получения пары accessToken и refreshToken.
* code - можно получить, авторизуясь по ссылке https://hh.ru/oauth/authorize?response_type=code&client_id={client_id}
*/
public function getFirstTokens($code)
{
$parameters = [
'grant_type' => 'authorization_code',
'client_id' => self::CLIENT_ID,
'client_secret' => self::CLIENT_SECRET,
'code' => $code
];
$uri = $this->getUri(self::SERVICE_URL, self::RESOURCE_AUTHENTICATE);
$currentTime = time();
$response = $this->doRequest($uri, self::METHOD_POST, $parameters, false);
$result = json_decode($response);
if ($result->access_token) {
$this->setAccessToken($result->access_token);
Option::set('main', 'access_token', $result->access_token);
}
if ($result->refresh_token) {
$this->setRefreshToken($result->refresh_token);
Option::set('main', 'refresh_token', $result->refresh_token);
}
if ($result->expires_in) {
$tokenEndTime = $currentTime + $result->expires_in;
$this->setTokenEndTime($tokenEndTime);
Option::set('main', 'token_end_time', $currentTime + $result->expires_in);
}
}
public function refreshTokens()
{
$refreshToken = $this->getRefreshToken();
if (!$refreshToken) {
return false;
}
$tokenEndTime = $this->getTokenEndTime();
$currentTime = time();
if ($tokenEndTime > $currentTime) {
return false;
}
$parameters = [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken
];
$uri = $this->getUri(self::SERVICE_URL, self::RESOURCE_AUTHENTICATE);
$response = $this->doRequest($uri, self::METHOD_POST, $parameters, false);
$result = json_decode($response);
if ($result->error) {
return false;
}
if ($result->access_token) {
$this->setAccessToken($result->access_token);
Option::set('main', 'access_token', $result->access_token);
}
if ($result->refresh_token) {
$this->setRefreshToken($result->refresh_token);
Option::set('main', 'refresh_token', $result->refresh_token);
}
if ($result->expires_in) {
$tokenEndTime = $currentTime + $result->expires_in;
$this->setTokenEndTime($tokenEndTime);
Option::set('main', 'token_end_time', $currentTime + $result->expires_in);
}
}
public function getResumes($parameters)
{
$pageNumber = $pagesCount = 0;
$items = [];
do {
$parameters['page'] = $pageNumber;
$pageNumber++;
$uri = $this->getUri(self::API_URL, self::RESOURCE_RESUMES, $parameters);
if ($uri) {
$response = $this->doRequest($uri, self::METHOD_GET);
$result = json_decode($response, true);
$items = array_merge($items, $result['items']);
$pagesCount = $result['pages'];
}
} while ($pageNumber < $pagesCount);
if ($items) {
$this->excludeAnonymousResumes($items, ['email', 'phones']);
}
return $items;
}
protected function excludeAnonymousResumes(&$items, $checkedFields) {
foreach ($items as $key => $item) {
$hiddenIds = [];
if ($item['hidden_fields'] && is_array($item['hidden_fields'])) {
foreach ($item['hidden_fields'] as $hiddenField) {
$hiddenIds[] = $hiddenField['id'];
}
if (!array_diff($checkedFields, $hiddenIds)) {
unset($items[$key]);
}
}
}
}
public function getContacts($items)
{
$contacts = [];
foreach ($items as $item) {
$urlContact = $item["actions"]["get_with_contact"]["url"] ? $item["actions"]["get_with_contact"]["url"] : $item["url"];
$contactData = $this->getResumeContact($urlContact);
$calcStage = floor($contactData["total_experience"]["months"] / 12);
$last = $contactData["total_experience"]["months"] - $calcStage * 12;
$calcStage .= ' лет '.$last.' мес.';
$stage = $contactData["total_experience"]["months"] > 12 ? $calcStage : $contactData["total_experience"]["months"].' м.';
$phone = '';
$email = '';
foreach ($contactData["contact"] as $contact) {
if ($contact["type"]["id"] == 'cell') {
$phone .= $contact["value"]["formatted"];
} elseif ($contact["type"]["id"] == 'email') {
$email = $contact["value"];
}
}
$contacts[] = [
"id" => $item["id"],
"ORIGIN_ID" => $item["id"],
"POST" => $contactData["title"],
"owner_id" => $contactData["owner"]["id"],
"NAME" => $contactData["first_name"],
"LAST_NAME" => $contactData["last_name"],
"SECOND_NAME" => $contactData["middle_name"],
"DATE_CREATE" => $contactData["created_at"],
"DATE_MODIFY" => $contactData["updated_at"],
"gender" => $contactData["gender"]["name"],
"mount" => $stage,
"TYPE_ID" => "CLIENT",
"UTM_SOURCE" => "HH.RU API",
"URL_DOWNLOAD_RESUME" => $this->downloadResume($item['actions']['download']['pdf']['url'], $item["id"]),
"PHONE" => [
[
"VALUE" => $phone,
"VALUE_TYPE" => "WORK"
]
],
"EMAIL" => [
[
"VALUE" => $email
]
],
"link_resume" => $contactData['alternate_url'],
"deal" => $item["salary"]
];
}
return $contacts;
}
public function downloadResume($url, $fileId = '')
{
$strChunk = explode('.',$url);
$expTmp = explode('?', end($strChunk));
$exp = array_shift($expTmp);
if ($fileId == '') {
$fileId = md5($url);
}
$saveName = $fileId.'.'.$exp;
$savePath = $_SERVER["DOCUMENT_ROOT"].'/upload/resume_'.$saveName;
$uri = $this->getUri($url, '');
if ($uri) {
$rawPdf = $this->doRequest($uri, self::METHOD_GET);
$isSave = file_put_contents($savePath, $rawPdf);
return [
"filePath" => $savePath,
"resultSave" => $isSave
];
}
return false;
}
public function getResumeContact($uri)
{
if (!$uri) {
return false;
}
$response = $this->doRequest($uri, self::METHOD_GET);
return json_decode($response, true);
}
public function getAreas()
{
$result = [];
$uri = $this->getUri(self::API_URL, self::RESOURCE_AREAS);
if ($uri) {
$response = $this->doRequest($uri, self::METHOD_GET);
$result = json_decode($response);
}
return $result;
}
public function getStartEventTime()
{
$startTimestamp = Option::get("main", "hhru_start_time", null);
$startTime = new \DateTime();
if ($startTimestamp) {
$startTime->setTimestamp($startTimestamp);
} else {
$startTime->modify('-1 month');
}
return $startTime;
}
public function setStartEventTime(\DateTime $startTime)
{
$startTimestamp = $startTime->getTimestamp();
Option::set("main", "hhru_start_time", $startTimestamp);
}
protected function setAccessToken($token)
{
$this->accessToken = $token;
}
protected function getAccessToken()
{
if (!$this->accessToken) {
$this->accessToken = Option::get('main', 'access_token');
}
return $this->accessToken;
}
protected function setRefreshToken($token)
{
$this->refreshToken = $token;
}
protected function getRefreshToken()
{
if (!$this->refreshToken) {
$this->refreshToken = Option::get('main', 'refresh_token');
}
return $this->refreshToken;
}
protected function setTokenEndTime($endTime)
{
$this->tokenEndTime = $endTime;
}
protected function getTokenEndTime()
{
if (!$this->tokenEndTime) {
$this->tokenEndTime = Option::get('main', 'token_end_time', 0);
}
return $this->tokenEndTime;
}
protected function getUri($url = self::API_URL, $resource, $params = [])
{
$uri = $url.$resource;
if ($params) {
$area = $params['area'];
unset($params['area']);
$uri .= '?'.http_build_query($params);
foreach($area as $l=>$v){
$uri .= '&area='.$v;
}
}
return $uri;
}
protected function buildRequestHeaders($tokenHeader)
{
if ($tokenHeader && $token = $this->getAccessToken()) {
return [
'Authorization: Bearer '.$token,
'User-Agent: '.self::APPLICATION_NAME.' ('.self::APPLICATION_EMAIL.')'
];
}
return ['Content-Type: application/x-www-form-urlencoded'];
}
protected function doRequest($uri, $method = self::METHOD_GET, $data = [], $tokenHeader = true)
{
$ch = curl_init($uri);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 180);
curl_setopt($ch, CURLOPT_HTTPHEADER, $this->buildRequestHeaders($tokenHeader));
if ($method == self::METHOD_POST) {
curl_setopt($ch, CURLOPT_POST, 0);
curl_setopt($ch, CURLOPT_POSTFIELDS, is_array($data) ? http_build_query($data) : $data);
}
elseif ($method == self::METHOD_GET) {
curl_setopt($ch, CURLOPT_HTTPGET, 1);
curl_setopt($ch, CURLOPT_BINARYTRANSFER, 1);
}
$response = curl_exec($ch);
curl_close($ch);
return $response;
}
}
Получение списка резюме и работа с контактами
После получения accessToken мы отправляем запрос на hh.ru/resumes — в ответ приходит список резюме, отфильтрованный по профессиям, региону и дате. Помимо основной информации в ответе приходит ключ actions.get_with_contact.url, по которому можно получить контактные данные кандидата. Это и есть платный просмотр — при вызове по этому URL списывается одна платная позиция из пакета.
Если контакт уже был открыт ранее, get_with_contact не возвращается. Вместо него появляется просто actions.url, и по нему можно получить данные бесплатно. Мы используем это поведение, чтобы избежать повторного списания.
Есть важный нюанс. Если кандидат скрыл контакты, например, убрал e‑mail или телефон из открытого доступа, get_with_contact все равно будет, и запрос списывает просмотр, но в ответе нужных данных не будет — они придут как null. Чтобы не терять лимит впустую, мы заранее проверяем поле hidden_fields. Если в нем нет e‑mail или телефона, резюме сразу отбрасывается — без запроса на просмотр.
Таким образом, перед тем как списывать позицию, мы гарантируем, что контактная информация действительно доступна. Это позволяет экономно расходовать лимит и не терять позиции на «пустых» резюме.
Из нюансов можно отметить, что проверку на уникальность контактов пришлось выполнять в цикле, отправляя запрос crm.contact.list.json на каждого автора резюме, полученного из hh.ru. Это связано с тем, что на момент выполнения задачи метод не поддерживал фильтрацию по нескольким значениям поля EMAIL.
Техподдержка Битрикса подтвердила, что такая возможность отсутствует, но уже подана заявка на доработку. Мы также рассматривали метод crm.duplicate.findbycomm, но он оказался неудобен — ограничен выборкой до 20 записей. Поэтому в задаче использовали проверку по одному e‑mail за запрос.
Когда кандидат проходит все предыдущие проверки — контакты открыты, лимит не спишется зря, и дублей нет, начинается этап работы с Bitrix24.
Контактные данные — ФИО, e‑mail, телефон, должность — подтягиваются напрямую из ответа hh.ru. С ними работаем следующим образом:
Проверяем наличие контакта по e‑mail — отправляем запрос в crm.contact.list.json.
Если контакт найден — резюме пропускаем. Создавать дубликаты нельзя.
Если e‑mail не найден в CRM — создаем новый контакт и сразу же сделку.
Скачиваем PDF-файл резюме через actions.download.pdf.url и прикрепляем к сделке.
Добавляем комментарий: источник — hh.ru, дата загрузки, регион.
Все действия выполняются через REST API Bitrix24. Мы не использовали нестандартные поля или обходные решения — все строго по документации и стандартной схеме. Это позволило сохранить гибкость на случай, если в будущем клиент решит перейти на другую CRM: контроллер легко переиспользовать.
Класс интеграции с CRM
Скрытый текст
<?php
namespace O2k\HeadHunter\Integration\Bitrix24;
use Bitrix\Main\Localization\Loc;
class Controller
{
protected const CONTACT_HASH_FIELD = 'UF_CRM_…';
protected const RESUME_FILE = 'UF_CRM_…';
protected const PROFESSION_FIELD = 'UF_CRM_…';
const ASSIGNED = 264; // Ответственный ID пользователя
const SOURCE = 104; // источник ID
protected $webHookParam = 'hh.ru';
protected $webHook;
protected $professions = ['сварщик', 'оператор ЧПУ'];
public function __construct()
{
$this->webHook = new \O2k\Crm\Webhook($this->webHookParam);
}
public function getWebHook()
{
return $this->webHook;
}
public function import($contacts)
{
$countVacancy = 0;
foreach ($contacts as $contact) {
if ($this->checkForDuplicate($contact)) {
$contactId = $this->addContact($contact);
$this->addDeal($contact, $contactId);
$countVacancy++;
}
}
}
protected function getContactEmails($contacts)
{
$emails = [];
foreach ($contacts as $contact) {
$emails[] = $contact['EMAIL'][0]['VALUE'];
}
return $emails;
}
protected function getContactIds($contacts)
{
$ids = [];
foreach ($contacts as $contact) {
$ids[] = $contact['id'];
}
return $ids;
}
protected function checkContactForExistence($contact, $existingContacts)
{
$contactEmail = $contact['EMAIL'][0]['VALUE'];
if (
$existingContacts[$contactEmail]
&& $existingContacts[$contactEmail]['NAME'] == $contact['NAME']
&& $existingContacts[$contactEmail]['SECOND_NAME'] == $contact['SECOND_NAME']
&& $existingContacts[$contactEmail]['LAST_NAME'] == $contact['LAST_NAME']
) {
return true;
}
return false;
}
/**
* Если есть контакт, то не будем проверять на наличие сделки,
* т.к. подразумевается, что она у него есть.
*/
public function getContactList($ids)
{
$webhook = $this->getWebHook();
$contactId = 0;
$finish = false;
$existingContacts = [];
if ($ids) {
while (!$finish) {
$arContactsData = [
'order' => ["ID" => "DESC"],
'filter' => [
'>ID' => $contactId,
self::CONTACT_HASH_FIELD => $ids
],
'select' => [
'ID', 'NAME', 'LAST_NAME', 'SECOND_NAME', "PHONE", 'LAST_NAME', 'EMAIL', self::CONTACT_HASH_FIELD
],
'start' => '-1'
];
$arContactList = $webhook->send('crm.contact.list.json', $arContactsData);
if (count($arContactList->result) > 0) {
foreach ($arContactList->result as $contact) {
$contactId = $contact->ID;
$phone = array_shift($contact->PHONE);
$email = array_shift($contact->EMAIL);
$existingContacts[$contact->{self::CONTACT_HASH_FIELD}] = [
'ID' => $contact->ID,
'FULL_NAME' => $this->getFullName($contact),
'NAME' => $contact->NAME,
'SECOND_NAME' => $contact->SECOND_NAME,
'LAST_NAME' => $contact->LAST_NAME,
'XML_ID' => $contact->{self::CONTACT_HASH_FIELD},
'EMAIL' => $email->VALUE,
'PHONE' => $phone->VALUE
];
}
} else {
$finish = true;
}
}
}
return $existingContacts;
}
public function getFullName($params)
{
return implode(' ',[
$params->NAME,
$params->SECOND_NAME,
$params->LAST_NAME,
]);
}
public function getContactsWithDeal($contactId)
{
$arContacts = [];
if ($contactId) {
$webhook = $this->getWebHook();
$dealId = 0;
$finish = false;
while (!$finish) {
$arDealData = [
'order' => ["ID" => "ASC"],
'filter' => ['>ID' => $dealId, 'CONTACT_ID' => $contactId],
'select' => ['ID', 'CONTACT_ID'],
'start' => '-1'
];
$arDealList = $webhook->send('crm.deal.list.json', $arDealData);
if (count($arDealList->result) > 0) {
foreach ($arDealList->result as $arDeal) {
$dealId = $arDeal->ID;
if ($dealId) {
$arContacts[$arDeal->CONTACT_ID] = $arDeal->CONTACT_ID;
}
}
} else {
$finish = true;
}
}
}
return $arContacts;
}
public function checkForDuplicate($data)
{
$webhook = $this->getWebHook();
if ($data['EMAIL'][0]['VALUE'] && $data['NAME']) {
$arContactsData = [
'filter' => [
'EMAIL' => $data['EMAIL'][0]['VALUE'],
'NAME' => $data['NAME'],
'SECOND_NAME' => $data['SECOND_NAME'],
'LAST_NAME' => $data['LAST_NAME']
],
'select' => ['ID']
];
$arContactList = $webhook->send('crm.contact.list.json', $arContactsData);
if (count($arContactList->result) == 0) {
return true;
}
}
return false;
}
public function addContact($data)
{
$webhook = $this->getWebHook();
$data[self::CONTACT_HASH_FIELD] = $data["id"];
$data['SOURCE_ID'] = self::SOURCE;
$data['ASSIGNED_BY_ID'] = self::ASSIGNED;
$arContactData = [
'fields' => $data,
'params' => ['REGISTER_SONET_EVENT' => 'Y']
];
$result = $webhook->send('crm.contact.add.json', $arContactData);
return $result->result;
}
public function updateContact($data, $id)
{
$webhook = $this->getWebHook();
$arContactData = [
'id' => $id,
'fields' => $data,
'params' => ['REGISTER_SONET_EVENT' => 'Y']
];
return $webhook->send('crm.contact.update.json', $arContactData);
}
public function addDeal($data, $contactId, $isFirstDeal = true)
{
$webhook = $this->getWebHook();
$arLeadData = [
'fields' => [
"TITLE" => $data["NAME"].' '.$data["LAST_NAME"]." ".$data["POST"],
"SOURCE_ID" => self::SOURCE,
"UTM_SOURCE" => "HH.RU API",
"CURRENCY_ID" => $this->prepareCurrency($data["deal"]["currency"]),
"CATEGORY_ID" => 5,
"ASSIGNED_BY_ID" => self::ASSIGNED,
"OPPORTUNITY" => $data["deal"]["amount"],
"BEGINDATE" => date('Y-m-dTH:i:s+03:00'),
"STAGE_ID" => "C5:NEW",
self::RESUME_FILE => $this->getFileResume($data["URL_DOWNLOAD_RESUME"])
],
'params' => ['REGISTER_SONET_EVENT' => 'Y']
];
if ($data["POST"] && $this->professions) {
foreach ($this->professions as $profession) {
if (mb_stripos($data["POST"], $profession) !== false) {
$arLeadData['fields'][self::PROFESSION_FIELD] = $profession;
break;
}
}
}
$deal = $webhook->send('crm.deal.add', $arLeadData);
if ($deal->result) {
if ($contactId) {
$arContactFields = [
'id' => intval($deal->result),
'fields' => [
'CONTACT_ID' => $contactId,
"IS_PRIMARY" => $isFirstDeal,
"SOURCE_ID" => self::SOURCE,
"UTM_SOURCE" => "HH.RU API",
]
];
$contact = $webhook->send('crm.deal.contact.add', $arContactFields);
}
}
$result = [
"deal" => $deal,
"contact" => $contact
];
return $result;
}
protected function prepareCurrency($currencyCode)
{
if ($currencyCode == 'RUR') {
return 'RUB';
}
return $currencyCode;
}
public function getFileResume($params = [])
{
if(!$params['filePath']) return false;
$filePath = $params['filePath'];
$resume = [
'fileData'=> [
end(
explode('/', $filePath)
),
base64_encode(
file_get_contents($filePath)
)
]
];
unlink($filePath);
return $resume;
}
public function getDeal($id)
{
$webhook = $this->getWebHook();
print_r($webhook->send('crm.deal.get', ["id" =>$id]));
}
public function updateDeal($id, $fields){
$webhook = $this->getWebHook();
$result = $webhook->send('crm.deal.update', [
"id" =>$id,
"fields" => $fields
]);
print_r([
$result,
func_get_args()
]);
}
}
Как работает весь цикл
Алгоритм автоматизации получился линейным и предсказуемым — всё завязано на последовательные проверки и работу с API hh.ru и Bitrix24. Распишем подробнее:
Получаем список резюме — используем метод hh.ru/resumes с нужными фильтрами (регион, профессия, период).
Проверяем наличие контактов — если в поле hidden_fields есть email или phone, продолжаем. Иначе пропускаем резюме, чтобы не тратить лимит на бесполезный просмотр.
Проверяем, был ли контакт уже открыт — если приходит get_with_contact.url, значит данные еще не открывались и за них спишется платная позиция. Если вместо этого actions.url — значит контакт уже был открыт ранее, можно использовать данные без списания.
Скачиваем PDF — обращаемся по actions.download.pdf.url и получаем файл резюме для прикрепления в сделку.
Проверяем наличие контакта в CRM — отправляем crm.contact.list с фильтром по e‑mail.
Создаем контакт и сделку — если e‑mail не найден, добавляем контакт с ФИО, телефоном и должностью. Далее — создаем сделку и прикрепляем PDF, добавляя комментарий с источником и датой.
Все этапы логируются. Если какая-то часть цепочки не срабатывает — например, нет доступа к токену или фильтр не сработал — в логах будет понятная ошибка с указанием причины.

Что в итоге получили
Интеграция закрыла задачу полностью — HR-отдел больше не занимается ручной сортировкой резюме, а система работает строго по заданному сценарию.
Резюме с hh подтягиваются автоматически
Каждый день запускается скрипт, который обращается к hh.ru, собирает резюме по нужным регионам и профессиям и сам фильтрует их по заданным параметрам. Не нужно заходить на сайт, скачивать вручную, сохранять, пересылать.Контакты и сделки создаются без участия HR
Если у кандидата есть контакты и он еще не добавлен в CRM, скрипт сам создает карточку контакта и сделки. Сразу же прикрепляет PDF-файл и добавляет комментарий. HR видит уже подготовленные сделки и работает только с релевантными откликами.



Никаких дублей
Каждое резюме проверяется по e‑mail: если контакт уже есть, новая запись не создается. Это позволяет избежать путаницы в CRM и не распылять внимание менеджеров на одних и тех же соискателей.Расход лимита — только на нужные резюме
Мы заранее проверяем наличие открытых контактов и hidden_fields. Это защищает от случайных списаний при скрытых данных. Контакты повторно не открываются, если уже были просмотрены — это экономит пакет позиций.Система работает стабильно и прозрачно
Все шаги логируются: от авторизации до загрузки PDF. Если возникает ошибка — например, слетел токен или вернулся пустой фильтр, ее видно сразу. HR ничего не запускает вручную.
Если появятся новые вакансии или регионы — подключаем в конфигурации, ничего не меняя в коде. То же самое с CRM: нужен другой API — пишем новый контроллер.
Это была не самая сложная интеграция, но с кучей нюансов. Мы несколько раз ловили ошибки, связанные с поведением hh API: например, резюме без контактов проходили фильтр, если не проверить заранее поле actions. Или слетал фильтр по региону, если не передать параметры в правильном формате. В процессе обкатали всю цепочку: от работы с токенами до повторного открытия резюме без двойного списания.
Если вы тоже автоматизировали импорт резюме — расскажите, как подошли к фильтрации, дублям и работе с токенами. А если только собираетесь — пишите вопросы в комментариях, подскажем, на чем можно сэкономить время и нервы.