Skip to content

Приём событий ​

Webhook сообщает об изменении, но не заменяет GET ресурса и проверку окончательного результата. Доставка асинхронная, с ограниченным числом повторов; возможны дубликаты и нарушение порядка. Автоматическое завершение покупки только по отсутствию ошибки HTTP или наличию domain.created неверно: запись домена может быть создана до окончания регистрации.

Создание и проверка подписки ​

Понадобятся публичный HTTPS endpoint вашего биллинга, API-ключ со scope webhooks.manage и защищённое хранилище секрета. Для чтения подписок/доставок нужен webhooks.read. Все изменяющие запросы требуют Idempotency-Key. $API_BASE ниже включает /api/reseller/v1, $RESELLER_API_TOKEN берётся из хранилища секретов, а не из этого руководства.

sh
curl --fail-with-body "$API_BASE/webhooks" \
  -H "Authorization: Bearer $RESELLER_API_TOKEN" \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: webhook-create-20261005-0001' \
  --data-binary '{"name":"Domain events","url":"https://reseller.example/webhooks/billing","event_subscriptions":["domain.operation.updated","domain.registration.updated","domain.verification.updated"],"timeout_seconds":10,"max_attempts":8}'

reseller.example является нерабочим обозначением: замените его вашим endpoint с публичным DNS. HTTP 201:

json
{"data":{"uuid":"22222222-2222-4222-8222-222222222222","environment":"test","name":"Domain events","url":"https://reseller.example/webhooks/billing","event_subscriptions":["domain.operation.updated","domain.registration.updated","domain.verification.updated"],"timeout_seconds":10,"max_attempts":8,"status":"active","rotated_at":null,"disabled_at":null,"created_at":"2026-10-05T09:00:00+00:00"},"signing_secret":"whsec_EXAMPLE0EXAMPLE0EXAMPLE0EXAMPLE0EXAMPLE0EXAMPLE0EXAMPLE0EXAMPLE0","secret_notice":"This signing secret is shown once and cannot be recovered."}

Секрет здесь демонстрационный. Храните настоящий секрет отдельно от токена REST API. GET подписки не возвращает секрет, но повтор создания/ротации с тем же Idempotency-Key в пределах срока HTTP replay может повторить первоначальный ответ с секретом. Не записывайте тело такого ответа в общие логи, аналитику браузера или чат.

Подписка относится к реселлеру и среде, не одному API-ключу. Другой ключ того же реселлера и среды с нужным scope может управлять ею. Создание проверяет URL, но ещё не подтверждает доступность HTTPS-приёмника. Выполните test:

sh
curl --fail-with-body -X POST "$API_BASE/webhooks/22222222-2222-4222-8222-222222222222/test" \
  -H "Authorization: Bearer $RESELLER_API_TOKEN" \
  -H 'Accept: application/json' \
  -H 'Idempotency-Key: webhook-test-20261005-0001'

HTTP 202:

json
{"data":{"uuid":"33333333-3333-4333-8333-333333333333","event_uuid":"44444444-4444-4444-8444-444444444444","event_type":"webhook.test","status":"pending","attempt_number":0,"http_status":null,"error_code":null,"next_attempt_at":"2026-10-05T09:01:00+00:00","delivered_at":null,"failed_at":null}}

webhook.test отправляется выбранной подписке даже без этого имени в event_subscriptions. Подписка должна быть active, иначе 409. Проверяйте GET /webhooks/{webhookUuid}/deliveries со scope webhooks.read: 202 означает очередь, а не успешную доставку.

