A API do Ouroboros é exposta na porta 8000 e é idêntica nos dois backends disponíveis:
- Node.js (
backend-node/) — Express + node:sqlite (SQLite embutido no Node.js v22+, sem dependência nativa externa) - Python (
backend-python/) — FastAPI + Uvicorn (ao usar o backend Python, a documentação interativa está disponível em/docs(Swagger UI) e/redoc(ReDoc) enquanto o servidor estiver rodando)
!!! note "Backend como fonte única de verdade" Toda a validação de dados é feita no backend. O frontend nunca deve ser a única barreira de proteção contra entradas inválidas. Um frontend alternativo pode ser construído em qualquer tecnologia — o backend garante a integridade independentemente do cliente.
http://localhost:8000/api
Em produção (rede local do evento), substituir localhost pelo IP da máquina servidora.
A API usa dois mecanismos de autenticação dependendo do canal:
As rotas administrativas exigem o header customizado token:
token: <admin_token>
O valor do token é definido pela variável ADMIN_TOKEN no arquivo .env.
!!! note "Rotas públicas"
As rotas GET /api/categories e GET /api/reports/analytics são públicas e não exigem token.
- /categories — os terminais de loja precisam consultar o catálogo de preços sem autenticação admin
- /reports/analytics — dados agregados para o telão/painel público do evento (sem dados individuais)
A autenticação é feita via query string na conexão:
ws://host/ws/admin?token=<admin_token>
ws://host/ws/store?token=<store_terminal_token>
| Recurso | Limite |
|---|---|
| Tamanho máximo do body (REST) | 10 KB |
| Conexões admin simultâneas (WebSocket) | 10 |
| Conexões de loja simultâneas (WebSocket) | Configurável via MAX_STORE_CONNECTIONS no .env (padrão: 100) |
| Mensagens por minuto por conexão WebSocket | 300 (admin e loja) |
| Máximo de comandas | Configurável via MAX_COMANDAS no .env (padrão: 1000) |
!!! tip "Variáveis opcionais de ambiente" Além das variáveis documentadas no guia de setup, existem duas variáveis opcionais para uso interno:
- `MAX_STORE_CONNECTIONS` — limite de conexões WebSocket simultâneas de loja (padrão: `100`)
- `STRESS_NO_RATELIMIT=true` — desativa o rate limiting de WebSocket para testes de carga (nunca usar em produção)
O backend impõe as seguintes restrições em todas as entradas. O frontend não precisa — e não deve — ser a única camada de validação.
| Campo | Regra |
|---|---|
holder_name |
String não vazia após trim. Obrigatório. |
initial_balance |
Inteiro não-negativo (≥ 0). Ausente = 0. Se 0, nenhum evento de crédito inicial é criado. |
amount (crédito/débito) |
Inteiro estritamente positivo (> 0). Rejeita floats, strings não-numéricas e NaN. |
comanda_code |
Sempre normalizado: trim + uppercase. Formato esperado: F001 a F999+. |
name (loja/categoria) |
String não vazia após trim. Obrigatório. |
price (categoria via REST) |
Inteiro estritamente positivo (> 0). |
price (categoria via WS register_category) |
Inteiro não-negativo (≥ 0). Zero = sem alteração de preço, apenas incrementa contagem. |
cart_items[].quantity |
Inteiro positivo. Itens com quantity inválida são silenciosamente ignorados. |
Retorna o status do servidor.
Auth: Pública
Response 200:
{ "status": "online", "mode": "local-first", "event": "Feira da Troca 2025" }Retorna a visão macro da economia da feira.
Auth: Admin (token header)
Response 200:
{
"total_issued": 486000,
"total_circulating": 173500,
"comandas_active": 243,
"stores_registered": 12
}| Campo | Descrição |
|---|---|
total_issued |
Soma de todos os créditos iniciais emitidos (eventos com note = 'Saldo inicial'). Créditos adicionais adicionados após o cadastro não entram neste total — consulte total_circulating para o saldo real em circulação. |
total_circulating |
Soma dos saldos atuais de todas as comandas |
comandas_active |
Total de comandas cadastradas |
stores_registered |
Total de lojas cadastradas |
Retorna dados agregados para o dashboard analítico público (ideal para telão do evento).
Auth: Pública (nenhum token necessário)
Response 200:
{
"kpis": {
"total_comandas": 257,
"total_emitido": 486000,
"total_gasto": 312500,
"total_circulante": 173500,
"total_transacoes": 2320,
"lojas_ativas": 12
},
"transactions_per_minute": [
{ "minute": "10:32", "credits": 0, "debits": 3, "total": 3 }
],
"top_stores": [
{ "name": "Cantina Italiana", "total": 45000, "count": 30 }
],
"category_distribution": [
{ "name": "Jaqueta", "count": 42, "price": 1500 }
]
}Retorna os detalhes de uma comanda pelo código curto (ex: F001). O código é normalizado para maiúsculas automaticamente.
Auth: Admin (token header)
Response 200:
{
"id": "uuid-interno",
"code": "F001",
"holder_name": "João Silva",
"balance": 1350,
"created_at": "2025-11-15T10:00:00Z"
}Retorna 404 se o código não existir.
Lista todas as lojas cadastradas.
Auth: Admin
Response 200:
[
{
"id": "uuid-da-loja",
"name": "Cantina Italiana",
"theme": "default",
"terminal_token": "XJ92KF"
}
]Cria uma nova loja com token de terminal gerado automaticamente.
Auth: Admin
Request body:
{
"name": "Cantina Italiana"
}Validação: name deve ser uma string não vazia após trim.
Response 201:
{
"id": "uuid-gerado",
"name": "Cantina Italiana",
"terminal_token": "XJ92KF"
}!!! note "Formato do token de loja"
O token gerado é uma string de 6 caracteres alfanuméricos maiúsculos (ex: XJ92KF).
O alfabeto utilizado é ABCDEFGHJKLMNPQRSTUVWXYZ23456789 — exclui os caracteres ambíguos 0, O, 1, I e L para facilitar leitura e digitação, e usa apenas os dígitos 2-9.
Erros:
{ "detail": "name is required" } // 400Atualiza o nome de uma loja existente.
Auth: Admin
Request body:
{
"name": "Novo Nome da Loja"
}Erros:
{ "detail": "name is required" } // 400
{ "detail": "Store not found" } // 404Gera um novo token para a loja, invalidando o anterior imediatamente. Qualquer terminal WebSocket usando o token antigo é desconectado na hora (código WS 1008 Token revoked).
Auth: Admin
Response 200:
{
"id": "uuid-da-loja",
"new_token": "KM74PQ"
}!!! warning "Efeito imediato da revogação"
Ao revogar o token, todos os terminais WebSocket ativos daquela loja são fechados imediatamente com código 1008. O novo token deve ser informado ao lojista para reconexão.
Lista todas as categorias de produto cadastradas com seus preços.
Auth: Pública (nenhum token necessário)
Response 200:
[
{
"id": "uuid-categoria",
"name": "Jaqueta",
"price": 1500,
"total_entries": 42,
"total_exits": 18
}
]!!! note "Unidade de valor"
O campo price é armazenado em ETC inteiros (sem decimais). 1500 = 1500 ETC.
Cria uma nova categoria de produto/preço.
Auth: Admin
Request body:
{
"name": "Bolsa",
"price": 1500
}Validação:
name: string não vazia após trimprice: inteiro estritamente positivo (> 0)- Nome duplicado (case-insensitive) é rejeitado
Response 201:
{
"id": "uuid-gerado",
"name": "Bolsa",
"price": 1500,
"total_entries": 0,
"total_exits": 0
}Erros:
{ "detail": "name is required" } // 400
{ "detail": "price must be a positive integer" } // 400
{ "detail": "Categoria já existe" } // 400O sistema de distribuição organiza os produtos cadastrados em caixas e as entrega às lojas participantes, com controle de montagem por voluntários.
Lista todas as rodadas de distribuição cadastradas.
Auth: Admin
Response 200:
[
{
"id": "uuid",
"name": "Rodada 1",
"num_boxes": 10,
"status": "planning",
"needs_recalc": 0,
"created_at": "2025-11-15T10:00:00Z",
"completed_at": null
}
]Status possíveis: planning, active, complete
Cria uma nova rodada de distribuição.
Auth: Admin
Request body:
{
"name": "Rodada 1",
"num_boxes": 10
}Response 201:
{
"id": "uuid-gerado",
"name": "Rodada 1",
"num_boxes": 10,
"status": "planning"
}Erros:
{ "detail": "name and num_boxes are required" } // 400Sugere automaticamente o número ideal de caixas com base no inventário cadastrado (categorias com total_entries > 0) e no número de lojas registradas.
Auth: Admin
Response 200:
{
"suggested": 12,
"reasoning": "10 lojas, 180 itens → mínimo 10 caixas (1/loja), ideal 12 caixas (~15 itens/caixa)"
}Retorna os detalhes completos de uma rodada, incluindo todas as caixas e seus itens.
Auth: Admin
Response 200:
{
"distribution": {
"id": "uuid",
"name": "Rodada 1",
"num_boxes": 10,
"status": "active",
"needs_recalc": 0,
"created_at": "2025-11-15T10:00:00Z",
"completed_at": null
},
"boxes": [
{
"id": "uuid-caixa",
"distribution_id": "uuid",
"box_number": 1,
"assigned_store_id": "uuid-loja",
"store_name": "Cantina Italiana",
"responsible_name": null,
"status": "pending",
"claimed_at": null,
"completed_at": null,
"items": [
{ "category_name": "Jaqueta", "target_quantity": 3 }
]
}
]
}Status de caixa possíveis: pending, in_progress, done
Retorna 404 se o ID não existir.
Executa o algoritmo de distribuição round-robin, calculando quais itens vão em quais caixas com base no inventário atual das categorias (total_entries). Substitui qualquer cálculo anterior da rodada.
Auth: Admin
Response 200:
{
"message": "Distribuição calculada com sucesso",
"warnings": [
"\"Bolsa\" tem apenas 3 itens — 7 caixa(s) ficarão sem esta categoria."
]
}Erros:
{ "detail": "Distribution not found" } // 404
{ "detail": "Nenhuma loja cadastrada para receber caixas." } // 400
{ "detail": "Nenhum produto cadastrado para distribuir." } // 400
{ "detail": "Impossível criar N caixas com apenas M itens." } // 400Remove uma rodada e todas as suas caixas e itens. Se a rodada estiver ativa e houver caixas com status in_progress, retorna 409.
Auth: Admin
Response 200:
{ "message": "Rodada excluída." }Erros:
{ "detail": "Distribution not found" } // 404
{ "detail": "Não é possível excluir: 2 caixa(s) estão sendo montadas agora." } // 409Ativa uma rodada para início do packing. Arquiva automaticamente qualquer rodada ativa anterior (status → complete). Transmite distribution_status_changed via WebSocket Packing para todos os voluntários conectados.
Auth: Admin
Response 200:
{ "status": "active" }Erros:
{ "detail": "Distribution not found" } // 404Rotas usadas pelos voluntários que montam fisicamente as caixas de produto.
Retorna a rodada de distribuição ativa com todas as caixas e estatísticas de progresso.
Auth: Admin
Response 200:
{
"distribution": {
"id": "uuid",
"name": "Rodada 1",
"status": "active"
},
"boxes": [
{
"id": "uuid-caixa",
"box_number": 1,
"store_name": "Cantina Italiana",
"responsible_name": null,
"status": "pending",
"items": [
{ "category_name": "Jaqueta", "target_quantity": 3 }
]
}
],
"stats": {
"total_boxes": 10,
"pending": 7,
"in_progress": 2,
"done": 1
}
}Erros:
{ "detail": "Nenhuma distribuição ativa no momento." } // 404Um voluntário assume a responsabilidade por uma caixa. Usa UPDATE atômico (WHERE responsible_name IS NULL AND status = 'pending') para evitar conflito entre dois voluntários tentando pegar a mesma caixa simultaneamente. Transmite box_claimed via WebSocket Packing.
Auth: Admin
Request body:
{ "responsible_name": "Ana" }Response 200:
{ "message": "Caixa assumida com sucesso!" }Erros:
{ "detail": "O seu nome é obrigatório para assumir a caixa." } // 400
{ "detail": "Caixa #3 já foi assumida por Carlos." } // 409
{ "detail": "Caixa não encontrada." } // 409Marca a caixa como montada (done). Se havia sinalização de recálculo pendente (needs_recalc = 1) e não restam mais caixas in_progress, o recálculo das caixas pending é executado automaticamente. Transmite box_completed (e distribution_recalculated se houver recálculo) via WebSocket Packing.
Auth: Admin
Response 200:
{ "message": "Caixa concluída com sucesso!", "recalc_triggered": false }Erros:
{ "detail": "Caixa não encontrada." } // 400Libera uma caixa de volta para pending, removendo o voluntário responsável. Se havia recálculo pendente e não restam mais caixas in_progress, o recálculo é executado. Transmite box_released (e distribution_recalculated se houver recálculo) via WebSocket Packing.
Auth: Admin
Response 200:
{ "message": "Caixa liberada para outros voluntários.", "recalc_triggered": false }Erros:
{ "detail": "Caixa não encontrada." } // 400O canal principal de operação das lojas. Cada terminal mantém uma conexão WebSocket persistente.
ws://localhost:8000/ws/store?token=<store_terminal_token>
Após conectar, o servidor envia:
{
"type": "connected",
"store_id": "uuid-da-loja",
"store_name": "Cantina Italiana",
"server_time": "2025-11-15T10:00:00Z"
}Erros de conexão:
| Código WS | Motivo | Causa |
|---|---|---|
1008 |
Store Token Unauthorized |
Token inválido ou inexistente |
1008 |
Max connections reached |
Limite de 100 conexões atingido |
1008 |
Token revoked |
Token revogado pelo admin enquanto conectado |
Consulta o saldo de uma comanda sem realizar débito. O campo comanda_code é normalizado (trim + uppercase) antes da busca.
{
"type": "balance_query",
"comanda_code": "F001"
}Resposta:
{
"type": "balance_response",
"comanda_code": "F001",
"holder_name": "João Silva",
"balance": 1350
}Erro:
{ "type": "error", "reason": "comanda_not_found" }Solicita um débito em uma comanda. O comanda_code é normalizado automaticamente. O amount deve ser um inteiro positivo.
{
"type": "debit_request",
"comanda_code": "F001",
"amount": 650
}Respostas possíveis:
debit_confirmed:
{
"type": "debit_confirmed",
"event_id": "uuid-evento",
"comanda_code": "F001",
"holder_name": "João Silva",
"amount": 650,
"new_balance": 700
}debit_rejected:
{
"type": "debit_rejected",
"reason": "insufficient_balance",
"current_balance": 400,
"requested": 650
}reason |
Causa |
|---|---|
comanda_not_found |
Código não existe ou veio vazio |
insufficient_balance |
Saldo atual menor que o valor solicitado. Inclui current_balance e requested. |
invalid_amount |
amount é zero, negativo, float ou não-numérico |
server_error |
Erro interno inesperado |
Disparado após qualquer débito confirmado no sistema. Permite que outros terminais que estejam visualizando a mesma comanda atualizem o saldo em tempo real.
{
"type": "balance_updated",
"comanda_code": "F001",
"new_balance": 700,
"event_type": "debit",
"store_id": "uuid-da-loja-que-debitou"
}{ "type": "error", "reason": "rate_limit_exceeded" }Enviado quando o terminal excede 300 mensagens por minuto. O frontend deve implementar debounce ou throttle para evitar isso.
Interface para operadores do Banco e Administradores.
ws://localhost:8000/ws/admin?token=<admin_token>
Após conectar:
{
"type": "connected",
"role": "admin",
"next_code": "F001"
}Erros de conexão:
| Código WS | Motivo | Causa |
|---|---|---|
1008 |
Unauthorized |
Token admin inválido |
1008 |
Max connections reached |
Limite de 10 conexões admin atingido |
Solicita a criação de uma nova comanda com saldo inicial. O campo cart_items é opcional e registra os itens avaliados no carrinho do Banco (incrementa total_entries das categorias correspondentes).
{
"type": "create_comanda",
"holder_name": "Maria Oliveira",
"initial_balance": 5000,
"cart_items": [
{ "name": "Jaqueta", "quantity": 2 },
{ "name": "Camiseta", "quantity": 1 }
]
}Validação:
holder_name: obrigatório, string não vazia após triminitial_balance: inteiro não-negativo (≥ 0), ausente = 0. Se 0, nenhum evento de crédito inicial é gerado.cart_items: opcional. Itens comnamevazio ouquantityinválida são ignorados.
Erros:
{ "type": "error", "reason": "holder_name is required" }
{ "type": "error", "reason": "invalid_amount" }
{ "type": "error", "reason": "Maximum number of comandas (1000) reached" }
{ "type": "error", "reason": "Concurrent comanda creation conflict. Please retry." }Adiciona crédito extra a uma comanda já existente. O campo cart_items segue a mesma lógica de create_comanda.
{
"type": "add_credit",
"comanda_code": "F001",
"amount": 2000,
"cart_items": [
{ "name": "Jaqueta", "quantity": 1 }
]
}Validação:
comanda_code: obrigatório, normalizado (trim + uppercase)amount: inteiro estritamente positivo (> 0)
!!! info "Campo note no evento gerado"
Ao receber add_credit, o servidor cria um evento de crédito com note = 'Crédito adicional'. Isso o diferencia dos créditos iniciais (note = 'Saldo inicial') na auditoria. Ambos contribuem para o saldo da comanda, mas apenas os créditos iniciais entram no total_issued de /reports/economy_state.
Erros:
{ "type": "error", "reason": "comanda_code is required" }
{ "type": "error", "reason": "invalid_amount" }
{ "type": "error", "reason": "comanda_not_found" }Cadastra ou atualiza uma categoria/preço de produto.
{
"type": "register_category",
"name": "Bolsa",
"price": 1500,
"total_entries": 10
}Validação:
name: obrigatório, string não vazia após trimprice: inteiro não-negativo (≥ 0). Se 0, o preço existente não é alterado — apenas ototal_entriesé incrementado.total_entries: inteiro não-negativo (≥ 0), ausente = 0
Erros:
{ "type": "error", "reason": "category name is required" }
{ "type": "error", "reason": "invalid_amount" }Notifica todos os terminais Admin quando uma nova comanda é gerada.
{
"type": "comanda_created",
"code": "F001",
"holder_name": "Maria Oliveira",
"balance": 5000
}Notifica todos os terminais Admin quando crédito extra é adicionado a uma comanda existente.
{
"type": "credit_confirmed",
"code": "F001",
"holder_name": "Maria Oliveira",
"amount": 2000,
"new_balance": 7000
}Sincroniza entre os terminais qual será o próximo código Fxxx disponível.
{
"type": "update_next_code",
"next_code": "F002"
}Disparado quando uma loja efetua um débito, para o painel Admin acompanhar a economia em tempo real.
{
"type": "admin_balance_updated",
"comanda_code": "F001",
"new_balance": 700,
"amount": 650,
"store_name": "Cantina Italiana"
}Notifica quando uma categoria de produto é criada ou atualizada.
{
"type": "category_updated",
"category": {
"id": "uuid",
"name": "Bolsa",
"price": 1500,
"total_entries": 10,
"total_exits": 0
}
}{ "type": "error", "reason": "rate_limit_exceeded" }Enviado quando o terminal excede 300 mensagens por minuto.
Canal de broadcast para atualizações em tempo real durante a montagem de caixas. Este canal é somente de leitura para o cliente — todas as ações (claim, complete, cancel) são realizadas via REST para garantir atomicidade.
ws://localhost:8000/ws/packing?token=<admin_token>
Após conectar:
{
"type": "connected",
"role": "packer",
"message": "Bem-vindo ao canal de distribuição Ouroboros."
}!!! note "Diferença entre backends"
O campo message da mensagem de boas-vindas tem texto ligeiramente diferente entre os dois backends (o backend Python inclui "(Python)" no texto). O campo type e role são idênticos — código cliente deve verificar type === 'connected' e ignorar o conteúdo exato de message.
Erros de conexão:
| Código WS | Motivo | Causa |
|---|---|---|
4001 |
Unauthorized |
Token admin inválido |
Disparado ao ativar uma rodada via PUT /api/distribution/{id}/activate.
{ "type": "distribution_status_changed", "status": "active" }Disparado quando um voluntário assume uma caixa via POST /api/packing/boxes/{boxId}/claim.
{
"type": "box_claimed",
"box_id": "uuid-caixa",
"responsible_name": "Ana"
}Disparado quando uma caixa é concluída via POST /api/packing/boxes/{boxId}/complete.
{ "type": "box_completed", "box_id": "uuid-caixa" }Disparado quando uma caixa é liberada via POST /api/packing/boxes/{boxId}/cancel.
{ "type": "box_released", "box_id": "uuid-caixa" }Disparado quando o algoritmo de recálculo automático é executado (após complete ou cancel quando não há mais caixas in_progress e havia needs_recalc = 1).
{ "type": "distribution_recalculated" }| Código HTTP | detail |
Causa |
|---|---|---|
400 |
name is required |
Nome de loja ou categoria vazio ou só espaços |
400 |
price must be a positive integer |
Preço zero, negativo ou não-inteiro em POST /categories |
400 |
Categoria já existe |
Nome de categoria duplicado (case-insensitive) |
400 |
name and num_boxes are required |
Campos obrigatórios ausentes em POST /distribution |
400 |
num_boxes must be a positive integer |
num_boxes é zero, negativo, float ou não-numérico em POST /distribution |
400 |
Nenhuma loja cadastrada para receber caixas. |
Tentativa de calcular distribuição sem lojas |
400 |
Nenhum produto cadastrado para distribuir. |
Tentativa de calcular sem categorias com entradas |
400 |
Impossível criar N caixas com apenas M itens. |
Mais caixas solicitadas do que itens disponíveis |
400 |
Caixa não encontrada. |
boxId inexistente em complete/cancel |
400 |
O seu nome é obrigatório para assumir a caixa. |
responsible_name ausente em claim |
401 |
Unauthorized |
Token ausente ou incorreto |
404 |
Comanda não encontrada |
Código de comanda não existe |
404 |
Store not found |
ID de loja não existe |
404 |
Distribution not found |
ID de distribuição não existe |
404 |
Nenhuma distribuição ativa no momento. |
Nenhuma rodada com status active |
409 |
Não é possível excluir: N caixa(s) estão sendo montadas agora. |
Tentativa de excluir rodada ativa com caixas in_progress |
409 |
Caixa #N já foi assumida por <nome>. |
Race condition em claim — caixa já assumida |
reason |
Contexto | Causa |
|---|---|---|
holder_name is required |
Admin | holder_name ausente ou vazio |
comanda_code is required |
Admin | comanda_code ausente ou vazio |
category name is required |
Admin | name ausente ou vazio em register_category |
comanda_not_found |
Admin / Loja | Código de comanda não existe |
invalid_amount |
Admin / Loja | Valor zero, negativo, float ou não-numérico |
insufficient_balance |
Loja | Saldo menor que o valor solicitado |
server_error |
Loja | Erro interno inesperado |
rate_limit_exceeded |
Admin / Loja | Excedeu o limite de mensagens por minuto |
Maximum number of comandas (...) reached |
Admin | Limite configurado em MAX_COMANDAS atingido |
Concurrent comanda creation conflict... |
Admin | Race condition em criação simultânea — tentar novamente |
O backend é completamente autossuficiente. Qualquer frontend — ou nenhum frontend — pode ser usado. A única interface necessária são as conexões HTTP REST e WebSocket descritas acima.
Exemplo mínimo de fluxo para um Terminal de Loja:
- Conectar via
ws://host:8000/ws/store?token=TOKEN - Aguardar
{ type: "connected" }para confirmar autenticação - Para consultar saldo: enviar
{ type: "balance_query", comanda_code: "F001" } - Para debitar: enviar
{ type: "debit_request", comanda_code: "F001", amount: 650 } - Tratar
debit_confirmed(sucesso) oudebit_rejected(comreasonespecífica) - Implementar reconexão automática com backoff exponencial (o servidor não armazena estado de sessão)
Exemplo mínimo de fluxo para Terminal Banco/Admin:
- Conectar via
ws://host:8000/ws/admin?token=ADMIN_TOKEN - Aguardar
{ type: "connected", next_code: "F001" }para saber o próximo código disponível - Para criar comanda: enviar
{ type: "create_comanda", holder_name: "João", initial_balance: 1000 } - Tratar
comanda_created(broadcast para todos os admins) ou{ type: "error", reason: "..." } - Para adicionar crédito extra: enviar
{ type: "add_credit", comanda_code: "F001", amount: 500 }