Pular para o conteúdo
Node.js

Node.js: integrando a transportadora Frenet

Paloma Macetko
Ilustracao magica: um unicornio conferindo uma balanca de pesagem, uma coruja carregando uma encomenda selada, uma etiqueta de envio lida por uma varinha e um cofre de moedas brilhando ao fundo

Olá meus Unicórnios! 🦄✨

Sabe quando você olha a documentação de uma API, vê cinco endpoints listadinhos e pensa "isso é uma tarde de trabalho"? 😅 Pois é. Eu pensei exatamente isso da Frenet — e a maior parte do tempo que gastei não foi escrevendo código, foi descobrindo que a resposta de sucesso nem sempre é sucesso.

A Frenet é uma integradora de frete: você conversa com ela, e ela conversa com os Correios, Jadlog e o resto das transportadoras. Em vez de uma integração por transportadora, você faz uma só. A promessa é ótima e ela cumpre — só não conta, na documentação, as coisas que eu vou contar aqui.

Neste artigo eu percorro as cinco chamadas que um envio precisa, na ordem em que elas acontecem de verdade: consultar o saldo, calcular o frete, criar o envio (que é quando a etiqueta é comprada), consultar e cancelar.

Exemplos_Frenet no GitHub Todo o código deste artigo, com uma página no navegador para testar as cinco chamadas sem escrever nada. github.com

🔑 Duas APIs, dois tokens — e é aqui que todo mundo tropeça

Esta é a primeira coisa que ninguém te avisa, e ela sozinha já custa uma manhã. A Frenet não tem uma API. Tem duas, em hosts diferentes, com autenticação diferente:

api.frenet.com.br          →  cotação de frete
                              header: token

whitelabel.frenet.com.br   →  saldo, envio, cancelamento, consulta
                              headers: token + x-partner-token

Repare no detalhe cruel: o header dos dois se chama token, mas o valor é diferente. São credenciais distintas, tiradas de lugares distintos do painel. Como o nome do header é o mesmo, o erro não parece erro — você copia o token que já tinha funcionado na cotação, manda para o whitelabel, e leva um "falha de autenticação" olhando para um header que está lá, preenchido, na sua frente. 😳

No meu cliente eu separei os três desde o começo, justamente para não confundir:

const API_COTACAO = 'https://api.frenet.com.br/';
const API_ENVIO = 'https://whitelabel.frenet.com.br/';

/* Le as credenciais do ambiente. Nunca escreva token dentro do codigo. */
function credenciais() {
  return {
    token: process.env.FRENET_TOKEN || '',
    tokenEnvio: process.env.FRENET_TOKEN_ENVIO || '',
    tokenParceiro: process.env.FRENET_PARTNER_TOKEN || '',
  };
}

Uma chamada só usa o primeiro; as outras quatro usam os dois últimos.

💰 1. Consultar o saldo — o passo que evita a falha lá na frente

Comprar etiqueta na Frenet é gastar saldo de uma carteira pré-paga. Sem saldo, a compra falha — e falha tarde, depois de você já ter montado o pedido inteiro com endereço, nota fiscal e volume. Consultar antes é o que transforma um erro no meio do processo num aviso limpo no começo.

/*
 * 1. Consultar o saldo da conta (wallet).
 *
 * A documentacao mostra os campos com inicial maiuscula (Balance, LabelLimit),
 * mas a API responde em minuscula (balance, labelLimit). Leio os dois.
 */
async function consultarSaldo() {
  const cred = credenciais();

  const resposta = await fetch(API_ENVIO + 'v1/wallet', {
    method: 'GET',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      token: cred.tokenEnvio,
      'x-partner-token': cred.tokenParceiro,
    },
  });

  const ret = await interpretar(resposta);
  if (!ret.ok) return ret;

  const d = ret.dados || {};

  /* Saldo bloqueado ja saiu do saldo: e etiqueta comprada e nao postada. */
  ret.saldo = {
    disponivel: numero(d.balance, d.Balance),
    bonus: numero(d.bonusBalance, d.BonusBalance),
    bloqueado: numero(d.blockedBalance, d.BlockedBalance),
    limiteEtiquetas: numero(d.labelLimit, d.LabelLimit),
  };

  return ret;
}

