O que é isto
A Balcaria é a vitrine e o balcão digital de um mercado de bairro: o cliente monta o pedido no site da loja e a conversa continua no WhatsApp, com o atendente. O catálogo, o estoque e os preços que aparecem ali são um espelho do que já existe no sistema de retaguarda do lojista.
Esta API é o caminho por onde esse espelho é atualizado. Quem chama é um programa do lado da loja (ou do fornecedor do sistema de retaguarda) que lê a base local e empurra o retrato do catálogo. Não há nada nosso lendo a base de ninguém.
As linhas vão CRUAS, como o SELECT as devolveu — sem normalizar caixa, acento, sentinela ou tipo. Toda a normalização roda no nosso servidor. É isso que faz uma regra nova ser um deploy nosso, e não uma atualização do seu programa em cada loja onde ele estiver instalado.
Seis palavras, antes de qualquer código
O resto desta página usa estes termos o tempo todo. Eles não são jargão de mercado: são nomes que valem aqui dentro, e quem não os tiver lido vai reler o protocolo três vezes sem entender por que existe um passo 1.
- Retrato (ou sincronização)
- Um ciclo inteiro de publicação: a foto completa do seu catálogo num instante. Não é um envio incremental — é o catálogo todo, e o que não aparecer nele é o que vai ser arquivado no fim.
- Lote
- Um pedaço do retrato, numa requisição. Um retrato de 1.700 produtos vira uns sete lotes. Lote não é transação: cada um grava o que conseguiu, e a conta só fecha no passo 3.
- totalEsperado
- Quantos produtos você promete enviar neste retrato, declarado na abertura. É a trava do ciclo inteiro, e o passo 3 vai cobrá-la por igualdade.
- Tabela de preço
- O mesmo produto tem mais de um preço: varejo, atacado no pix, atacado no cartão. Cada um vem numa linha de preço própria, com o nome da tabela como está no SEU sistema.
- Conexão
- O par loja + credencial. Cada instalação do seu programa numa loja tem a sua, com token próprio, e os limites de uso são contados por conexão.
- Conector local
- O modo de operação em que quem chama é um programa rodando na loja, lendo a base de lá. É o único modo que esta API atende hoje — não há nada nosso alcançando a sua base.
Um ciclo completo, do zero
Um retrato de dois produtos, inteiro, para rodar no terminal antes de escrever qualquer linha. Troque o host e o token pelos da sua loja e execute na ordem — os três passos são a integração toda; o resto desta página é o detalhe de cada campo.
O token sai do painel da loja, na tela de Integrações, e quem o gera é o lojista. Ele aparece uma vez só — veja a seção da credencial.
export TOKEN='<connectionId>.<segredo>'
export LOJA='https://sualoja.balcaria.com.br/api/erp/sincronizacao'
# PASSO 1 — abrir o retrato, declarando quantos produtos virão.
curl -sX POST "$LOJA" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"totalEsperado":2,"agenteVersao":"0.1.0"}'
# 201 {"sincronizacaoId":"9f1c2b7a-4d3e-4f61-9a8b-0c1d2e3f4a5b"}
export SINC='9f1c2b7a-4d3e-4f61-9a8b-0c1d2e3f4a5b'As linhas abaixo vão como o SELECT as devolveu — caixa alta, acento, a sentinela “Se Marca” do seu próprio cadastro. Não normalize nada: quem faz isso é o nosso servidor.
# PASSO 2 — empurrar o pedaço. Produto e preço do mesmo item, na MESMA requisição.
curl -sX POST "$LOJA/$SINC/lote" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"produtos": [
{
"id_produto": "10432",
"descricao": "QUEIJO MUSSARELA PECA",
"observacao": "Peça inteira, maturação de 45 dias.",
"categoria": "FRIOS E LATICINIOS",
"marca": "TIROLEZ",
"unidade": "KG",
"estoque": 37
},
{
"id_produto": "10433",
"descricao": "REFRIGERANTE COLA 2L",
"observacao": null,
"categoria": "BEBIDAS",
"marca": "Se Marca",
"unidade": "UN",
"estoque": 240
}
],
"precos": [
{"id_produto":"10432","tabela":"A - VAREJO","preco":"44.90","unidade":"KG"},
{"id_produto":"10433","tabela":"A - VAREJO","preco":"8.99","unidade":"UN"}
]
}'
# 201 {"sincronizacaoId":"9f1c…","produtosNoLote":2,"produtosRecebidos":2,"recusas":[]}
#
# "recusas" vindo PREENCHIDO não é erro: as outras linhas entraram. Anote e siga.# PASSO 3 — fechar. É o único passo que arquiva alguma coisa.
curl -sX POST "$LOJA/$SINC/concluir" -H "Authorization: Bearer $TOKEN"
# 201 {"status":"concluida","totalEsperado":2,"produtosRecebidos":2,
# "produtosArquivados":6,"precosRecebidos":2,"precosRecusados":0,"motivo":null}Erra sem dar erro
Leia o campo status do passo 3. Quando os números não batem, a resposta ainda é 201 — sucesso de HTTP — mas vem com status “falhou”, o motivo carregando os dois números, e NADA foi arquivado. Um agente que trate 201 como “sincronizou” vai achar que publicou um catálogo que não publicou.
201 {"status":"falhou","totalEsperado":2,"produtosRecebidos":1,"produtosArquivados":0,
"precosRecebidos":1,"precosRecusados":0,
"motivo":"O retrato veio incompleto: o agente anunciou 2 produtos e enviou 1. Nenhum produto foi arquivado."}Funcionou com dois produtos? O ciclo de verdade é o mesmo, com um laço no meio. O que ele acrescenta é o fatiamento — e o fatiamento tem uma armadilha, que o código abaixo evita de propósito: cada fatia leva os preços dos produtos DELA, nunca os preços todos de uma vez.
import requests
BASE = "https://sualoja.balcaria.com.br/api/erp/sincronizacao"
sessao = requests.Session()
sessao.headers["Authorization"] = "Bearer <connectionId>.<segredo>"
produtos = ler_produtos_do_erp() # a sua view, CRUA
precos = ler_precos_do_erp() # idem
# 1 — o total é um COUNT(*), inteiro. O passo 3 vai cobrá-lo por IGUALDADE,
# então ele tem de sair da MESMA lista que você vai enviar.
abertura = sessao.post(BASE, json={"totalEsperado": len(produtos), "agenteVersao": "0.1.0"})
abertura.raise_for_status()
sinc = abertura.json()["sincronizacaoId"]
# 2 — fatie por PRODUTO e leve junto só os preços dessa fatia.
# Mandar produtos num lote e preços em outro publica o catálogo inteiro
# sem preço nenhum, com 201 em todas as respostas.
por_produto = {}
for p in precos:
por_produto.setdefault(p["id_produto"], []).append(p)
TAMANHO = 200 # 200 produtos x 4 tabelas = 800 preços, dentro do teto de 1.000
for i in range(0, len(produtos), TAMANHO):
fatia = produtos[i : i + TAMANHO]
lote = {
"produtos": fatia,
"precos": [pr for p in fatia for pr in por_produto.get(p["id_produto"], [])],
}
r = sessao.post(f"{BASE}/{sinc}/lote", json=lote)
r.raise_for_status()
for recusa in r.json()["recusas"]:
registrar(recusa) # anote e SIGA: isto não é falha do lote
# 3 — fechar, e LER o status. 201 não quer dizer que arquivou.
fim = sessao.post(f"{BASE}/{sinc}/concluir").json()
if fim["status"] != "concluida":
alertar(fim["motivo"]) # nada foi arquivado; o catálogo anterior segue no ar- Deu 429? Espere os segundos do cabeçalho Retry-After e repita a MESMA chamada. Repetir antes só empurra a janela para a frente.
- Seu programa desistiu no meio? Chame /falhar em vez de sumir — a tela do lojista distingue “deu errado” de “sumiu”, e as duas frases mandam o lojista fazer coisas diferentes.
- Precisa recomeçar? Abra um retrato novo. Um retrato aberto que nunca fechou expira sozinho em 6 horas e também não arquiva nada.
O endereço, e por que ele é por loja
Cada loja tem o próprio host, e a credencial vale só nele. O host é a autoridade sobre qual loja está sendo falada — um token válido apresentado no endereço de outra loja é recusado com 401.
https://<loja>.balcaria.com.br/api/erp/sincronizacaoDuas filiais da mesma loja normalmente leem a mesma base e têm cada uma a sua conexão — logo, cada uma o seu token —, mas compartilham o mesmo endereço.
A credencial
Authorization: Bearer <connectionId>.<segredo>O token é emitido pelo Admin da loja, na tela de Integrações do painel, e aparece uma única vez: do nosso lado fica só um hash. Não há como recuperá-lo depois — perdeu, emite outro, e o anterior deixa de valer na hora.
Ele não é um usuário: não tem papel, não troca senha e não expira por tempo. É revogável sozinho, sem mexer em mais nada da loja.
O protocolo, em três passos
POST /api/erp/sincronizacao
Abre o retrato e devolve o sincronizacaoId que os outros dois usam. O corpo leva o totalEsperado — quantos produtos vêm neste ciclo.
POST /api/erp/sincronizacao/{id}/lote
Empurra um pedaço: { produtos: [...], precos: [...] }, até 1.000 linhas por campo. Repita quantas vezes precisar — é este o passo que se repete, e só ele.
POST /api/erp/sincronizacao/{id}/concluir
Fecha o retrato, confere a conta e arquiva o que ficou de fora. É o único passo que muda o que o cliente vê.
Erra sem dar erro
Só o passo 3 arquiva alguma coisa, e só quando a contagem bate EXATAMENTE com o totalEsperado que você declarou na abertura. A comparação é igualdade, não “pelo menos”: receber mais do que se anunciou também é um retrato que não bate. Quando não bate, a resposta ainda é de sucesso, com status “falhou” e o motivo carregando os dois números — e nada é arquivado. É essa trava que impede uma queda de rede no meio do envio de esvaziar o catálogo de uma loja inteira.
Um retrato que abriu e nunca fechou expira sozinho em 6 horas, e também não arquiva nada. Se o seu programa desistir, chame /falhar: a tela do lojista distingue “o agente disse que deu errado” de “o agente sumiu no meio”, e as duas frases mandam o lojista fazer coisas diferentes.
Reenviar o mesmo lote não duplica DADO: produto e preço são regravados no lugar, e produtosRecebidos é recontado do banco. Mas precosRecebidos e precosRecusados são acumuladores — reenviar soma de novo neles. Eles servem à tela do lojista, não à sua reconciliação.
As linhas que você envia
| Campo de produto | O que é |
|---|---|
| id_produto | O identificador do SEU sistema. É ele que casa o produto entre um retrato e o seguinte. Não use código de barras: um produto pode ter vários, e casar por código cria produtos-fantasma a cada ciclo. |
| descricao | Vira o NOME do produto na vitrine. Linha sem ela é recusada. |
| observacao | Vira a DESCRIÇÃO do produto. É fácil não notar que são dois campos diferentes: sem observacao, o produto entra na vitrine sem nenhum texto descritivo. |
| categoria | Texto livre. A categoria é criada se não existir; a organização do menu é nossa. |
| marca | Sentinelas do seu sistema (por exemplo “Se Marca”) viram ausência, não viram uma marca. |
| unidade | LIDA E DESCARTADA. Não existe unidade no cadastro de produto do nosso lado — ela é atributo do preço. Mandar é inofensivo; confiar nela, não. Veja a linha unidade da tabela de preço. |
| estoque | Saldo. Negativo vira zero, e fracionário é guardado com até 3 casas: 2,7 fica 2,7 e 0,4 fica 0,4 — o item a granel continua disponível. Da 4ª casa em diante, arredonda. Valor ilegível ou ausente vira zero, em silêncio. |
| Campo de preço | O que é |
|---|---|
| id_produto | O mesmo identificador da linha de produto. |
| tabela | O nome da tabela de preço, como está no seu sistema. Nome desconhecido é RECUSADO com motivo — nunca adivinhado, porque adivinhar erra para o lado de dar desconto sem dar erro. |
| preco | Decimal em TEXTO (“44.90”): até 8 dígitos inteiros e no máximo 2 casas decimais, maior que zero. FORA DISSO A LINHA É DESCARTADA EM SILÊNCIO — um preço com três casas some sem aparecer em recusas nem em precosRecusados. Confira do seu lado antes de enviar. |
| unidade | É ESTA a unidade que persiste, não a da linha de produto. Ausente, vira UN — o que num item vendido por quilo troca o significado do preço sem dar erro nenhum. Sempre envie. |
Erra sem dar erro
Cada linha de preço tem de viajar na MESMA requisição que a linha de produto dela. Preço cujo produto não veio junto é descartado — sem recusa, sem contagem, com resposta de sucesso. Fatiar produtos e preços em requisições separadas publica o catálogo inteiro sem preço nenhum, e nada no retorno avisa. Pelo mesmo motivo, um produto recusado leva junto os preços dele.
Erra sem dar erro
Linhas de PRODUTO recusadas voltam nomeadas, com motivo legível, numa resposta de SUCESSO — as demais entraram. Tratar isso como falha e reenviar o retrato inteiro em laço é o engano mais provável de quem integra sem ler esta linha. Para PREÇO, porém, a única recusa reportada é nome de tabela desconhecido: as demais somem caladas.
Erros, e o que fazer com cada um
| Código | O que significa | O que fazer |
|---|---|---|
| 400 | Duas coisas diferentes: corpo inválido (a mensagem diz qual campo) OU retrato já encerrado/expirado. | Corpo inválido: corrigir o programa, repetir não resolve. Retrato expirado: repetir a MESMA chamada nunca funciona, mas abrir um retrato novo e refazer o ciclo sim. |
| 401 | Token ausente, malformado, desconhecido — ou apresentado no host de outra loja. | Pedir um token novo ao lojista. |
| 403 | O token está bom; a loja é que não aceita a chamada agora. Quatro causas: integração desativada, plano sem integração de ERP, conexão fora do modo de conector local, ou a loja estar suspensa. | Reemitir o token NÃO resolve nenhuma das quatro. As três primeiras o lojista resolve no painel dele; a suspensão, não — essa é entre a loja e a plataforma. |
| 404 | Não existe retrato com esse id PARA ESTA CONEXÃO — id de outra conexão da mesma loja também dá 404. Há ainda um 404 de corpo diferente (“Loja nao encontrada.”): o host não é de loja nenhuma. | Retrato: abrir um novo. Loja não encontrada: conferir o endereço, não o retrato. |
| 429 | Teto de uso: cerca de 60 requisições por minuto por conexão. | Esperar o que o cabeçalho Retry-After disser. Repetir antes só empurra a janela para a frente. |
A distinção entre 401 e 403 é contrato, não estilo: são as duas situações em que um programa automático faria a coisa errada se as confundisse — pedir credencial nova quando o problema é a loja, ou insistir com a mesma credencial quando ela já não vale.
Limites de uso
- Até 1.000 linhas por campo, por requisição — e como cada preço tem de viajar junto do produto dele, é o campo precos que costuma limitar: com quatro tabelas por item, cabem ~250 produtos por lote.
- Cerca de 60 requisições por minuto por conexão. Há um segundo teto, mais folgado, que agrupa as chamadas por origem — dimensione pelo primeiro.
- O 429 traz o cabeçalho Retry-After em segundos, e o mesmo número no corpo.
- Um ciclo completo do catálogo do nosso lojista-piloto (≈1.700 produtos com quatro preços cada) cabe em cerca de 9 requisições: sete lotes, mais a abertura e a conclusão.
Os números acima são o teto praticado hoje, não um compromisso contratual: a contagem é por processo do servidor, então o valor efetivo pode ser maior. Trate-os como ordem de grandeza para dimensionar o seu ciclo, e obedeça sempre ao Retry-After em vez de a um número fixo.
O que esta API não faz hoje
Erra sem dar erro
Ela não entrega pedidos de volta para o sistema de retaguarda. Não existe rota de leitura de pedido, e isso é escopo decidido — não um detalhe omitido desta página.
O sentido implementado hoje é só o de entrada: catálogo, estoque e preços vindos do sistema de retaguarda para a vitrine. Enquanto a rota de saída não existir, o atendente lança o pedido manualmente na retaguarda ao faturar — que é como o lojista-piloto opera.
- Não há escrita de pedido ou orçamento na retaguarda.
- Não há webhook nosso avisando de pedido novo.
- Não há cadastro de cliente vindo da retaguarda.
- Não oferecemos rota de leitura para integração. (A vitrine da loja é pública por desenho e legível por HTTP — mas ela é uma loja para o cliente final, não uma API para você.)
Se a integração do seu lado depende de receber pedidos, diga — mas trate isso como uma conversa à parte, e não como algo já planejado. Enviar pedido para a retaguarda seria um trabalho próprio, desenhado com quem for consumir, e não está no roteiro de hoje.
O contrato de máquina
A especificação OpenAPI 3.1 completa — esquemas, exemplos executáveis e todos os códigos de resposta — está publicada e não exige credencial para ser lida.
/api/docs/openapi.json