> ## Documentation Index
> Fetch the complete documentation index at: https://docs.campeaosec.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook

> Receba notificações em tempo real quando uma verificação KYC é concluída.

## Visão Geral

Quando uma verificação KYC termina, a Campeão Sec faz um `POST` para a URL que você configurou. O webhook é a forma recomendada de saber o resultado — mais confiável do que polling e mais completo do que o `onSuccess` do widget.

***

## Configurar URL

Você pode definir a URL de webhook de duas formas:

**1. No portal** — em **app.campeaosec.com → Webhooks**, configure uma URL padrão para o ambiente sandbox e outra para produção.

**2. Por sessão** — envie `webhook_url` ao criar a sessão para sobrescrever a URL padrão:

```json theme={null}
{
  "country": "BR",
  "external_id": "user-456",
  "ambiente": "sandbox",
  "webhook_url": "https://seu-servidor.com/webhook/kyc"
}
```

***

## Eventos

| Evento                       | Quando ocorre                               |
| ---------------------------- | ------------------------------------------- |
| `verification.completed`     | Verificação aprovada ou reprovada           |
| `verification.manual_review` | Verificação encaminhada para revisão manual |

***

## Payload

### `verification.completed` (aprovado ou reprovado)

```json theme={null}
{
  "event": "verification.completed",
  "verification_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "external_id": "user-456",
  "status": "approved",
  "country": "BR",
  "modo": "FULL_KYC",
  "created_at": "2026-07-30T10:00:00Z",
  "completed_at": "2026-07-30T10:00:05Z",
  "scores": {
    "composto": 87,
    "face_match": 92,
    "liveness": 90,
    "antifraude": 85
  },
  "ocr": {
    "nome": "João da Silva",
    "documento_numero": "***456",
    "data_nascimento": "1990-05-15"
  }
}
```

### `verification.manual_review`

```json theme={null}
{
  "event": "verification.manual_review",
  "verification_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "external_id": "user-456",
  "status": "manual_review",
  "country": "BR",
  "modo": "FULL_KYC",
  "created_at": "2026-07-30T10:00:00Z",
  "completed_at": "2026-07-30T10:00:05Z",
  "scores": {
    "composto": 65
  }
}
```

### Campos

| Campo                  | Tipo           | Descrição                                                |
| ---------------------- | -------------- | -------------------------------------------------------- |
| `event`                | string         | `verification.completed` ou `verification.manual_review` |
| `verification_id`      | string         | UUID da verificação                                      |
| `external_id`          | string \| null | ID enviado na criação da sessão                          |
| `status`               | string         | `approved`, `rejected`, ou `manual_review`               |
| `country`              | string         | País da verificação                                      |
| `modo`                 | string         | `FULL_KYC` ou `SEM_DOC`                                  |
| `scores.composto`      | number \| null | Score consolidado (0–100)                                |
| `scores.face_match`    | number \| null | Similaridade face/documento (0–100)                      |
| `scores.liveness`      | number \| null | Score de vivacidade (0–100)                              |
| `scores.antifraude`    | number \| null | Score antifraude (0–100)                                 |
| `ocr.nome`             | string \| null | Nome extraído do documento                               |
| `ocr.documento_numero` | string \| null | Número mascarado (ex: `***456`)                          |
| `ocr.data_nascimento`  | string \| null | Data no formato `YYYY-MM-DD`                             |
| `motivos_rejeicao`     | string\[]      | Presentes apenas em `status: "rejected"`                 |

<Note>
  O número do documento é sempre mascarado no payload (apenas os 3 últimos dígitos visíveis) para conformidade com a LGPD.
</Note>

***

## Validar Assinatura

Cada request inclui o header `X-Campeao-Signature` com uma assinatura HMAC-SHA256 do payload. **Sempre valide a assinatura** antes de processar o evento.

```
X-Campeao-Signature: sha256=a3f8c2d1b4e9...
X-Campeao-Event: verification.completed
X-Campeao-Attempt: 1
```