Duas coisas nesse trecho merecem explicação.

A documentação e a API discordam sobre o nome dos campos. A referência oficial lista Balance e LabelLimit, com inicial maiúscula. A API responde balance e labelLimit, minúsculas. Eu leio os dois — não porque seja elegante, mas porque não quero que a integração quebre no dia em que eles padronizarem.

Saldo não é a única coisa que bloqueia a compra. Existe um labelLimit, um limite de etiquetas, que pode zerar mesmo com dinheiro sobrando na conta. São dois motivos independentes para o mesmo "não dá para comprar agora", e vale checar os dois:

const ret = await consultarSaldo();

if (ret.saldo.disponivel < 10) {
  return 'Saldo baixo: R$ ' + ret.saldo.disponivel.toFixed(2);
}

if (ret.saldo.limiteEtiquetas <= 0) {
  return 'A conta nao tem limite de etiquetas disponivel';
}

Tem ainda um blockedBalance, que é dinheiro já comprometido: etiqueta comprada e ainda não postada. Ele já saiu do balance, então não desconte de novo — é informativo, não um segundo débito.

📦 2. Calcular o frete — e a mentira do HTTP 200

Esta é a única chamada no host da cotação, e a única que dispensa o x-partner-token. Você manda os dois CEPs, o valor e as dimensões do volume; ela devolve a lista de transportadoras com preço e prazo.

/*
 * 2. Calcular o frete (shipping/quote).
 *
 * Esta e a unica chamada no host api.frenet.com.br, e a unica que NAO usa
 * o x-partner-token.
 */
async function calcularFrete(entrada) {
  const cred = credenciais();

  const corpo = {
    SellerCEP: limpar(entrada.cepOrigem),
    RecipientCEP: limpar(entrada.cepDestino),
    ShipmentInvoiceValue: entrada.valor,
    ShippingItemArray: [
      {
        Weight: entrada.peso,
        Width: entrada.largura,
        Height: entrada.altura,
        Length: entrada.comprimento,
        Quantity: 1,
      },
    ],
  };

  const resposta = await fetch(API_COTACAO + 'shipping/quote', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      token: cred.token,
    },
    body: JSON.stringify(corpo),
  });

  const ret = await interpretar(resposta);
  if (!ret.ok) return ret;

Agora a parte que eu descobri rodando, e que é o coração deste artigo. Se você só for ler um pedaço aqui, leia este. 🙏

Eu subi o servidor sem definir o token, esperando ver um erro de autenticação bonitinho para conferir o tratamento. O que apareceu foi isto:

== COTACAO ==
{"ok":true,"status":200,"dados":{"Message":"Acesso negado!","Timeout":0},"servicos":[],"recusados":[]}

Isso mesmo: ok: true com "Acesso negado" dentro. 🤯 A cotação responde HTTP 200 mesmo recusando o token, e enfia a falha no corpo. Como a lista de serviços simplesmente não vem, o meu código — que checava o código de status, como todo mundo faz — leu aquilo como "deu certo, e não há frete disponível para este CEP".

Pense no estrago. Em produção isso não vira um erro na tela: vira uma loja que, silenciosamente, para de oferecer frete. O cliente chega no checkout, não vê opção de entrega nenhuma, e vai embora. Ninguém recebe alerta, porque tecnicamente nada falhou. 😱

O conserto é checar o corpo, não o status:

  /*
   * ARMADILHA: este endpoint devolve HTTP 200 mesmo com o token recusado,
   * e poe a falha no corpo como {"Message":"Acesso negado!"}. Sem esta
   * verificacao, o erro de autenticacao vira "nenhum frete disponivel" -
   * porque a lista de servicos simplesmente nao vem.
   */
  if (ret.dados && ret.dados.Message && !ret.dados.ShippingSevicesArray) {
    return { ok: false, status: resposta.status, dados: ret.dados, mensagem: ret.dados.Message };
  }

E aí sim, com o mesmo token recusado:

== COTACAO (token recusado) ==
{"ok":false,"status":200,"dados":{"Message":"Acesso negado!","Timeout":0},"mensagem":"Acesso negado!"}

"ok":false com "status":200 — exatamente o que eu queria ver. O erro agora aparece como erro.

