Envelope e erros
O formato único das respostas JSON, as exceções a ele e o catálogo completo de códigos de erro com o status HTTP de cada um.
O envelope
Toda resposta JSON vem no mesmo envelope:
{ "success": true, "data": { "uid": "…" } }
{ "success": false, "error": { "code": "SEAT_NOT_ACTIVE", "message": "…", "details": { } } }- Os handlers só montam o lado do sucesso, com
reply.ok(200),reply.created(201),reply.accepted(202) ereply.noContent(204). Pra falhar, o handler lança umAppError, e só oerrorHandlertransforma isso em HTTP. codeé o contrato. É estável e o cliente decide por ele.messageé texto pra gente, em inglês, e pode mudar.detailsé opcional e traz dados de máquina (por exemploretryAt,maxDays,retryAfterSeconds).- Uma página paginada põe
nextCursorao lado dedata, ou dentro dedata. Veja Paginação.
Exceções ao envelope
| Rota | Por quê |
|---|---|
GET /health | Sonda de infraestrutura, lida por orquestradores. Responde { status, uptime, services } cru, com 200 ou 503 (degraded) |
GET /accounts/{uid}/events e GET /consent-requests/{token}/events | SSE tem o próprio formato de frame (veja Tempo real) |
GET /seats/{uid}/pairing/avatar | Bytes de imagem, pra ir direto num <img> (reply.bytes) |
Respostas 204 | Sem corpo |
Gateway (/webhooks/*) | É outro processo, fala com provedores e não usa o envelope |
A família AppError
backend/src/core/contracts/errors.ts define a base e as classes genéricas. Cada domínio lança essas classes ou um DomainError com código próprio.
| Classe | Status | code |
|---|---|---|
BadRequestError | 400 | BAD_REQUEST |
UnauthorizedError | 401 | UNAUTHORIZED (sempre a mesma mensagem: o motivo nunca é revelado) |
ForbiddenError | 403 | FORBIDDEN |
NotFoundError | 404 | NOT_FOUND |
ConflictError | 409 | CONFLICT |
DomainError | 422 | o código da regra (tabelas abaixo) |
TooManyRequestsError | 429 | RATE_LIMITED, com details.retryAfterSeconds e header Retry-After |
UnavailableError | 503 | UNAVAILABLE |
Códigos que não saem de uma classe:
code | Status | Quem gera |
|---|---|---|
VALIDATION | 400 | O defaultHook do OpenAPIHono quando o zod recusa o pedido. A mensagem lista cada problema (campo: motivo; …) |
NOT_FOUND | 404 | notFoundHandler, quando a rota não existe |
HTTP_ERROR | o da exceção | Um HTTPException do Hono |
INTERNAL | 500 | Qualquer erro não previsto. É logado, e a resposta não diz nada |
STOPPED_BY_DEPENDENT | 409 | AppError direto: a própria pessoa parou a proteção, e só um novo consentimento retoma |
EVIDENCE_VERIFICATION_UNAVAILABLE | 503 | A verificação de selfie e áudio não rodou (provedor fora). Nada é guardado. Tente de novo |
Códigos de domínio (422)
Contas, vagas e onboarding
code | Quando |
|---|---|
EMAIL_DOMAIN_INVALID | O e-mail não recebe mensagens (details.suggestion) |
OWNER_HAS_NO_RELATIONSHIP | Tentou definir vínculo pro owner |
OWNER_NOT_REMOVABLE | Tentou remover o owner |
SEAT_NOT_PENDING | A operação é só pra vaga pendente |
SEAT_NOT_ACTIVE | A operação exige vaga ativa (habilitar canal, pausar) |
SEAT_ACTIVE | Não dá pra arquivar uma vaga ativa |
DEPENDENT_REQUIRED / DEPENDENT_INCOMPLETE | Falta a pessoa, ou falta telefone ou documento |
DEPENDENT_LOCKED | A pessoa já disse sim a esses dados, que não mudam mais |
BIRTH_DATE_OUT_OF_RANGE | A data não combina com o kind (minor precisa ter menos de 18) |
CAMPAIGN_NOT_FOUND / CAMPAIGN_ALREADY_APPLIED | Campanha desconhecida ou já aplicada |
PAUSE_UNTIL_PAST / PAUSE_TOO_LONG | until no passado / mais de 30 dias (details.maxDays) |
Consentimento
code | Quando |
|---|---|
CONSENT_REQUIRED | Falta o sim da pessoa (ativação D2, pareamento) |
RELATIONSHIP_REFUTED | A checagem de vínculo refutou a relação declarada |
CONSENT_ALREADY_GIVEN | Já existe sim: ative a vaga |
CONSENT_EXPIRED | O link venceu ou foi trocado |
CONSENT_NOT_GIVEN | O link de proteção não tem consentimento dado por trás |
PROTECTION_LINK_REPLACED | Um consentimento mais novo substituiu este link |
RESEND_LOCKED | Espera depois de recusa ou parada (details.retryAt) |
AUDIO_PHRASE_MISMATCH | O áudio não bate com a frase (details: expected, heard, missing, score) |
AUDIO_UNINTELLIGIBLE | Não deu pra entender nenhuma palavra |
SELFIE_NO_FACE | Nenhum rosto, ou mais de um (details.faces, details.quality) |
Canais e pareamento
code | Quando |
|---|---|
CHANNEL_UNAVAILABLE | O canal ainda não está disponível |
CHANNEL_NOT_ENABLED | O canal não foi habilitado na vaga |
PAIRING_EXISTS | Já existe um pareamento aberto |
PAIRING_NOT_OPEN | Não há pareamento aberto pra renovar o desafio |
PAIRING_NOT_AWAITING_CONFIRMATION | Não há confirmação de número pendente |
CONFIRMATION_NOT_SUPPORTED | O canal nunca pede essa confirmação |
PAIRING_NOT_SWITCHABLE | Já digitaram ou escanearam algo, e não dá mais pra trocar o método |
DEPENDENT_PHONE_IS_GUARDIAN | O telefone da pessoa é de um responsável |
NO_PROXY_CAPACITY | Todos os endereços de proxy estão cheios |
Importação de histórico (fila): NOTHING_TO_SYNC, NO_OPEN_OFFER, OFFER_EXPIRED, ALREADY_ADMITTED, NOT_IN_QUEUE, SEAT_KEY_GONE (a vaga não tem chave: nunca foi ativada, ou o consentimento foi retirado).
Backoffice: SELF_INVITE, MEMBER_UNKNOWN, SELF_CHANGE, LAST_OWNER (membros operadores); TOO_MANY, PRODUCT_NOT_ALLOWED, BUDGET_EXCEEDED, ORDER_UNKNOWN, QUOTE_NOT_AN_EXTENSION, QUOTE_EXPIRED, PROXY_UNREACHABLE, PROVIDER_UNKNOWN, PROVIDER_NOT_CONFIGURED (proxies e fornecedores).
Login: os códigos OTP_* (401)
Todos respondem 401 em POST /auth/otp/verify (classe OtpRefusedError, backend/src/domains/auth/errors.ts):
code | Quando | details |
|---|---|---|
OTP_INVALID | Código errado. Um e-mail sem conta recebe exatamente a mesma resposta | attemptsLeft |
OTP_LOCKED | A 5ª tentativa errada e todas depois dela, mesmo com o código certo. Um pedido novo desbloqueia | — |
OTP_EXPIRED | Código certo, mas venceu ou foi trocado por um mais novo | — |
OTP_USED | Código certo, mas já usado | — |
OTP_LINK_INVALID | O token não é de um link nosso | — |
OTP_LINK_EXPIRED | O link venceu ou foi trocado | — |
OTP_LINK_USED | O link já foi usado | — |
RATE_LIMITED (429) vale no login (request, verify, resend), no link de consentimento e no pareamento pelo celular da pessoa. Veja Limites de taxa.
Lendo um erro
const res = await fetch(`${API}/seats/${seatUid}/activate`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify({ guardianAttestation: true }),
});
const body = await res.json();
if (!body.success) {
switch (body.error.code) {
case "CONSENT_REQUIRED":
return goTo("await_consent");
case "RATE_LIMITED":
return wait(body.error.details.retryAfterSeconds);
default:
return showGeneric(body.error.message);
}
}Em aberto
Não existe ainda uma união ErrorCode publicada pros fronts nem
details.field nos erros de formulário (item G-19 do api-gaps.md). Os
códigos aparecem hoje nas descrições das respostas 422 de cada rota no
OpenAPI.
Fontes
backend/src/http/response.ts,backend/src/http/app.ts,backend/src/http/middleware/error-handler.tsbackend/src/core/contracts/errors.tsDomainError/AppErrorembackend/src/domains/**(levantamento por grep nas classes e nas strings de código)backend/src/domains/consents/verification/errors.ts,backend/src/domains/subscriptions/use-cases/monitoring.tsbackend/src/http/routes/system/health.route.tsbackend/src/domains/auth/errors.ts.specs/features/redesign/api-gaps.md(G-19, G-42)
Tempo real (SSE)
Os dois streams de Server-Sent Events (o da conta e o da pessoa protegida), o ticket de 60 segundos, a retomada por Last-Event-ID, os heartbeats e o catálogo de eventos.
Limites de taxa
Quais rotas têm limite, as janelas e os números de cada uma, a resposta 429 RATE_LIMITED com Retry-After e como o contador funciona no Redis.
