Тема
Приём событий
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-Agent | Billing-ITGroup-Webhook/1.0 |
X-Webhook-ID | UUID подписки |
X-Webhook-Timestamp | Unix time в секундах для текущей попытки |
X-Webhook-Signature | v1= + 64 lowercase hex символа HMAC-SHA256 |
X-Request-ID | UUID корреляции доставки; сохраняется при автоматическом повторе и 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 timeout | min(5 секунд, общий timeout) |
| Максимум попыток | 8 по умолчанию, настраивается 1..20, первая включена |
| Базовая задержка после попытки n | min(21600, 60 × 2^(n-1)) секунд; cap 6 часов |
| Поддержка Retry-After | Только ответы получателя 429 и 503; integer seconds или HTTP-date |
| Максимально учитываемый Retry-After | 86400 секунд (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_error | DNS, 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 и одновременной подписи двумя ключами. Отложенная попытка использует новый секрет, уже отправленная может прийти со старым. Практический порядок:
- Подготовьте у приёмника поддержку двух секретов на короткий переходный период; старый пока основной.
- Выполните rotate-secret один раз с сохранённым Idempotency-Key, получите новый секрет и установите его в приёмнике. Между ответом API и обновлением приёмника возможны неудачные попытки; они будут повторены.
- Проверьте
/testи реальные доставки. Уберите старый секрет после вашего окна допустимого запаздывания и свежести подписи. - При неизвестном результате rotate-secret сначала повторите тот же запрос с тем же ключом для HTTP replay, не запускайте следующую ротацию новым ключом вслепую.
При критичной недопустимости такого окна согласуйте временное отключение/сверку: disabled не является гарантированным буфером событий. События, опубликованные во время отключения или до создания подписки, не обещаны к последующей доставке, а отменённые доставки требуют redeliver. Новая подписка не получает автоматически всю прошлую историю.
События и payload
Ниже перечислены все принимаемые имена. Подписки используют точное совпадение; wildcard * не поддерживается. Даже для действующего события не обещается уведомление на каждое изменение поля или каждое действие через все интерфейсы: периодическая REST-сверка обязательна.
| Имя | Реальное назначение / data |
|---|---|
| customer.created | customer_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.updated | operation_uuid, domain_uuid, action, status; результат читайте GET operation |
| domain.updated | Снимок домена при отслеживаемом изменении, включая локальное удаление |
| domain.verification.submitted | domain_uuid, external_id, verification_uuid, status; принятие подписи, не окончательная проверка |
| domain.verification.updated | Снимок домена при изменении проверки владельца или образовательной лицензии |
| domain.auto_renew.failed | domain_uuid, code=auto_renew_unavailable, обобщённое message; не более одного события на домен за календарный час |
| domain.transfer.updated | Снимок домена с transfer.status и transfer.registry_transfer_status |
| order.created | order_uuid, external_id, status; deleted может присутствовать в варианте lifecycle |
| order.updated | order_uuid, external_id, status, deleted |
| service.updated | service_uuid, external_id, status, deleted, auto_renew, expires_at |
| ticket.created | ticket_uuid, external_id, status при создании через reseller API |
| ticket.updated | ticket_uuid, message_uuid, status при ответе через reseller API; не обещает все ответы оператора через другие интерфейсы |
| webhook.test | webhook_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 описаны в руководстве надёжности.