🐛 O campo com erro de digitação na própria API

Repare no nome que eu leio na resposta:

const lista = (ret.dados && ret.dados.ShippingSevicesArray) || [];

ShippingSevicesArray. Sem o "r" de Services. Não é erro de digitação meu, nem do artigo: é assim que a Frenet responde, e está assim na documentação oficial também. Escrever certo devolve undefined — e, como você já sabe a esta altura, um undefined aqui vira "nenhum frete disponível" sem reclamar de nada. 😅

Copie o nome errado. Ele está certo.

⚠️ Serviço com erro vem na mesma lista dos que deram certo

Outra que não está escrita em lugar nenhum: quando uma transportadora recusa a cotação (CEP fora da área, peso acima do limite), ela não some da lista. Ela vem junto, marcada com Error: true e preço zerado.

Se você não filtrar, oferece ao cliente um frete de R$ 0,00 que não existe. Por isso a separação:

  /*
   * O nome do campo esta escrito errado na propria API: "Sevices", sem o "r"
   * de Services. Nao e erro de digitacao aqui - e assim que ela responde.
   */
  const lista = (ret.dados && ret.dados.ShippingSevicesArray) || [];

  /*
   * Servico com erro vem na MESMA lista dos que deram certo, marcado com
   * Error: true e preco zerado. Filtrar aqui evita oferecer ao cliente um
   * frete que nao existe.
   */
  ret.servicos = lista
    .filter(function (s) {
      return s.Error !== true && s.Error !== 'True' && Number(s.ShippingPrice) > 0;
    })
    .map(function (s) {
      return {
        transportadora: s.Carrier,
        servico: s.ServiceDescription,
        codigoServico: s.ServiceCode,
        preco: Number(s.ShippingPrice),
        prazoDias: Number(s.DeliveryTime),
        permiteComprarEtiqueta: String(s.AllowBuyLabel) === '1',
      };
    })
    .sort(function (a, b) {
      return a.preco - b.preco;
    });

  ret.recusados = lista
    .filter(function (s) {
      return s.Error === true || s.Error === 'True';
    })
    .map(function (s) {
      return { transportadora: s.Carrier, motivo: s.Msg || 'sem motivo informado' };
    });

  return ret;
}

Eu guardo os recusados em recusados em vez de jogar fora — quando um cliente pergunta "por que não aparece Jadlog para o meu CEP?", a resposta está ali, com o motivo que a própria transportadora deu.

🏷️ 3. Criar o envio — o momento em que o dinheiro sai

Este é o passo que compra a etiqueta de verdade. Ele depende do anterior: o ShippingServiceCode tem de ser exatamente o ServiceCode que a cotação devolveu.

A primeira armadilha é logo na forma do corpo — ele é um array, mesmo para um envio só:

/*
 * 3. Criar o envio (orders/oneclick).
 *
 * O corpo e um ARRAY, mesmo para um envio so. Mandar o objeto sozinho
 * devolve erro de validacao.
 *
 * O ServiceCode tem de ser o mesmo devolvido pela cotacao. E por isso que
 * este passo depende do anterior.
 */
