Документация API

Одна точка входа для отправки уведомлений, один заголовок для аутентификации. Первый рабочий запрос занимает минуту; остальное на этой странице нужно уже потом — когда захочется эскалацию, дедупликацию и подписанные вебхуки.

Базовый адрес этого экземпляра
Определяем адрес…

Быстрый старт#

  1. Создайте API-ключ в кабинете, раздел «API-ключи». Ключ вида fa_… показывается один раз — сохраните его сразу в переменную окружения или в хранилище секретов.
  2. Подключите хотя бы одного получателя: включите push в браузере, установите приложение или добавьте контакт (email, телефон, вебхук). Без получателей уведомление всё равно будет принято и сохранено, но в ответе придёт поле warning, а доставлять его будет некуда.
  3. Отправьте запрос. Ниже — тот же самый curl, который кабинет показывает сразу после создания ключа.
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:

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нет Текст. Переводы строк сохраняются.
groupIdUUIDнет Идентификатор группы. Самый однозначный способ адресации.
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

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 — с файлами

Те же имена полей плюс любое количество файловых полей. Имя файлового поля не важно — в уведомление попадают все файлы формы. Так удобно приложить лог или дамп конфигурации.

multipart · алерт с приложенным логом
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. Группу и приоритет здесь задать нельзя, уведомление уйдёт в группу ключа или в группу по умолчанию.

text/plain · вывод команды целиком в уведомление
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 и prioritydedupKey, dedupWindowSeconds и url в GET-варианте не читаются.

GET · параметры в query
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#

ПолеТипЗначение
notificationIdUUIDИдентификатор уведомления.
queuedцелоеСколько попыток доставки поставлено в очередь по всем каналам.
channelsмассив строк Каналы, по которым запланирована доставка: PushIos, PushAndroid, WebPush, Email, Sms, Call, Webhook.
deduplicatedbooltrue, если запрос схлопнулся в уже существующее уведомление по dedupKey.
replayedbooltrue, если сработал Idempotency-Key и это повтор прежнего вызова.
devicesNotifiedцелоеСколько push-каналов задействовано. Поле оставлено для совместимости с первой версией API.
warningстрока или nullЗаполняется, когда в аккаунте нет ни одного устройства и ни одного контакта: уведомление сохранено, но доставлять его некуда.

Коды ошибок эндпойнта#