Транспорт и ограничения URL ​

  • Только HTTPS с проверкой сертификата, цепочки доверия и hostname/SNI. Нужен публично доверенный сертификат; self-signed без публичного доверия не поддерживается. Настройки custom CA и mutual TLS через этот API нет.
  • Допустим публичный hostname или глобальный IP с соответствующим сертификатом. Для IDN используйте ASCII punycode. Допустим явный корректный HTTPS port, не только 443.
  • Все разрешённые DNS A/AAAA проверяются. Если хотя бы один адрес частный/локальный/зарезервированный, multicast или запрещённый NAT64, URL отклоняется. DNS проверяется при создании, при изменении/включении и перед отправкой.
  • Соединение закрепляется за проверенным первым адресом. В одной попытке не обещается обход всех адресов DNS при сбое. TLS проверяет исходное имя, не подменённый IP.
  • Запрещены userinfo (user:password@host), URL fragment, localhost, пробельные/управляющие символы и обратные слеши. Query string разрешён, но секреты в нём передавать не следует.
  • Redirect не выполняется: 301/302/307/308 будут неудачной доставкой. Настройте точный конечный URL. Прокси-переменные отправителя не используются; произвольные дополнительные заголовки авторизации/Basic auth через API не настраиваются.
  • Протокол не обещает фиксированный исходящий IP. Не стройте allowlist по адресу, увиденному один раз; подпись обязательна независимо от сетевого фильтра.

Смена DNS на запрещённый адрес после сохранения подписки приведёт к transport_error без обхода сетевой проверки, а не к отправке в частную сеть. PATCH {"status":"disabled"} с неизменным URL позволяет отключить подписку даже при неработающем DNS.

Конверт и заголовки ​

Отправитель выполняет POST с Content-Type: application/json, Accept: application/json и следующими прикладными заголовками:

ЗаголовокЗначение
User-AgentBilling-ITGroup-Webhook/1.0
X-Webhook-IDUUID подписки
X-Webhook-TimestampUnix time в секундах для текущей попытки
X-Webhook-Signaturev1= + 64 lowercase hex символа HMAC-SHA256
X-Request-IDUUID корреляции доставки; сохраняется при автоматическом повторе и redeliver

Отдельного X-Webhook-Event, номера попытки или delivery UUID в заголовках нет. REST Bearer-ключ отправитель вам не присылает. Тело события:

json
{"id":"44444444-4444-4444-8444-444444444444","type":"webhook.test","created_at":"2026-10-05T09:01:00+00:00","environment":"test","provider":"55555555-5555-4555-8555-555555555555","data":{"webhook_uuid":"22222222-2222-4222-8222-222222222222"}}

id идентифицирует событие, а не попытку. created_at относится к событию; при повторе меняется timestamp подписи, но не эта дата. provider допускает null при недоступной связи; приёмник для конкретного реселлера должен проверять ожидаемый UUID и среду. event_version, request_id, correlation_id, delivery_uuid и последовательного номера в теле нет. Не принимайте внутренние технические поля за часть публичного протокола.

Проверка подписи ​

Вычисление:

text
signature = "v1=" + hex_lowercase(HMAC_SHA256(signing_secret, timestamp + "." + raw_body))

Используйте исходные байты HTTP-тела до JSON decode/re-encode. Подписываются timestamp и тело; X-Webhook-ID и X-Request-ID сами по себе не включены в HMAC. Сопоставляйте endpoint/секрет с ожидаемой подпиской, а provider и environment проверяйте в подписанном теле. Сравнивайте подписи constant-time функцией.

Проверяйте также свежесть timestamp. Рекомендуемое окно приёмника: не более 300 секунд в прошлое/будущее с синхронизированными часами. Это рекомендация для вашего приёмника, не встроенный лимит REST API. Не проверяйте свежесть по created_at: старая дата события законна при повторе доставки. Подпись без контроля времени и дедупликации не защищает от replay.

Воспроизводимый тест подписи ​

Для точного однострочного JSON выше без завершающего перевода строки, timestamp 1791190860 и демонстрационного секрета whsec_EXAMPLE0EXAMPLE0EXAMPLE0EXAMPLE0EXAMPLE0EXAMPLE0EXAMPLE0EXAMPLE0 ожидается:

