API CRUD centralizada (/data) + dicionário de metadados (/dictionary). Endpoint único para todas as operações com modo structured (recomendado) ou raw.
Banco de dados (CRUD)
API centralizada para acesso ao banco de dados da Plataforma Keeptor. Dois endpoints:
/data: operações CRUD genéricas (SELECT/INSERT/UPDATE/DELETE/UPSERT) com 2 modos:structured(recomendado) eraw/dictionary: retorna metadados de uma entidade (cabeçalhos de tabelas dinâmicas, chaves estrangeiras como objetos aninhados, suporte a i18n)
Modos do /data
/data🔷 Modo structured (recomendado)
structured (recomendado)Use para operações comuns. Mais seguro (parametrizado) e fácil de usar:
- SELECT: buscar dados com filtros, joins, paginação, ordenação
- INSERT: inserir um ou mais registros (
datacomo array) - UPDATE: atualizar registros com
filters(datacomo objeto) - UPSERT: inserir ou atualizar com
onConflictdeclarado - DELETE: remover com
filters
Gravação em lote (INSERT e UPSERT): até 1.000 registros por requisição. A requisição é atômica (grava tudo ou nada): se um registro for recusado, nenhum é gravado e a resposta 400 explica o motivo. Mais de 1.000 registros: divida em várias requisições (cada uma atômica por si). No UPSERT só-chave (todas as colunas enviadas estão no onConflict, como num vínculo entre dois cadastros), os registros que já existem são ignorados: data.rows/data.affected trazem só os inseridos e data.ignored, quantos já existiam.
Operadores de filtro: =, <>, >, >=, <, <=, like, ilike, in, not in, is, is not. Não existe != nem IS NULL (usar <> e is + value: null).
Marcadores "$me" e "$now". Alguns valores de filtro são resolvidos pela própria API no momento da consulta, a partir da sessão do token. A mesma requisição (ou visão salva) serve qualquer usuário e qualquer dia, e nem o id do usuário nem a data calculada no aplicativo trafegam.
| Marcador | Significa | Operadores aceitos |
|---|---|---|
"$me" | o usuário logado | = e como item de uma lista in (pode misturar com ids). Com like/ilike é texto comum de busca |
"$now" | agora | =, <>, >, >=, <, <= (comparação de datas) |
"$now(-24 hours)", "$now(+1 day)" | agora mais ou menos um intervalo: sinal obrigatório, número de 1 a 9999 (para year, de 1 a 100), unidade minute, hour, day, week, month ou year (singular ou plural) | os mesmos de "$now" |
{ "column": "assigned_user_id", "operator": "=", "value": "$me" }
{ "column": "assigned_user_id", "operator": "in", "value": ["$me", 4] }
{ "column": "last_message_at", "operator": ">=", "value": "$now(-24 hours)" }- Valem em
filters(também de UPDATE e DELETE) e nas condições dejoinsehaving. Emdata(gravação) não têm efeito especial. - Marcador com operador não aceito,
insem lista ou forma inválida ("$me(1)","$now(-24 horas)","$now(-0 hours)","$now(24 hours)","$now(-101 years)"):400eerror.key = core.facets.invalid_condition(params.field,params.operator). - Só a forma exata (minúsculas, sem espaços nas pontas).
"$ME"é texto comum.
⚙️ Modo raw (avançado)
raw (avançado)Aceita SQL parametrizado direto (apenas SELECT). Use somente quando o modo structured não cobrir o caso. Sempre valide o payload contra o schema OpenAPI antes de enviar.
Nomes em vez de códigos: resolução de chave estrangeira
Uma consulta comum devolve city_id: 3, e a tela precisa exibir Belo Horizonte. Em vez de buscar o nome de cada cidade em uma chamada separada (uma por linha, ou uma por identificador distinto), declare no próprio select quais chaves estrangeiras quer resolver:
{
"mode": "structured",
"operation": "select",
"tables": ["kpt_shared_con"],
"columns": ["contact_id", "contact_name", "city_id", "cargo_id"],
"resolve": [
{ "column": "city_id" },
{ "column": "city_id.state_id" },
{ "column": "cargo_id", "as": "cargo_descricao" }
],
"locale": "pt"
}A resposta chega achatada, na mesma linha e na mesma chamada de rede:
{
"contact_id": 7,
"contact_name": "Contato Exemplo",
"city_id": 1,
"city_name": "São Paulo",
"state_name": "São Paulo",
"cargo_id": 13,
"cargo_descricao": "Analista"
}O que vale saber:
- É opcional. Sem o campo
resolve, nada muda: mesma consulta, mesma resposta de sempre. - Você não descreve o caminho. Nada de tabela de destino, chave física ou condição de junção: só o nome da coluna. Quem descobre isso é a plataforma, que também garante o isolamento por organização.
- O identificador continua lá.
city_idpermanece com o mesmo nome, tipo e valor; o rótulo vem como uma chave irmã. Nada que já consumia a resposta quebra. - A chave do rótulo é a coluna de exibição do destino (
city_id→city_name). Useaspara renomear. - Vários níveis.
"city_id.state_id"traz o estado da cidade do contato. O limite é 5 níveis. - Ordenar e filtrar pelo rótulo funciona: use a chave de saída (
city_name) emorderefilters, como qualquer outra coluna. localetraduz o rótulo (pt,en,es) quando a entidade de destino tem descrição traduzida. A chave de dados não muda entre idiomas, só o valor.- Nomes repetidos são recusados, não sobrescritos. Se duas resoluções produzirem a mesma chave de saída, a consulta não é executada e a resposta é
400. Para corrigir, dê outro nome a uma delas comas. - Limites: vale apenas na operação
select, e não pode ser combinado comjoinsexplícitos, porque é justamente o trabalho que ele substitui.
Erros do resolve. Todo erro de resolução responde 400 com error.code igual a KPT_FK_ERROR e error.field igual a resolve. O motivo exato vem em error.key (estável, use no seu código) e em error.params.reason:
error.key | params.reason | Quando |
|---|---|---|
core.fk.collision_resolve | KPT_FK_COLLISION | Duas resoluções geram a mesma chave de saída (params.key) |
core.fk.locale_invalid | KPT_FK_LOCALE_INVALID | locale fora de pt, en, es |
core.fk.no_constraint | KPT_FK_NO_CONSTRAINT | A coluna não aponta para outro cadastro |
Exemplo real (duas resoluções que geram entity_name):
{
"success": false,
"message": "Duas colunas resolvidas produzem a mesma chave de saída: \"entity_name\".",
"error": {
"type": "VALIDATION_ERROR",
"code": "KPT_FK_ERROR",
"message": "Duas colunas resolvidas produzem a mesma chave de saída: \"entity_name\".",
"key": "core.fk.collision_resolve",
"params": { "key": "entity_name", "path": "fk_entity_id", "reason": "KPT_FK_COLLISION" },
"field": "resolve",
"internal": "Rótulo de chave estrangeira: duas resoluções produzem o mesmo nome de saída; informe outro nome com \"as\". Params: key, path.",
"fields": ["resolve"]
}
}Segurança e isolamento
- Token obrigatório: todas as rotas exigem
Authorization: Bearer <access_token> - Isolamento por organização: você só acessa dados da sua própria organização. Tentar referenciar dados de outra organização retorna
404 NOT_FOUND
i18n
Queries que dependem do /dictionary (catálogos traduzíveis) devem incluir o campo locale (pt, en, es) no payload.
Erros
Todo erro segue o envelope { "success": false, "message", "error": { "type", "code", "message" } }. message é o texto para exibir ao usuário.
Nos erros de regra de negócio, de permissão e nas validações da própria requisição (campo obrigatório, nome inválido, filtro mal formado, função não liberada), error traz também:
| Campo | Para que serve |
|---|---|
key | Chave estável do erro (ex.: access.denied). Não muda com o idioma nem quando o texto é revisto: use no seu código para reconhecer o erro |
params | Valores usados no texto, separados (ex.: { "action": "delete", "entity": "kpt_shared_cct" }) |
field | Campo do registro que causou o erro, para mostrar a mensagem embaixo dele (também em fields[0]). Pode ser null |
internal | Descrição da regra para o time técnico/suporte (em português). Exiba de forma discreta, se exibir |
Nesses erros, message já vem no idioma do usuário logado (português, inglês ou espanhol). Se o usuário não escolheu idioma, vale o cabeçalho Accept-Language da requisição (pt-BR, en-US, es-ES); sem ele, ou sem tradução, português.
Os erros antes do login (token ausente, sessão expirada ou encerrada) saem em português, com key (ex.: gateway.auth.session_expired): traduza pela chave no seu app.
Com key, error.message é sempre o mesmo texto de message (nunca um detalhe técnico). Quando há um motivo técnico útil para diagnóstico, ele vem em error.details.reason; não o exiba ao usuário. error.code está sempre presente: o código do banco quando o erro veio dele (ex.: 23505, 42501, P0001), senão o mesmo valor de type.
No /dictionary: 400 com gateway.dictionary.entity_required (sem entity), 422 com core.entity.not_found (entidade inexistente, params.entity) e 500 com gateway.dictionary.load_failed.
| HTTP | error.type | Quando |
|---|---|---|
| 400 | VALIDATION_ERROR | Requisição mal formada (campo obrigatório, nome de tabela/campo inválido, função não liberada) |
| 400 | CONSTRAINT_ERROR / DATA_ERROR | Dado recusado pelo banco: duplicado, obrigatório, formato inválido. O texto não repete valores enviados nem dados do registro |
| 401 | UNAUTHORIZED | Sem token ou sessão encerrada/expirada |
| 403 | ACCESS_DENIED | Sem permissão para a operação |
| 404 | NOT_FOUND | Registro não existe ou não pertence à sua organização |
| 422 | BUSINESS_RULE | Uma regra de negócio recusou a operação; message explica o motivo |
| 500 | QUERY_ERROR / DATABASE_ERROR | Falha inesperada. Tente de novo; se persistir, abra chamado com o horário |
Exemplo (usuário com idioma inglês):
{
"success": false,
"message": "You do not have permission to delete Cost center.",
"error": {
"type": "ACCESS_DENIED",
"code": "42501",
"message": "You do not have permission to delete Cost center.",
"key": "access.denied",
"params": { "action": "delete", "entity": "kpt_shared_cct" },
"field": null,
"internal": "Acesso negado pela checagem de permissão: o usuário não tem a permissão exigida para essa ação nessa entidade. Params: action (create/read/update/delete), entity (entidade)."
}
}