Integração de fornecedor · v1

Conecte seu ERP à Peçafy por webhook

Sem API para você consultar em loop. A Peçafy manda um evento assinado quando precisa de algo — preço, cadastro, crédito, pedido — e você responde. Quando alguma coisa muda no seu ERP, você empurra o evento de volta. Este documento é o contrato inteiro.

Protocolo versão 1 Assinatura HMAC-SHA256 Campos em snake_case Corpo JSON

Como funciona

A conversa é de mão dupla e sempre por evento. Nenhum dos dois lados fica consultando o outro.

SentidoQuem começaPara 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.

Peçafy Seu ERP POST evento assinado 200 · resultado no corpo rápido — o comprador vê o preço na hora ou 202 · aceito, sem resultado POST no callback · in_reply_to seu ERP demora o quanto precisar

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.

AmbienteEndereçoPara quê
Homologaçãodemo.pecafy.com.br Desenvolver e validar a integração. Pedidos daqui não são reais.
Produçãoapp.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

ItemO 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 t fora 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": { }
}
CampoPara 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çãoRespondaO 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

ModoPrazoO 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 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

EventoPrazoPor 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.

  1. 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çãoEventoVocê faz
    → Peçafyping Valida a assinatura e responde {"pong": true}

    Enquanto o ping não voltar, nada mais adianta: o problema é assinatura ou URL.

  2. 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):

    CaminhoO que você mandaQuando escolher
    Catálogo
    catalog.items.upserted
    Item 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.upserted
    Só "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çãoEventoO que acontece
    → Peçafycatalog.sync.requested Pedimos o catálogo. Responda 202.
    ← vocêcatalog.items.upserted Você empurra os itens, em lotes de até 5000. Pode mandar vários eventos.
    ← vocêprice.changed
    stock.changed
    Daí em diante, só o que mudou. É o caminho barato — use bastante.
    ← vocêcatalog.items.removed Item que saiu de linha.

    Você também pode empurrar catalog.items.upserted sem a gente pedir — o catalog.sync.requested é só a carga inicial.

  3. 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çãoEventoQuando você recebe
    → Peçafyproducts.quote.requested Você 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çafyitems.search.requested Você 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.

  4. 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çãoEventoPrazo
    → Peçafyproduct.quote.requested 3s. Não deu? 202 e 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.

  5. Fase 4 · O comprador vira seu cliente

    Acontece quando ele escolhe você e ainda não tem cadastro no seu ERP.

    DireçãoEventoO que acontece
    → Peçafycustomer.lookup.requested Esse CNPJ já é seu cliente? Se registered: false, o comprador vê o botão "Cadastrar".
    → Peçafycustomer.register.requested Ele clicou em cadastrar. Crie o cliente e devolva o cli_cod.
    → Peçafycustomer.credit.requested Limite e condição de pagamento, para o checkout.
    ← vocêcustomer.credit.changed Depois, sempre que o limite ou o bloqueio mudar no seu ERP.

    Sem cadastro confirmado, a Peçafy não deixa fechar pedido com você.

  6. Fase 5 · O pedido

    DireçãoEventoO que acontece
    → Peçafyorder.created Lance no ERP e devolva external_order_ref. Guarde o order_id.
    ← vocêorder.status.changed Faturado (com a NF), enviado (com o rastreio), entregue. Um evento por mudança — o comprador é notificado a cada um.
    → Peçafyorder.cancel.requested Se o comprador cancelar.

O mesmo caminho, em uma imagem

Peçafy Seu ERP FASE 0 · CONEXÃO ping FASE 1 · QUAIS PEÇAS VOCÊ TEM catalog.items.upserted (catálogo) catalog.refs.upserted (de-para) — ou os dois FASE 2 · BUSCA "pastilha de freio" products.quote.requested (só os itens buscados) a tela não espera: responde com a cotação guardada FASE 3 · PREÇO AO VIVO DE UM ITEM (OPCIONAL) product.quote.requested 200 na hora · ou 202 e callback depois FASE 4 · CLIENTE lookup → register → credit FASE 5 · PEDIDO order.created order.status.changed · faturado → enviado → entregue

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 é.