text
v1=55b46b56493f21432a77ad0e59e88eb824000c3528f7af5b8ea6459e903c96a8

Это тест криптографического расчёта. При проверке в другой день реальный приёмник должен отвергнуть этот timestamp как устаревший. Для end-to-end используйте /test, не отключайте свежесть на рабочем endpoint.

Пример приёмника PHP и PostgreSQL ​

Пример принимает событие в устойчивую очередь вашего биллинга и отвечает 204 только после commit. Он не выполняет покупку/продление внутри webhook. Подготовьте таблицу в своей БД:

sql
CREATE TABLE billing_webhook_inbox (
    webhook_uuid uuid NOT NULL,
    event_uuid uuid NOT NULL,
    provider_uuid uuid NOT NULL,
    environment text NOT NULL CHECK (environment IN ('test', 'live')),
    event_type text NOT NULL,
    raw_body text NOT NULL,
    received_at timestamptz NOT NULL DEFAULT now(),
    processed_at timestamptz,
    PRIMARY KEY (webhook_uuid, event_uuid)
);

Переменные окружения примера: WEBHOOK_UUID, WEBHOOK_PROVIDER_UUID, WEBHOOK_ENVIRONMENT, WEBHOOK_SIGNING_SECRET, необязательный WEBHOOK_PREVIOUS_SECRET только на переходный период, INBOX_PDO_DSN, INBOX_DB_USER, INBOX_DB_PASSWORD. Это параметры вашего приёмника, не требования к настройке сервера провайдера.

php
<?php
declare(strict_types=1);

function reply(int $status): never
{
    http_response_code($status);
    exit;
}

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    header('Allow: POST');
    reply(405);
}

$webhook = (string) getenv('WEBHOOK_UUID');
$provider = (string) getenv('WEBHOOK_PROVIDER_UUID');
$environment = (string) getenv('WEBHOOK_ENVIRONMENT');
$secret = (string) getenv('WEBHOOK_SIGNING_SECRET');
if ($webhook === '' || $provider === '' || $secret === ''
    || !in_array($environment, ['test', 'live'], true)) {
    reply(503);
}

$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$webhookHeader = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';
if ($raw === false || !preg_match('/\A[0-9]{1,12}\z/', $timestamp)
    || !preg_match('/\Av1=[a-f0-9]{64}\z/', $signature)
    || abs(time() - (int) $timestamp) > 300
    || !hash_equals($webhook, $webhookHeader)) {
    reply(401);
}

$secrets = array_filter([$secret, (string) getenv('WEBHOOK_PREVIOUS_SECRET')]);
$valid = false;
foreach ($secrets as $candidate) {
    $expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $raw, $candidate);
    $matches = hash_equals($expected, $signature);
    $valid = $matches || $valid;
}
if (!$valid) {
    reply(401);
}

try {
    $event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
    reply(400);
}
$uuidPattern = '/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i';
if (!is_array($event) || !is_string($event['id'] ?? null)
    || !preg_match($uuidPattern, $event['id'])
    || !is_string($event['type'] ?? null)
    || !is_string($event['created_at'] ?? null)
    || !is_array($event['data'] ?? null)) {
    reply(400);
}
if (($event['provider'] ?? null) !== $provider
    || ($event['environment'] ?? null) !== $environment) {
    reply(403);
}