O segredo para validação fica em **app.campeaosec.com → Webhooks → Secret**.

### Exemplos de Validação

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto')

  function validarWebhook(req, secret) {
    const assinatura = req.headers['x-campeao-signature']
    if (!assinatura) return false

    const body = JSON.stringify(req.body) // use raw body, não parsed
    const esperado = 'sha256=' + crypto
      .createHmac('sha256', secret)
      .update(body, 'utf8')
      .digest('hex')

    return crypto.timingSafeEqual(
      Buffer.from(assinatura),
      Buffer.from(esperado)
    )
  }

  // Express
  app.post('/webhook/kyc', express.raw({ type: 'application/json' }), (req, res) => {
    const body = req.body.toString('utf8')
    const esperado = 'sha256=' + crypto
      .createHmac('sha256', process.env.WEBHOOK_SECRET)
      .update(body)
      .digest('hex')

    if (!crypto.timingSafeEqual(Buffer.from(req.headers['x-campeao-signature']), Buffer.from(esperado))) {
      return res.status(401).send('Assinatura inválida')
    }

    const evento = JSON.parse(body)
    // processar evento...
    res.status(200).send('OK')
  })
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  from fastapi import Request, HTTPException

  async def validar_webhook(request: Request, secret: str) -> dict:
      body = await request.body()
      assinatura = request.headers.get('x-campeao-signature', '')

      esperado = 'sha256=' + hmac.new(
          secret.encode('utf-8'),
          body,
          hashlib.sha256
      ).hexdigest()

      if not hmac.compare_digest(assinatura, esperado):
          raise HTTPException(status_code=401, detail='Assinatura inválida')

      return json.loads(body)

  # FastAPI
  @app.post('/webhook/kyc')
  async def webhook_kyc(request: Request):
      evento = await validar_webhook(request, os.environ['WEBHOOK_SECRET'])
      # processar evento...
      return {'ok': True}
  ```

  ```php PHP theme={null}
  function validarWebhook($payload, $assinatura, $secret) {
      $esperado = 'sha256=' . hash_hmac('sha256', $payload, $secret);
      return hash_equals($esperado, $assinatura);
  }

  $payload    = file_get_contents('php://input');
  $assinatura = $_SERVER['HTTP_X_CAMPEAO_SIGNATURE'] ?? '';
  $secret     = getenv('WEBHOOK_SECRET');

  if (!validarWebhook($payload, $assinatura, $secret)) {
      http_response_code(401);
      exit('Assinatura inválida');
  }

  $evento = json_decode($payload, true);
  // processar evento...
  http_response_code(200);
  echo 'OK';
  ```
</CodeGroup>

***

## Retry Automático

Se seu servidor não responder `200 OK` em até 10 segundos, a entrega é reagendada automaticamente:

| Tentativa | Espera antes de tentar |
| --------- | ---------------------- |
| 1         | Imediata               |
| 2         | 30 segundos            |
| 3         | 5 minutos              |
| 4         | 30 minutos             |
| 5         | 2 horas                |

Após 5 tentativas sem sucesso, a verificação muda para `status: "webhook_failed"`. Nesse caso, use o fallback abaixo.

***

## Fallback: Consultar Resultado Diretamente

Se o webhook não chegar (por problemas de rede, servidor offline etc.), consulte o resultado via API:

```bash theme={null}
curl -X GET https://api-kyc.campeaosec.com/v1/verify/{verification_id} \
  -H "Authorization: Bearer sk_test_sua_chave"
```

O `verification_id` está disponível no `onSuccess` do widget (`result.verification_id`).

***

## Boas Práticas

* **Responda `200 OK` rapidamente** — processe o evento de forma assíncrona (fila, background job) e retorne 200 imediatamente
* **Seja idempotente** — o mesmo evento pode ser entregue mais de uma vez; use `verification_id` como chave de deduplicação
* **Sempre valide a assinatura** — nunca processe eventos sem confirmar o HMAC
* **Use o raw body para validação** — parse o JSON somente após validar a assinatura no payload original
