Skip to content

Guia de Implementação de Recebimento de Payment Webhook

Visão Geral

Este guia explica como implementar uma API de recebimento de Payment Webhook com NENE2. Oferece verificação de assinatura HMAC-SHA256, processamento idempotente (restrição UNIQUE de event_id) e guarda de transição de status.


Schema do Banco de Dados

sql
CREATE TABLE payments (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    external_id TEXT    NOT NULL UNIQUE,
    amount      INTEGER NOT NULL,               -- Unidade mínima de moeda (iene, centavo)
    currency    TEXT    NOT NULL DEFAULT 'usd',
    status      TEXT    NOT NULL DEFAULT 'pending'
                        CHECK (status IN ('pending', 'succeeded', 'failed', 'refunded')),
    created_at  TEXT    NOT NULL,
    updated_at  TEXT    NOT NULL
);

CREATE TABLE webhook_events (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    event_id     TEXT    NOT NULL UNIQUE,   -- Chave de idempotência
    event_type   TEXT    NOT NULL,
    payload      TEXT    NOT NULL,          -- JSON
    processed_at TEXT    NOT NULL
);

webhook_events.event_id é o núcleo do processamento idempotente. Mesmo que o mesmo event_id seja recebido duas vezes, é processado apenas uma vez.


Design dos Endpoints

MétodoCaminhoDescrição
POST/webhooks/paymentReceber e processar evento Webhook
GET/paymentsListar pagamentos
GET/payments/{id}Detalhes do pagamento

Transições de Status

[criado] → pending → succeeded → refunded
                   ↘ failed

Gerenciado com tabela de transições:

php
private const array VALID_TRANSITIONS = [
    'payment.succeeded' => ['from' => 'pending',   'to' => 'succeeded'],
    'payment.failed'    => ['from' => 'pending',   'to' => 'failed'],
    'payment.refunded'  => ['from' => 'succeeded', 'to' => 'refunded'],
];

Transições inválidas (ex.: failed → succeeded) retornam 409 Conflict.


Pontos-Chave de Design

Verificação de assinatura HMAC-SHA256

Verifica o corpo completo da requisição com HMAC-SHA256. Usa o header X-Webhook-Signature: sha256=<hex> compatível com Stripe:

php
private function verifySignature(string $body, string $header): bool
{
    if (!str_starts_with($header, 'sha256=')) {
        return false;
    }
    $provided = substr($header, 7);
    $expected = hash_hmac('sha256', $body, $this->webhookSecret);
    return hash_equals($expected, $provided); // Prevenir ataque de timing
}

Use hash_equals() para comparação em tempo constante. === e strcmp() terminam antecipadamente e são vulneráveis.

Processamento idempotente

Provedores de webhook fazem retentativas. Elimine duplicatas com event_id:

php
// Verificar antes de processar
if ($this->repo->isEventProcessed($eventId)) {
    return $this->json->create(['status' => 'already_processed']);
}

// Registrar após o processamento
$this->repo->recordEvent($eventId, $eventType, $payload, $now);

Ordem de processamento

1. Verificação de assinatura → 401
2. Verificação de event_id duplicado → 200 already_processed
3. Processamento por tipo de evento
4. Registrar em webhook_events
5. Retornar 200 processed

Realizar a verificação de assinatura primeiro impede que atacantes poluam a tabela de event_id.

Retornar 200 para tipos de evento desconhecidos

Quando um provedor adiciona um novo tipo de evento, retornar 4xx causa retentativas. Retorne silenciosamente 200 e registre tipos desconhecidos:

php
// Tipo de evento desconhecido — reconhecer sem processar
return null; // → 200 processed

Teste: gerar assinatura com SECRET injetado

php
private const string SECRET = 'test-webhook-secret';

private function signedReq(string $path, array $body): ResponseInterface
{
    $rawBody = json_encode($body);
    $sig     = 'sha256=' . hash_hmac('sha256', $rawBody, self::SECRET);
    // ...
}

Passe o mesmo segredo para o aplicativo com AppFactory::createSqlite($dbFile, self::SECRET).


Exemplos de Payload de Evento

payment.created

json
{
  "event_id": "evt_001",
  "event_type": "payment.created",
  "data": {"id": "pay_abc", "amount": 5000, "currency": "jpy"}
}

payment.succeeded

json
{
  "event_id": "evt_002",
  "event_type": "payment.succeeded",
  "data": {"id": "pay_abc"}
}

Resposta (sucesso)

json
{"status": "processed", "event_type": "payment.succeeded"}

Resposta (reenvio idempotente)

json
{"status": "already_processed"}

Referência de Implementação

../NENE2-FT/paymentlog/ — FT163 Field Trial (18 testes, verificação de assinatura, processamento idempotente, guarda de transição)

Publicado sob a licença MIT.