Search by

hookpilot / webhook-client

exhum4n

HookPilot PHP SDK: отправка подписанных событий в адрес приёма и типизированный клиент публичного API

v1.0.0 2026-10-03 11:04 UTC

This package is auto-updated.

Last update: 2026-11-01 17:00:27 UTC


README

Два независимых клиента в одном пакете:

КлассЗачемАвторизация
ManagementClientПубличное API: эндпоинты, доставки, команда, участники, аудит, биллингAPI-ключ hp_sk_...
WebhookClientОтправка подписанного события в адрес приёмаHMAC на подписывающем секрете эндпоинта

Первый управляет конфигурацией, второй гоняет события. Ключ для отправки событий не нужен.

Требования

  • PHP 8.3+
  • Guzzle 7.x
  • реализация PSR-7 (psr/http-message 1.x или 2.x)

Установка

composer require hookpilot/webhook-client

Внутри монорепозитория пакет подключён как path-репозиторий симлинком — отдельная установка не нужна.

Документация: hookpilot.ru/help/php-webhook-client.

ManagementClient

use HookPilot\WebhookClient\ManagementClient;

$api = new ManagementClient(getenv('HOOKPILOT_API_KEY'));
Параметр конструктораПо умолчаниюКомментарий
$apiKey—обязан начинаться с hp_sk_, иначе InvalidArgumentException
$baseUrlhttps://api.hookpilot.ruManagementClient::BASE_URL
$guzzleConfig[]перекрывает настройки Guzzle (таймауты, handler для тестов, прокси)

Таймауты по умолчанию: 10 с на запрос, 5 с на соединение. Заголовки Authorization, Accept и User-Agent клиент проставляет сам.

Ключ выпускается в личном кабинете: «API-ключи» → «Создать ключ». У каждого ключа свой набор прав, метод без нужного права отвечает 403.

Эндпоинты — $api->endpoints()

МетодHTTPПравоВозвращает
list()GET /v1/endpointsendpoints:readEndpoint[]
get(string $endpointId)GET /v1/endpoints/{id}endpoints:readEndpoint
create(EndpointDraft $draft)POST /v1/endpointsendpoints:writeIssuedEndpoint
update(string $endpointId, EndpointChanges $changes)PUT /v1/endpoints/{id}endpoints:writeEndpoint
delete(string $endpointId)DELETE /v1/endpoints/{id}endpoints:writevoid
activate(string $endpointId)PATCH /v1/endpoints/{id}/activateendpoints:writevoid
deactivate(string $endpointId)PATCH /v1/endpoints/{id}/deactivateendpoints:writevoid
rotateSecret(string $endpointId)POST /v1/endpoints/{id}/rotate-secretendpoints:writeIssuedEndpoint
use HookPilot\WebhookClient\Data\EndpointDraft;

$issued = $api->endpoints()->create(new EndpointDraft(
    name: 'Заказы',
    url: 'https://shop.example.com/hooks/hookpilot',
    subscribedEvents: ['order.*'],
    customHeaders: ['X-Source' => 'hookpilot'],
    timeoutMs: 5000,
));

echo $issued->endpoint->id;
echo $issued->signingSecret;

signingSecret отдаётся только в ответе на создание и ротацию — сохраните его сразу, повторно получить нельзя. После rotateSecret() старый секрет какое-то время принимается наравне с новым, так что отправители успевают переключиться.

url должен быть публичным: приватные и локальные адреса, как и домены без DNS-записи, сервис отклоняет с 422.

use HookPilot\WebhookClient\Data\EndpointChanges;

$endpoint = $api->endpoints()->update($id, new EndpointChanges(
    subscribedEvents: ['order.created', 'order.paid'],
    timeoutMs: 8000,
));

EndpointChanges отправляет только заполненные поля: null означает «не трогать».

Доставки — $api->webhooks()

МетодHTTPПравоВозвращает
list()GET /v1/webhookswebhooks:readWebhook[]
attempts(string $webhookId)GET /v1/webhooks/{id}/attemptswebhooks:readAttempt[]
foreach ($api->webhooks()->list() as $webhook) {
    if ($webhook->status === WebhookStatus::Failed) {
        foreach ($api->webhooks()->attempts($webhook->id) as $attempt) {
            echo "$attempt->responseCode за $attempt->durationMs мс\n";
        }
    }
}