FaseEventoDireçãoObrigatório
0ping→ vocêsim
1catalog.sync.requested→ vocêsim
1catalog.items.upserted← vocêsim
1catalog.items.removed← vocêrecomendado
1price.changed · stock.changed← vocêrecomendado
1catalog.refs.upserted← vocêrecomendado
1catalog.refs.removed← vocêrecomendado
2products.quote.requested→ vocêsim
2items.search.requested→ vocêopt-in
3product.quote.requested→ vocêopcional
4customer.lookup.requested→ vocêsim
4customer.register.requested→ vocêsim
4customer.credit.requested→ vocêsim
4customer.credit.changed← vocêrecomendado
5order.created→ vocêsim
5order.status.changed← vocêsim
5order.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.upserted ou catalog.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.changed incrementais, 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

CampoObrigatórioPara que a busca usa
sku_id_origemsim 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 gtinpelo 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.
descricaosim 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, aplicacaorecomendado 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_disponivelrecomendado 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:

RegraCasa quandoExemplo
GTIN exatoo 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çãoO 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.

→ Peçafy mandaping

Teste de conexão, disparado pelo botão "Testar conexão" do portal (Integração).

Responda

{ "pong": true }
→ Peçafy mandacatalog.sync.requested

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.

→ Peçafy mandaproduct.quote.requested 3s

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.

→ Peçafy mandaproducts.quote.requested 3s

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.

→ Peçafy mandaitems.search.requested 3s

Ú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.

→ Peçafy mandacustomer.lookup.requested

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.

→ Peçafy mandacustomer.register.requested

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
}
→ Peçafy mandacustomer.credit.requested

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.

→ Peçafy mandaorder.created

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.

→ Peçafy mandaorder.cancel.requested

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.

← você mandacatalog.refs.upserted

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.

← você mandacatalog.items.upserted

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

  • gtin ou oem, pelo menos um. Sem chave de matching o item não casa com nada e vai para a quarentena.
  • gtin com 8 a 14 dígitos. Um GTIN de 15 dígitos é rejeitado.
  • descricao obrigató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.

← você mandacatalog.items.removed

Itens que saíram de linha. Eles são desativados, não apagados — pedido antigo continua referenciando.

{ "items": [ { "sku_id_origem": "ABC-123" } ] }
← você manda price.changedstock.changed

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
    }
  ]
}
← você mandaorder.status.changed

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ê mandaO comprador vê
pendentePendente
confirmado · faturadoConfirmado
em_separacao · separacaoEm separação
enviado · em_transitoEnviado
entregueEntregue

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.

← você mandacustomer.credit.changed

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.

EventoEfeito na PeçafyO 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:

Tentativa1234567
Esperaimediata30s2min10min1h6h24h

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ê

RespostaSignificado
{"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.
401Assinatura inválida, ausente, ou t fora da janela de 5 minutos.
400Payload fora do contrato — ou callback com o id do nosso evento no lugar de um id seu. A mensagem diz o quê.
429Mais de 600 eventos por minuto.
500Falha nossa ao aplicar. Retente.

Rede e limites

O que a sua equipe de infraestrutura precisa saber antes de abrir o acesso.

ItemValor
IP de origem
de onde as chamadas partem — use para liberar no firewall
185.197.195.29
IPv4 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 t com mais de 5 minutos.
  • Dedupo por event.id — o mesmo id chega duas vezes e não pode virar dois pedidos.
  • Respondo 202 quando vou demorar, em vez de segurar a conexão.
  • Uso {"status":"error"} para "não posso", e 5xx só quando estou quebrado.
  • Todo POST meu para a Peçafy vai assinado.
  • Guardo o order_id do order.created para mandar o status depois.
  • Fiz a carga inicial completa (sku_id_origem + oem/gtin + descricao) e li result.rejeitados.
  • No callback, o id é meu; o da Peçafy vai só em in_reply_to.
  • Números vão como número; gtin ou oem sempre presente.
  • Minha URL é https://, com certificado válido, e não redireciona.
  • Trato X-Pecafy-Delivery-Attempt maior que 1 como retentativa.
  • Liberei o IP 185.197.195.29 no firewall, se houver allowlist.
  • Distingo estoque_disponivel: null de 0.