J&T Express: rastreando e cotando frete pela API
Olá meus Unicórnios! 🦄✨
Sabe quando você integra com uma transportadora achando que vai ser meia hora de trabalho? 😅 Pois é. Eu também achei.
Precisei conectar uma loja à J&T Express para duas coisas simples: mostrar onde está a encomenda e calcular o frete antes da compra. O que eu não sabia é que boa parte do que se precisa saber não está escrita em lugar nenhum. 🙃
A documentação é enxuta, a API é autenticada (então não tem exemplo
solto pela internet), e — esta é a parte que dói — alguns dos
erros mais caros não são erros. A API responde
success, com cara de tudo certo, e te entrega um número
errado. 💸
Depois de muita tentativa e erro, resolvi escrever tudo aqui. O código é Node.js puro, sem framework, e cada trecho roda sozinho — é só copiar. 🚀
🚚 A OpenAPI da J&T em dois minutos
O modelo é simples e um pouco fora de moda. Você monta um JSON chamado
bizContent, assina esse JSON com a sua chave privada, e envia
tudo como application/x-www-form-urlencoded — o JSON vai
dentro de um campo de formulário, não como corpo JSON.
Três headers acompanham a requisição: apiAccount,
digest (a assinatura) e timestamp. O host de
produção no Brasil é openapi.jtjms-br.com.
Dois avisos que economizam horas:
- Não confunda os hosts. O
demogw.jtjms-br.com, que a documentação chama de "Official address", é um gateway de demonstração. Credencial de produção usada lá devolve145003010 API account does not exist— e você vai passar o dia achando que a sua conta está errada. - Não confunda as duas APIs. Existe a OpenAPI B2B
(esta, autenticada por
apiAccounte assinatura) e existe o painel de rastreio emvip.jtjms-br.com, que usa sessão de usuário logado e responde em outro formato. Exemplo achado no Google costuma ser do segundo.
🔑 Como conseguir as credenciais
Essa dica aqui talvez valha mais que o resto do artigo inteiro. 😄
Você vai receber quatro dados diferentes, e a confusão entre eles é a primeira fonte de erro:
apiAccount— identifica a sua aplicação.privateKey— assina as requisições.customerCode— identifica o cliente J&T (começa comJ).- Senha API — um quarto valor, usado só na cotação.
Não é a
privateKey. Guarde essa distinção; ela volta mais adiante.
🔐 A assinatura de toda requisição
A fórmula é a mesma para qualquer serviço da plataforma:
digest = Base64( MD5( bizContent + privateKey ) )
O detalhe que derruba muita implementação: o MD5 é consumido como
buffer bruto antes de virar Base64. Se você gerar o hex
do MD5 e depois passar para Base64, a assinatura sai errada e a J&T
recusa com 145003030.
const crypto = require('crypto');
// digest = Base64( MD5( bizContent + privateKey ) )
function buildDigest(bizContent, privateKey) {
return crypto
.createHash('md5')
.update(bizContent + privateKey, 'utf8')
.digest() // Buffer bruto — não o hex
.toString('base64');
}
Repare também que a assinatura cobre a string exata do
bizContent. Se você montar o JSON, assinar, e depois
reserializar o objeto para enviar, a menor diferença de ordem ou de
espaço quebra tudo. Assine a string que você vai mandar, e mande a string
que você assinou.
📦 Rastreando uma encomenda
O serviço de rastreio fica em
/webopenplatformapi/api/logistics/trace. O
bizContent tem três campos: os códigos, o
customerCode e o idioma.
const axios = require('axios');
const BASE_URL = 'https://openapi.jtjms-br.com';
const TRACE_PATH = '/webopenplatformapi/api/logistics/trace';
const API_ACCOUNT = 'SUA_API_ACCOUNT';
const PRIVATE_KEY = 'SUA_PRIVATE_KEY';
const CUSTOMER_COD = 'SEU_CUSTOMER_CODE';
async function rastrear(codigos) {
// ATENÇÃO: string separada por vírgula, nunca um array.
const billCodes = Array.isArray(codigos) ? codigos.join(',') : codigos;
const bizContent = JSON.stringify({
billCodes: billCodes,
customerCode: CUSTOMER_COD,
lang: 'pt',
});
const resposta = await axios.post(
BASE_URL + TRACE_PATH,
new URLSearchParams({ bizContent }).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
apiAccount: API_ACCOUNT,
digest: buildDigest(bizContent, PRIVATE_KEY),
timestamp: Date.now().toString(),
},
timeout: 15000,
// A J&T devolve erro de negócio com HTTP 200.
validateStatus: () => true,
}
);
const dados = resposta.data;
if (dados.code !== '1' || !dados.data || dados.data.length === 0) {
throw new Error('J&T recusou: ' + dados.code + ' ' + dados.msg);
}
return dados.data[0];
}
O validateStatus: () => true não é preciosismo. A
J&T responde HTTP 200 mesmo quando recusa — o erro
vem no corpo, em code e msg. Se o seu código
confia no status HTTP, ele vai tratar recusa como sucesso.
📬 O que volta
{
"code": "1",
"msg": "success",
"data": [{
"billCode": "880000000000001",
"invoiceAccessKey": "42260600000000000149550010000000001000000001",
"details": [{
"scanTime": "2026-07-07 15:03:15",
"desc": "[Teresópolis] O pacote foi assinado! O signatário é [porteiro]...",
"scanType": "assinatura de encomenda",
"scanCode": "100",
"scanNetworkTypeName": "网点",
"scanNetworkName": "TRS -RJ",
"scanNetworkCity": "Teresópolis",
"scanNetworkProvince": "RJ",
"problemType": "",
"signer": "Ana Portaria",
"signatureUrl": "https://pro-jmsbr-file.jtjms-br.com/...png",
"sigPicUrl": "https://pro-jmsbr-file.jtjms-br.com/...jpeg"
}]
}]
}
Três campos costumam surpreender:
scanNetworkTypeNamevem em chinês:网点é ponto/filial de entrega,中心é centro de distribuição. Não é corrupção de encoding — é o dado mesmo.invoiceAccessKeyé a chave de acesso da NF-e, 44 dígitos. Útil para casar o rastreio com o seu pedido.signerestaffName(o entregador) são dados pessoais de terceiros. Pense duas vezes antes de exibi-los na tela do cliente ou jogá-los em log.
E details vem do mais recente para o mais
antigo. O evento atual é details[0], não o último.
Armadilha: billCodes é string, não array
Essa foi a que mais me custou tempo, porque o sintoma mente. 🤥
O campo billCodes aceita vários códigos, então parece a
coisa mais natural do mundo mandar um array JSON, certo? A J&T
aceita, responde success todo feliz — e devolve
details vazio. Você fica horas achando que a encomenda não
existe. 😤
| Enviado | Resposta |
|---|---|
"880...,990..." (string) | ✅ success com o rastreio completo |
["880..."] (array) | ⚠️ success com details: [] |
billCode (campo singular) | ❌ 145105023 billcodes not exist |
Compare as duas respostas de "não encontrei". São quase idênticas:
// Código realmente inexistente
{ "code": "1", "msg": "success", "data": [] }
// Array enviado por engano — MESMO "success", details vazio
{ "code": "1", "msg": "success",
"data": [{ "billCode": "[\"880000000000001\"]", "details": [] }] }
A assinatura do bug está no billCode devolvido: ele volta
com colchetes e aspas escapadas, porque a J&T tratou o array inteiro
como se fosse um código literal. Se você ver
details vazio com um
billCode estranho, o problema é o formato — não a encomenda.
🗺️ Lendo os eventos: a tabela de scanCode
Cada item de details traz um scanCode
numérico e um scanType em texto. A J&T não publica essa
tabela. Os valores abaixo foram levantados rastreando encomendas reais até
o fim:
scanCode | scanType | Significado |
|---|---|---|
10 | coleta de encomenda | O entregador coletou o pacote com o remetente. Primeiro evento. |
210 | bipe de coleta recebida | A coleta foi registrada na primeira unidade — o pacote entrou na rede. |
50 | bipe de expedição | Saiu de uma unidade rumo à próxima. nextStopName diz para onde. Repete várias vezes. |
90 / 92 | bipe de recebimento | Chegou a uma unidade. 90 é centro de distribuição, 92 é filial de entrega. |
94 | bipe de saída para entrega | Saiu para a entrega final. Repete se a entrega falhar e for retentada. |
110 | bipe de pacote problemático | Insucesso ou ocorrência. O motivo vem em problemType. Não é estado final. |
100 | assinatura de encomenda | Entregue. Traz signer e a URL do comprovante. Estado final. |
Para detectar entrega, teste o número e o rótulo. O
scanType é a tradução do termo de baixa da J&T e tende a
ser mais estável que o código:
function estaEntregue(details) {
if (!Array.isArray(details)) return false;
return details.some(function (evento) {
// O número e o rótulo: o rótulo é mais estável que o código.
return evento.scanCode === '100'
|| /assinatura de encomenda/i.test(evento.scanType || '');
});
}
// O evento mais recente é o PRIMEIRO do array.
function ultimoEvento(details) {
return (details && details.length) ? details[0] : null;
}
💰 Cotação: por que são dois digests
A cotação de frete e prazo fica em
/webopenplatformapi/api/spmComCost/getComCostAndTime. Guarde
bem esse /api/ no meio do caminho — ele volta no fim do
artigo com uma historinha que me custou semanas. 😩
E aqui vem a maior diferença para o rastreio: a cotação exige uma
segunda assinatura, dentro do bizContent.
Isso mesmo, duas! 🤯 E a de dentro usa a Senha
API do cliente, não a privateKey:
ciphertext = MD5( senhaApi + "jadada236t2" ) → hex MAIÚSCULO
digestInterno = Base64( MD5( customerCode + ciphertext + privateKey ) )
O jadada236t2 é um sal fixo da plataforma. Não é segredo
seu — é constante do protocolo, igual para todo mundo.
// Sal fixo da J&T. Não é segredo: é constante do protocolo.
const PASSWORD_SALT = 'jadada236t2';
// digest interno = Base64( MD5( customerCode + ciphertext + privateKey ) )
// onde ciphertext = MD5( senhaApi + sal ) em HEX MAIÚSCULO.
function buildBizDigest(customerCode, senhaApi, privateKey) {
const ciphertext = crypto
.createHash('md5')
.update(senhaApi + PASSWORD_SALT, 'utf8')
.digest('hex')
.toUpperCase();
return crypto
.createHash('md5')
.update(customerCode + ciphertext + privateKey, 'utf8')
.digest()
.toString('base64');
}
Sem esse digest interno, a resposta é
145003031 Business parameter signature verification failed —
uma mensagem que não dá nenhuma pista de que falta uma segunda assinatura.
Com as duas assinaturas no lugar, a chamada:
const QUOTE_PATH = '/webopenplatformapi/api/spmComCost/getComCostAndTime';
const SENHA_API = 'SUA_SENHA_API'; // NÃO é a privateKey
async function cotar(params) {
const bizContent = JSON.stringify({
customerCode: CUSTOMER_COD,
// O digest INTERNO — sem ele: 145003031.
digest: buildBizDigest(CUSTOMER_COD, SENHA_API, PRIVATE_KEY),
productTypeCode: params.productTypeCode || 'EZ',
destinationZipCode: params.destinationZipCode,
originZipCode: params.originZipCode,
weight: params.weight, // em KG
insuredAmount: params.insuredAmount, // em REAIS
});
const resposta = await axios.post(
BASE_URL + QUOTE_PATH,
new URLSearchParams({ bizContent }).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
apiAccount: API_ACCOUNT,
// O digest do HEADER — outro, com a mesma função de sempre.
digest: buildDigest(bizContent, PRIVATE_KEY),
timestamp: Date.now().toString(),
},
timeout: 15000,
validateStatus: () => true,
}
);
const dados = resposta.data;
if (dados.code !== '1') {
throw new Error('J&T recusou: ' + dados.code + ' ' + dados.msg);
}
return dados.data;
}
A resposta é curta:
{
"code": "1",
"msg": "success",
"data": {
"cost": "10.74",
"aging": 4,
"riskPremiumFee": "1.96",
"riskPremiumWaybillFee": "12.70"
}
}
cost é o frete, aging é o prazo em dias,
riskPremiumFee é seguro mais taxa de risco e
riskPremiumWaybillFee é o total. Cuidado com os tipos:
cost vem como string e aging
como número. Somar cost sem converter
concatena texto.
🚨 As duas armadilhas que custam dinheiro
Se você só for ler um pedaço deste artigo, leia este. 🙏
As duas falhas a seguir não geram erro. A J&T
responde code: "1", msg: "success", tudo lindo
— e te entrega um número errado. Se esse número virar preço na tela do
seu cliente, o prejuízo é seu. 😱
🎁 Frete zero não é frete grátis
O campo productTypeCode escolhe a modalidade de envio. Se
você pedir uma modalidade que a sua conta não contratou,
a J&T não recusa — ela cota zero:
// productTypeCode "standard" (não contratado) — sucesso, custo zero
{ "code": "1", "msg": "success",
"data": { "cost": "0", "aging": 4, "riskPremiumFee": "0" } }
Nada nessa resposta indica problema. O aging até vem
preenchido, o que reforça a impressão de que a cotação funcionou. Nas
contas testadas, só EZ (encomenda comum) devolvia preço real
— standard, express, CRD e até um
código inventado voltavam zerados.
A defesa é simples: use EZ como padrão e trate custo zero
como erro, nunca como promoção. 🚫
💸 Centavos: o erro de 100×
Esse aqui é ainda pior, porque o valor errado é plausível. 😬
O campo insuredAmount espera reais com ponto
decimal. Só que sistemas de e-commerce costumam guardar preço em
centavos — é o padrão da casa em quase todo lugar. Mande
os centavos direto e a J&T aceita numa boa, interpretando tudo como
reais:
// insuredAmount: "150.50" -> correto
{ "code": "1", "msg": "success",
"data": { "cost": "10.74", "riskPremiumFee": "1.96" } }
// insuredAmount: "15050" -> lido como R$ 15.050,00
{ "code": "1", "msg": "success",
"data": { "cost": "10.74", "riskPremiumFee": "195.65" } }
Repare no detalhe cruel: o frete é idêntico nos dois
casos. Só o seguro salta de R$ 1,96 para R$ 195,65 — cem vezes
mais. Quem valida a cotação comparando o cost não percebe
absolutamente nada.
Uma boa notícia no meio: a vírgula decimal é rejeitada com erro claro
(145205005). E o peso, que vai em quilos,
também falha de forma segura — mandar "500" pensando em
gramas estoura a faixa aceita (0,01 a 30 kg) e a J&T recusa com
145003092. O erro caro é só o do valor declarado.
| Você quer | Envie | Resultado |
|---|---|---|
| R$ 150,50 declarados | "150.50" | ✅ seguro R$ 1,96 |
| R$ 150,50 com vírgula | "150,50" | ✅ recusado: 145205005 |
| R$ 150,50 em centavos | "15050" | 🚨 aceito como R$ 15.050,00 |
| 500 gramas | "0.5" | ✅ cota |
| 500 gramas escritas em gramas | "500" | ✅ recusado: 145003092 |
Como não há como o servidor adivinhar (R$ 15.050,00 é um valor perfeitamente legítimo), a proteção tem que ser sua:
// Converte centavos (padrão em e-commerce) para reais.
function centavosParaReais(centavos) {
return (centavos / 100).toFixed(2); // 15050 -> "150.50"
}
// Barra a cotação suspeita ANTES de ela virar preço na tela.
function validarCotacao(cotacao) {
const frete = parseFloat(cotacao.cost);
if (!(frete > 0)) {
throw new Error(
'Frete ZERO: productTypeCode provavelmente não contratado. ' +
'Isto não é frete grátis — não cobre do cliente.'
);
}
return {
frete: frete,
prazoDias: cotacao.aging,
seguro: parseFloat(cotacao.riskPremiumFee || '0'),
};
}
❌ Quando a J&T recusa
Todos os erros têm a mesma forma, sempre com HTTP 200:
{ "code": "145003092",
"msg": "The weight information is not legal",
"data": null }
O code deixa de ser "1" e a msg
vem em inglês, mesmo com lang: "pt". Guarde
os dois no seu log: sem eles, o suporte da J&T não tem como ajudar.
| Código | Mensagem | Causa provável |
|---|---|---|
| 145003092 | The weight information is not legal | Peso fora de 0,01–30 kg, ou enviado em gramas |
| 145205005 | The insuredAmount information is not legal | Valor declarado com vírgula ou formato inválido |
| 145003066 | Illegal receiver postcode | CEP de destino ausente ou malformado |
| 145003031 | Business parameter signature verification failed | Falta o digest interno, ou a Senha API está errada |
| 145003030 | Headers signature verification failed | privateKey errada, ou digest do header mal gerado |
| 145003010 | API account does not exist | apiAccount errado — ou você está no host de demo |
| 999005030 | Invalid interwaybill address | Path errado. Veja abaixo. |
| 145105023 | billcodes not exist | Campo singular billCode, ou código de outro cliente |
A história do 999005030
Preciso contar essa, porque a lição vale muito além da J&T. 😅
A cotação passou meses devolvendo
999005030 Invalid interwaybill address. E a explicação
parecia óbvia: a conta não tem escopo de cotação liberado.
Confortável, né? A culpa era da transportadora, não tinha nada a fazer
além de abrir chamado e esperar. 🤷♀️
Só que estava errada. 🙈 Quando finalmente sentei e testei
uma variável por vez, com o mesmo
bizContent e a mesma conta, apareceu isto:
| Path | Digest interno | Resposta |
|---|---|---|
com /api/ | presente | ✅ a cotação |
com /api/ | ausente | 145003031 |
sem /api/ | presente | 999005030 |
Eram dois bugs meus: um segmento /api/
faltando no path e o digest interno que eu nem sabia que existia. O escopo
sempre esteve liberado. 😳 O 999005030 nunca
foi "falta de permissão" — era um 404 de rota com um nome
horrível.
🚀 Um script para rodar agora
Junte tudo em um jt.js: as duas funções de assinatura,
rastrear, cotar, estaEntregue e
validarCotacao. Só falta a leitura dos argumentos:
// jt.js — junte aqui buildDigest, buildBizDigest, rastrear,
// cotar, estaEntregue e validarCotacao.
async function main() {
const [comando, ...args] = process.argv.slice(2);
if (comando === 'rastrear') {
const track = await rastrear(args[0]);
const ultimo = ultimoEvento(track.details);
console.log('Código: ', track.billCode);
console.log('Entregue: ', estaEntregue(track.details) ? 'sim' : 'não');
console.log('Último: ', ultimo ? ultimo.scanType : '(sem eventos)');
console.log('Quando: ', ultimo ? ultimo.scanTime : '-');
return;
}
if (comando === 'cotar') {
const [origem, destino, peso] = args;
const bruto = await cotar({
originZipCode: origem,
destinationZipCode: destino,
weight: peso,
});
const cotacao = validarCotacao(bruto);
console.log('Frete: R$', cotacao.frete);
console.log('Prazo: ', cotacao.prazoDias, 'dias');
return;
}
console.log('uso: node jt.js rastrear <código>');
console.log(' node jt.js cotar <cepOrigem> <cepDestino> <pesoKg>');
}
main().catch(function (erro) {
console.error('Erro:', erro.message);
process.exit(1);
});
Uma dependência só:
npm init -y && npm i axios
node jt.js rastrear 880000000000001
node jt.js cotar 89010-000 08570-431 2
Para produção, tire as credenciais das constantes e coloque em
variáveis de ambiente. A privateKey e a Senha API assinam
requisições em nome da sua conta: nunca versione, nunca logue o corpo
das requisições em texto plano, e sempre HTTPS.
📌 Resumo do que morde
billCodesé string separada por vírgula. Array devolvesuccesscomdetailsvazio.- A J&T responde HTTP 200 mesmo recusando. Cheque
code, não o status. - A cotação precisa de dois digests, e o interno usa a
Senha API — não a
privateKey. - O path da cotação tem
/api/no meio. Sem ele:999005030. cost: "0"não é frete grátis — é modalidade não contratada.- Peso em quilos, valor declarado em reais. Centavos como inteiro passam calados e multiplicam o seguro por cem.
details[0]é o evento mais recente.
Nada disso está na documentação. 😅 Cada item dessa lista me custou horas — algumas delas achando que o problema estava do lado da transportadora, quando estava aqui no meu código mesmo. 🙈
Se este artigo te poupar essas horas, ele já valeu a pena! 💜
💾 O repositório
Os trechos deste artigo estão lá montados num arquivo só, pronto para rodar — e o README resume as armadilhas, começando pelos dois digests:
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
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.