Быстрый старт#
-
Создайте API-ключ в кабинете, раздел «API-ключи». Ключ вида
fa_…показывается один раз — сохраните его сразу в переменную окружения или в хранилище секретов. -
Подключите хотя бы одного получателя: включите push в браузере, установите
приложение или добавьте контакт (email, телефон, вебхук). Без получателей уведомление
всё равно будет принято и сохранено, но в ответе придёт поле
warning, а доставлять его будет некуда. - Отправьте запрос. Ниже — тот же самый curl, который кабинет показывает сразу после создания ключа.
curl -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: fa_ваш_ключ" \
-H "Content-Type: application/json" \
-d '{"title": "Сервер упал", "body": "Проверьте прод", "priority": "high"}'
В ответ приходит 202 Accepted:
{
"notificationId": "b3f1c0de-4b1a-4c9b-9f3d-5a6b7c8d9e01",
"queued": 2,
"channels": ["WebPush", "Email"],
"deduplicated": false,
"replayed": false,
"warning": null,
"devicesNotified": 1
}
Ответ возвращается до доставки. Уведомление и план доставки коммитятся в базу, дальше работает очередь — поэтому эндпойнт отвечает за миллисекунды даже когда какой-то из провайдеров лежит.
Аутентификация#
Ключ передаётся заголовком. Основной вариант — X-Api-Key; если инструмент
умеет только bearer-токен, тот же ключ можно отправить в Authorization.
Второй вариант принимается только когда значение начинается с fa_,
поэтому спутать его с JWT из кабинета нельзя.
curl -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: fa_ваш_ключ" -d "title=Проверка связи"
curl -X POST https://fastalert.ru/api/v1/notify \
-H "Authorization: Bearer fa_ваш_ключ" -d "title=Проверка связи"
Ключ показывается ровно один раз — при создании. На сервере хранится только его SHA-256-хеш, поэтому восстановить утраченный ключ невозможно: его можно лишь отозвать и выпустить новый. В списке ключей виден только префикс — первые 12 символов.
Ограничения ключа#
| Ограничение | Что делает | Что будет при нарушении |
|---|---|---|
| Привязка к группе | Ключ публикует только в одну группу. Если в запросе группа не указана вообще — берётся именно она. | 403 key_scope_mismatch |
| Список IP | Через запятую: адреса и CIDR-подсети, IPv4 и IPv6 — например 203.0.113.7, 198.51.100.0/24. |
401, попытка пишется в журнал |
| Срок жизни | Задаётся в днях при создании. Ключ перестаёт работать в момент истечения. | 401 |
| Отзыв | Действует немедленно, без периода благодати. | 401 |
Лимит запросов считается по значению заголовка X-Api-Key. Если вы шлёте
ключ через Authorization, лимит начинает считаться по IP-адресу
отправителя — это заметно, когда за одним NAT стоит много серверов. Для
высоконагруженной отправки предпочитайте X-Api-Key.
POST /api/v1/notify#
POST https://fastalert.ru/api/v1/notify
Единственный эндпойнт, который нужен для отправки. Обязательное поле ровно одно —
title. Всё остальное имеет разумное значение по умолчанию.
Параметры#
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
title | строка, ≤ 200 | да | Заголовок. Пустой или из одних пробелов — 400 title_required. |
body | строка, ≤ 8000 | нет | Текст. Переводы строк сохраняются. |
groupId | UUID | нет | Идентификатор группы. Самый однозначный способ адресации. |
group | строка, ≤ 60 | нет | Slug группы, например prod-db. Регистр не важен. Сюда же можно вставить UUID — он тоже будет распознан. |
priority | строка | нет | low, normal, high, critical. По умолчанию normal; неизвестное значение тоже трактуется как normal. |
dedupKey | строка, ≤ 200 | нет | Ключ схлопывания повторов. См. раздел 5. |
dedupWindowSeconds | целое | нет | Окно дедупликации в секундах, по умолчанию 300. Значение 0 и меньше выключает схлопывание. |
url | строка, ≤ 2000 | нет | Ссылка, которая показывается кнопкой в уведомлении: дашборд, runbook, страница инцидента. |
Если ни groupId, ни group не заданы, группа определяется по
порядку: привязка ключа → группа по умолчанию → самая старая корневая группа.
Подробнее — раздел 6.
Три формата тела#
application/json
curl -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: fa_ваш_ключ" \
-H "Content-Type: application/json" \
-d '{
"group": "prod-db",
"title": "Реплика отстала на 240 секунд",
"body": "pg-replica-2, lag растёт последние 10 минут.",
"priority": "high",
"dedupKey": "replication-lag:pg-replica-2",
"dedupWindowSeconds": 1800,
"url": "https://grafana.example.com/d/pg/replication"
}'
multipart/form-data — с файлами
Те же имена полей плюс любое количество файловых полей. Имя файлового поля не важно — в уведомление попадают все файлы формы. Так удобно приложить лог или дамп конфигурации.
curl -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: fa_ваш_ключ" \
-F "group=prod-api" \
-F "title=Деплой откатился" \
-F "body=Health-check не прошёл на 3 узлах из 4." \
-F "priority=critical" \
-F "log=@/var/log/deploy.log"
По умолчанию принимается до 10 файлов на уведомление и до 25 МБ на файл; тариф может опустить оба порога. Общий размер запроса ограничен 120 МБ.
text/plain — первая строка становится заголовком
Самая короткая интеграция: тело целиком — обычный текст. Первая строка идёт в
title, всё остальное — в body. Группу и приоритет здесь задать
нельзя, уведомление уйдёт в группу ключа или в группу по умолчанию.
df -h / | curl -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: fa_ваш_ключ" \
-H "Content-Type: text/plain" \
--data-binary @-
Заголовок Content-Type: text/plain обязателен. Без него curl отправит
application/x-www-form-urlencoded, тело разберётся как форма, поля
title в ней не окажется — и придёт 400 title_required.
GET /api/v1/notify#
GET https://fastalert.ru/api/v1/notify?title=…
Вариант для инструментов, которые умеют только дёрнуть URL: поля передаются в query.
Поддерживаются title, body, group,
groupId и priority — dedupKey,
dedupWindowSeconds и url в GET-варианте не читаются.
curl -G https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: fa_ваш_ключ" \
--data-urlencode "title=Бэкап завершён" \
--data-urlencode "body=42 ГБ за 18 минут" \
--data-urlencode "group=prod-db" \
--data-urlencode "priority=low"
GET решает проблему тела запроса, но не заголовка: аутентификации через query-параметр
нет и не будет. Ключ всё равно передаётся в X-Api-Key или
Authorization — иначе он оседал бы в логах прокси и в истории браузера.
Заголовок Idempotency-Key#
Клиент, который отправляет алерт, почти всегда повторяет запрос при таймауте — а таймаут случается уже после того, как уведомление принято. Без защиты потерянный ответ превращается в две эскалации в три часа ночи.
Передайте Idempotency-Key (принимается и синоним
X-Idempotency-Key, до 200 символов). Повтор с тем же ключом в пределах
аккаунта не создаёт второе уведомление: вернётся тот же notificationId,
replayed: true и queued: 0. Две гонящиеся попытки тоже
разрешаются корректно — выигрывает одна, второй возвращается её результат.
curl -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: fa_ваш_ключ" \
-H "Idempotency-Key: deploy-2026-08-04-a1b2c3d" \
-H "Content-Type: application/json" \
-d '{"title": "Деплой 2026.8.4 завершён", "priority": "low"}'
Ответ 202#
| Поле | Тип | Значение |
|---|---|---|
notificationId | UUID | Идентификатор уведомления. |
queued | целое | Сколько попыток доставки поставлено в очередь по всем каналам. |
channels | массив строк | Каналы, по которым запланирована доставка: PushIos, PushAndroid, WebPush, Email, Sms, Call, Webhook. |
deduplicated | bool | true, если запрос схлопнулся в уже существующее уведомление по dedupKey. |
replayed | bool | true, если сработал Idempotency-Key и это повтор прежнего вызова. |
devicesNotified | целое | Сколько push-каналов задействовано. Поле оставлено для совместимости с первой версией API. |
warning | строка или null | Заполняется, когда в аккаунте нет ни одного устройства и ни одного контакта: уведомление сохранено, но доставлять его некуда. |
Коды ошибок эндпойнта#
| HTTP | error | Когда и что делать |
|---|---|---|
| 400 | invalid_body |
Тело не разобралось: битый JSON или пустой запрос. В detail — позиция ошибки от парсера. Повторять бессмысленно, чинится в коде отправителя. |
| 400 | title_required |
Поле title отсутствует или состоит из пробелов. Частая причина — не тот Content-Type. |
| 404 | group_not_found |
Группа с таким groupId или slug не найдена в аккаунте. Проверьте slug в кабинете; он не совпадает с названием группы. |
| 403 | key_scope_mismatch |
Ключ привязан к одной группе, а запрос адресован в другую. Либо уберите поле группы из запроса, либо возьмите ключ без привязки. |
| 402 | quota_exceeded |
Исчерпан месячный лимит уведомлений тарифа. Обновляется 1-го числа; в detail — название тарифа и лимит. |
| 400 | too_many_files |
Файлов в форме больше, чем разрешено тарифом (по умолчанию 10). |
| 400 | file_too_large |
Один из файлов превышает лимит (по умолчанию 25 МБ). В detail — имя файла и действующий лимит. |
| 429 | rate_limited |
Превышен лимит запросов для ключа. Ответ содержит Retry-After: 60; повторите через минуту. |
{
"error": "title_required",
"detail": "Укажите заголовок уведомления в поле title."
}
Приоритеты#
Приоритет — это не украшение, он меняет три вещи: пройдёт ли уведомление сквозь тихие часы, с какой срочностью его отдадут push-сервису и какие шаги эскалации вообще сработают (у каждого маршрута есть минимальный приоритет).
| Значение | Тихие часы | Push | Остальное |
|---|---|---|---|
low |
Откладывается до конца тихих часов | Web Push: Urgency: low, TTL 1 ч. Android: обычный приоритет |
Для «фоновых» событий: деплой прошёл, бэкап снят |
normal |
Откладывается до конца тихих часов | Web Push: Urgency: normal, TTL 4 ч |
Значение по умолчанию |
high |
Не откладывается | Web Push: Urgency: high, TTL 12 ч, уведомление не скрывается само |
Письмо помечается важным (X-Priority: 1). Включаются шаги маршрутов с минимальным приоритетом «high» — например SMS |
critical |
Не откладывается | Web Push: Urgency: high, TTL 24 ч. iOS: interruption-level: time-sensitive — пробивает режим фокусирования |
Включает всю цепочку эскалации, вплоть до шагов со звонком; в звонке текст сообщения повторяется |
Кроме английских значений принимаются низкий, высокий,
критический и urgent. Любое другое значение молча
превращается в normal — отправка не падает из-за опечатки в приоритете.
Тихие часы задаются в кабинете и работают в часовом поясе аккаунта. Окно может пересекать полночь (например 23:00 → 08:00): отложенное уведомление уедет на границу окна, а не потеряется.
Дедупликация и повторы#
Проверка, которая мигает, не должна порождать сорок уведомлений. Передайте
dedupKey — и повторы в пределах окна свернутся в первое уведомление,
у которого просто вырастет счётчик повторов.
Как это работает#
- Совпадение ищется по
dedupKeyв пределах всего аккаунта, а не одной группы. Поэтому в ключ стоит класть хост и имя проверки:disk-full:web-03:/var. - Учитываются только уведомления, созданные не раньше чем
dedupWindowSecondsназад (по умолчанию 300) и ещё не подтверждённые. - При совпадении новое уведомление не создаётся: увеличивается счётчик повторов, обновляется время последнего повтора, доставка не планируется. Ответ:
202,deduplicated: true,queued: 0и тот жеnotificationId. - Как только уведомление подтверждено (ack), оно перестаёт поглощать повторы: следующий такой же алерт создаст новое уведомление и разбудит снова. Это ровно то поведение, которого ждут от повторяющегося инцидента.
Пример: мигающая проверка#
Проверка доступности API крутится каждые 30 секунд. Сервис лежит 20 минут — это 40 сработок. С окном в 30 минут придёт одно уведомление.
curl -fsS -m 5 -o /dev/null https://api.example.com/health || \
curl -fsS -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: $ACE_KEY" \
-F "group=prod-api" \
-F "title=api.example.com не отвечает" \
-F "body=/health не отдал 200 за 5 секунд." \
-F "priority=high" \
-F "dedupKey=health:api.example.com" \
-F "dedupWindowSeconds=1800" >/dev/null
{
"notificationId": "b3f1c0de-4b1a-4c9b-9f3d-5a6b7c8d9e01",
"queued": 0,
"channels": [],
"deduplicated": true,
"replayed": false,
"devicesNotified": 0
}
dedupKey и Idempotency-Key — разные вещи#
| dedupKey | Idempotency-Key | |
|---|---|---|
| Задача | Схлопнуть повторяющееся состояние | Обезвредить повторный вызов |
| Значение | Описывает условие: disk-full:web-03 | Уникально для попытки: deploy-2026-08-04-a1b2c3d |
| Срок | dedupWindowSeconds, по умолчанию 5 минут | Хранится за уведомлением |
| Флаг в ответе | deduplicated: true | replayed: true |
Повторы доставки#
Это уже про то, что происходит после приёма. Каждая попытка доставки повторяется до 6 раз с экспоненциальной задержкой — от 5 секунд, с потолком в 15 минут и случайным разбросом, чтобы восстановившийся провайдер не получил всю очередь одним залпом. Постоянные ошибки (например «такого адреса не существует») не повторяются вовсе, мёртвые устройства удаляются автоматически.
Группы и маршруты#
Адресация#
Группа определяется по первому сработавшему правилу:
groupId— точный UUID;group— slug, приведённый к нижнему регистру (в это же поле можно вставить UUID);- группа, к которой привязан ключ;
- группа аккаунта по умолчанию;
- самая старая корневая группа.
Slug устойчив к переименованию группы и читается в скриптах, поэтому в конфигах лучше хранить его. UUID уместен там, где конфиг генерируется автоматически и опечатка не должна тихо уехать в чужую группу.
Наследование маршрутов#
Дерево двухуровневое: группа и подгруппы. Если у подгруппы нет ни одного собственного правила маршрутизации, применяются правила родителя. Настроили эскалацию один раз на «Прод» — она действует на «Прод / БД», «Прод / API» и всё остальное под ней. Как только у подгруппы появляется хотя бы одно своё правило, родительские перестают применяться целиком: это замена, а не слияние.
Если правил нет вообще нигде, уведомление рассылается на все зарегистрированные push-устройства аккаунта. Новый аккаунт без всякой настройки работает с первого curl.
Эскалация#
Отдельной сущности «эскалация» нет — это просто маршруты с разной задержкой. Каждое
правило состоит из канала, получателя, задержки (delaySeconds, 0…86400),
минимального приоритета и флага «отменять при подтверждении».
| Задержка | Канал | Минимальный приоритет |
|---|---|---|
| сразу | Push в браузер, iOS, Android | low |
| сразу | normal | |
| 5 минут | SMS | high |
| 10 минут | Звонок | critical |
Все шаги пресета помечены «отменять при подтверждении»: как только человек нажал «Подтверждаю» в письме, приложении или по ссылке из SMS, ещё не отправленные шаги отменяются. Звонок в 03:10 не уходит, если в 03:04 алерт уже приняли в работу.
Если два правила ведут на один и тот же адрес по одному каналу (например собственное правило и унаследованное), останется одно — с самой ранней отправкой.
Мониторинг#
Heartbeat: «сообщи, если я замолчу»#
GET · POST · HEAD https://fastalert.ru/ping/{token}
Создайте монитор типа heartbeat в кабинете — получите персональный URL. Дальше задача дёргает его после каждого успешного прогона. Если сигнала нет дольше, чем интервал плюс запас (grace), приходит алерт.
Эндпойнт намеренно неприхотлив: отвечает на GET, POST и HEAD, не требует ни ключа, ни заголовков, ни тела. Ограничение — 240 запросов в минуту с одного IP.
{ "status": "ok", "monitor": "Ночной бэкап", "receivedAt": "2026-08-04T02:31:07.412Z" }
Неизвестный токен — 404 с {"error": "unknown_ping_token"}.
Токен можно перевыпустить в кабинете, старый URL сразу перестаёт работать.
Если монитор был в состоянии «молчит», первый же пинг закрывает инцидент и (когда включено уведомление о восстановлении) присылает сообщение с длительностью тишины — не дожидаясь следующего цикла проверки.
crontab
# Пинг только если бэкап отработал без ошибки.
0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 -o /dev/null https://fastalert.ru/ping/ВАШ_ТОКЕН
# Или просто «сервер жив», раз в 5 минут.
*/5 * * * * curl -fsS -m 10 -o /dev/null https://fastalert.ru/ping/ВАШ_ТОКЕН
systemd timer
[Unit]
Description=Heartbeat в FastAlert
[Service]
Type=oneshot
ExecStart=/usr/bin/curl -fsS -m 10 -o /dev/null https://fastalert.ru/ping/ВАШ_ТОКЕН
[Unit]
Description=Heartbeat в FastAlert каждые 5 минут
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=15s
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now ace-heartbeat.timer
Docker HEALTHCHECK
HEALTHCHECK --interval=60s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS -m 3 http://localhost:8080/health \
&& curl -fsS -m 5 -o /dev/null https://fastalert.ru/ping/ВАШ_ТОКЕН \
|| exit 1
Образу нужен curl; в alpine это apk add --no-cache curl.
GitHub Actions
- name: Heartbeat
if: success()
run: curl -fsS -m 10 -o /dev/null "$ACE_PING_URL"
env:
ACE_PING_URL: ${{ secrets.ACE_PING_URL }}
HTTP-проверки#
Второй тип монитора: мы сами ходим на ваш адрес. Настраивается в кабинете, из API
управляется через /api/monitors (нужна сессия кабинета, не API-ключ).
| Параметр | Диапазон | Смысл |
|---|---|---|
| Адрес и метод | http/https, любой метод | Что и как дёргать. По умолчанию GET. |
| Ожидаемые коды | строка, например 200,204 | Любой другой ответ считается неудачей. |
| Подстрока в теле | строка | Проверка содержимого: ответ 200 с текстом «database unavailable» — тоже сбой. |
| Таймаут | 1…60 с | Медленный ответ приравнивается к отсутствию ответа. |
| Порог отказов | 1…10 | Сколько подряд неудачных проверок нужно, чтобы поднять алерт. |
| Интервал | ограничен тарифом снизу | Как часто проверять. |
| Приоритет | low…critical | С каким приоритетом создавать уведомление. По умолчанию high. |
| Повторное уведомление | 1…10080 минут | Как часто напоминать, пока проблема не закрыта. |
| Сообщать о восстановлении | да/нет | Отдельное уведомление, когда сервис ожил, с длительностью простоя. |
Адреса во внутренних сетях проверять нельзя — при сохранении монитора такой URL
отклоняется с url_not_allowed. Для сервисов без публичного адреса
используйте heartbeat: инициатива на вашей стороне, дыра наружу не нужна.
Подтверждение доставки#
Статус Succeeded означает, что провайдер принял отправку. Это не то же
самое, что «дошло до экрана». Push-каналы умеют подтверждать факт получения: браузер
или приложение шлёт квитанцию, как только уведомление пришло.
| Канал | Подтверждает доставку |
|---|---|
| Браузер, iOS, Android | да |
| Email, SMS, звонок, webhook | нет — на той стороне нет нашего кода |
В GET /api/notifications/{id} у каждой доставки есть поля:
| Поле | Значение |
|---|---|
canConfirm | умеет ли канал подтверждать доставку |
confirmedAt | когда устройство подтвердило получение |
confirmLatencyMs | задержка от отправки до подтверждения |
Эскалация по факту недоставки
Если ни один push-канал не подтвердил доставку за 45 секунд, следующие шаги эскалации выполняются немедленно, не дожидаясь своей задержки. Логика простая: раз уведомление не дошло, ждать реакции человека бессмысленно.
Отключается флагом accelerateIfUndelivered у правила маршрутизации.
Если у группы нет ни одного канала с подтверждением, задержки работают как обычно.
Проверка канала
Раз в неделю на устройство, которое давно ничего не подтверждало, уходит тихая проверка связи. Не подтвердило её — в кабинете появляется отметка «не подтверждает доставку». Так протухший токен или отозванное разрешение находятся до аварии, а не во время неё.
Подтверждение#
Подтверждение — это не «прочитано». Взгляд на список не отменяет звонок; отменяет его явное «беру в работу». При подтверждении все ещё не отправленные шаги эскалации, помеченные флагом «отменять при подтверждении», переводятся в статус «отменено».
Ссылка из письма или SMS#
GET https://fastalert.ru/ack/{token}
Токен подставляется в каждое письмо и SMS. Переход по ссылке открывает самодостаточную HTML-страницу с подтверждением — без входа в аккаунт, потому что в три часа ночи с чужого телефона форма логина означает лишний звонок. Повторный переход безопасен: страница просто сообщит, что подтверждение уже было.
Из кабинета и приложения#
POST https://fastalert.ru/api/notifications/{id}/ack
Требует сессии кабинета (Authorization: Bearer с JWT), API-ключ здесь
не подходит. Метод идемпотентен.
{
"acknowledged": true,
"alreadyAcknowledged": false,
"acknowledgedAt": "2026-08-04T03:04:11.882Z"
}
Подтверждение снимает уведомление с дедупликации: следующий алерт с тем же
dedupKey создаст новое уведомление, даже если окно ещё не истекло.
Инцидент, который вернулся, должен разбудить снова.
Webhook#
Webhook — обычный канал доставки, наравне с push и email. Добавьте контакт типа «вебхук» в кабинете: там задаётся адрес и один раз показывается секрет подписи. Дальше вебхук участвует в маршрутах на общих основаниях, включая задержки и отмену по подтверждению.
Что приходит#
POST ваш адрес · Content-Type: application/json
| Заголовок | Значение |
|---|---|
User-Agent | FastAlert/1.0 |
X-FastAlert-Delivery | UUID уведомления — годится как ключ идемпотентности на вашей стороне |
X-FastAlert-Timestamp | Unix-время отправки, в секундах |
X-FastAlert-Signature | sha256= и HMAC-SHA256 в нижнем регистре, hex |
{
"id": "b3f1c0de-4b1a-4c9b-9f3d-5a6b7c8d9e01",
"title": "Реплика отстала на 240 секунд",
"body": "pg-replica-2, lag растёт последние 10 минут.",
"priority": "high",
"group": "Прод / БД",
"createdAt": "2026-08-04T03:01:44.1180000+00:00",
"url": "https://grafana.example.com/d/pg/replication",
"ackUrl": "https://fastalert.ru/ack/9dQ2…",
"attachments": 0,
"data": {
"notificationId": "b3f1c0de-4b1a-4c9b-9f3d-5a6b7c8d9e01",
"groupId": "0f2b1a77-9c3e-4d21-8b60-1e5f7a2c4d90",
"priority": "high",
"source": "api",
"dedupKey": "replication-lag:pg-replica-2"
}
}
В data ключи dedupKey и monitorId присутствуют
только когда они заданы. source — откуда пришло уведомление:
api или monitor. Переход по ackUrl подтверждает
уведомление и гасит оставшуюся эскалацию — это полноценная кнопка «принял» для вашей
системы.
Проверка подписи#
Подпись считается по строке {timestamp}.{сырое тело}: HMAC-SHA256 с вашим
секретом, hex в нижнем регистре. Три обязательных условия корректной проверки:
- берите сырые байты тела, до разбора JSON — пересериализация меняет байты и ломает подпись;
- сравнивайте за постоянное время, а не оператором
==; - проверяйте свежесть
X-FastAlert-Timestamp— без этого перехваченный запрос можно переиграть позже, подпись-то остаётся верной.
import hashlib
import hmac
import os
import time
from flask import Flask, request, abort
SECRET = os.environ["ACE_WEBHOOK_SECRET"].encode()
MAX_SKEW_SECONDS = 300
app = Flask(__name__)
@app.post("/ace-webhook")
def fastalert_webhook():
raw = request.get_data() # сырые байты, до разбора JSON
ts = request.headers.get("X-FastAlert-Timestamp", "")
signature = request.headers.get("X-FastAlert-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > MAX_SKEW_SECONDS:
abort(400) # слишком старый запрос — возможен повтор перехваченного
expected = "sha256=" + hmac.new(
SECRET, ts.encode() + b"." + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
event = request.get_json()
app.logger.warning("%s: %s", event["priority"], event["title"])
return "", 200
const crypto = require("node:crypto");
const express = require("express");
const SECRET = process.env.ACE_WEBHOOK_SECRET;
const MAX_SKEW_SECONDS = 300;
const app = express();
// express.raw обязателен: подпись считается по телу до разбора JSON.
app.post("/ace-webhook", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.get("X-FastAlert-Timestamp") || "";
const signature = req.get("X-FastAlert-Signature") || "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > MAX_SKEW_SECONDS) {
return res.sendStatus(400);
}
const expected = "sha256=" + crypto
.createHmac("sha256", SECRET)
.update(Buffer.concat([Buffer.from(`${ts}.`, "utf8"), req.body]))
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
// timingSafeEqual бросает исключение на разной длине — длину проверяем заранее.
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
process.stdout.write(`${event.priority}: ${event.title}\n`);
res.sendStatus(200);
});
app.listen(3000);
Что мы делаем с вашим ответом#
| Ответ | Наше поведение |
|---|---|
| 2xx | Доставка засчитана. |
| 408, 429, 5xx | Временная ошибка: повтор с экспоненциальной задержкой, до 6 попыток. |
| 410 | Считаем, что эндпойнт выведен из эксплуатации: повторов не будет, вебхук отключается. |
| остальное | Постоянная ошибка, повторов нет. Сюда же попадают 3xx: редиректы мы не ходим. |
Таймаут запроса — 15 секунд, из ответа читается не более 2 КБ и он нигде не используется. Адреса, ведущие во внутренние сети, блокируются, причём повторно в момент установления соединения — чтобы имя нельзя было переразрешить на внутренний адрес между проверкой и подключением.
Готовые интеграции#
Функция в bash#
export ACE_KEY="fa_ваш_ключ"
# ace "Заголовок" ["Текст"] ["приоритет"]
ace() {
curl -fsS -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: $ACE_KEY" \
-F "title=$1" \
-F "body=${2:-}" \
-F "priority=${3:-normal}" >/dev/null
}
# Длинная команда, уведомление по завершении:
# make deploy; ace "Деплой закончился (код $?)"
Форма -F избавляет от экранирования JSON: кавычки, переводы строк и
кириллица в тексте не требуют никакой подготовки.
Cron: сообщать только о падении#
#!/bin/sh
# Обёртка для cron-задачи: при успехе молчит, при падении шлёт алерт с логом.
set -u
LOG=$(mktemp)
"$@" >"$LOG" 2>&1
CODE=$?
if [ "$CODE" -eq 0 ]; then
rm -f "$LOG"
exit 0
fi
curl -fsS -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: $ACE_KEY" \
-F "title=$(hostname): $1 завершилась с кодом $CODE" \
-F "body=<$LOG" \
-F "priority=high" \
-F "dedupKey=cron:$(hostname):$1" \
-F "log=@$LOG" >/dev/null
rm -f "$LOG"
exit "$CODE"
ACE_KEY=fa_ваш_ключ
30 3 * * * root /usr/local/bin/run-or-alert /usr/local/bin/backup.sh
-F "body=<$LOG" подставляет содержимое файла в поле, а
-F "log=@$LOG" прикладывает тот же файл вложением — в уведомлении будет и
хвост вывода, и полный лог.
systemd: OnFailure#
[Unit]
Description=Обработчик очереди
OnFailure=ace-alert@%n.service
[Unit]
Description=Алерт в FastAlert о падении %i
[Service]
Type=oneshot
EnvironmentFile=/etc/acenotifier.env
ExecStart=/usr/local/bin/ace-unit-alert %i
#!/bin/sh
# Аргумент — полное имя упавшего юнита (systemd подставит его через %n).
set -eu
journalctl -u "$1" -n 50 --no-pager | curl -fsS -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: $ACE_KEY" \
-F "title=$(hostname): юнит $1 упал" \
-F "body=<-" \
-F "priority=high" \
-F "dedupKey=systemd:$(hostname):$1" >/dev/null
В /etc/acenotifier.env одна строка ACE_KEY=fa_…; права
chmod 600, иначе ключ прочитает любой пользователь системы.
Zabbix#
Тип носителя Script, параметры по порядку: {ALERT.SENDTO},
{ALERT.SUBJECT}, {ALERT.MESSAGE}. В поле «Отправлять на» у
пользователя указывается slug группы.
#!/bin/sh
set -eu
ACE_KEY="fa_ваш_ключ"
curl -fsS -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: $ACE_KEY" \
-F "group=$1" \
-F "title=$2" \
-F "body=$3" \
-F "priority=high" \
-F "dedupKey=zabbix:$2" >/dev/null
Alertmanager и Grafana#
Оба инструмента шлют вебхук со своей фиксированной структурой тела, в которой нет поля
title, — прямой вызов нашего эндпойнта вернёт
400 title_required. Нужен либо шаблон полезной нагрузки (если ваша версия
это умеет), либо крошечный релей ниже. Аутентификация в обоих случаях —
Authorization: Bearer fa_…, ровно ради таких инструментов этот вариант
и поддержан.
receivers:
- name: acenotifier
webhook_configs:
- url: http://127.0.0.1:9099/alertmanager
send_resolved: true
import os
import requests
from flask import Flask, request
ACE = "https://fastalert.ru/api/v1/notify"
KEY = os.environ["ACE_KEY"]
app = Flask(__name__)
session = requests.Session()
session.headers["X-Api-Key"] = KEY
@app.post("/alertmanager")
def relay():
payload = request.get_json(silent=True) or {}
resolved = payload.get("status") == "resolved"
for alert in payload.get("alerts", []):
labels = alert.get("labels", {})
annotations = alert.get("annotations", {})
name = labels.get("alertname", "Alertmanager")
session.post(
ACE,
json={
"group": labels.get("group", "prod"),
"title": ("Закрыт: " if resolved else "") + name,
"body": annotations.get("description") or annotations.get("summary", ""),
"priority": "low" if resolved else "high",
"dedupKey": f"am:{name}:{labels.get('instance', '-')}",
"dedupWindowSeconds": 1800,
"url": alert.get("generatorURL"),
},
timeout=10,
)
return "", 204
Uptime Kuma#
Тип уведомления Webhook, адрес https://fastalert.ru/api/v1/notify,
в «Additional Headers» добавьте {"X-Api-Key": "fa_…"}. Если версия умеет
«Custom Body» — задайте тело нашего формата:
{
"group": "prod-web",
"title": "{{ monitor.name }}",
"body": "{{ msg }}",
"priority": "high",
"dedupKey": "kuma:{{ monitor.name }}",
"dedupWindowSeconds": 1800
}
GitHub Actions#
- name: Уведомить о падении сборки
if: failure()
env:
ACE_KEY: ${{ secrets.ACE_KEY }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
curl -fsS -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: $ACE_KEY" \
-F "title=CI упал: $REPO" \
-F "body=Ветка $BRANCH, запуск $RUN_URL" \
-F "priority=high" \
-F "url=$RUN_URL" \
-F "dedupKey=ci:$REPO:$BRANCH"
Значения проходят через env, а не подставляются прямо в командную строку —
иначе имя ветки становится вектором инъекции в shell.
Python: обработчик logging#
import logging
import requests
class AceHandler(logging.Handler):
"""Отправляет записи уровня ERROR и выше в FastAlert."""
def __init__(self, api_key, base_url="https://fastalert.ru", group=None, timeout=5):
super().__init__(level=logging.ERROR)
self.url = base_url.rstrip("/") + "/api/v1/notify"
self.group = group
self.timeout = timeout
self.session = requests.Session()
self.session.headers["X-Api-Key"] = api_key
def emit(self, record):
try:
payload = {
"title": f"{record.levelname} {record.name}: {record.getMessage()}"[:200],
"body": self.format(record)[:8000],
"priority": "critical" if record.levelno >= logging.CRITICAL else "high",
# Один и тот же кадр стека не должен будить всю ночь.
"dedupKey": f"log:{record.name}:{record.module}:{record.lineno}",
"dedupWindowSeconds": 900,
}
if self.group:
payload["group"] = self.group
self.session.post(self.url, json=payload, timeout=self.timeout)
except Exception:
self.handleError(record)
logger = logging.getLogger("app")
handler = AceHandler(api_key="fa_ваш_ключ", group="prod-api")
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s\n%(message)s"))
logger.addHandler(handler)
emit вызывается синхронно и блокирует поток на время HTTP-запроса.
В веб-приложении заверните обработчик в logging.handlers.QueueHandler
с QueueListener — тогда отправка уедет в отдельный поток.
Лимиты и коды ошибок#
Частота запросов#
Лимит на отправку считается по ключу, а не по IP: одна зациклившаяся машина не должна съедать бюджет всех остальных серверов за тем же NAT. Реализован он как token bucket — по умолчанию 120 запросов, полностью пополняется раз в минуту. Всплеск в двадцать алертов, когда что-то действительно упало, проходит целиком; устойчивый поток упирается в потолок.
Очереди нет: при исчерпании корзины приходит 429 с заголовком
Retry-After: 60 и телом {"error": "rate_limited", …}.
Heartbeat-эндпойнт живёт по своему правилу — 240 запросов в минуту с одного IP.
Размеры#
| Что | Предел |
|---|---|
title | 200 символов, длиннее — обрезается |
body | 8000 символов |
url | 2000 символов |
group (slug) | 60 символов |
dedupKey | 200 символов |
Idempotency-Key | 200 символов |
| Файлов на уведомление | 10 по умолчанию, ниже по тарифу |
| Размер файла | 25 МБ по умолчанию, ниже по тарифу |
| Размер запроса целиком | 120 МБ |
Формат ошибки#
Все ошибки API отвечают одним конвертом: машиночитаемый error и
человекочитаемый detail на русском. Разбирайте error,
показывайте detail.
{
"error": "quota_exceeded",
"detail": "Исчерпан месячный лимит тарифа «Старт»: 1000 уведомлений. Лимит обновится 1-го числа."
}
Поле detail присутствует не всегда: в некоторых ответах есть только
error. Не завязывайте на него логику.
HTTP-статусы#
| Код | Значение | Повторять? |
|---|---|---|
| 202 | Уведомление принято и поставлено в очередь доставки. | — |
| 400 | Запрос некорректен: invalid_body, title_required, too_many_files, file_too_large. | Нет, чинить в коде |
| 401 | Ключ не передан, не найден, отозван, истёк или запрещён с этого IP. | Нет |
| 402 | Упёрлись в тариф: quota_exceeded, limit_reached, channel_not_in_plan. | Нет, до смены тарифа или начала месяца |
| 403 | key_scope_mismatch — ключ привязан к другой группе. | Нет |
| 404 | Объект не найден: group_not_found, unknown_ping_token. | Нет |
| 429 | rate_limited, есть Retry-After. | Да, через указанное время |
| 500 | Внутренняя ошибка. | Да, с экспоненциальной задержкой |
SDK и примеры#
Отдельных библиотек нет и не планируется: интеграция — это один HTTP-запрос, обёртка вокруг которого добавила бы вам зависимость и ничего больше. Ниже — по одному рабочему примеру на язык.
Python · requests#
import os
import requests
response = requests.post(
"https://fastalert.ru/api/v1/notify",
headers={
"X-Api-Key": os.environ["ACE_KEY"],
"Idempotency-Key": "backup-2026-08-04",
},
json={
"group": "prod-db",
"title": "Бэкап не удался",
"body": "pg_dump вернул код 1: could not connect to server",
"priority": "high",
"dedupKey": "backup:prod-db",
"dedupWindowSeconds": 3600,
},
timeout=10,
)
response.raise_for_status()
notification_id = response.json()["notificationId"]
Node.js · fetch#
const response = await fetch("https://fastalert.ru/api/v1/notify", {
method: "POST",
headers: {
"X-Api-Key": process.env.ACE_KEY,
"Content-Type": "application/json",
"Idempotency-Key": `deploy-${process.env.GITHUB_SHA ?? Date.now()}`,
},
body: JSON.stringify({
group: "prod-api",
title: "Деплой завершён",
body: "Версия 2026.8.4 выкачена на 4 узла.",
priority: "low",
}),
});
if (response.status !== 202) {
const { error, detail } = await response.json();
throw new Error(`FastAlert: ${error}${detail ? " — " + detail : ""}`);
}
Go#
package acenotifier
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
type Notification struct {
Group string `json:"group,omitempty"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
Priority string `json:"priority,omitempty"`
DedupKey string `json:"dedupKey,omitempty"`
}
var client = &http.Client{Timeout: 10 * time.Second}
func Notify(apiKey string, n Notification) error {
raw, err := json.Marshal(n)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost,
"https://fastalert.ru/api/v1/notify", bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("X-Api-Key", apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
return fmt.Errorf("acenotifier: HTTP %d", resp.StatusCode)
}
return nil
}
PHP#
<?php
function fastalert_notify(string $apiKey, array $payload): void
{
$ch = curl_init('https://fastalert.ru/api/v1/notify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_HTTPHEADER => ['X-Api-Key: ' . $apiKey, 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 202) {
throw new RuntimeException("FastAlert: HTTP $code $body");
}
}
fastalert_notify(getenv('ACE_KEY'), [
'group' => 'prod-api',
'title' => 'Очередь не разбирается',
'body' => 'В очереди 12 400 задач, воркеры простаивают.',
'priority' => 'high',
'dedupKey' => 'queue-stuck:prod-api',
]);
C##
using System.Net;
using System.Net.Http.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://fastalert.ru") };
http.DefaultRequestHeaders.Add("X-Api-Key", Environment.GetEnvironmentVariable("ACE_KEY"));
var response = await http.PostAsJsonAsync("/api/v1/notify", new
{
group = "prod-api",
title = "Миграция не применилась",
body = "relation \"orders\" does not exist",
priority = "critical",
dedupKey = "migrate:orders",
});
if (response.StatusCode != HttpStatusCode.Accepted)
{
throw new InvalidOperationException(
$"FastAlert: HTTP {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
}
Bash#
#!/bin/sh
set -eu
curl -fsS -X POST https://fastalert.ru/api/v1/notify \
-H "X-Api-Key: $ACE_KEY" \
-F "group=prod-api" \
-F "title=$(hostname): свободно менее 5% диска" \
-F "body=$(df -h / | tail -n 1)" \
-F "priority=high" \
-F "dedupKey=disk:$(hostname):/" \
-F "dedupWindowSeconds=3600" >/dev/null
PowerShell#
$body = @{
group = 'prod-win'
title = "$env:COMPUTERNAME: служба остановлена"
body = 'W3SVC не поднялась после перезапуска.'
priority = 'high'
dedupKey = "service:$env:COMPUTERNAME:W3SVC"
} | ConvertTo-Json
# Windows PowerShell 5.1 отправляет строку в кодировке по умолчанию,
# поэтому кириллицу передаём готовыми UTF-8 байтами.
Invoke-RestMethod -Method Post `
-Uri 'https://fastalert.ru/api/v1/notify' `
-Headers @{ 'X-Api-Key' = $env:ACE_KEY } `
-ContentType 'application/json; charset=utf-8' `
-Body ([Text.Encoding]::UTF8.GetBytes($body))