async function criarEnvio(entrada) {
  const cred = credenciais();

  const pedido = {
    Order: {
      /* Id seu, nao da Frenet. Repetir um Id ja usado devolve erro. */
      Id: entrada.numeroPedido,
      Value: entrada.valor,
      Created: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
      /* Usa o remetente cadastrado no painel da Frenet. */
      UseFrenetRegistration: true,
      Items: entrada.itens.map(function (nome) {
        return { ProductName: nome };
      }),
      To: {
        Name: entrada.destinatario.nome,
        Email: entrada.destinatario.email,

Mandar o objeto sozinho, sem os colchetes, devolve erro de validação. O endpoint é preparado para lote; enviar um é enviar um lote de tamanho um.

O Order.Id é seu, não da Frenet — é como você vai reconhecer o pedido depois. E ele não pode repetir: reaproveitar um Id já usado devolve erro. Por isso, no exemplo, o número do pedido cai para um valor com Date.now() quando o campo vem vazio.

O UseFrenetRegistration: true economiza um bloco inteiro: ele diz "use o remetente que já está cadastrado no painel", e você não precisa mandar o seu endereço em cada envio.

💣 HTTP 200 aqui também não quer dizer que deu certo

A segunda vez que o código de status mente — e essa é bem mais cara que a primeira, porque envolve pagamento.

A resposta do oneclick vem com HTTP 200 e um items[0] dentro. Quem decide se a etiqueta foi realmente comprada é o shipmentStatus:

1  → Criado
2  → Pendente de pagamento     ← falhou
3  → Falha no pagamento        ← falhou
4  → Pagamento aprovado        ← ÚNICO caso de sucesso
5  → Postado
6  → Cancelamento agendado
7  → Cancelado
9  → Excluído
18 → Entregue no ponto de postagem

Os status 2 e 3 chegam dentro de uma resposta 200, com cara de sucesso. Se você tratar 200 como "comprou", vai gravar no banco um envio que não existe, com shipmentId vazio, e descobrir isso só quando alguém for imprimir a etiqueta.

  const item = (ret.dados && ret.dados.items && ret.dados.items[0]) || {};

  /*
   * ARMADILHA: HTTP 200 aqui NAO quer dizer que a etiqueta foi comprada.
   * O que decide e o shipmentStatus. Status 3 (falha no pagamento) chega
   * dentro de uma resposta 200 com aparencia de sucesso.
   */
  if (item.errors && item.errors[0]) {
    return { ok: false, status: resposta.status, mensagem: item.errors[0].message, dados: ret.dados };
  }

  if (String(item.shipmentStatus) !== '4') {
    return {
      ok: false,
      status: resposta.status,
      mensagem: 'Etiqueta nao paga: ' + descreverStatus(item.shipmentStatus),
      dados: ret.dados,
    };
  }

Repare que existem dois jeitos de falhar aqui: o errors dentro do item, e o status diferente de 4. Os dois precisam de tratamento, e nenhum dos dois muda o código HTTP.

🔍 4. Consultar o envio — cuidado com qual id você guarda

A consulta é simples, mas tem uma pegadinha de identificador que já me custou um bom tempo de confusão.

/*
 * 5. Buscar os dados de um envio (orders/{id}).
 *
 * O id da URL e o shipmentId devolvido pelo oneclick - nao o Order.Id
 * que voce mandou.
 */
async function buscarEnvio(shipmentId) {
  const cred = credenciais();

  const resposta = await fetch(API_ENVIO + 'v1/orders/' + shipmentId, {
    method: 'GET',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      token: cred.tokenEnvio,
      'x-partner-token': cred.tokenParceiro,
    },
  });

  const ret = await interpretar(resposta);
  if (!ret.ok) return ret;

O endpoint é /v1/orders/{id}orders, plural de pedido. É natural supor que o {id} seja o Order.Id que você mandou na criação. Não é. É o shipmentId que a Frenet devolveu.

Ou seja: a rota se chama orders mas quer o id do shipment. Guarde o shipmentId no seu banco no momento da criação — sem ele você não consulta nem cancela, e não existe caminho de volta a partir do seu próprio número de pedido.

🚫 5. Cancelar — é POST, e o sucesso vem vazio

O cancelamento tem duas surpresas pequenas e uma consequência grande.

A primeira: é POST, não DELETE. A rota termina em /cancel, então o verbo que faz sentido para a Frenet é POST mesmo — mas a mão vai sozinha no DELETE quando a operação se chama "cancelar".

A segunda: o sucesso é HTTP 204, sem corpo nenhum.

/*
 * 4. Cancelar o envio (shipments/{id}/cancel).
 *
 * E POST, nao DELETE. E responde 204 sem corpo nenhum: quem esperar JSON
 * de volta quebra aqui.
 */
async function cancelarEnvio(shipmentId) {
  const cred = credenciais();

  const resposta = await fetch(API_ENVIO + 'v1/shipments/' + shipmentId + '/cancel', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      token: cred.tokenEnvio,
      'x-partner-token': cred.tokenParceiro,
    },
  });

  return await interpretar(resposta);
}

É por isso que a função que interpreta as respostas trata a faixa inteira de 200 a 204 como sucesso, e não estranha corpo vazio:

/*
 * Traduz a resposta HTTP num formato unico para todas as chamadas.
 *
 * O cancelamento responde 204 sem corpo nenhum: por isso a faixa de sucesso
 * vai de 200 a 204, e o corpo vazio nao pode ser tratado como erro de JSON.
 */
async function interpretar(resposta) {
  const texto = await resposta.text();

  let dados = null;
  if (texto.trim() !== '') {
    try {
      dados = JSON.parse(texto);
    } catch (erro) {
      dados = null;
    }
  }

  if (resposta.status >= 200 && resposta.status <= 204) {
    return { ok: true, status: resposta.status, dados: dados, mensagem: '' };
  }

  return {
    ok: false,
    status: resposta.status,
    dados: dados,
    mensagem: mensagemDeErro(resposta.status, dados, texto),
  };
}

Aquele if (texto.trim() !== '') não é preciosismo: sem ele, o JSON.parse de uma string vazia lança exceção, e o cancelamento que deu certo vira um erro na sua aplicação. Um cancelamento bem-sucedido que o sistema registra como falha é especialmente ruim — você tenta de novo, e a segunda tentativa reclama que o envio já está cancelado.

🖥️ Testando as cinco sem escrever código

Deixei no repositório uma página que faz as cinco chamadas, na ordem, com o resultado de cada uma aparecendo na tela. Ela existe por um motivo prático: os passos são encadeados — a cotação alimenta a criação, que alimenta a consulta e o cancelamento — e ficar copiando código de serviço e shipmentId entre chamadas de curl é um convite ao erro.

Na página, clicar num serviço da cotação já preenche o campo do passo 3, e o shipmentId que nasce na criação já cai nos campos 4 e 5.

npm install

export FRENET_TOKEN="seu-token-da-cotacao"
export FRENET_TOKEN_ENVIO="seu-token-do-whitelabel"
export FRENET_PARTNER_TOKEN="seu-token-de-parceiro"

npm start

O token nunca vai para o navegador: a página chama o seu servidor, e o servidor chama a Frenet. Numa integração de frete isso não é detalhe — token de transportadora no front-end é etiqueta comprada com o seu saldo por quem abrir o DevTools.

🧪 O que eu testei de verdade (e o que não deu)

Sendo honesta com você sobre o que foi verificado, porque isso muda o quanto você deve confiar em cada parte.

Testado contra a API real: as cinco chamadas saem e chegam. Eu rodei o servidor e bati nos cinco endpoints da Frenet de verdade, sem token válido — e foi exatamente isso que revelou o problema do HTTP 200, além de mostrar que os dois hosts erram de formas diferentes:

== SALDO (whitelabel.frenet.com.br) ==
ok: false | status HTTP: 400
erro: {"token":["The token field is required."],"x-partner-token":["The xPartnerToken field is required."]}

== COTACAO (api.frenet.com.br) ==
{"ok":false,"status":200,"dados":{"Message":"Acesso negado!","Timeout":0},"mensagem":"Acesso negado!"}

O whitelabel devolve 400 com uma lista de erros de validação por campo; a cotação devolve 200 com uma frase solta. Mesma empresa, mesmo produto, dois contratos de erro completamente diferentes — é por isso que o tratamento de erro do cliente precisa olhar para os dois formatos.

Não testado com credencial válida: a compra da etiqueta de ponta a ponta. Comprar etiqueta gasta saldo real e gera uma postagem real; eu não vou queimar dinheiro para poder escrever "testei" num artigo. O tratamento do shipmentStatus e o mapa de status vêm da documentação oficial e do comportamento observado num sistema que mantenho — mas o caminho feliz completo, com etiqueta paga saindo, esse eu não executei aqui.

Prefiro te contar isso a fingir uma saída de terminal bonita. 🙏

📌 O resumo do que quebra

Se você levar só uma ideia daqui, que seja esta: na Frenet, o código HTTP não é a fonte da verdade. Duas das cinco chamadas devolvem 200 em situações de falha — a cotação com o token recusado, e a criação com o pagamento negado. Nas duas, a informação que importa está no corpo, e ignorá-la produz o pior tipo de bug: o que não aparece.

Exemplos_Frenet no GitHub As cinco chamadas em Node.js, a página de teste no navegador e as armadilhas resumidas no README. github.com

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! 🌈🌟

Leia também