Como funciona
A conversa é de mão dupla e sempre por evento. Nenhum dos dois lados fica consultando o outro.
| Sentido | Quem começa | Para quê |
|---|---|---|
| → Peçafy manda | Peçafy faz POST na sua URL |
Precisamos de uma resposta agora: preço de um item, se um CNPJ é seu cliente, qual o limite dele, lançar um pedido. |
| ← você manda | Você faz POST na URL de callback |
Algo mudou no seu ERP e a Peçafy precisa saber: catálogo, preço, estoque, andamento do pedido, crédito. |
O ponto que costuma gerar dúvida: a resposta pode ser depois
Quando a gente pergunta o preço de uma peça, tem um comprador olhando a tela. Por isso o
prazo é curto — 3 segundos por padrão. Se o seu ERP não responde nesse
tempo, você não precisa segurar a conexão: devolva 202 e
mande o resultado depois, no callback. Os dois caminhos são igualmente válidos e usam o
mesmo evento.
Seu acesso e sua configuração
Você mesmo configura, no portal, em Integração.
Você tem dois ambientes
Ao ter o cadastro aprovado, você recebe dois pares de credenciais, com o mesmo identificador de fornecedor nos dois. Comece pelo de homologação: é lá que você testa à vontade, sem tocar em pedido real.
| Ambiente | Endereço | Para quê |
|---|---|---|
| Homologação | demo.pecafy.com.br |
Desenvolver e validar a integração. Pedidos daqui não são reais. |
| Produção | app.pecafy.com.br |
Só depois que o fluxo passar em homologação. |
A URL de callback e a chave são de cada ambiente. Configure os dois separadamente, e não use a chave de homologação em produção — a assinatura não vai bater.
O que você configura no portal
| Item | O que é |
|---|---|
| URL do webhook | Onde entregamos os eventos. Obrigatoriamente https://, sem redirect.
Endereço de rede interna é recusado no cadastro. |
| Chave secreta | 25 caracteres. É exibida uma única vez, na geração. Guarde na hora — não há como consultá-la depois, só gerar outra. |
| URL de callback | https://<ambiente>/api/webhooks/inbound/<seu_supplier_id>É para lá que você posta tudo. Muda por ambiente — a do seu ambiente aparece pronta no portal, em Integração. |
| Eventos assinados | Por padrão você recebe todos. Restringir é com a equipe Peçafy — não há opção no portal. |
| Timeout da cotação | Quanto esperamos pelo preço antes de cair no modo assíncrono. Padrão 3000 ms; ajuste (até 15 s) com a equipe Peçafy. |
| Validade da cotação | Por quanto tempo a sua última resposta continua valendo. Dentro do prazo, reaproveitamos em vez de perguntar de novo — é você quem controla a frequência com que batemos no seu servidor. Padrão 24 h. |
| Estoque baixo | Abaixo desta quantidade o prazo acima é ignorado e perguntamos sempre. É a faixa em que um número velho vira venda de item que você não tem. Padrão 5; 0 desliga. |
| Aceitar busca por texto | Desligado por padrão. Ligue só se o seu sistema souber procurar por descrição
— é o que libera o items.search.requested. |
Testar conexão. Depois de salvar a URL e gerar a chave, o botão
"Testar conexão" no portal dispara um ping no seu servidor e mostra o
HTTP e o tempo de resposta. Enquanto ele não voltar {"pong": true}, o
problema é URL ou assinatura — nada mais adianta antes disso.
Rotação de chave. Ao gerar uma chave nova, a anterior continua válida por 24 horas. Isso existe para você trocar sem parar a integração: coloque a nova em produção dentro dessa janela.
Assinatura
Vale nos dois sentidos, com a mesma chave. A gente assina o que manda; você assina o que manda.
POST /seu-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Pecafy-Webhooks/1.0
X-Pecafy-Signature: t=1756304591,v1=<hex>
X-Pecafy-Event-Id: evt_9f2c7a1b4d6e8035
X-Pecafy-Event-Type: product.quote.requested
X-Pecafy-Delivery-Attempt: 1
X-Pecafy-Delivery-Attempt começa em 1. Vindo
2 ou mais, é uma retentativa de um evento que já pode
ter chegado — é o sinal para conferir a sua deduplicação por id antes
de processar de novo.
v1 é HMAC-SHA256(chave, "<t>.<corpo_cru>") em
hexadecimal. Corpo cru quer dizer os bytes exatos que chegaram — antes de
qualquer parse ou reserialização. Se o seu framework já transformou o JSON em objeto, você
perdeu o que precisa assinar; guarde o buffer original.
- Confira a assinatura antes de olhar o conteúdo.
- Recuse
tfora de uma janela de 5 minutos — é o que impede replay. - Compare em tempo constante (
hash_equals,crypto.timingSafeEqual), nunca com==. - Aplicamos exatamente as mesmas regras no que vem de você.
Exemplo — Node
const [t, v1] = header.split(',').map(p => p.split('=')[1]);
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return recusar();
const esperado = crypto.createHmac('sha256', SEGREDO)
.update(`${t}.${corpoCru}`).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(esperado), Buffer.from(v1))) return recusar();
Exemplo — PHP
[$t, $v1] = array_map(fn($p) => explode('=', $p)[1], explode(',', $header));
if (abs(time() - (int)$t) > 300) return recusar();
$esperado = hash_hmac('sha256', "$t." . $corpoCru, $segredo);
if (!hash_equals($esperado, $v1)) return recusar();
Envelope
Mesma estrutura nos dois sentidos. O que muda é o type e o data.
{
"id": "evt_9f2c7a1b4d6e8035",
"type": "product.quote.requested",
"version": 1,
"occurred_at": "2026-08-28T14:03:11.482Z",
"supplier_id": "sup_exemplo",
"correlation_id":"cor_4b8e12aa",
"reply": {
"mode": "sync_or_async",
"callback_url": "https://app.pecafy.com.br/api/webhooks/inbound/sup_exemplo",
"timeout_ms": 3000,
"expires_at": "2026-08-28T14:08:11.482Z"
},
"data": { }
}
| Campo | Para que serve |
|---|---|
id |
Sua chave de idempotência. O mesmo id pode chegar
duas vezes — um timeout de rede faz a gente reenviar. Processar duas vezes vira
pedido duplicado. Nos eventos que você manda, gere um id
único seu (ex.: evt_ + UUID) — sem ele a gente gera um, e a sua
retentativa deixa de ser reconhecida como duplicata. |
correlation_id |
Amarra a conversa inteira: consulta → cadastro → crédito → pedido → status.
No order.created ele é o id do pedido. |
reply |
Só aparece nos eventos que esperam resposta. Traz o prazo e para onde mandar o callback. |
version |
Versão do protocolo. Hoje sempre 1. |
Como responder
Quatro respostas possíveis, e cada uma significa uma coisa diferente para a fila.
| Situação | Responda | O que a Peçafy faz |
|---|---|---|
| Tenho o resultado agora | 200 + {"status":"ok","data":{…}} |
encerra usa o dado na hora |
| Vou demorar | 202 |
aguarda espera o seu callback |
| Não posso atender (regra de negócio sua) |
200 + {"status":"error","error":{"message":"…"}} |
recusado não retenta, não conta falha |
| Estou com problema | 5xx, timeout ou queda |
retenta entra no backoff |
| O evento chegou torto | 4xx |
desiste erro de contrato, não retenta |
"Cliente não cadastrado" não é erro de sistema. Use
{"status":"error"} com 200 para responder "não" às nossas
perguntas. Isso é registrado como recusa de negócio: não entra em retry, não conta para o
circuit breaker e não marca seu ERP como fora do ar. Guarde o 5xx para quando
você realmente estiver com problema.
Quanto tempo você tem, exatamente
| Modo | Prazo | O que acontece ao estourar |
|---|---|---|
| Síncrono (responder 200 na hora) |
3 s nos eventos de cotação — configurável na sua conexão até
o teto de 15 s. 10 s nos demais (cadastro, crédito, pedido). |
Paramos de esperar. Na busca ninguém fica travado: a tela já respondeu com a cotação anterior. |
| Assíncrono (responder 202 e postar depois) |
5 minutos é a referência — é o reply.expires_at
que vai no envelope, o tempo em que a resposta ainda chega a quem pediu. |
Nada é recusado: o callback continua sendo aceito e aplicado (ver abaixo). Só não alcança mais a tela daquele momento. |
Respondeu em 10 minutos: adianta ou não? Para a tela daquela busca,
não — o comprador já viu o resultado e provavelmente já saiu. Mas o preço é gravado
assim mesmo, e é ele que a próxima busca serve, sem perguntar de novo,
enquanto estiver dentro da validade que você configurou. A única resposta recusada é a
de um evento que já foi respondido ({"status":"duplicate"}).
Por isso o caminho de quem é lento não é responder tarde: é
empurrar. price.changed e stock.changed
mantêm o seu preço fresco sem ninguém esperando por você, e aí a cotação da busca quase
sempre já encontra o dado válido em cache.
Resposta atrasada ainda vale. Os dois modos funcionam em todos os eventos que esperam resposta — não só na cotação. E se o seu callback chegar depois do prazo, ele não é descartado: a Peçafy aplica o efeito assim mesmo.
Na prática: se você confirmou um cadastro com atraso, o comprador viu um erro
na hora, mas o cadastro é gravado quando o callback chega e ele consegue comprar
na sequência. Um order.created respondido tarde tira o pedido de
"falha ao enviar" e grava o seu número. O que não volta é a resposta imediata ao
comprador — essa requisição já terminou.
Por isso, não deixe de mandar o callback só porque passou do prazo. Mandar tarde é sempre melhor que não mandar: sem ele, você faz o trabalho e a Peçafy nunca fica sabendo.
Quanto tempo a Peçafy espera
| Evento | Prazo | Por quê |
|---|---|---|
product.quote.requested |
3 s | Tem comprador olhando a tela. Configurável por conexão, teto de 15 s. |
products.quote.requested |
3 s | Mesmo prazo, mas ninguém fica travado: a busca já respondeu com o que tinha. |
items.search.requested |
3 s | Idem. Não achou nada? Responda { "items": [] } em vez de estourar. |
| todos os outros | 10 s | Ninguém está esperando na tela, mas a fila não pode ficar presa. |
Estourar o prazo não é erro: só significa que a resposta vai pelo callback em vez de vir no corpo. Veja o aviso acima — resposta atrasada continua valendo.
Corpo do callback assíncrono
É um evento seu como qualquer outro — assinado, com id novo — só que carrega
in_reply_to apontando para o evento que estamos esperando.
O id do callback é seu, não o nosso. Copiar o
id do evento recebido para o id do callback é o erro mais comum
na implementação: a resposta é 400 com a mensagem explicando, e o nosso
evento fica esperando. O nosso id vai só em in_reply_to.
{
"id": "evt_<um id novo, seu>",
"type": "product.quote.requested.reply",
"version": 1,
"occurred_at": "2026-08-28T14:03:16.000Z",
"supplier_id": "sup_exemplo",
"in_reply_to": "evt_9f2c7a1b4d6e8035",
"status": "ok",
"data": { "preco_unitario": 176.30, "estoque_disponivel": 12 }
}
Passo a passo, do zero ao pedido entregue
A ordem real das chamadas, quem inicia cada uma e o que precisa estar pronto antes. Se você está implementando do zero, siga daqui.
O que costuma ser mal entendido: a busca do comprador
dispara products.quote.requested, mas
não espera a sua resposta. A tela é respondida na hora com a última
cotação que temos guardada; quando a sua resposta chega, o preço se atualiza ali.
É por isso que a busca não fica refém da soma das latências de todos os ERPs. E é por isso que a fase 1 continua existindo: sem o catálogo publicado, não sabemos que você tem o item e você nem chega a ser perguntado.
Também não perguntamos a cada busca. Enquanto a última cotação estiver dentro da validade que você configura no portal, reaproveitamos a resposta em vez de bater no seu servidor de novo — só o item vencido (ou com estoque na faixa baixa que você definiu) é perguntado. Uma busca por dois produtos vira um evento com dois itens, não dois eventos.
-
Fase 0 · Ligar a conexão uma vez
Você salva a URL e gera a chave no portal, em Integração. Para conferir que os dois lados se entendem, o botão "Testar conexão" (no mesmo lugar) dispara:
Direção Evento Você faz → Peçafy pingValida a assinatura e responde {"pong": true}Enquanto o
pingnão voltar, nada mais adianta: o problema é assinatura ou URL. -
Fase 1 · Carga inicial: dizer quais peças você tem obrigatória
Sem carga inicial a integração não faz nada. A Peçafy só pergunta preço de peça que sabe ser sua — e só sabe pelo que você mandou: o seu código interno (
sku_id_origem), o OEM/GTIN e a descrição. Há dois caminhos, e você pode usar os dois (detalhes em Carga inicial e busca):Caminho O que você manda Quando escolher Catálogo catalog.items.upsertedItem completo: descrição, preço, estoque, imagem. Quando quiser aparecer na comparação já com preço, sem depender de cotação. De-para catalog.refs.upsertedSó "meu código = este OEM", mais marca e aplicação se tiver. Muito mais barato. Passa a ser cotado por peças que não publicou — o OEM vem do catálogo de qualquer fornecedor. O caminho do catálogo, em detalhe:
Direção Evento O que acontece → Peçafy catalog.sync.requestedPedimos o catálogo. Responda 202.← você catalog.items.upsertedVocê empurra os itens, em lotes de até 5000. Pode mandar vários eventos. ← você price.changedstock.changedDaí em diante, só o que mudou. É o caminho barato — use bastante. ← você catalog.items.removedItem que saiu de linha. Você também pode empurrar
catalog.items.upsertedsem a gente pedir — ocatalog.sync.requestedé só a carga inicial. -
Fase 2 · O comprador busca aqui sai evento
Alguém procura "pastilha de freio". Perguntamos o preço só das peças dessa busca — nunca do catálogo inteiro — e não esperamos a sua resposta: a tela é respondida na hora com a última cotação guardada, e o preço se atualiza quando você responde.
Direção Evento Quando você recebe → Peçafy products.quote.requestedVocê tem a peça (por catálogo ou de-para) e a última cotação venceu. Uma busca por dois produtos é um evento com dois itens. → Peçafy items.search.requestedVocê não tem a peça por nenhum caminho e ativou "aceitar busca por texto". Mandamos o termo e o que sabemos da peça. Você controla a frequência. Enquanto a última cotação estiver dentro da validade que você define no portal, reaproveitamos a resposta em vez de bater no seu servidor. A exceção é o estoque na faixa baixa que você configurar, onde perguntamos sempre — é onde um número velho vira venda de item que não existe.
-
Fase 3 · Preço ao vivo de um item opcional
Fora da busca, o comprador pode pedir o preço atualizado de uma opção específica — a sua. Aí sai um evento só, e só para você.
Direção Evento Prazo → Peçafy product.quote.requested3s. Não deu? 202e responda no callback.É a versão de um item só do evento da fase 2 — mesmo formato de resposta, sem o
items[]. O preço da tabela daquele cliente entra nos dois. -
Fase 4 · O comprador vira seu cliente
Acontece quando ele escolhe você e ainda não tem cadastro no seu ERP.
Direção Evento O que acontece → Peçafy customer.lookup.requestedEsse CNPJ já é seu cliente? Se registered: false, o comprador vê o botão "Cadastrar".→ Peçafy customer.register.requestedEle clicou em cadastrar. Crie o cliente e devolva o cli_cod.→ Peçafy customer.credit.requestedLimite e condição de pagamento, para o checkout. ← você customer.credit.changedDepois, sempre que o limite ou o bloqueio mudar no seu ERP. Sem cadastro confirmado, a Peçafy não deixa fechar pedido com você.
-
Fase 5 · O pedido
Direção Evento O que acontece → Peçafy order.createdLance no ERP e devolva external_order_ref. Guarde oorder_id.← você order.status.changedFaturado (com a NF), enviado (com o rastreio), entregue. Um evento por mudança — o comprador é notificado a cada um. → Peçafy order.cancel.requestedSe o comprador cancelar.
O mesmo caminho, em uma imagem
Mapa de chamadas
Todo o protocolo em uma tela. Você só precisa expor UM endpoint — os
eventos chegam todos nele, e o type diz qual é.
| Fase | Evento | Direção | Obrigatório |
|---|---|---|---|
| 0 | ping | → você | sim |
| 1 | catalog.sync.requested | → você | sim |
| 1 | catalog.items.upserted | ← você | sim |
| 1 | catalog.items.removed | ← você | recomendado |
| 1 | price.changed · stock.changed | ← você | recomendado |
| 1 | catalog.refs.upserted | ← você | recomendado |
| 1 | catalog.refs.removed | ← você | recomendado |
| 2 | products.quote.requested | → você | sim |
| 2 | items.search.requested | → você | opt-in |
| 3 | product.quote.requested | → você | opcional |
| 4 | customer.lookup.requested | → você | sim |
| 4 | customer.register.requested | → você | sim |
| 4 | customer.credit.requested | → você | sim |
| 4 | customer.credit.changed | ← você | recomendado |
| 5 | order.created | → você | sim |
| 5 | order.status.changed | ← você | sim |
| 5 | order.cancel.requested | → você | recomendado |
Ordem sugerida de implementação
Cada etapa é útil sozinha — dá para ir ao ar antes de terminar tudo.
- 1. Endpoint + verificação de assinatura +
ping. Sem isso nada funciona. - 2.
catalog.items.upsertedoucatalog.refs.upserted. O catálogo já mostra a sua peça com preço; o de-para é bem mais barato e também te coloca no jogo. Fazer os dois é o ideal. - 3.
products.quote.requested. É o que a busca dispara — sem ele, a sua peça aparece com o preço da última carga e envelhece. - 4.
customer.lookup+register+credit. Aqui já dá para comprar de você. - 5.
order.created+order.status.changed. Fluxo fechado ponta a ponta. - 6.
price.changed/stock.changedincrementais, em vez de republicar tudo. - 7.
product.quote.requested(um item) e, se o seu sistema souber buscar por descrição,items.search.requested.
Carga inicial e busca: como o comprador acha a sua peça
O que você manda na carga inicial é exatamente o que a busca usa para achar você. Aqui está a regra, sem mistério — para você mandar o dado certo e ser encontrado.
O que vai na carga inicial
| Campo | Obrigatório | Para que a busca usa |
|---|---|---|
sku_id_origem | sim | O seu código interno. É por ele que perguntamos preço e que o pedido chega no seu ERP. Nunca muda de significado. |
oem ou gtin | pelo menos um | A chave universal da peça. É o que casa a sua peça com a mesma peça de outros fornecedores e com o código que o comprador digita. Sem nenhum dos dois o item vai para a quarentena. |
descricao | sim | Busca por texto ("pastilha de freio") e exibição. Descrição curta e padronizada (peça + posição + aplicação) acha mais que texto de nota fiscal. |
marca, aplicacao | recomendado | Também entram na busca por texto — "Fras-le" e "Celta" acham a peça mesmo quando a descrição não os cita — e no filtro de marca do comprador. |
preco_unitario, estoque_disponivel | recomendado | Fazem você aparecer na comparação já com preço, antes da primeira cotação. Só de-para (sem preço) também funciona — você aparece quando responder à cotação. |
Mande a carga em lotes de até 5000 itens em catalog.items.upserted (ou só o
de-para em catalog.refs.upserted), leia result.rejeitados de cada
lote e corrija o que caiu. Depois disso, só o que mudar: price.changed,
stock.changed, catalog.items.removed.
Como a busca casa o que o comprador digitou
A busca é exata por código e lexical por texto. Não existe busca "semântica" nem por similaridade: em autopeça, o parecido vende a peça errada. O que o comprador digita passa por estas regras, todas ao mesmo tempo, sobre o catálogo de todos os fornecedores:
| Regra | Casa quando | Exemplo |
|---|---|---|
| GTIN exato | o termo é igual ao gtin |
7891234567890 |
| OEM exato ou por prefixo, ignorando pontuação e caixa | o termo, sem hífen/espaço/ponto, é igual ao oem normalizado ou é o
começo dele |
FD-88, fd 88 e FD88 acham o mesmo item;
0815 acha 0815T140 |
| Texto completo (português) | as palavras aparecem em descrição, marca, OEM, GTIN ou aplicação, com flexão simples (pastilha/pastilhas) | pastilha freio celta |
| Todas as palavras | cada palavra (2+ letras) aparece em algum dos campos acima — a primeira como início da descrição | filtro comb hilux |
Quando você é perguntado
A busca responde na hora com o que está no catálogo. Em paralelo, decide para quem perguntar preço — e a decisão é só esta:
| Sua situação | O que sai para você |
|---|---|
| A peça achada está no seu catálogo e a última cotação venceu (ou o estoque está na faixa baixa) | products.quote.requested com o seu
sku_id_origem |
| A peça achada tem OEM/GTIN que está no seu de-para, mas não no seu catálogo | products.quote.requested com o sku_id_origem do
de-para. Se responder com descricao, o item entra no seu
catálogo. |
| Nem catálogo nem de-para, e você ligou "aceitar busca por texto" | items.search.requested com o termo, os OEMs conhecidos, marcas e
aplicações da peça |
| Nem catálogo nem de-para, sem busca por texto | nada. É por isso que a carga inicial é obrigatória. |
Resumo para quem está começando: mande sku_id_origem +
oem (ou gtin) + descricao de tudo que você vende.
Com isso você é encontrado por código e por texto, e é perguntado pelo seu próprio código.
O resto é refinamento.
Eventos que a Peçafy manda
Chegam por POST na sua URL. Todos assinados.
Teste de conexão, disparado pelo botão "Testar conexão" do portal (Integração).
Responda
{ "pong": true }
Queremos o seu catálogo. Este é o único evento em que 202 é a resposta
esperada: aceite e empurre os itens em catalog.items.upserted, em lotes.
Recebe
{
"delta": false,
"since": null
}
Responda
202 Accepted
delta: true pede só o que mudou desde since.
Preço e estoque de um item, para um comprador específico, agora. Tem gente esperando na tela — é aqui que o modo assíncrono importa.
Recebe
{
"item": {
"catalog_item_id": 52,
"sku_id_origem": "ABC-123",
"gtin": null,
"oem": "FD88",
"descricao": "lona freio"
},
"comprador": {
"cnpj": "19131243000197",
"external_ref": "19131243000197",
"cli_cod": "1001",
"tabela_preco": 1
}
}
Responda
{
"preco_unitario": 176.30,
"estoque_disponivel": 12,
"estoque_unidade": "un",
"promocao_ativa": false,
"promocao_valor": null,
"prazo_entrega": {
"valor": 1,
"unidade": "dia(s)"
}
}
comprador vem null quando quem está buscando ainda não é seu
cliente — responda com preço de tabela cheia.
estoque_disponivel: null significa "não sei";
0 significa "não tenho". São coisas diferentes na tela do
comprador: o primeiro não mostra "sem estoque", o segundo mostra.
Desconto de tabela não é promoção. Se o comprador tem tabela com
você, abata direto em preco_unitario e deixe
promocao_ativa: false. A promoção é para campanha de fato.
Quando usar promocao_ativa: true, o promocao_valor tem
de ser menor que preco_unitario e não pode ficar abaixo
de 10% dele — abaixo disso a resposta é recusada. É proteção contra
casa decimal trocada: sem ela, uma peça de R$ 264,15 sai vendida a R$ 21,60.
Se o desconto for real e enorme mesmo, mande como preco_unitario.
O mesmo que o evento acima, para vários itens de uma vez. É o que a busca dispara: o comprador procurou por dois produtos seus, você recebe uma pergunta com os dois. Ninguém fica travado esperando — a tela já foi respondida com a cotação anterior, e a sua resposta atualiza o preço quando chegar.
Recebe
{
"items": [
{ "catalog_item_id": 52, "sku_id_origem": "ABC-123",
"gtin": null, "oem": "FD88", "descricao": "pastilha de freio" },
{ "catalog_item_id": 77, "sku_id_origem": "VOL-9",
"gtin": null, "oem": "VL21", "descricao": "volante celta" }
],
"comprador": {
"cnpj": "19131243000197",
"external_ref": "19131243000197",
"cli_cod": "1001",
"tabela_preco": 1
}
}
Responda
{
"items": [
{ "sku_id_origem": "ABC-123", "preco_unitario": 176.30,
"estoque_disponivel": 12, "promocao_ativa": false },
{ "sku_id_origem": "VOL-9", "preco_unitario": 402.00,
"estoque_disponivel": 0, "promocao_ativa": false }
]
}
O sku_id_origem é obrigatório em cada item da resposta —
é por ele que casamos cada preço com o item perguntado. Item devolvido sem ele é
descartado.
Pode devolver menos itens do que recebeu: o que não vier fica com a cotação anterior. Não invente preço para item que você não tem.
Pode devolver mais itens do que recebeu — um equivalente, outra
embalagem do mesmo produto. Item que ainda não temos no catálogo entra, desde que venha
com descricao; ele passa pelas mesmas regras do catálogo empurrado, e o que
for inválido cai na mesma quarentena. Sem descricao o item extra é
descartado, porque não teríamos como exibi-lo.
Valem as mesmas regras do evento de item único: estoque_disponivel: null
é "não sei" e 0 é "não tenho"; desconto de tabela entra no
preco_unitario, não em promoção.
Você controla a frequência. No portal, em Integração, define por quanto tempo a sua cotação vale — dentro desse prazo não perguntamos de novo. A exceção é o estoque na faixa baixa que você configurar, onde perguntamos sempre.
Último recurso, e opt-in: só chega em quem não publicou catálogo nem de-para. Mandamos o que o comprador digitou, junto com os OEMs que já conhecemos para aquele produto, e você procura do jeito que souber.
Recebe
{
"termo": "pastilha de freio celta",
"oems": ["FD88"],
"gtins": [],
"marcas": ["Fras-le"],
"aplicacoes": ["Celta 2006-2015"],
"refs": ["PF-1234"],
"comprador": { "cnpj": "19131243000197", "cli_cod": "1001" }
}
Responda
{
"items": [
{ "sku_id_origem": "ABC-123",
"descricao": "Pastilha de freio dianteira",
"oem": "FD88",
"preco_unitario": 176.30,
"estoque_disponivel": 12 }
]
}
Aqui a descricao é obrigatória: como o item ainda não
existe no nosso catálogo, sem ela não teríamos como exibi-lo. Item sem descrição é
descartado.
Não achou nada? Responda { "items": [] }. É uma resposta legítima e
melhor que deixar estourar o prazo.
refs traz as suas próprias referências internas, quando
o seu de-para já as declarou. Comece por elas: aí a busca deixa de ser por texto e vira
por código, do seu lado.
marcas e aplicacoes vêm do catálogo agregado — é o que
outros fornecedores já publicaram para a mesma peça. Use como filtro, não como
verdade absoluta.
Prefira o catalog.refs.upserted a este evento: casar por código é
exato, casar por texto é palpite — e o palpite é seu.
Este CNPJ já é seu cliente?
Recebe
{
"cnpj": "19131243000197",
"cli_cod": null
}
Responda
{
"registered": true,
"external_ref": "19131243000197",
"cli_cod": "1001",
"tabela_preco": 1,
"credit_limit": 250000
}
registered tem de ser booleano de verdade, não a string "true".
Se for true, external_ref ou cli_cod é obrigatório —
é por ele que o pedido acha o cliente depois.
Cadastre este comprador como cliente e devolva o código que o seu ERP gerou.
Recebe
{
"tenant_id": "tn_abc",
"cnpj": "19131243000197",
"razao_social": "Oficina Exemplo Ltda",
"inscricao_estadual": "ISENTO",
"email": "compras@oficina.com.br",
"telefone": "5585999990000",
"endereco": {
"cep": "60000000",
"logradouro": "Rua Exemplo",
"numero": "100",
"bairro": "Centro",
"cidade": "Fortaleza",
"estado": "CE"
}
}
Responda
{
"external_ref": "19131243000197",
"cli_cod": "1001",
"tabela_preco": 1
}
Limite e condição de pagamento do comprador.
Recebe
{
"external_ref": "19131243000197",
"cli_cod": "1001"
}
Responda
{
"credit_limit": 250000,
"credit_used": 12000,
"credit_available": 238000,
"blocked": false,
"payment_term": "Boleto 28 dias",
"due_days": 28
}
payment_term vira a condição pré-selecionada no checkout
do comprador com você, e due_days o vencimento exibido. Os dois ficam
gravados até a próxima consulta ou um customer.credit.changed. Sem
credit_limit maior que zero o comprador só compra à vista.
Pedido fechado na Peçafy. Lance no seu ERP e devolva o número dele. Guarde o
order_id — é por ele que você manda o andamento depois.
Recebe
{
"order_id": "ord_a1b2c3",
"cliente": {
"external_ref": "19131243000197",
"cli_cod": "1001",
"cnpj": "19131243000197",
"razao_social": "Oficina Exemplo Ltda",
"email": "compras@oficina.com.br",
"telefone": "5585999990000"
},
"itens": [
{
"sku_id_origem": "ABC-123",
"descricao": "lona freio Fras-le",
"quantidade": 2,
"preco_unitario": 186.17
}
],
"pagamento": {
"condicao": "Boleto 28 dias",
"prazo_dias": 28
},
"entrega": {
"tipo": "entrega",
"endereco": { }
},
"cobranca": { "endereco": { } }
}
Responda
{
"external_order_ref": "PED-5001",
"status": "confirmado"
}
external_order_ref é obrigatório. Sem o número do pedido no seu ERP não
há como rastrear nada depois.
entrega.tipo é entrega ou retirada.
Cancelamento de um pedido já enviado.
Recebe
{
"order_id": "ord_a1b2c3",
"external_order_ref": "PED-5001",
"motivo": "…"
}
Responda
{ "cancelled": true }
Eventos que você manda
POST na sua URL de callback, assinado com a mesma chave. Não precisa de
pergunta prévia — mande quando o dado mudar no seu ERP.
O de-para entre o seu código e o OEM/GTIN da peça. Sem preço, sem estoque, sem imagem — é o dado mais barato que você tem, e o que menos muda.
Envie
{
"items": [
{ "sku_id_origem": "ABC-123",
"oem": "FD88",
"descricao": "Pastilha de freio dianteira",
"marca": "Fras-le",
"aplicacao": "Celta 2006-2015",
"ref_fornecedor": "PF-1234" },
{ "sku_id_origem": "XYZ-9", "gtin": "7891234567895" }
]
}
Por que vale a pena
Com o de-para, você passa a ser cotado por peças que nunca
sincronizou: quando outro fornecedor publica a peça com o OEM
FD88, sabemos que o ABC-123 é você e perguntamos pelo
seu código. Sem ele, só chegamos até você pelo catálogo que publicou.
sku_id_origem mais oem ou gtin
são obrigatórios — item sem identificador universal é descartado, porque não haveria
como casar com a busca.
Comparamos ignorando pontuação e caixa: FD-88, fd 88 e
FD88 são o mesmo OEM.
Mande descricao, marca, aplicacao e
ref_fornecedor sempre que tiver. Não são obrigatórios, mas mudam
o que você recebe depois: com eles, o items.search.requested chega dirigido
(marca e veículo, e a sua referência interna em vez de texto solto), e o item
pode aparecer para o comprador antes mesmo da primeira cotação. Sem eles, sobra o
identificador — dá para cotar, mas não para buscar bem.
Campo descritivo não é apagado por um lote posterior que mande só o identificador: o que você já declarou uma vez fica.
Pode mandar em lotes de até 5000 itens, quantas vezes quiser: o de-para é
idempotente por sku_id_origem.
Saiu de linha? Mande catalog.refs.removed com
{ "items": [{ "sku_id_origem": "ABC-123" }] }. O de-para não tem
sincronização completa que o corrija sozinho — sem a remoção, continuaríamos
perguntando por esse código para sempre.
Itens criados ou alterados. Este é o formato canônico do catálogo.
{
"items": [
{
"sku_id_origem": "ABC-123",
"gtin": "7891234567890",
"oem": "FD88",
"descricao": "Lona de freio dianteira",
"marca": "Fras-le",
"modelo": null,
"aplicacao": "VW 8-160",
"categoria_nome": "Freios",
"categoria_path": ["Freios", "Lonas"],
"imagens": ["https://cdn.exemplo.com/abc123.jpg"],
"preco_unitario": 186.17,
"preco_promocao_ativa": false,
"preco_promocao_valor": null,
"estoque_disponivel": 12,
"estoque_unidade": "un",
"peso": 2.4, "altura": 10, "largura": 20, "comprimento": 30,
"ativo": true,
"atualizado_em": "2026-08-28T14:00:00Z"
}
]
}
Regras de aceitação
gtinouoem, pelo menos um. Sem chave de matching o item não casa com nada e vai para a quarentena.gtincom 8 a 14 dígitos. Um GTIN de 15 dígitos é rejeitado.descricaoobrigatória.- Números como número JSON (
186.17). String numérica ("186.17","186,17") é convertida no catálogo, mas na resposta de cotação"186,17"é recusada — não conte com a conversão. - Máximo 5000 itens por evento. Quebre em lotes.
- O seu id de fornecedor e o prazo de entrega vêm da sua conexão, não do payload — não adianta mandar.
Reenviar um item idêntico não reescreve nada: a comparação é por hash do conteúdo. Item rejeitado não derruba o lote — ele vai para a quarentena e o resto entra.
O que a Peçafy responde
{
"status": "ok",
"event_id": "evt_…",
"result": {
"processed": 3, "upserted": 1, "skipped": 1, "quarantined": 1, "errors": 0,
"rejeitados": [
{ "sku_id_origem": "XYZ-9", "erros": ["Nenhuma chave de matching: gtin e oem são ambos nulos."] }
]
}
}
skipped é item idêntico ao que já tínhamos. rejeitados lista
(até 50) o que caiu na quarentena e por quê — leia na carga inicial: é o que diz o que
corrigir do seu lado. O campo só aparece quando algo foi recusado.
Itens que saíram de linha. Eles são desativados, não apagados — pedido antigo continua referenciando.
{ "items": [ { "sku_id_origem": "ABC-123" } ] }
Caminho barato para mudar só o que mudou. Um stock.changed não mexe em preço,
e vice-versa.
price.changed
{
"items": [
{
"sku_id_origem": "ABC-123",
"preco_unitario": 179.90,
"preco_promocao_ativa": false
}
]
}
stock.changed
{
"items": [
{
"sku_id_origem": "ABC-123",
"estoque_disponivel": 3
}
]
}
Andamento do pedido. O comprador recebe notificação a cada mudança e vê a nota fiscal e o rastreio na tela de acompanhamento.
{
"order_id": "ord_a1b2c3",
"external_order_ref": "PED-5001",
"status": "faturado",
"nota_fiscal": {
"numero": "90001",
"chave": "3526…",
"url": "https://…"
},
"rastreio": {
"codigo": "BR123456789BR",
"url": "https://…"
}
}
Valores aceitos em status
| Você manda | O comprador vê |
|---|---|
pendente | Pendente |
confirmado · faturado | Confirmado |
em_separacao · separacao | Em separação |
enviado · em_transito | Enviado |
entregue | Entregue |
Mande o order_id sempre que tiver. Ele é o identificador
da Peçafy e é exato. O external_order_ref é o número do seu ERP e
serve de alternativa — mas se a sua numeração reinicia ou se repete, ele sozinho é
ambíguo.
Limite ou bloqueio do comprador mudou no seu ERP.
{
"cnpj": "19131243000197",
"external_ref": "19131243000197",
"credit_limit": 300000,
"blocked": false,
"payment_term": "Boleto 30 dias",
"due_days": 30
}
Todo campo é opcional e só o que vier muda: limite ausente não zera o limite atual, condição ausente mantém a anterior — a gente preserva o valor em vez de apagar crédito real por causa de um payload incompleto.
O que cada evento faz do lado de cá
Para você saber o efeito real de cada mensagem antes de mandá-la.
| Evento | Efeito na Peçafy | O comprador percebe |
|---|---|---|
catalog.items.upserted |
Passa pelo mesmo pipeline de validação do catálogo puxado de ERP; item válido entra no catálogo, inválido vai para a quarentena. | A peça aparece na busca, com o seu preço e prazo. |
catalog.items.removed |
Item marcado como inativo. | Some da busca; pedidos antigos continuam íntegros. |
price.changed · stock.changed |
Atualiza só as colunas de preço/estoque da linha. | Preço e disponibilidade novos na próxima busca. |
catalog.refs.upserted |
Grava o de-para entre o seu código e o OEM/GTIN, com a chave normalizada. | Nada na hora — mas você passa a ser cotado por peças que não publicou. |
products.quote.requested resposta |
Atualiza preço e estoque dos itens perguntados. Item devolvido a mais, com descrição, entra no catálogo pelo mesmo pipeline de validação. | Preço novo na linha, sem ninguém esperar: a tela já tinha respondido. |
items.search.requested resposta |
Mesma coisa, para peças que ainda não existiam no seu catálogo aqui. | A sua oferta passa a aparecer numa busca em que você não aparecia. |
product.quote.requested resposta |
Sobrescreve preço e estoque daquele item na hora. Resposta tardia também é gravada — não se perde. | O preço da sua linha muda na tela, marcado como consulta ao vivo. |
customer.register.requested resposta |
Grava o código do cliente no seu ERP e libera a compra com você. | Sai de "Cadastrar" e passa a poder comprar. |
customer.credit.requested resposta |
Atualiza limite, bloqueio e condição de pagamento. | Vê o limite disponível e as condições no checkout. |
order.created resposta |
Marca o pedido como enviado ao fornecedor e guarda o seu número. | Pedido confirmado, com o número do seu ERP visível. |
order.status.changed |
Move o status do pedido e grava NF e rastreio. | Recebe notificação; vê a nota e o código de rastreio. |
customer.credit.changed |
Atualiza o limite do comprador com você. | Limite novo no checkout. |
Nada aqui escreve fora do seu escopo. Todo evento é resolvido dentro do
seu supplier_id: você só altera itens do seu catálogo, pedidos que têm item
seu e o cadastro de compradores na relação com você.
Falhas e retry
O que acontece quando a entrega não completa.
Backoff
Falha que dá para retentar — 5xx, timeout, conexão recusada — volta para a
fila com espera crescente:
| Tentativa | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| Espera | imediata | 30s | 2min | 10min | 1h | 6h | 24h |
Depois da sétima, o evento vai para a dead-letter e só sai de lá por reenvio manual no backoffice. Há variação aleatória de ±20% em cada espera, para várias entregas não baterem no seu servidor no mesmo instante.
Circuit breaker
Cinco falhas seguidas e o seu fornecedor é marcado como offline.
A gente para de mandar evento não-crítico até uma entrega dar certo — a primeira entrega
boa fecha o circuito e volta tudo ao normal. Recusa de negócio
({"status":"error"}) não conta como falha.
Idempotência
Todo evento carrega um id estável. Reenvio por timeout de rede é comum: se você
já processou aquele id, responda 200 e não faça de novo. Do nosso
lado vale o mesmo — mandar o mesmo id duas vezes devolve
{"status":"duplicate"} sem reprocessar.
Log de entregas
Cada tentativa fica registrada com a URL, o código HTTP, a duração e o corpo que você devolveu. Quem opera a Peçafy consegue ver exatamente o que saiu e o que voltou — se algo não está funcionando, essa é a primeira coisa a pedir.
O que a Peçafy responde para você
| Resposta | Significado |
|---|---|
{"status":"ok","event_id":"…","result":{…}} | Aplicado. |
{"status":"duplicate"} | Esse id já tinha chegado. Nada foi refeito. |
{"status":"ignored"} | Tipo de evento que ainda não tratamos. Não é erro — não retente. |
401 | Assinatura inválida, ausente, ou t fora da janela de 5 minutos. |
400 | Payload fora do contrato — ou callback com o id
do nosso evento no lugar de um id seu. A mensagem diz o quê. |
429 | Mais de 600 eventos por minuto. |
500 | Falha nossa ao aplicar. Retente. |
Rede e limites
O que a sua equipe de infraestrutura precisa saber antes de abrir o acesso.
| Item | Valor |
|---|---|
| IP de origem de onde as chamadas partem — use para liberar no firewall |
185.197.195.29IPv4 apenas; não usamos IPv6 |
| Método e formato | POST com Content-Type: application/json, corpo UTF-8 |
| Tamanho máximo do corpo nos eventos que você nos envia |
10 MB — é o que cabe no teto de 5000 itens por evento |
| Limite de chamadas seus eventos para a Peçafy |
600 por minuto; acima disso a resposta é 429 |
| Certificado | TLS válido e confiável. Certificado autoassinado é recusado na entrega. |
| Redirect | Não seguimos 3xx. Cadastre a URL final. |
O IP pode mudar. Se você depender de allowlist por IP, avise a equipe Peçafy — a gente comunica antes de trocar. A verificação que não depende de IP nenhum é a assinatura, e ela é a que realmente prova que a chamada é nossa.
Antes de ligar em produção
A lista que evita as falhas que a gente mais vê.
- Verifico a assinatura sobre o corpo cru, com comparação em tempo constante.
- Recuso
tcom mais de 5 minutos. - Dedupo por
event.id— o mesmo id chega duas vezes e não pode virar dois pedidos. - Respondo
202quando vou demorar, em vez de segurar a conexão. - Uso
{"status":"error"}para "não posso", e5xxsó quando estou quebrado. - Todo
POSTmeu para a Peçafy vai assinado. - Guardo o
order_iddoorder.createdpara mandar o status depois. - Fiz a carga inicial completa (
sku_id_origem+oem/gtin+descricao) e liresult.rejeitados. - No callback, o
idé meu; o da Peçafy vai só emin_reply_to. - Números vão como número;
gtinouoemsempre presente. - Minha URL é
https://, com certificado válido, e não redireciona. - Trato
X-Pecafy-Delivery-Attemptmaior que 1 como retentativa. - Liberei o IP
185.197.195.29no firewall, se houver allowlist. - Distingo
estoque_disponivel: nullde0.