Documentação da API
Tudo que você precisa pra integrar o Pangeia CAPTCHA: um script no front, uma chamada no back.
Prefere spec formal? /apidoc/openapi.yaml (OpenAPI 3.0.3, validado) — mesmo padrão do Pangeia ID e da Pangeia PAY.
Visão geral
O fluxo tem três peças: o widget (roda no navegador de quem visita seu site), o challenge/verify (nossa API pública) e o webhook de saída (opcional, avisa seu backend quando uma verificação acontece).
Credenciais por site, geradas no painel: site_key (pública, vai no HTML) e secret_key (privada, só no seu backend — nunca no front).
Widget
Cole no seu HTML. O widget cuida do desafio invisível (prova de trabalho + sinais de comportamento) e só escala pro desafio visual/áudio se o score pedir.
<script defer src="https://captcha.pangeialabs.com/widget.js"></script> <div class="pangeia-captcha" data-sitekey="pk_live_..."></div>
O widget cria um campo oculto pangeia-captcha-response dentro da div — é esse valor que seu formulário envia junto com o resto dos dados, e que seu backend repassa pro /api/v1/verify.
Challenge
Chamado automaticamente pelo widget — você normalmente não precisa chamar isso na mão.
curl -X POST https://captcha.pangeialabs.com/api/v1/challenge \
-H "Content-Type: application/json" \
-d '{"site_key": "pk_live_..."}'
# resposta
{"ok": true, "challenge_id": "<jwt>", "seed": "...", "difficulty": 4}
Verify
Chamado pelo seu backend, servidor-a-servidor, depois que o formulário chega com o pangeia-captcha-response preenchido.
curl -X POST https://captcha.pangeialabs.com/api/v1/verify \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"challenge_id": "...", "response": "..."}'
# resposta (sucesso)
{"success": true, "score": 12}
# resposta (erro)
{"success": false, "error": "response_already_used"}
Cada response só pode ser verificado uma vez — tentativas repetidas com o mesmo valor recebem 409.
Critério de score (documentado, sem caixa-preta)
| Sinal | Peso |
|---|---|
| Prova de trabalho ausente ou incorreta | +40 |
| Resposta em menos de 400ms (rápido demais pra ser humano) | +30 |
| Honeypot preenchido | +50 |
| Nenhum movimento de ponteiro registrado | +20 |
Score ≥ 50 reprova o desafio invisível e escala pro fallback visual.
Fallback visual/áudio
Disparado automaticamente pelo widget quando o score do desafio invisível pede — geralmente você não chama isso direto.
Essas três rotas só respondem pra um challenge_id que já passou por /api/v1/solve e recebeu needs_visual: true — chamar direto, sem isso, devolve 403 visual_not_unlocked.
Recebe {"challenge_id": "..."}", devolve uma imagem (base64) com um código de 5 caracteres.
Devolve o mesmo código pra leitura via speechSynthesis do navegador — alternativa de acessibilidade.
Recebe {"challenge_id", "answer"}, devolve um response pra usar no /api/v1/verify se acertar — uso único: um challenge_id só gera um response, seja por aqui ou por /api/v1/solve.
Webhook de saída
Configure a URL no painel, na página do seu site. Toda verificação concluída dispara um POST assinado:
POST /seu-endpoint HTTP/1.1
Content-Type: application/json
X-Pangeia-Signature: <hmac-sha256 hex>
{
"event_id": "...",
"type": "verification.completed",
"site_key": "pk_live_...",
"success": true,
"score": 12,
"timestamp": 1784650000
}
Validando a assinatura (Python)
import hmac, hashlib
def valido(corpo_bruto: bytes, assinatura: str, secret: str) -> bool:
esperado = hmac.new(secret.encode(), corpo_bruto, hashlib.sha256).hexdigest()
return hmac.compare_digest(assinatura, esperado)
O webhook_secret fica visível na página do site no painel. Guarde event_id pra idempotência — reentregas usam o mesmo id.
Limites
| Rota | Limite |
|---|---|
| /api/v1/challenge | 30/min por IP · 120/min por site |
| /api/v1/solve | 60/min por IP |
| /api/v1/visual | 20/min por IP |
| /api/v1/audio | 10/min por IP |
| /api/v1/solve-visual | 20/min por IP |
| /api/v1/verify | 300/min por site |
Erros comuns
| Código | Situação |
|---|---|
| 404 invalid_site_key | site_key não existe |
| 403 domain_not_allowed | site tem domínios cadastrados e a origem da chamada não bate com nenhum deles |
| 400 invalid_or_expired_challenge | challenge_id inválido, adulterado ou expirado (TTL de 120s) |
| 409 challenge_already_used | esse challenge_id já foi resolvido antes — cada um só gera um response |
| 403 visual_not_unlocked | chamou /visual, /audio ou /solve-visual sem o challenge_id ter passado por /solve e reprovado |
| 401 invalid_secret_key | secret_key errada ou não pertence ao site do challenge |
| 400 invalid_response | token de resposta inválido ou expirado |
| 409 response_already_used | essa resposta já foi verificada antes |
| 429 rate_limited | limite da rota excedido — tente de novo em instantes |