HookRelay é um webhook receiver idempotente construído com Laravel. A proposta é receber webhooks externos com validação de assinatura, proteção contra replay attack, persistência de eventos, processamento assíncrono com retry e histórico auditável.
O objetivo do projeto é simular um serviço real de integração, focado em segurança, rastreabilidade e operação. Em vez de tratar webhook como apenas uma rota POST, o sistema registra o evento como um fato externo que pode ser validado, deduplicado, processado, auditado e reprocessado.
Webhooks parecem simples em tutoriais, mas em produção eles costumam falhar de formas importantes:
- o mesmo evento pode chegar mais de uma vez;
- uma requisição pode ser forjada;
- um payload antigo pode ser reenviado fora da janela esperada;
- o sistema consumidor pode estar fora do ar;
- o processamento pode falhar temporariamente;
- sem histórico, fica difícil auditar o que aconteceu;
- sem replay, a recuperação depende do provedor reenviar o evento.
HookRelay existe para explorar essas decisões de engenharia em um projeto pequeno, mas com preocupações reais.
- Cadastro de fontes de webhook.
- Endpoint único por fonte.
- Secret de assinatura por fonte.
- Validação HMAC SHA-256.
- Proteção contra replay attack usando timestamp.
- Idempotência por
X-HookRelay-Idempotency-Keyou hash do payload. - Persistência de payload, headers, IP, user-agent e status.
- Processamento assíncrono com Laravel Queue.
- Registro de tentativas de entrega.
- Retry com backoff auditável.
- Diferenciação entre falha temporária (
retrying) e falha final (failed). - Replay manual de eventos.
- Histórico visual de eventos.
- Filtro de eventos por status e por fonte.
- Testes automatizados para os fluxos principais.
- PHP 8.4+
- Laravel 12
- SQLite no ambiente local
- Laravel Queue com driver
database - Blade
- PHPUnit
Provider externo
|
v
POST /webhooks/{sourceUuid}
|
v
WebhookReceiverController
|
|-- valida fonte ativa
|-- valida timestamp
|-- valida assinatura HMAC
|-- verifica idempotência
|
v
WebhookEvent
|
v
ProcessWebhookEvent
|
v
WebhookDeliveryAttempt
|
v
Histórico / Replay / Retry
Representa uma origem externa autorizada a enviar webhooks.
Campos principais:
uuidnameslugsigning_secrettarget_urlis_active
Representa cada evento recebido.
Campos principais:
webhook_source_iduuididempotency_keypayload_hashsignature_headertimestamp_headerstatusrejection_reasonpayloadheadersip_addressuser_agentreceived_atprocessed_at
Representa cada tentativa de processamento/entrega do evento.
Campos principais:
webhook_event_idattempt_numberstatusresponse_statusresponse_bodyerror_messageattempted_atnext_retry_at
| Status | Significado |
|---|---|
received |
Evento aceito e persistido. |
queued |
Evento reenfileirado manualmente via replay. |
processing |
Evento em processamento. |
retrying |
A tentativa falhou, mas ainda existe retry pendente. |
processed |
Evento processado com sucesso ou sem destino configurado. |
failed |
Evento falhou após esgotar as tentativas. |
rejected |
Evento rejeitado por regra de segurança. |
Para enviar um webhook válido, a requisição deve incluir:
| Header | Descrição |
|---|---|
X-HookRelay-Timestamp |
Unix timestamp usado na assinatura e na proteção contra replay. |
X-HookRelay-Signature |
Assinatura HMAC no formato sha256={hash}. |
X-HookRelay-Idempotency-Key |
Chave opcional para evitar processamento duplicado. |
A assinatura é calculada usando:
timestamp.payload_bruto
Com HMAC SHA-256 e o signing_secret da fonte.
Formato final:
sha256={hash_hmac_sha256}
Instale as dependências:
composer install
npm installCrie o .env:
cp .env.example .env
php artisan key:generateCrie o banco SQLite, se ainda não existir:
touch database/database.sqliteRode migrations e seed:
php artisan migrate --seedInicie o servidor:
php artisan serveEm outro terminal, rode o worker da fila:
php artisan queue:workAcesse:
http://127.0.0.1:8000/events
O seed cria uma fonte de demonstração:
UUID: 11111111-1111-4111-8111-111111111111
Secret: hookrelay-demo-secret
Endpoint: /webhooks/11111111-1111-4111-8111-111111111111
Para praticar o fluxo sem depender de Postman, curl ou outro sistema externo, use o comando Artisan:
php artisan hookrelay:send-demo accepted --url=http://localhost/webhook-receiver/publicEsse comando simula um provedor externo enviando um webhook assinado para a fonte demo.
Depois de rodar, abra:
http://localhost/webhook-receiver/public/events
Você deve ver um novo evento no histórico.
Para simular um evento duplicado:
php artisan hookrelay:send-demo duplicate --url=http://localhost/webhook-receiver/publicEsse cenário envia duas requisições com a mesma X-HookRelay-Idempotency-Key. A primeira deve ser aceita e a segunda deve retornar duplicate.
Para simular assinatura inválida:
php artisan hookrelay:send-demo rejected --url=http://localhost/webhook-receiver/publicEsse cenário envia uma assinatura propositalmente incorreta. O evento deve aparecer como rejected no histórico.
Se estiver usando php artisan serve, a URL normalmente será:
php artisan hookrelay:send-demo accepted --url=http://127.0.0.1:8000Exemplo usando PHP para gerar assinatura e enviar o webhook:
<?php
$payload = json_encode([
'event' => 'invoice.paid',
'id' => 'evt_123',
'amount' => 19990,
]);
$timestamp = time();
$secret = 'hookrelay-demo-secret';
$signature = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
$ch = curl_init('http://127.0.0.1:8000/webhooks/11111111-1111-4111-8111-111111111111');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-HookRelay-Timestamp: '.$timestamp,
'X-HookRelay-Signature: '.$signature,
'X-HookRelay-Idempotency-Key: evt_123',
],
CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($ch);Resposta esperada:
{
"status": "accepted",
"event_id": "..."
}Se o mesmo evento for reenviado com a mesma idempotency key:
{
"status": "duplicate",
"event_id": "..."
}Se a assinatura for inválida:
{
"status": "rejected",
"reason": "invalid_signature",
"event_id": "..."
}| Rota | Descrição |
|---|---|
/events |
Histórico de eventos recebidos. |
/events/{uuid} |
Detalhe do evento, payload, headers e tentativas. |
/sources |
Lista de fontes cadastradas. |
/sources/create |
Cadastro de nova fonte. |
/sources/{uuid} |
Detalhe da fonte, endpoint, secret e eventos recentes. |
O contrato HTTP atual esta documentado em:
docs/openapi.yaml
O arquivo descreve o endpoint publico de recebimento de webhooks, as respostas accepted, duplicate e rejected, os headers esperados e as rotas operacionais de eventos e fontes.
O projeto tambem pode ser executado com Docker Compose. Por padrao, o container HTTP usa a porta 8080 para evitar conflito com o Laragon na porta 80.
Suba os containers:
docker compose up -d --buildInstale as dependencias PHP dentro do container, se ainda nao existir vendor:
docker compose exec app composer installCrie o .env e gere a chave da aplicacao, se necessario:
docker compose exec app cp .env.example .env
docker compose exec app php artisan key:generateCrie o arquivo SQLite, rode migrations e seed:
docker compose exec app touch database/database.sqlite
docker compose exec app php artisan migrate --seedAcesse:
http://localhost:8080/events
O worker da fila roda no servico worker:
docker compose logs -f workerPara testar o envio demo pelo Docker:
docker compose exec app php artisan hookrelay:send-demo accepted --url=http://nginxPara parar:
docker compose downEventos que não foram rejeitados por segurança podem ser reprocessados manualmente pela tela de detalhe.
O replay não cria um novo WebhookEvent. Ele cria uma nova linha em webhook_delivery_attempts, muda o evento para queued e dispara o job de processamento novamente.
Eventos com status rejected não podem ser reprocessados, pois foram recusados por regra de segurança, como assinatura inválida ou timestamp ausente/antigo.
O processamento usa Laravel Queue com até 3 tentativas.
Backoff atual:
1 tentativa falhou -> próximo retry em 60 segundos
2 tentativa falhou -> próximo retry em 300 segundos
3 tentativa falhou -> falha final
Enquanto ainda há retry pendente, o evento fica com status retrying. Quando as tentativas acabam, o evento fica com status failed.
Rode:
php artisan testOs testes cobrem:
- recebimento de webhook assinado;
- rejeição por assinatura inválida;
- idempotência;
- replay manual;
- bloqueio de replay para eventos rejeitados;
- retry temporário;
- falha final;
- gestão de fontes;
- filtro de eventos por fonte.
- Receber webhooks assinados.
- Persistir payload, headers e metadados.
- Evitar duplicidade com idempotency key/hash.
- Expor histórico visual.
- Adicionar replay manual.
- Melhorar retry/backoff auditável.
- Adicionar gestão de fontes.
- Adicionar documentação OpenAPI.
- Adicionar Docker Compose.
- Adicionar autenticação para o painel.
- Adicionar rotação/regeneração de signing secret.
- Adicionar timeline visual do evento.
- Adicionar métricas operacionais.
- O endpoint público usa
uuidda fonte, nãoidincremental. - A assinatura usa o payload bruto para evitar divergência após parse JSON.
- Eventos rejeitados também são persistidos para auditoria.
- Replay cria nova tentativa, não novo evento.
- O histórico prioriza operabilidade e debug, não apenas apresentação visual.
- O projeto usa SQLite localmente para reduzir barreira de execução.
Projeto de estudo. Ajuste a licença conforme a estratégia do repositório antes de publicar.