hookpilot / webhook-client
HookPilot PHP SDK: отправка подписанных событий в адрес приёма и типизированный клиент публичного API
Requires
- php: ^8.3
- guzzlehttp/guzzle: ^7.0
- psr/http-message: ^1.0|^2.0
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Два независимых клиента в одном пакете:
| Класс | Зачем | Авторизация |
|---|---|---|
ManagementClient | Публичное API: эндпоинты, доставки, команда, участники, аудит, биллинг | API-ключ hp_sk_... |
WebhookClient | Отправка подписанного события в адрес приёма | HMAC на подписывающем секрете эндпоинта |
Первый управляет конфигурацией, второй гоняет события. Ключ для отправки событий не нужен.
Требования
- PHP 8.3+
- Guzzle 7.x
- реализация PSR-7 (
psr/http-message1.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 |
$baseUrl | https://api.hookpilot.ru | ManagementClient::BASE_URL |
$guzzleConfig | [] | перекрывает настройки Guzzle (таймауты, handler для тестов, прокси) |
Таймауты по умолчанию: 10 с на запрос, 5 с на соединение. Заголовки Authorization,
Accept и User-Agent клиент проставляет сам.
Ключ выпускается в личном кабинете: «API-ключи» → «Создать ключ». У каждого ключа свой
набор прав, метод без нужного права отвечает 403.
Эндпоинты — $api->endpoints()
| Метод | HTTP | Право | Возвращает |
|---|---|---|---|
list() | GET /v1/endpoints | endpoints:read | Endpoint[] |
get(string $endpointId) | GET /v1/endpoints/{id} | endpoints:read | Endpoint |
create(EndpointDraft $draft) | POST /v1/endpoints | endpoints:write | IssuedEndpoint |
update(string $endpointId, EndpointChanges $changes) | PUT /v1/endpoints/{id} | endpoints:write | Endpoint |
delete(string $endpointId) | DELETE /v1/endpoints/{id} | endpoints:write | void |
activate(string $endpointId) | PATCH /v1/endpoints/{id}/activate | endpoints:write | void |
deactivate(string $endpointId) | PATCH /v1/endpoints/{id}/deactivate | endpoints:write | void |
rotateSecret(string $endpointId) | POST /v1/endpoints/{id}/rotate-secret | endpoints:write | IssuedEndpoint |
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/webhooks | webhooks:read | Webhook[] |
attempts(string $webhookId) | GET /v1/webhooks/{id}/attempts | webhooks:read | Attempt[] |
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/team | team:read | Team |
rename(string $name) | PUT /v1/team | team:write | Team |
Участники — $api->members()
| Метод | HTTP | Право | Возвращает |
|---|---|---|---|
list() | GET /v1/members | members:read | Member[] |
changeRole(string $memberId, MemberRole $role) | PUT /v1/members/{id} | members:write | Member |
remove(string $memberId) | DELETE /v1/members/{id} | members:write | void |
Аудит — $api->audit()
| Метод | HTTP | Право | Возвращает |
|---|---|---|---|
logs(?AuditFilter $filter = null) | GET /v1/audit/logs | audit:read | AuditEntry[] |
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/balance | billing:read | Balance |
usage() | GET /v1/billing/usage | billing:read | Usage |
Данные
Все объекты в HookPilot\WebhookClient\Data — readonly, собираются из ответа через
fromArray(). Даты приходят как ?DateTimeImmutable.
| Класс | Поля |
|---|---|
Endpoint | id, teamId, name, url, subscribedEvents, customHeaders, timeoutMs, isActive, createdAt |
IssuedEndpoint | endpoint, signingSecret |
EndpointDraft | name, url, subscribedEvents, customHeaders, timeoutMs — тело для create() |
EndpointChanges | те же плюс isActive, все необязательные — тело для update() |
Webhook | id, eventId, endpointId, eventType, status, nextRetryAt, createdAt |
Attempt | id, webhookId, status, responseCode, durationMs, attemptedAt, createdAt |
Team | id, name, ownerId, createdAt, updatedAt |
Member | id, teamId, userId, email, role, status, invitedAt, acceptedAt, createdAt, метод hasAcceptedInvitation() |
AuditEntry | id, userId, apiKeyId, teamId, action, entityType, entityId, metadata, createdAt, метод isMadeByApiKey() |
AuditFilter | action, from, to, page, perPage — фильтр для logs() |
Balance | amountMinor, currency, dailyBurnMinor, daysLeft, isSuspended, negativeSince, suspendAt |
Usage | periodStart, periodEnd, eventsTotal, eventsLimit, usagePercent, endpointsTotal, endpointsLimit, membersTotal, membersLimit |
Деньги — в копейках (amountMinor, dailyBurnMinor).
endpointsTotal и endpointsLimit в Usage названы так исторически, а считают
подключения — сумму эндпоинтов и источников. Тот же лимит проверяется при
создании эндпоинта и источника.
Перечисления
HookPilot\WebhookClient\Enums:
| Енам | Значения |
|---|---|
WebhookStatus | pending, processing, succeeded, failed |
AttemptStatus | success, failed |
MemberRole | owner, administrator, moderator, client, viewer |
Scope | endpoints: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 | сеть, таймаут, ответ не JSON | statusCode = 0 |
UnauthorizedException | 401 — ключ не тот или отозван | |
ForbiddenException | 403 — нет права или запрет по роли | isScopeMissing() |
NotFoundException | 404 | |
ValidationFailedException | 422 | errors(): array<string, string[]> |
RateLimitedException | 429 — лимит запросов, лимит событий тарифа, приостановка команды | retryAfterSeconds |
ServerErrorException | 5xx | |
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-Signature | HMAC-SHA256 от timestamp.body |
X-HookPilot-Timestamp | unix-время формирования запроса |
Content-Type | всегда application/json |
Подпись
body = json({"payload": ..., "headers": ...})
signature = HMAC-SHA256(signing_secret, timestamp + "." + body)
Приём отклоняет запрос с 401, если метка времени старше 5 минут или больше чем на
минуту в будущем. Подпись проверяется по действующему секрету эндпоинта, а во время
ротации — ещё и по предыдущему, пока тот не истёк.
Ответ
WebhookResponse — readonly:
| Поле | Тип | Смысл |
|---|---|---|
statusCode | int | код ответа, 0 при ошибке сети |
body | string | тело ответа, обрезано до 64 КБ |
durationMs | int | длительность запроса в миллисекундах |
successful | bool | код в диапазоне 2xx |
redirectCount | int | сколько редиректов прошли (максимум 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