Do ERP à primeira análise
Neste tutorial você escreve o script que um ERP roda para manter o Catálogo de Produtos no Cotejar e conferir as exigências: envia os produtos em lotes, valida, aplica, pede a análise completa e lê todos os achados. Cada resposta mostrada aqui foi capturada de uma execução real do fluxo.
Você vai precisar de: uma credencial com Consultar conta, Consultar catálogo, Enviar produtos, Aplicar lotes e Executar análises (veja o guia rápido), Node.js 20 ou mais recente e o cliente HTTP salvo como cotejar.mjs.
-
Escolha o catálogo de destino
Seção intitulada “Escolha o catálogo de destino”Cada estabelecimento tem seus catálogos. Guarde o
iddo catálogo que o ERP alimenta; ele vai em todas as ingestões.200 OK {"itens": [{"id": "7c7b354f-07b6-4461-9240-263418c81cdb","id_externo": "CAT-ERP-002","estabelecimento_id": "d905fb0d-2f5e-4a67-ad96-955a90fc4431","cnpj_estabelecimento": "12345678000276","nome": "Catálogo da filial","estado": "ativo","versao": 1,"produtos": 0}],"proximo_id": null} -
Inicie a ingestão
Seção intitulada “Inicie a ingestão”const { dados: ingestao } = await chamar('POST', '/v1/catalogos/ingestoes', {corpo: { catalogo_id: CATALOGO_ID, operacao: 'mesclar', referencia_externa: 'erp-2026-10-13' },idempotencia: randomUUID(),})Campo Uso catalogo_idCatálogo de destino. Obrigatório quando a empresa tem mais de um catálogo ativo. operacaomesclaradiciona e atualiza os enviados e preserva os ausentes.substituicao_catalogosubstitui os vínculos daquele catálogo.referencia_externaSeu identificador da execução, até 200 caracteres. Ajuda a cruzar logs. 201 Created { "ingestao_id": "5306d957-04d7-44dd-9c63-e4d04d2e19fe", "estado": "recebendo_lotes", "repetida": false, "catalogo_id": "7c7b354f-07b6-4461-9240-263418c81cdb", "versao_catalogo": 1 } -
Envie os produtos em lotes
Seção intitulada “Envie os produtos em lotes”Numere as sequências a partir de 1. Cada lote leva até 200 produtos e 256 KiB; a ingestão inteira, até 50 mil produtos.
Corpo de um lote {"produtos": [{"codigo_cliente": "000123","descricao_cliente": "Luva cirúrgica estéril de látex","ncm": "40151900","desativado_por_ato": false,"atributos": [{ "codigo": "ATT_123", "valores": ["valor informado"] }]}]}Campo Regra codigo_clienteCódigo do produto no ERP, até 256 caracteres. É a identidade estável: mande sempre o mesmo. descricao_clienteDescrição, até 2.000 caracteres. ncmOito dígitos, como texto. Preserve zeros à esquerda. desativado_por_atotruequando o produto foi desativado por ato administrativo no Portal Único.atributosAté 128 atributos { codigo: "ATT_<n>", valores: [...] }, cada um com até 50 valores.201 Created { "ingestao_id": "5306d957-04d7-44dd-9c63-e4d04d2e19fe", "sequencia": 1, "produtos_recebidos": 3, "repetido": false }Se a resposta se perder, reenvie o mesmo lote com a mesma
Idempotency-Key: a API devolve200com"repetido": truee não conta o lote duas vezes. -
Feche e valide
Seção intitulada “Feche e valide”Informe a última sequência enviada. A API confere que nenhuma falta, valida cada NCM contra a nomenclatura vigente e devolve a prévia com o
hash_previa.{"ingestao_id": "5306d957-04d7-44dd-9c63-e4d04d2e19fe","estado": "pronta_para_aplicar","hash_previa": "4bee6b75d2d85b7a1ed8f4dd9657f131c7d9b1dca79c0eac2c3c912091d131ae","totais": { "lotes": 1, "produtos": 3, "incompletos": 0, "atributos": 0 },"guardas": []}{"ingestao_id": "2e61f52d-9802-4a25-bf23-6ccbe9dc39ea","estado": "precisa_revisao","hash_previa": "1e85ffa7b501a0999f7acc29dbcc773d683f7f915e243852440f04571db7b456","totais": { "lotes": 1, "produtos": 3, "incompletos": 3, "atributos": 0 },"guardas": ["linha_sem_ncm", "ncm_inexistente"]}Leia os itens recusados em
GET /v1/catalogos/ingestoes/{id}:{ "id_temporario": "1:1", "codigo_cliente": "000123", "ncm": "40151100", "posicao": 1, "erros": ["NCM não existe na nomenclatura vigente."] }Em
precisa_revisao, pare e avise alguém:aplicardevolveria409 REVISAO_HUMANA_NECESSARIA. Corrija no ERP e comece uma ingestão nova. -
Aplique e acompanhe
Seção intitulada “Aplique e acompanhe”Envie o
hash_previaque você validou. Se o catálogo mudar entre a validação e a aplicação, a prévia é invalidada e nada é publicado pela metade.202 Accepted · Location: /v1/catalogos/ingestoes/5306d957-… { "ingestao_id": "5306d957-04d7-44dd-9c63-e4d04d2e19fe", "estado": "aplicando", "repetida": false }202não é catálogo atualizado. Consulte a ingestão atéaplicadaoufalhou_aplicacao:{ "estado": "aplicando", "progresso_aplicacao": { "fase": "preparando", "itens_preparados": 0, "itens_previstos": 3 }, "aplicada_em": null }{ "estado": "aplicada", "progresso_aplicacao": { "fase": "concluida", "itens_preparados": 3, "itens_previstos": 3 }, "codigo_falha": null, "aplicada_em": "2026-10-13T16:48:04.093Z" }Com um destino configurado, o mesmo momento chega ao seu receptor:
{ "id": "10cad8ab-68b8-48a6-a296-868597fb7ccb", "versao": 1, "empresa": "12345678", "ocorridoEm": "2026-10-13T16:48:03.875Z", "dados": { "tipo": "catalogo.aplicado", "catalogoId": "7c7b354f-07b6-4461-9240-263418c81cdb", "versao": 2 } } -
Peça a análise completa
Seção intitulada “Peça a análise completa”A análise considera todos os produtos ativos da empresa. O corpo é um objeto vazio.
202 Accepted · Location: /v1/analises/e0da8bf7-0252-4556-9d52-856cc1349507 { "id": "e0da8bf7-0252-4556-9d52-856cc1349507", "estado": "pendente" }Consulte o
Locationatéconcluidaoufalhou. Enquanto isso,resultadoénull, o que não significa zero achados.GET /v1/analises/e0da8bf7-… · concluída {"id": "e0da8bf7-0252-4556-9d52-856cc1349507","estado": "concluida","terminada_em": "2026-10-13T16:47:03.109Z","codigo_falha": null,"resultado": {"snapshot_id": "snap-338","versao_portal": "338","versao_avaliador": "2","executada_em": "2026-10-13T16:47:01.581Z","regras_coletadas_em": "2026-10-12T16:00:00.000Z","ultima_coleta_util_em": "2026-10-13T06:32:54.000Z","regulamento_degradado": false,"assinante": true,"franquia_consumida": false,"produtos_avaliados": 2847,"divergencias": { "orfao": 4, "bloqueante": 8911, "deriva": 0, "anvisa": 2787 },"catalogos": [{ "id": "7c7b354f-07b6-4461-9240-263418c81cdb", "versao": 1 }],"paginas_resultado": 59}}regulamento_degradado: trueavisa que o Cotejar não conseguiu confirmar que a regra do Portal Único usada está atualizada. Mostre essa marcação a quem usa o resultado: nessa condição, zero achados não comprova que o catálogo está em dia. O eventoanalise.concluidatraz o mesmoanaliseIde essa marcação. -
Leia todos os achados
Seção intitulada “Leia todos os achados”Comece em
pagina=1e sigaproxima_paginaaténull. Cada página tem até 200 achados e também é limitada por bytes: não calcule a última página a partir do total.200 OK · pagina=1 {"itens": [{"produto_id": "0001628c-0c07-48ab-85f7-011db27a16ed","codigo_produto": "0001628c-0c07-48ab-85f7-011db27a16ed","ncm": "30021500","tipo": "bloqueante","codigo_atributo": "ATT_1000","detalhe": "Atributo obrigatório ATT_1000 — Marca comercial não preenchido."}],"proxima_pagina": 2}tipoSignificado orfaoProduto desativado por ato ou NCM fora da tabela vigente: não há regra contra a qual comparar. bloqueanteAtributo obrigatório não preenchido ou valor que o Portal Único não aceita: a DUIMP seria rejeitada. derivaA regra mudou: um atributo deixou de ser exigido e o produto ainda o informa, ou passou a constar e o produto não o informa. Não trava a DUIMP hoje. anvisaCategoria do produto para a Anvisa ausente ou não aceita: o produto depende de regularização da anuência. Os valores de exemplo vêm de um ambiente local com regulamento de teste: NCMs, atributos e contagens ilustram o formato e não descrevem exigências reais.
-
Baixe o CSV, se precisar do arquivo
Seção intitulada “Baixe o CSV, se precisar do arquivo”O mesmo resultado sai em um CSV único, com tamanho e SHA-256 nos cabeçalhos. Use o validador de CSV antes de importar.
O script completo
Seção intitulada “O script completo”O arquivo abaixo é o fluxo inteiro, já com o tratamento de recusa da prévia. Ele foi executado do início ao fim contra uma instância local da API.
// Do ERP à primeira análise: envia o catálogo, aplica, analisa e lê os achados.import { randomUUID } from 'node:crypto'import { aguardar, chamar } from './cotejar.mjs'
const CATALOGO_ID = process.env.COTEJAR_CATALOGO_IDconst produtos = [ // na integração real, venha do ERP { codigo_cliente: '000123', descricao_cliente: 'Luva cirúrgica estéril de látex', ncm: '40151900', desativado_por_ato: false, atributos: [] }, { codigo_cliente: '000124', descricao_cliente: 'Agulha hipodérmica 25x7', ncm: '90183210', desativado_por_ato: false, atributos: [] }, { codigo_cliente: '000125', descricao_cliente: 'Monitor de pressão arterial de braço', ncm: '90189010', desativado_por_ato: false, atributos: [] },]
// 1. Iniciar a ingestão. Em produção, persista cada chave antes de enviar.const { dados: ingestao } = await chamar('POST', '/v1/catalogos/ingestoes', { corpo: { catalogo_id: CATALOGO_ID, operacao: 'mesclar', referencia_externa: `erp-${new Date().toISOString().slice(0, 10)}` }, idempotencia: randomUUID(),})const base = `/v1/catalogos/ingestoes/${ingestao.ingestao_id}`
// 2. Enviar lotes de até 200 produtos, numerados a partir de 1.let sequencia = 0for (let i = 0; i < produtos.length; i += 200) { sequencia++ await chamar('PUT', `${base}/lotes/${sequencia}`, { corpo: { produtos: produtos.slice(i, i + 200) }, idempotencia: randomUUID() })}
// 3. Fechar e validar. Recusas param o fluxo antes de qualquer alteração.const { dados: previa } = await chamar('POST', `${base}/validar`, { corpo: { ultima_sequencia: sequencia }, idempotencia: randomUUID() })if (previa.estado !== 'pronta_para_aplicar') { const { dados } = await chamar('GET', base) const recusados = dados.itens.filter(item => item.erros.length > 0) console.error(`Prévia em ${previa.estado}; guardas: ${previa.guardas.join(', ')}`) for (const item of recusados) console.error(` ${item.codigo_cliente}: ${item.erros.join(' ')}`) process.exit(1)}
// 4. Aplicar a prévia validada e acompanhar até o fim.await chamar('POST', `${base}/aplicar`, { corpo: { hash_previa: previa.hash_previa }, idempotencia: randomUUID() })const aplicada = await aguardar(base, ['aplicada', 'falhou_aplicacao'])if (aplicada.estado !== 'aplicada') throw new Error(`Aplicação falhou: ${aplicada.codigo_falha}`)console.log(`Catálogo aplicado: ${aplicada.totais.produtos} produtos.`)
// 5. Pedir a análise completa e acompanhar pelo Location.const pedido = await chamar('POST', '/v1/analises', { corpo: {}, idempotencia: randomUUID() })const analise = await aguardar(pedido.location, ['concluida', 'falhou'])if (analise.estado !== 'concluida') throw new Error(`Análise falhou: ${analise.codigo_falha}`)const { produtos_avaliados, divergencias } = analise.resultadoconsole.log(`Análise ${analise.id}: ${produtos_avaliados} produtos avaliados`, divergencias)
// 6. Percorrer todos os achados até proxima_pagina ser null.let total = 0for (let pagina = 1; pagina !== null;) { const { dados } = await chamar('GET', `/v1/analises/${analise.id}/resultados?pagina=${pagina}`) total += dados.itens.length // grave cada página no seu sistema aqui pagina = dados.proxima_pagina}console.log(`${total} achados lidos.`)COTEJAR_API_KEY=ctj_api_... COTEJAR_CATALOGO_ID=7c7b354f-07b6-4461-9240-263418c81cdb node sincronizar.mjsCatálogo aplicado: 3 produtos.Análise 8c59328a-3e5d-4af1-a4b4-b7914876644b: 3 produtos avaliados { orfao: 0, bloqueante: 9, deriva: 0, anvisa: 3 }12 achados lidos.Em produção
Seção intitulada “Em produção”- Rode a sincronização num agendamento, não a cada alteração no ERP: o teto de ingestão é de 12 operações por minuto por empresa.
- Persista
ingestao_id, cada chave de idempotência e ohash_previaantes de enviar. Um processo reiniciado retoma do último passo confirmado. - Prefira o webhook
catalogo.aplicadoeanalise.concluidaà consulta em laço. Mantenha a consulta como plano B, com prazo. - Não peça uma análise a cada lote. Uma execução concluída e ainda atual é reaproveitada; análises novas contam na franquia.