Node.js: integrando a transportadora Frenet
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.
🔑 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.
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.