Команда — $api->team()

МетодHTTPПравоВозвращает
get()GET /v1/teamteam:readTeam
rename(string $name)PUT /v1/teamteam:writeTeam

Участники — $api->members()

МетодHTTPПравоВозвращает
list()GET /v1/membersmembers:readMember[]
changeRole(string $memberId, MemberRole $role)PUT /v1/members/{id}members:writeMember
remove(string $memberId)DELETE /v1/members/{id}members:writevoid

Аудит — $api->audit()

МетодHTTPПравоВозвращает
logs(?AuditFilter $filter = null)GET /v1/audit/logsaudit:readAuditEntry[]
use HookPilot\WebhookClient\Data\AuditFilter;

$entries = $api->audit()->logs(new AuditFilter(
    action: 'endpoint.updated',
    from: new DateTimeImmutable('-7 days'),
    perPage: 50,
));

Незаполненные поля фильтра в запрос не попадают. Даты уходят в формате ATOM.

Биллинг — $api->billing()

МетодHTTPПравоВозвращает
balance()GET /v1/billing/balancebilling:readBalance
usage()GET /v1/billing/usagebilling:readUsage

Данные

Все объекты в HookPilot\WebhookClient\Data — readonly, собираются из ответа через fromArray(). Даты приходят как ?DateTimeImmutable.

КлассПоля
Endpointid, teamId, name, url, subscribedEvents, customHeaders, timeoutMs, isActive, createdAt
IssuedEndpointendpoint, signingSecret
EndpointDraftname, url, subscribedEvents, customHeaders, timeoutMs — тело для create()
EndpointChangesте же плюс isActive, все необязательные — тело для update()
Webhookid, eventId, endpointId, eventType, status, nextRetryAt, createdAt
Attemptid, webhookId, status, responseCode, durationMs, attemptedAt, createdAt
Teamid, name, ownerId, createdAt, updatedAt
Memberid, teamId, userId, email, role, status, invitedAt, acceptedAt, createdAt, метод hasAcceptedInvitation()
AuditEntryid, userId, apiKeyId, teamId, action, entityType, entityId, metadata, createdAt, метод isMadeByApiKey()
AuditFilteraction, from, to, page, perPage — фильтр для logs()
BalanceamountMinor, currency, dailyBurnMinor, daysLeft, isSuspended, negativeSince, suspendAt
UsageperiodStart, periodEnd, eventsTotal, eventsLimit, usagePercent, endpointsTotal, endpointsLimit, membersTotal, membersLimit

Деньги — в копейках (amountMinor, dailyBurnMinor).

endpointsTotal и endpointsLimit в Usage названы так исторически, а считают подключения — сумму эндпоинтов и источников. Тот же лимит проверяется при создании эндпоинта и источника.

Перечисления

HookPilot\WebhookClient\Enums:

ЕнамЗначения
WebhookStatuspending, processing, succeeded, failed
AttemptStatussuccess, failed
MemberRoleowner, administrator, moderator, client, viewer
Scopeendpoints:read, endpoints:write, webhooks:read, team:read, team:write, members:read, members:write, audit:read, billing:read

Ошибки

Все ошибки ManagementClient наследуют ApiException (сам он наследует RuntimeException). У каждой есть statusCode, error — машинный код от API, и details.

КлассКогдаДополнительно
TransportExceptionсеть, таймаут, ответ не JSONstatusCode = 0
UnauthorizedException401 — ключ не тот или отозван
ForbiddenException403 — нет права или запрет по ролиisScopeMissing()
NotFoundException404
ValidationFailedException422errors(): array<string, string[]>
RateLimitedException429 — лимит запросов, лимит событий тарифа, приостановка командыretryAfterSeconds
ServerErrorException5xx
ApiExceptionостальные коды >= 400
use HookPilot\WebhookClient\Exceptions\ForbiddenException;
use HookPilot\WebhookClient\Exceptions\RateLimitedException;
use HookPilot\WebhookClient\Exceptions\TransportException;
use HookPilot\WebhookClient\Exceptions\ValidationFailedException;