try {
    $db = new PDO(
        (string) getenv('INBOX_PDO_DSN'),
        (string) getenv('INBOX_DB_USER'),
        (string) getenv('INBOX_DB_PASSWORD'),
        [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
    );
    $db->beginTransaction();
    $insert = $db->prepare(
        'INSERT INTO billing_webhook_inbox '
        . '(webhook_uuid,event_uuid,provider_uuid,environment,event_type,raw_body) '
        . 'VALUES (:webhook,:event,:provider,:environment,:type,:body) '
        . 'ON CONFLICT (webhook_uuid,event_uuid) DO NOTHING'
    );
    $insert->execute([
        'webhook' => $webhook, 'event' => $event['id'],
        'provider' => $provider, 'environment' => $environment,
        'type' => $event['type'], 'body' => $raw,
    ]);
    $db->commit();
} catch (Throwable) {
    if (isset($db) && $db->inTransaction()) {
        $db->rollBack();
    }
    header('Retry-After: 60');
    reply(503);
}
reply(204);

Отдельный обработчик вашей очереди берёт записи без processed_at, сверяет ресурс через REST и атомарно сохраняет бизнес-результат и отметку обработки. Уникальный event id предотвращает повторную вставку при сетевом таймауте после commit; сами финансовые/доменные действия также должны иметь собственный ключ идемпотентности. Если один endpoint обслуживает несколько подписок, выбирайте секрет только из заранее заданной таблицы подписок, а не из произвольного URL/поля входного запроса.

Для неизвестного типа сохраните событие, отправьте 2xx после надёжной записи и поднимите внутреннее уведомление; не создавайте заказ по незнакомому payload. Отклонённый 401/400 не остановит автоматические повторы отправителя: исправляйте endpoint, а не полагайтесь на такой ответ как unsubscribe.

Доставка, повторы и диагностика ​

ПараметрЗначение
УспехЛюбой HTTP 200..299, тело ответа не проверяется
Неуспех HTTPЛюбой не-2xx, включая 3xx, 400, 401, 404, 409 и 410
Общий timeout попытки10 секунд по умолчанию, настраивается 1..30
Connect timeoutmin(5 секунд, общий timeout)
Максимум попыток8 по умолчанию, настраивается 1..20, первая включена
Базовая задержка после попытки nmin(21600, 60 × 2^(n-1)) секунд; cap 6 часов
Поддержка Retry-AfterТолько ответы получателя 429 и 503; integer seconds или HTTP-date
Максимально учитываемый Retry-After86400 секунд (24 часа)
Итоговая паузаmax(базовая задержка, корректный Retry-After), не меньше базовой
Проверка готовых повторовПримерно раз в минуту плюс задержка очереди; точное время не гарантируется

При стандартных 8 попытках и отсутствии Retry-After задержки между попытками составляют 60, 120, 240, 480, 960, 1920, 3840 секунд. Суммарно 127 минут до восьмой попытки без учёта времени HTTP и очереди. После восьмой неуспешной попытки запись становится failed, следующая автоматически не планируется. Ограничение 6 часов относится к базовому backoff, не к итоговой паузе: Retry-After может увеличить её до 24 часов. Некорректное значение игнорируется, прошедшая дата даёт 0, стандартный backoff всё равно действует. На иных статусах Retry-After игнорируется. Серверного jitter в этой формуле нет.

GET deliveries возвращает состояние последней попытки, не отдельную запись на каждую попытку:

json
{"data":[{"uuid":"33333333-3333-4333-8333-333333333333","event_uuid":"44444444-4444-4444-8444-444444444444","event_type":"webhook.test","status":"retry_scheduled","attempt_number":1,"http_status":503,"error_code":"http_error","next_attempt_at":"2026-10-05T09:02:00+00:00","delivered_at":null,"failed_at":null}],"meta":{"next_cursor":null,"per_page":25}}
status / error_codeЗначение и действие
pending / nullЕщё не выполнена попытка; не запускайте redeliver параллельно
retry_scheduled / http_errorПолучен не-2xx; исправьте endpoint, смотрите http_status и next_attempt_at
retry_scheduled / transport_errorDNS, TLS, connect, timeout или запрет URL; HTTP status обычно null. Временной таймаут не доказывает, что получатель не сохранил событие
delivered / nullПолучен 2xx, delivered_at заполнено
failed / http_error или transport_errorЛимит попыток исчерпан, next_attempt_at null; исправьте причину, затем redeliver
cancelled / webhook_disabledПолучатель отключён, попытка не отправлена; включение само по себе не оживляет cancelled
cancelled / delivery_scope_mismatchКонтекст доставки не совпадает с подпиской/событием; обратитесь к провайдеру, не обходите изоляцию

response_excerpt, HTTP-тело ответа приёмника, текст исключения, request_id и payload_hash в GET deliveries не возвращаются. Диагностику точной причины TLS/DNS/500 ведите по своему access/error log и запросу в поддержку провайдера с UUID доставки/события. В тело ошибки HTTP REST не подставляется error_code доставки.

Ручной повтор и ротация ​

POST /webhooks/{webhookUuid}/deliveries/{deliveryUuid}/redeliver разрешён только для delivered/failed/cancelled и active-подписки. Возвращает 202 с той же delivery UUID, сброшенным attempt_number=0, null в http_status/error_code/delivered_at/failed_at и новым next_attempt_at. Event id и X-Request-ID доставки сохраняются. Используются текущие URL и секрет. Нельзя использовать redeliver как способ повторно выполнить уже обработанное бизнес-действие: приёмник должен ответить 2xx на дубликат без побочного эффекта. Для повторной проверки работоспособности удобнее /test, создающий новое событие.

PATCH может изменить timeout, max_attempts, URL и список подписок для дальнейших попыток. Он не отменяет уже начатый HTTP-вызов. Удаление webhook возвращает 204 без тела и удаляет связанные записи доставок. Не удаляйте подписку, если вам ещё нужны их UUID и история состояния.

POST /webhooks/{webhookUuid}/rotate-secret немедленно заменяет секрет отправителя и возвращает новый. На стороне отправителя нет grace period и одновременной подписи двумя ключами. Отложенная попытка использует новый секрет, уже отправленная может прийти со старым. Практический порядок:

  1. Подготовьте у приёмника поддержку двух секретов на короткий переходный период; старый пока основной.
  2. Выполните rotate-secret один раз с сохранённым Idempotency-Key, получите новый секрет и установите его в приёмнике. Между ответом API и обновлением приёмника возможны неудачные попытки; они будут повторены.
  3. Проверьте /test и реальные доставки. Уберите старый секрет после вашего окна допустимого запаздывания и свежести подписи.
  4. При неизвестном результате rotate-secret сначала повторите тот же запрос с тем же ключом для HTTP replay, не запускайте следующую ротацию новым ключом вслепую.

При критичной недопустимости такого окна согласуйте временное отключение/сверку: disabled не является гарантированным буфером событий. События, опубликованные во время отключения или до создания подписки, не обещаны к последующей доставке, а отменённые доставки требуют redeliver. Новая подписка не получает автоматически всю прошлую историю.

События и payload ​

Ниже перечислены все принимаемые имена. Подписки используют точное совпадение; wildcard * не поддерживается. Даже для действующего события не обещается уведомление на каждое изменение поля или каждое действие через все интерфейсы: периодическая REST-сверка обязательна.

ИмяРеальное назначение / data
customer.createdcustomer_uuid, external_id
customer.updatedЛибо customer_uuid/external_id, либо customer_uuid/contact_uuid/action для contact_created, contact_updated, contact_deleted
domain.createdСнимок локального домена, не подтверждение регистрации
domain.registration.updatedИзменение registration_status и снимок домена
domain.operation.updatedoperation_uuid, domain_uuid, action, status; результат читайте GET operation
domain.updatedСнимок домена при отслеживаемом изменении, включая локальное удаление
domain.verification.submitteddomain_uuid, external_id, verification_uuid, status; принятие подписи, не окончательная проверка
domain.verification.updatedСнимок домена при изменении проверки владельца или образовательной лицензии
domain.auto_renew.faileddomain_uuid, code=auto_renew_unavailable, обобщённое message; не более одного события на домен за календарный час
domain.transfer.updatedСнимок домена с transfer.status и transfer.registry_transfer_status
order.createdorder_uuid, external_id, status; deleted может присутствовать в варианте lifecycle
order.updatedorder_uuid, external_id, status, deleted
service.updatedservice_uuid, external_id, status, deleted, auto_renew, expires_at
ticket.createdticket_uuid, external_id, status при создании через reseller API
ticket.updatedticket_uuid, message_uuid, status при ответе через reseller API; не обещает все ответы оператора через другие интерфейсы
webhook.testwebhook_uuid

Зарезервированные имена без действующих отправителей в текущей версии: domain.nameservers.updated, domain.contacts.updated, domain.renewed, domain.transfer.requested, hosting.created, hosting.updated, invoice.created, invoice.updated, payment.completed. API принимает эти имена при создании подписки, но интеграция не должна ждать их для завершения заказа. Используйте domain.operation.updated, domain.updated, order.updated, service.updated и GET соответствующего ресурса. Для этих зарезервированных имён конкретная форма data пока не обещана.

Пример фактического события операции:

json
{"id":"77777777-7777-4777-8777-777777777777","type":"domain.operation.updated","created_at":"2026-10-05T09:05:00+00:00","environment":"test","provider":"55555555-5555-4555-8555-555555555555","data":{"operation_uuid":"66666666-6666-4666-8666-666666666666","domain_uuid":"88888888-8888-4888-8888-888888888888","action":"renew","status":"completed"}}

Пример другой формы customer.updated:

json
{"id":"99999999-9999-4999-8999-999999999999","type":"customer.updated","created_at":"2026-10-05T09:03:00+00:00","environment":"test","provider":"55555555-5555-4555-8555-555555555555","data":{"customer_uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","contact_uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","action":"contact_updated"}}

Полные типы всех действующих payload описаны в машинном контракте по ссылке #/components/schemas/WebhookEventEnvelope; в examples есть полные конверты для всех 16 действующих имён. Исходящие заголовки описаны в #/components/headers/WebhookId, #/components/headers/WebhookTimestamp, #/components/headers/WebhookSignature и #/components/headers/WebhookRequestId. Эти заголовки относятся к входящему POST вашего приёмника, не к ответу REST API. При генерации приёмника не применяйте к нему Bearer security REST API: проверяется HMAC, а успешный ответ получателя может быть любым 2xx.

Схема учитывает nullable поля и разные варианты customer.updated/order.created. В события не включаются auth-code домена, ЭЦП, пароль хостинга, тело обращения или signing_secret. Поля могут расширяться: игнорируйте неизвестные поля, но валидируйте обязательные для вашей обработки.

Дедупликация, порядок и сверка ​

Не обещаются exactly-once, глобальный порядок, последовательность событий одного домена и гарантированная доставка после исчерпания попыток. Таймаут может произойти после вашего commit: повтор с тем же event id нормален. Уникальность по событию внутри провайдера не означает, что один бизнес-переход всегда представлен ровно одним событием: могут прийти и domain.updated, и domain.registration.updated.

Для одной подписки ключ inbox: (webhook UUID, event id). Для общего бизнес-обработчика нескольких подписок дополнительно дедуплицируйте по (environment, provider, event id) и собственному ID операции/финансового действия. Не используйте X-Request-ID как ID бизнес-события и не сравнивайте UUID по порядку.

Храните событие до 2xx. Если сохранение не удалось, отдайте 503 с Retry-After. Если сохранение удалось, но последующая бизнес-обработка не удалась, повторяйте её из своей очереди, а не теряйте уже подтверждённое событие. При получении старого события не перезаписывайте более новое состояние слепо: created_at не является версией ресурса; выполните GET. Регулярно сверяйте незавершённые заказы и операции, в том числе при тишине webhook.

Поведение Retry-After описано с учётом RFC 9110; конкретные интервалы, caps и список статусов выше являются контрактом этой реализации, а не требованиями RFC. Политика обработки HTTP-ошибок и ограничения REST описаны в руководстве надёжности.