Toda entrega de webhook é um HTTP POST com corpo JSON e três headers relevantes.
| Header | Valor | Descrição |
|---|
Content-Type | application/json | Tipo do corpo |
x-kiwify-digital-signature | Base64url (sem padding) | Assinatura EdDSA-Ed25519 |
x-kiwify-timestamp | Unix ms (ex: 1705423200000) | Timestamp usado na mensagem assinada |
Exemplo:
POST /webhooks/kiwibank HTTP/1.1
Content-Type: application/json
x-kiwify-digital-signature: gnfHkqEfBhKN3lYmGDF_J9fO3jSQtq8d1-agAZPMn4u3huef0kg5XqbnxSj5SCb_
x-kiwify-timestamp: 1705423200000
{"id":"550e8400-e29b-41d4-a716-446655440000","type":"CASHOUT.PIX.TRANSFERS.COMPLETED",...}
Obter a chave pública
Use GET /v1/webhooks-keys para listar chaves públicas. Use a chave com is_active: true.
Cache a chave por até 24 horas e atualize periodicamente.
Processo de verificação
Passo 1: Validar timestamp
Rejeite entregas com timestamp fora de uma janela de 5 minutos do horário atual (proteção contra replay).
Passo 2: Reconstruir a mensagem assinada
Formato PoP (mesmo padrão da autenticação da API, com diferença no uri):
{url_path}:POST:{raw_body}:{timestamp}
| Componente | Descrição |
|---|
url_path | Somente o path da URL registrada (ex: /webhooks/kiwibank), não a URL completa |
POST | Método HTTP (sempre POST) |
raw_body | Corpo JSON exatamente como recebido — não re-serialize |
timestamp | Valor do header x-kiwify-timestamp |
Passo 3: Verificar a assinatura
- Calcule SHA-256 dos bytes UTF-8 da mensagem
- Decodifique
x-kiwify-digital-signature de base64url (sem padding)
- Verifique com EdDSA-Ed25519 usando a chave pública ativa
A assinatura de webhook usa Ed25519 prehashed (SHA-256 da mensagem antes da verificação). Isso difere ligeiramente de alguns exemplos genéricos de Ed25519 que assinam a mensagem diretamente.
Exemplo (Python)
import base64
import hashlib
import time
from urllib.parse import urlparse
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
def verify_webhook(endpoint_url: str, body_bytes: bytes, signature_b64: str, timestamp_ms: int, public_key_pem: str) -> bool:
if abs(int(time.time() * 1000) - timestamp_ms) > 300_000:
return False
path = urlparse(endpoint_url).path or "/"
message = f"{path}:POST:{body_bytes.decode('utf-8')}:{timestamp_ms}"
digest = hashlib.sha256(message.encode("utf-8")).digest()
public_key = serialization.load_pem_public_key(public_key_pem.encode())
padding = (4 - len(signature_b64) % 4) % 4
signature = base64.urlsafe_b64decode(signature_b64 + "=" * padding)
try:
public_key.verify(signature, digest)
return True
except Exception:
return False
Erros comuns
- Usar a URL completa em vez de apenas o path na mensagem assinada
- Re-serializar o JSON (espaços/ordem de chaves alteram a assinatura)
- Verificar Ed25519 diretamente sobre a mensagem sem SHA-256 prévio