try {
    $api->endpoints()->create($draft);
} catch (ValidationFailedException $exception) {
    foreach ($exception->errors() as $field => $messages) {
        echo "$field: " . implode(', ', $messages) . "\n";
    }
} catch (ForbiddenException $exception) {
    echo $exception->isScopeMissing() ? 'Ключу не хватает прав' : 'Действие запрещено';
} catch (RateLimitedException $exception) {
    sleep($exception->retryAfterSeconds ?? 60);
} catch (TransportException $exception) {
    // сеть — можно повторить
}

Повторять имеет смысл TransportException, RateLimitedException и 5xx. Остальное означает, что запрос неправильный, и повтор ничего не изменит.

WebhookClient

Отправляет подписанное событие на POST {baseUrl}/delivery/callback/{endpointId}. Дальше HookPilot сам доставляет его подписчикам эндпоинта, с повторами и журналом.

use HookPilot\WebhookClient\WebhookClient;

$client = new WebhookClient(
    signingSecret: getenv('HOOKPILOT_SIGNING_SECRET'),
    guzzleConfig: ['base_uri' => 'https://api.hookpilot.ru'],
);

$response = $client->send(
    endpointId: '019fea2c-...',
    eventId: $eventId,
    eventType: 'order.created',
    payload: ['order_id' => 12345, 'total' => '99.90'],
    headers: ['X-Order-Source' => 'shop'],
);

echo "Принято: HTTP $response->statusCode за $response->durationMs мс\n";

Второй аргумент конструктора — конфигурация Guzzle. Без base_uri клиент бьёт в http://localhost:8080, так что для прода его нужно задать явно.

eventId — ключ идемпотентности. Повторная отправка того же eventId не создаёт второй доставки, поэтому после ошибки сети событие можно смело повторить с тем же значением. Ждёт UUID.

headers попадают в тело события и уезжают получателю вместе с payload; это не заголовки HTTP-запроса к HookPilot.

Заголовки запроса

ЗаголовокСодержимое
X-HookPilot-Event-Idидентификатор события, он же ключ идемпотентности
X-HookPilot-Eventтип события, например order.created
X-HookPilot-SignatureHMAC-SHA256 от timestamp.body
X-HookPilot-Timestampunix-время формирования запроса
Content-Typeвсегда application/json

Подпись

body      = json({"payload": ..., "headers": ...})
signature = HMAC-SHA256(signing_secret, timestamp + "." + body)

Приём отклоняет запрос с 401, если метка времени старше 5 минут или больше чем на минуту в будущем. Подпись проверяется по действующему секрету эндпоинта, а во время ротации — ещё и по предыдущему, пока тот не истёк.

Ответ

WebhookResponse — readonly:

ПолеТипСмысл
statusCodeintкод ответа, 0 при ошибке сети
bodystringтело ответа, обрезано до 64 КБ
durationMsintдлительность запроса в миллисекундах
successfulboolкод в диапазоне 2xx
redirectCountintсколько редиректов прошли (максимум 5)

Методы: isDelivered(), isClientError(), isServerError(), isNetworkError().

Ошибки

Любой не-2xx и любая сетевая ошибка бросают WebhookDeliveryException. В $exception->response лежит WebhookResponse — для сетевой ошибки с statusCode = 0.

use HookPilot\WebhookClient\Exceptions\WebhookDeliveryException;

try {
    $response = $client->send(...);
} catch (WebhookDeliveryException $exception) {
    $response = $exception->response;

    if ($response === null || $response->isNetworkError()) {
        // повторить с тем же eventId
        return;
    }

    echo match (true) {
        $response->statusCode === 401 => 'Подпись не сошлась либо метка времени вне окна',
        $response->statusCode === 404 => 'Эндпоинта с таким ID нет',
        $response->statusCode === 413 => 'Тело больше 1 МБ',
        $response->statusCode === 422 => 'Тело не прошло валидацию',
        $response->statusCode === 429 => 'Исчерпан лимит тарифа или команда приостановлена',
        default => 'Повторять имеет смысл только 5xx',
    };
}

Примеры

Готовые сценарии — в examples/, разбор в examples/README.md:

  • manage-endpoint.php — полный цикл по API-ключу: команда, список эндпоинтов, создание, изменение, отключение и включение, ротация секрета, доставки, расход;
  • send-event.php — отправка подписанного события.

Локальный контур

$api = new ManagementClient($apiKey, 'http://localhost:8080');
$client = new WebhookClient($signingSecret, ['base_uri' => 'http://localhost:8080']);

Лицензия

MIT