API телефонии Ringline
Получайте события о звонках в момент, когда они происходят, читайте справочник внутренних номеров, скачивайте записи разговоров и показывайте оператору карточку клиента, пока телефон ещё звонит.
https://app.ringline.uz — один хост для всех эндпоинтов, только HTTPS.
Машиночитаемая спецификация: openapi.json (OpenAPI 3.0 — импортируется
в Postman, из неё генерируется клиент).
Что можно собрать
Ringline — облачная АТС. Стыковок с внешней системой четыре, и использовать можно любое подмножество:
| Возможность | Направление | Как |
|---|---|---|
| События звонков звонит, ответили, завершён, пропущен |
Ringline → вы | Webhook с подписью HMAC-SHA256. См. типы событий. |
| Записи разговоров | вы → Ringline | В каждом завершённом звонке приходит ссылка на скачивание. См. записи. |
| Справочник номеров | вы → Ringline | Сопоставить своих пользователей с операторами и узнать, кто сейчас на линии. |
| Карточка звонящего данные клиента на экране оператора |
Ringline → вы | Вы отдаёте один эндпоинт, мы дёргаем его на каждом входящем. См. карточка клиента. |
Быстрый старт
-
Получите ключ. Мы выдаём его и передаём отдельным каналом. Выглядит как
rl_live_…, около 51 символа. -
Проверьте, что он работает.
Кодcurl -H "Authorization: Bearer rl_live_ВАШ_КЛЮЧ" \ https://app.ringline.uz/api/v1/account200и полеdomain_nameозначают, что связь есть. -
Подпишите свой адрес. В ответе придёт
signing_secret— сохраните его, больше он не показывается.curl -X POST https://app.ringline.uz/api/v1/webhooks \ -H "Authorization: Bearer rl_live_ВАШ_КЛЮЧ" \ -H "Content-Type: application/json" \ -d '{"url":"https://crm.example.de/hooks/ringline","events":["*"]}' -
Убедитесь в доставке, не дожидаясь живого звонка.
В ответ придёт HTTP-код и тело, которые вернул ваш сервер, — видно, прошла ваша проверка подписи или нет.curl -X POST https://app.ringline.uz/api/v1/webhooks/test \ -H "Authorization: Bearer rl_live_ВАШ_КЛЮЧ" \ -H "Content-Type: application/json" \ -d '{"event_type":"call.completed"}' -
Обрабатывайте события. Проверьте подпись, затем разбирайте
call.*. Отвечайте2xxв пределах 10 секунд.
Аутентификация
В каждом запросе — постоянный API-ключ в виде bearer-токена:
Authorization: Bearer rl_live_4Vb9esauzSwtlt1js-Ati4KV0Nwtp6QirYeGkFgSzU4
Никакого OAuth, обновления токена и срока действия. Ключ живёт до отзыва в панели Ringline, и отзыв действует с первого же следующего запроса.
У нас хранится только SHA-256 от ключа, поэтому потерянный ключ восстановить нельзя — его отзывают и выдают новый. Обращайтесь с ним как с паролем: только на сервере, не в браузере, не в мобильном приложении и не в репозитории.
Области доступа
У ключа есть набор областей. Какие именно — покажет GET /account; на эндпоинт без нужной
области придёт 403 insufficient_scope.
| Область | Нужна для |
|---|---|
calls.read | Получения событий о звонках |
recordings.read | GET /api/v1/recordings/{call_uuid} |
operators.read | GET /api/v1/extensions |
webhooks.manage | Подписки, отписки, отправки тестового события |
Песочница
Разработка идёт на отдельном арендаторе sandbox — на том же хосте, что и продакшен.
В нём два внутренних номера (1001, 1002) и два оператора, живого трафика нет, так что испортить данные
реального клиента оттуда невозможно.
Ключ песочницы запросите у нас. С ним доступно всё, кроме настоящих звонков: проверка ключа, чтение
справочника, подписка и отправка образцов событий через
POST /webhooks/test в любом количестве.
Эндпоинты
Проверяет ключ и описывает арендатора, которому он принадлежит. Подходит для кнопки «проверить подключение».
{
"domain_uuid": "0e0d5944-a54e-4af6-ab27-14f0757851df",
"domain_name": "sandbox",
"key": {
"id": 5,
"name": "Sandbox - integration partners",
"prefix": "rl_live_KCYp",
"scopes": ["calls.read", "recordings.read", "operators.read", "webhooks.manage"],
"created_at": "2026-11-09 15:20:00+05",
"last_used_at": "2026-11-09 15:29:24+05"
},
"webhook": null,
"extensions": [
{
"number": "1001",
"label": "Operator: Sandbox One",
"enabled": true,
"operator_id": 50,
"operator_name": "Sandbox One",
"operator_email": "op1@sandbox.local",
"medflow_user_id": null
}
]
}
domain_uuid идентифицирует арендатора и приходит в каждом событии, так что
мультиарендная интеграция может маршрутизировать события по нему. Поле medflow_user_id
игнорируйте — оно от другой интеграции.
Тот же справочник плюс текущая доступность. Для сопоставления своих пользователей с операторами
берите operator_email — внутренние номера переназначают, адреса нет.
{
"extensions": [
{
"number": "1001",
"label": "Operator: Sandbox One",
"enabled": true,
"operator_id": 50,
"operator_name": "Sandbox One",
"operator_email": "op1@sandbox.local",
"presence_mode": "full",
"inbound_enabled": true,
"outbound_enabled": false
}
]
}
| Поле | Значение |
|---|---|
presence_mode | full — принимает звонки; outgoing_only и offline не звонят вообще. |
inbound_enabled | Административный переключатель. False — оператор исключён из входящих. |
enabled | Существует ли сам внутренний номер в АТС. |
Оператор не в режиме full или с выключенными входящими никогда не появится в событии
call.ringing. Это первое, что стоит проверить при вопросе «почему этому человеку не
пришёл звонок».
{ "subscribed": true, "url": "https://crm.example.de/hooks/ringline", "events": ["*"] }
Секрет подписи здесь намеренно не возвращается: он показывается один раз, при подписке.
Регистрирует адрес для событий и отдаёт секрет, которым они подписываются.
// запрос
{ "url": "https://crm.example.de/hooks/ringline", "events": ["*"] }
// ответ
{
"subscribed": true,
"url": "https://crm.example.de/hooks/ringline",
"events": ["*"],
"signing_secret": "v6tZj-XCkc4pMOCL05vthvT8OTRmoYAP2AN_WdjI7-ulG15HSnF3y2l5C_BLFZuU"
}
На ключ приходится одна подписка: повторный POST заменяет адрес и список событий. Секрет привязан к ключу и не меняется — чтобы его сменить, нужно выдать новый ключ.
Требования к адресу, проверяются и при регистрации, и заново перед каждой отправкой:
- только
https://, не длиннее 2048 символов; - должен разрешаться в публичный адрес — loopback,
10/8,172.16/12,192.168/16, CGNAT и диапазоны облачных метаданных отклоняются; - без логина и пароля в URL;
- редиректы не отслеживаются — указывайте конечный адрес.
Отсутствие events равнозначно ["*"]. Неизвестный тип события даёт
422, а не молчаливое игнорирование.
Прекращает доставку. Ключ остаётся действительным, секрет не меняется — подписаться заново можно без перенастройки.
Отправляет на ваш адрес одно подписанное событие сейчас и сообщает, что ответил ваш сервер. Так приёмник проверяется до того, как у клиента пойдёт первый звонок.
// запрос (тело необязательно, по умолчанию минимальный «ping»)
{ "event_type": "call.completed" }
// ответ
{
"delivered": true,
"url": "https://crm.example.de/hooks/ringline",
"event_type": "call.completed",
"http_status": 200,
"response_body": "{\"received\":true}",
"sent_payload": { "...": "ровно то, что было подписано и отправлено" }
}
| Поле | Значение |
|---|---|
delivered | True, если ваш сервер ответил 2xx. |
http_status | Ваш код ответа. 0 означает, что не удалось само соединение — DNS, TLS или таймаут. |
response_body | Первые 2000 символов вашего ответа — обычно этого хватает, чтобы увидеть собственное сообщение об ошибке. |
"test": true, а образец звонка использует номера
+998000000000 и +998000000001, которые не выдаются абонентам. В журнал
доставок такое событие не пишется и не повторяется.
Отдаёт аудио завершённого звонка. call_uuid — это call.call_uuid из события;
готовая ссылка лежит в call.recording.url.
curl -H "Authorization: Bearer rl_live_ВАШ_КЛЮЧ" \
-o call.mp3 \
https://app.ringline.uz/api/v1/recordings/71dc4322-a21b-48d7-8adf-b743b4ac4e19
В ответе — само аудио, не JSON:
HTTP/1.1 200 OK
Content-Type: audio/mpeg
Content-Disposition: attachment; filename="71dc4322-a21b-48d7-8adf-b743b4ac4e19.mp3"
Accept-Ranges: bytes
- Контейнер не фиксирован. Сейчас записи в MP3, старые — в WAV. Смотрите
Content-Typeилиcall.recording.format, а не предполагайте расширение. - Range-запросы поддерживаются —
Range: bytes=0-65535даёт206, прерванную загрузку можно продолжить. - Изоляция арендаторов.
call_uuidчужого арендатора отвечает404— так же, как несуществующий.
404. Если записи нужны дольше, скачивайте их по событию
call.completed и храните у себя.
Типы событий
Четыре события, все про звонки. Подпишитесь на ["*"], чтобы получать всё, или перечислите
нужные.
| Событие | Когда | Типичное применение |
|---|---|---|
call.ringing |
Телефон начал звонить. | Показать карточку клиента на экране, запустить таймер. |
call.answered |
Оператор снял трубку. | Отметить начало разговора и того, кто его взял. |
call.completed |
Отвеченный звонок закончился. | Записать в историю, забрать запись разговора. |
call.missed |
Звонок закончился, оператор так и не ответил. | Создать задачу на обратный звонок. |
call.completed.
События о смене статуса оператора в этом канале тоже нет. Подписка на что-либо вне списка
отклоняется с кодом 422, а не принимается с тем, чтобы никогда не прийти.
Групповой обзвон даёт ветки
Когда входящий звонит нескольким операторам сразу, вы получаете сам звонок плюс по одной
ветке на каждое плечо оператора. У веток is_branch: true и
parent_call_uuid, указывающий на родительский звонок.
Если нужна одна запись на разговор — обычный случай — игнорируйте все события с
is_branch: true. Разбирайте ветки только если вам действительно важно знать,
какие именно телефоны звонили.
Состав события
У всех событий одинаковая оболочка; поля, которые ещё неизвестны, равны null:
{
"event_id": "evt_71dc4322-a21b-48d7-8adf-b743b4ac4e19_callcompleted_k5",
"event_type": "call.completed",
"occurred_at": "2026-11-09T15:24:45+05:00",
"domain_uuid": "0e0d5944-a54e-4af6-ab27-14f0757851df",
"call": {
"call_uuid": "71dc4322-a21b-48d7-8adf-b743b4ac4e19",
"is_branch": false,
"parent_call_uuid": null,
"direction": "inbound",
"from_number": "+4930123456",
"to_number": "+998781136247",
"customer_number": "+4930123456",
"status": "completed",
"answered_by_operator": true,
"ringing_at": "2026-11-09T15:24:21+05:00",
"answered_at": "2026-11-09T15:24:26+05:00",
"ended_at": "2026-11-09T15:24:45+05:00",
"wait_seconds": 5,
"talk_seconds": 19,
"total_seconds": 24,
"operator": {
"extension": "1001",
"name": "Sandbox One",
"email": "op1@sandbox.local"
},
"recording": {
"available": true,
"url": "https://app.ringline.uz/api/v1/recordings/71dc4322-a21b-48d7-8adf-b743b4ac4e19",
"format": "mp3",
"duration_seconds": 19
},
"hangup_cause": "NORMAL_CLEARING"
}
}
| Поле | Пояснение |
|---|---|
event_id | Уникален и постоянен для пары (звонок, тип события). Используйте как ключ идемпотентности — повтор доставки несёт тот же event_id. |
occurred_at | ISO 8601 со смещением арендатора (Узбекистан — +05:00, перехода на летнее время нет). |
domain_uuid | Какому арендатору принадлежит звонок. |
customer_number | Сопоставляйте свои записи по этому полю. Это номер человека вне компании независимо от направления, так что ветвиться по direction не нужно. |
from_number / to_number | Номер звонящего и набранный номер в том виде, в каком их видела АТС. |
answered_by_operator | True только когда ответил живой человек. Ответ автоинформатора или голосовой почты не считается, и тогда answered_at равен null, а talk_seconds — нулю. |
wait_seconds | Сколько звонил телефон до ответа — показатель качества обслуживания. |
talk_seconds | Длительность разговора. Ноль, если никто не ответил. |
total_seconds | Весь звонок, включая время дозвона. |
operator | Кто обработал звонок. Null у неотвеченного группового звонка — назвать одного из тех, кто просто не снял трубку, было бы неверно. |
recording.available | False, когда записи нет, а также для файлов короче 0,5 с — это артефакты тишины, а не разговоры. |
hangup_cause | Причина от FreeSWITCH/SIP: NORMAL_CLEARING, USER_BUSY, NO_ANSWER, ORIGINATOR_CANCEL, … |
USER_BUSY). Отличить намеренный отказ от занятого телефона невозможно — не стройте
логику на этом различии.
Проверка подписи
В каждой доставке приходят такие заголовки:
X-Ringline-Event: call.completed
X-Ringline-Timestamp: 1794219829
X-Ringline-Signature: t=1794219829,v1=1f6b1093b401382ef7bae0a324e6d1bb2bb9a5fa9ae7bd86fb7366cb45160ab8
User-Agent: Ringline-Webhook/1.0
Подпись — это HMAC-SHA256 от строки "<t>.<сырое тело запроса>" на
вашем signing_secret, в шестнадцатеричном виде.
<?php
$secret = getenv('RINGLINE_SIGNING_SECRET');
$raw = file_get_contents('php://input'); // сырые байты, не разобранные
$header = $_SERVER['HTTP_X_RINGLINE_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $p) {
[$k, $v] = array_pad(explode('=', trim($p), 2), 2, '');
$parts[$k] = $v;
}
$ts = (int) ($parts['t'] ?? 0);
if (abs(time() - $ts) > 300) { // окно воспроизведения
http_response_code(400);
exit('stale timestamp');
}
$expected = hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($expected, $parts['v1'] ?? '')) { // сравнение за одно время
http_response_code(401);
exit('bad signature');
}
$event = json_decode($raw, true);
// ... обработка $event, затем ответ 2xx
http_response_code(200);
echo '{"received":true}';
import hashlib, hmac, time
from flask import Flask, request
app = Flask(__name__)
SECRET = b"ваш-signing-secret"
@app.post("/hooks/ringline")
def ringline():
raw = request.get_data() # сырые байты, не request.json
header = request.headers.get("X-Ringline-Signature", "")
parts = dict(
p.strip().split("=", 1) for p in header.split(",") if "=" in p
)
ts = int(parts.get("t", 0))
if abs(time.time() - ts) > 300: # окно воспроизведения
return "stale timestamp", 400
expected = hmac.new(
SECRET, f"{ts}.".encode() + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
return "bad signature", 401
event = request.get_json()
# ... обработка события
return {"received": True}, 200
// ASP.NET Core minimal API
app.MapPost("/hooks/ringline", async (HttpRequest req) =>
{
using var reader = new StreamReader(req.Body);
var raw = await reader.ReadToEndAsync(); // сырое тело
var header = req.Headers["X-Ringline-Signature"].ToString();
var parts = header.Split(',')
.Select(p => p.Trim().Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1]);
var ts = long.Parse(parts.GetValueOrDefault("t", "0"));
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - ts) > 300)
return Results.BadRequest("stale timestamp");
var secret = Encoding.UTF8.GetBytes(
Environment.GetEnvironmentVariable("RINGLINE_SIGNING_SECRET")!);
using var hmac = new HMACSHA256(secret);
var computed = Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes($"{ts}.{raw}"))
).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computed),
Encoding.UTF8.GetBytes(parts.GetValueOrDefault("v1", ""))))
return Results.Unauthorized();
// ... обработка события
return Results.Ok(new { received = true });
});
const crypto = require('crypto');
const express = require('express');
const app = express();
// нужно именно сырое тело — express.json() его потеряет
app.post('/hooks/ringline',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.get('X-Ringline-Signature') || '';
const parts = Object.fromEntries(
header.split(',').map(p => p.trim().split('='))
);
const ts = Number(parts.t || 0);
if (Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(400).send('stale timestamp');
}
const expected = crypto
.createHmac('sha256', process.env.RINGLINE_SIGNING_SECRET)
.update(`${ts}.`)
.update(req.body) // Buffer, без преобразований
.digest('hex');
const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1 || '')
);
if (!ok) return res.status(401).send('bad signature');
const event = JSON.parse(req.body.toString('utf8'));
// ... обработка события
res.json({ received: true });
});
Доставка и повторы
| Свойство | Значение |
|---|---|
| Метод | POST, Content-Type: application/json; charset=utf-8 |
| Ожидаемый ответ | Любой 2xx в пределах 10 секунд |
| Первая попытка | Сразу же, из демона событий АТС |
| Повторы | Через 1 мин, 5 мин, 30 мин, 2 ч, далее раз в 24 часа |
| Предел попыток | 10, после чего событие оставляется |
| Ваш ответ 4xx | Считается окончательным: пишется в журнал, не повторяется |
| 5xx, таймаут, сбой TLS или DNS | Повторяется по расписанию выше |
| Порядок | Не гарантирован. Повторно доставленный ringing может прийти после completed. |
| Редиректы | Не отслеживаются |
Что из этого следует для приёмника:
- Будьте идемпотентны. Отсеивайте дубли по
event_id. Доставка повторится, если ваш2xxпотерялся по дороге обратно. - Сначала ответ, потом работа. Положите событие в очередь и сразу ответьте
2xx: десять секунд — это немного, если внутри обработчика вы дёргаете другие сервисы. - Не упорядочивайте по времени прихода. Сортируйте по
occurred_atили считайтеcall.completedитоговой записью о звонке. - Отдавайте 4xx только на действительно некорректный запрос. 4xx означает «больше это не присылайте». Если вы временно сломаны, отвечайте 5xx — событие вернётся.
Карточка клиента из вашей CRM
Обратное направление: при входящем звонке Ringline спрашивает вашу систему и показывает результат оператору до того, как он снял трубку, — имя клиента, уровень членства, ближайшую бронь.
Вы отдаёте один HTTP-эндпоинт, запрос и сопоставление полей мы настраиваем у себя, отдельно для каждого арендатора. Под новую CRM ничего разрабатывать не нужно, так что настройка занимает минуты, как только ваш эндпоинт готов.
Что отправляем мы
Метод GET или POST, ваши заголовки (обычно токен) и ваш шаблон тела. В них
подставляются такие метки:
| Метка | Во что превращается | Пример |
|---|---|---|
{phone} | Номер ровно в том виде, в каком его видела АТС | +4930123456 |
{phone_e164} | Приведён к E.164 с ведущим + | +998901234567 |
{phone_local} | Только цифры, без + и разделителей | 998901234567 |
{domain_uuid} | Арендатор — пригодится, если вы обслуживаете несколько наших клиентов | 0e0d5944-… |
Девятизначный узбекский номер дополняется до +998…; всё остальное передаётся как есть с
ведущим +, так что зарубежные звонящие приходят без искажений.
Пример настроенного POST-запроса:
POST https://crm.example.de/api/phone-lookup
Authorization: Bearer ВАШ_ТОКЕН_CRM
Content-Type: application/json
{ "msisdn": "{phone_e164}", "tenant": "{domain_uuid}" }
Если выбран GET и метки в URL нет, мы сами добавим ?phone=….
Что возвращаете вы
Любой JSON, который у вас уже есть. Мы достаём из него значения простыми путями —
$.customer.name, $.bookings[0].area — и раскладываем по карточке из пяти
полей, которую софтфон показывает как есть:
| Поле карточки | Где видно | Что уместно |
|---|---|---|
title | Заголовок | Имя клиента |
subtitle | Вторая строка | Уровень членства, сегмент |
info_1 | Строка деталей | Ближайшая бронь: дата и зона |
info_2 | Строка деталей | Задолженность, пометка, число визитов |
url | Ссылка | Прямая ссылка на клиента в вашей CRM |
Для такого ответа:
{
"success": true,
"customer": {
"id": 8821,
"name": "Klaus Berger",
"membership": { "level": "Gold", "valid_until": "2027-03-01" },
"bookings": [
{ "date": "2026-11-12", "area": "Sauna Panorama", "guests": 2 }
],
"profile_url": "https://crm.example.de/customers/8821"
}
}
сопоставление будет таким:
title = $.customer.name → «Klaus Berger»
subtitle = $.customer.membership.level → «Gold»
info_1 = $.customer.bookings[0].area → «Sauna Panorama»
info_2 = $.customer.bookings[0].date → «2026-11-12»
url = $.customer.profile_url → ссылка
found = $.success → false означает «такого клиента нет»
Синтаксис путей намеренно простой: $.a.b, $.a[0].b,
$["ключ с пробелами"]. Без фильтров, подстановочных знаков и рекурсивного спуска.
Требования
- Отвечайте быстро. Таймаут настраивается от 1 до 30 секунд, но запрос идёт, пока телефон звонит, — всё, что дольше примерно 2 секунд, уже бесполезно.
- Сообщайте «не найдено» явно, если можете, — логическим полем, на которое мы
укажем
found. Иначе пустая карточка трактуется как «не найдено». - Публичный адрес.
http://допускается для систем, стоящих на своём сервере, но хост не должен разрешаться во внутреннюю сеть. - Ожидайте повторов. Результаты кэшируются по номеру, так что постоянный клиент не дёргает ваш API при каждом звонке.
Пришлите адрес эндпоинта, заголовок авторизации, пример ответа и то, какое поле в какую строку карточки положить. Мы настроим и проверим на живом эндпоинте прямо из панели.
Ошибки
Аутентификация и права — 401, 403:
{ "error": "invalid_token", "error_description": "api key invalid or revoked" }
{ "error": "insufficient_scope", "error_description": "required scope: recordings.read" }
Проверка данных и состояние — 409, 422:
{ "success": false, "message": "url must be an absolute https:// URL" }
| Код | Значение | Что делать |
|---|---|---|
200 | Успех | — |
401 | Нет заголовка, ключ искажён или отозван | Проверить заголовок, попросить перевыпуск |
403 | Ключ верный, области не хватает | Попросить расширить области ключа |
404 | Записи нет, она чужого арендатора или истёк срок хранения | Не повторять запрос вслепую |
409 | Действие требует подписки, которой нет | Сначала подписаться |
422 | Неверный адрес, неизвестный тип события | Прочитать message и исправить запрос |
5xx | Наша сторона | Повторить с задержкой, сообщить нам, если не проходит |
Ограничения и хранение
| Предмет | Значение |
|---|---|
| Хранение записей | 90 дней, затем удаляются |
| Формат записей | Сейчас MP3 (8 кГц, ~32 кбит/с); старые звонки — WAV |
| Таймаут вебхука | 10 с на попытку |
| Число попыток | 10, растянуты примерно на 5 суток |
| Окно против воспроизведения | 5 минут |
| Срок жизни ключа | Не ограничен до отзыва |
| Подписок на ключ | 1 |
| Частота запросов | Жёсткого предела нет; /account опрашивать не чаще раза в минуту |
| Часовой пояс | Asia/Tashkent, +05:00 круглый год, без перехода на летнее время |
Эндпоинта для вытягивания истории звонков в этом канале нет, поэтому вебхуки — единственный источник данных о звонках. Если приёмник недоступен дольше окна повторов, те звонки отсюда уже не восстановить — напишите нам, мы можем выслать их повторно из журнала доставок.
Чек-лист запуска
GET /api/v1/accountс ключом песочницы отвечает200.- Ваш адрес подписан, а
POST /api/v1/webhooks/testсообщает"delivered": true. - Ваша проверка подписи отклоняет подменённое тело — проверьте, изменив один байт.
- Обработчик отсеивает дубли по
event_idи игнорируетis_branch: true. - Вы отвечаете
2xxзаметно быстрее 10 секунд, а настоящую работу делаете асинхронно. - Скачивание записи работает, и вы сохраняете то, что нужно, — помните про окно в 90 дней.
- Если используете карточку клиента: ваш эндпоинт отвечает быстрее 2 секунд и явно сообщает «не найдено».
- Запросите продакшен-ключ и замените строку. Больше ничего менять не нужно.