HTTPerrorКогда и что делать
400invalid_body Тело не разобралось: битый JSON или пустой запрос. В detail — позиция ошибки от парсера. Повторять бессмысленно, чинится в коде отправителя.
400title_required Поле title отсутствует или состоит из пробелов. Частая причина — не тот Content-Type.
404group_not_found Группа с таким groupId или slug не найдена в аккаунте. Проверьте slug в кабинете; он не совпадает с названием группы.
403key_scope_mismatch Ключ привязан к одной группе, а запрос адресован в другую. Либо уберите поле группы из запроса, либо возьмите ключ без привязки.
402quota_exceeded Исчерпан месячный лимит уведомлений тарифа. Обновляется 1-го числа; в detail — название тарифа и лимит.
400too_many_files Файлов в форме больше, чем разрешено тарифом (по умолчанию 10).
400file_too_large Один из файлов превышает лимит (по умолчанию 25 МБ). В detail — имя файла и действующий лимит.
429rate_limited Превышен лимит запросов для ключа. Ответ содержит Retry-After: 60; повторите через минуту.
400 Bad Request · формат любой ошибки
{
  "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 минут придёт одно уведомление.

Проверка раз в 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
Ответ на 2-й и последующие вызовы внутри окна
{
  "notificationId": "b3f1c0de-4b1a-4c9b-9f3d-5a6b7c8d9e01",
  "queued": 0,
  "channels": [],
  "deduplicated": true,
  "replayed": false,
  "devicesNotified": 0
}

dedupKey и Idempotency-Key — разные вещи#

dedupKeyIdempotency-Key
ЗадачаСхлопнуть повторяющееся состояниеОбезвредить повторный вызов
ЗначениеОписывает условие: disk-full:web-03Уникально для попытки: deploy-2026-08-04-a1b2c3d
СрокdedupWindowSeconds, по умолчанию 5 минутХранится за уведомлением
Флаг в ответеdeduplicated: truereplayed: true

Повторы доставки#

Это уже про то, что происходит после приёма. Каждая попытка доставки повторяется до 6 раз с экспоненциальной задержкой — от 5 секунд, с потолком в 15 минут и случайным разбросом, чтобы восстановившийся провайдер не получил всю очередь одним залпом. Постоянные ошибки (например «такого адреса не существует») не повторяются вовсе, мёртвые устройства удаляются автоматически.

Группы и маршруты#

Адресация#

Группа определяется по первому сработавшему правилу:

  1. groupId — точный UUID;
  2. group — slug, приведённый к нижнему регистру (в это же поле можно вставить UUID);
  3. группа, к которой привязан ключ;
  4. группа аккаунта по умолчанию;
  5. самая старая корневая группа.

Slug устойчив к переименованию группы и читается в скриптах, поэтому в конфигах лучше хранить его. UUID уместен там, где конфиг генерируется автоматически и опечатка не должна тихо уехать в чужую группу.

Наследование маршрутов#

Дерево двухуровневое: группа и подгруппы. Если у подгруппы нет ни одного собственного правила маршрутизации, применяются правила родителя. Настроили эскалацию один раз на «Прод» — она действует на «Прод / БД», «Прод / API» и всё остальное под ней. Как только у подгруппы появляется хотя бы одно своё правило, родительские перестают применяться целиком: это замена, а не слияние.

Если правил нет вообще нигде, уведомление рассылается на все зарегистрированные push-устройства аккаунта. Новый аккаунт без всякой настройки работает с первого curl.

Эскалация#

Отдельной сущности «эскалация» нет — это просто маршруты с разной задержкой. Каждое правило состоит из канала, получателя, задержки (delaySeconds, 0…86400), минимального приоритета и флага «отменять при подтверждении».

Готовый пресет эскалации из кабинета. Шаги, недоступные в тарифе или без подтверждённого контакта, просто пропускаются.
ЗадержкаКаналМинимальный приоритет
сразуPush в браузер, iOS, Androidlow
сразуEmailnormal
5 минутSMShigh
10 минутЗвонокcritical

Все шаги пресета помечены «отменять при подтверждении»: как только человек нажал «Подтверждаю» в письме, приложении или по ссылке из SMS, ещё не отправленные шаги отменяются. Звонок в 03:10 не уходит, если в 03:04 алерт уже приняли в работу.

Если два правила ведут на один и тот же адрес по одному каналу (например собственное правило и унаследованное), останется одно — с самой ранней отправкой.

Мониторинг#

Heartbeat: «сообщи, если я замолчу»#

GET · POST · HEAD https://fastalert.ru/ping/{token}

Создайте монитор типа heartbeat в кабинете — получите персональный URL. Дальше задача дёргает его после каждого успешного прогона. Если сигнала нет дольше, чем интервал плюс запас (grace), приходит алерт.

Эндпойнт намеренно неприхотлив: отвечает на GET, POST и HEAD, не требует ни ключа, ни заголовков, ни тела. Ограничение — 240 запросов в минуту с одного IP.

200 OK
{ "status": "ok", "monitor": "Ночной бэкап", "receivedAt": "2026-08-04T02:31:07.412Z" }

Неизвестный токен — 404 с {"error": "unknown_ping_token"}. Токен можно перевыпустить в кабинете, старый URL сразу перестаёт работать.

Если монитор был в состоянии «молчит», первый же пинг закрывает инцидент и (когда включено уведомление о восстановлении) присылает сообщение с длительностью тишины — не дожидаясь следующего цикла проверки.

crontab

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

/etc/systemd/system/ace-heartbeat.service
[Unit]
Description=Heartbeat в FastAlert

[Service]
Type=oneshot
ExecStart=/usr/bin/curl -fsS -m 10 -o /dev/null https://fastalert.ru/ping/ВАШ_ТОКЕН
/etc/systemd/system/ace-heartbeat.timer
[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

Dockerfile · пинг только при здоровом контейнере
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

.github/workflows/nightly.yml
      - 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-ключ здесь не подходит. Метод идемпотентен.

200 OK
{
  "acknowledged": true,
  "alreadyAcknowledged": false,
  "acknowledgedAt": "2026-08-04T03:04:11.882Z"
}

Подтверждение снимает уведомление с дедупликации: следующий алерт с тем же dedupKey создаст новое уведомление, даже если окно ещё не истекло. Инцидент, который вернулся, должен разбудить снова.

Webhook#

Webhook — обычный канал доставки, наравне с push и email. Добавьте контакт типа «вебхук» в кабинете: там задаётся адрес и один раз показывается секрет подписи. Дальше вебхук участвует в маршрутах на общих основаниях, включая задержки и отмену по подтверждению.

Что приходит#

POST ваш адрес · Content-Type: application/json

ЗаголовокЗначение
User-AgentFastAlert/1.0
X-FastAlert-DeliveryUUID уведомления — годится как ключ идемпотентности на вашей стороне
X-FastAlert-TimestampUnix-время отправки, в секундах
X-FastAlert-Signaturesha256= и 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 в нижнем регистре. Три обязательных условия корректной проверки:

  1. берите сырые байты тела, до разбора JSON — пересериализация меняет байты и ломает подпись;
  2. сравнивайте за постоянное время, а не оператором ==;
  3. проверяйте свежесть X-FastAlert-Timestamp — без этого перехваченный запрос можно переиграть позже, подпись-то остаётся верной.
Python · Flask
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
Node.js · Express
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#

~/.bashrc
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: сообщать только о падении#

/usr/local/bin/run-or-alert
#!/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"
/etc/cron.d/backup
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
/etc/systemd/system/ace-alert@.service
[Unit]
Description=Алерт в FastAlert о падении %i

[Service]
Type=oneshot
EnvironmentFile=/etc/acenotifier.env
ExecStart=/usr/local/bin/ace-unit-alert %i
/usr/local/bin/ace-unit-alert
#!/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 группы.

/usr/lib/zabbix/alertscripts/acenotifier.sh
#!/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_…, ровно ради таких инструментов этот вариант и поддержан.

alertmanager.yml
receivers:
  - name: acenotifier
    webhook_configs:
      - url: http://127.0.0.1:9099/alertmanager
        send_resolved: true
Релей · Python + Flask
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» — задайте тело нашего формата:

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#

fastalert_logging.py
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.

Размеры#

ЧтоПредел
title200 символов, длиннее — обрезается
body8000 символов
url2000 символов
group (slug)60 символов
dedupKey200 символов
Idempotency-Key200 символов
Файлов на уведомление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.Нет, до смены тарифа или начала месяца
403key_scope_mismatch — ключ привязан к другой группе.Нет
404Объект не найден: group_not_found, unknown_ping_token.Нет
429rate_limited, есть Retry-After.Да, через указанное время
500Внутренняя ошибка.Да, с экспоненциальной задержкой

SDK и примеры#

Отдельных библиотек нет и не планируется: интеграция — это один HTTP-запрос, обёртка вокруг которого добавила бы вам зависимость и ничего больше. Ниже — по одному рабочему примеру на язык.

Python · requests#

notify.py
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#

notify.mjs
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#

notify.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#

notify.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##

Program.cs
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#

notify.sh
#!/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#

Notify.ps1
$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))